scripts/bash/radar-search.sh
#!/usr/bin/env bash
# radar-search.sh — deterministic search over the Tikal Tech Radar OKF knowledge tree.
# bash + jq only (no Python/Node).
#
# Knowledge source: gitlab.tikalk.dev/tikalk/engineering/agentic-sdlc/adlc-radar
# `knowledge/technologies/` is the system of record. The helper keeps a shallow
# clone in a cache directory and refreshes it after ADLC_RADAR_TTL seconds
# (default 3 days). When the repository is unreachable it falls back — in order —
# to the stale clone (with a warning) and then to the public radar JSON
# (https://tikalk.com/radar.json). JSON notices go to stderr; rows carry source metadata.
#
# Usage:
# bash radar-search.sh <query1> [query2 ...] [options]
# bash radar-search.sh --json flask
#
# Options:
# --json JSON array output instead of the markdown table
# --refresh force a git refresh even inside the TTL
# --quadrant <Q> filter: DevOps | Backend | AI/ML | Web/Mobile
# --ring <R> filter: Try | Start | Keep | Stop
# --limit <N> max rows per category (body-term) match (default 5)
# --include-excluded include docs marked `include: no` (excluded from radar.json)
# -h, --help
#
# Environment:
# ADLC_RADAR_URL git remote (default: SSH GitLab adlc-radar)
# ADLC_RADAR_BRANCH branch to track (default: main)
# ADLC_RADAR_CACHE cache checkout (default: ${XDG_CACHE_HOME:-$HOME/.cache}/adlc/radar)
# ADLC_RADAR_REPO use this existing local clone instead (no git network calls)
# ADLC_RADAR_TTL refresh interval in seconds (default: 259200 = 3 days)
# ADLC_RADAR_FALLBACK_URL public radar JSON used only when no checkout is usable
# ADLC_RADAR_CONNECT_TIMEOUT SSH/HTTP connect timeout seconds (default: 10)
set -euo pipefail
DEFAULT_REPO_URL="git@gitlab.tikalk.dev:tikalk/engineering/agentic-sdlc/adlc-radar.git"
REPO_URL="${ADLC_RADAR_URL:-$DEFAULT_REPO_URL}"
REPO_BRANCH="${ADLC_RADAR_BRANCH:-main}"
CACHE_TTL="${ADLC_RADAR_TTL:-259200}"
CACHE_DIR="${ADLC_RADAR_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/adlc/radar}"
LOCAL_REPO="${ADLC_RADAR_REPO:-}"
FALLBACK_URL="${ADLC_RADAR_FALLBACK_URL:-https://tikalk.com/radar.json}"
CONNECT_TIMEOUT="${ADLC_RADAR_CONNECT_TIMEOUT:-10}"
QUERIES=()
OUTPUT_JSON=false
FORCE_REFRESH=false
FILTER_QUADRANT=""
FILTER_RING=""
LIMIT=5
INCLUDE_EXCLUDED=false
MODE=""
REASON=""
SHA=""
SYNCED_AT=""
LOCK_DIR=""
usage() {
sed -n '9,30p' "$0" | sed 's/^# \{0,1\}//'
}
while [ $# -gt 0 ]; do
case "$1" in
--json) OUTPUT_JSON=true ;;
--refresh) FORCE_REFRESH=true ;;
--include-excluded) INCLUDE_EXCLUDED=true ;;
--quadrant)
[ $# -ge 2 ] || { echo "Error: --quadrant requires a value" >&2; exit 1; }
FILTER_QUADRANT="$2"
shift
;;
--ring)
[ $# -ge 2 ] || { echo "Error: --ring requires a value" >&2; exit 1; }
FILTER_RING="$2"
shift
;;
--limit)
[ $# -ge 2 ] || { echo "Error: --limit requires a value" >&2; exit 1; }
LIMIT="$2"
shift
;;
-h|--help|help)
usage
exit 0
;;
--*)
echo "Error: unknown option $1" >&2
exit 1
;;
*) QUERIES+=("$1") ;;
esac
shift
done
if [ ${#QUERIES[@]} -eq 0 ]; then
echo "Usage: radar-search.sh <tech1> [tech2 ...] [--json] [--refresh]" >&2
exit 1
fi
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/radar-search.XXXXXX")"
INDEX_FILE="$WORK_DIR/index.tsv"
RESULTS_FILE="$WORK_DIR/results.tsv"
SEEN_FILE="$WORK_DIR/seen.txt"
cleanup() {
rm -rf "$WORK_DIR"
if [ -n "$LOCK_DIR" ]; then rmdir "$LOCK_DIR" 2>/dev/null || true; fi
}
trap cleanup EXIT
: > "$SEEN_FILE"
: > "$RESULTS_FILE"
lc() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
notice() {
# Visible markdown blockquote — the skill re-emits these lines in its answer.
if [ "$OUTPUT_JSON" = true ]; then
printf '> ⚠️ %s\n\n' "$1" >&2
else
printf '> ⚠️ %s\n\n' "$1"
fi
}
file_mtime() {
stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0
}
synced_at() {
local ref="$CACHE_DIR/.adlc-radar-synced"
local stamped=""
if [ -f "$ref" ]; then
stamped="$(cat "$ref" 2>/dev/null || true)"
case "$stamped" in
''|*[!0-9]*) stamped="" ;;
esac
fi
if [ -n "$stamped" ]; then
date -r "$stamped" -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -d "@$stamped" -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| echo unknown
return 0
fi
ref="$CACHE_DIR/.git/FETCH_HEAD"
[ -f "$ref" ] || { echo "unknown"; return 0; }
local mtime
mtime="$(file_mtime "$ref")"
date -r "$mtime" -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| date -d "@$mtime" -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| echo unknown
}
cache_age() {
local ref="$CACHE_DIR/.adlc-radar-synced"
if [ -f "$ref" ]; then
local stamped
stamped="$(cat "$ref" 2>/dev/null || true)"
case "$stamped" in
''|*[!0-9]*) stamped=0 ;;
esac
if [ "$stamped" -gt 0 ]; then
echo $(( $(date +%s) - stamped ))
return 0
fi
fi
ref="$CACHE_DIR/.git/FETCH_HEAD"
[ -f "$ref" ] || { echo 999999999; return 0; }
local mtime
mtime="$(file_mtime "$ref")"
echo $(( $(date +%s) - mtime ))
}
git_ssh_cmd="ssh -o ConnectTimeout=$CONNECT_TIMEOUT -o BatchMode=yes"
clone_cache() {
mkdir -p "$(dirname "$CACHE_DIR")"
git -c "core.sshCommand=$git_ssh_cmd" clone --depth 1 --quiet \
--branch "$REPO_BRANCH" "$REPO_URL" "$CACHE_DIR"
}
refresh_cache() {
git -C "$CACHE_DIR" -c "core.sshCommand=$git_ssh_cmd" fetch --depth 1 --quiet \
origin "$REPO_BRANCH" \
&& git -C "$CACHE_DIR" reset --hard --quiet FETCH_HEAD
}
stamp_synced() {
printf '%s\n' "$(date +%s)" > "$CACHE_DIR/.adlc-radar-synced" 2>/dev/null || true
}
# Resolve the knowledge source. Sets MODE to okf | local | stale, or leaves MODE
# empty (and REASON populated) when no checkout is usable.
ensure_data_source() {
if [ -n "$LOCAL_REPO" ]; then
if [ -d "$LOCAL_REPO/knowledge/technologies" ]; then
MODE="local"
SHA="$(git -C "$LOCAL_REPO" rev-parse --short HEAD 2>/dev/null || echo unknown)"
SYNCED_AT="local clone"
return 0
fi
REASON="ADLC_RADAR_REPO=$LOCAL_REPO has no knowledge/technologies"
return 1
fi
# The lock is a sibling of the cache, so its parent must exist on first use.
if ! mkdir -p "$(dirname "$CACHE_DIR")" 2>/dev/null; then
REASON="cache directory unavailable ($CACHE_DIR)"
return 1
fi
local have_lock=0
if mkdir "${CACHE_DIR}.lock" 2>/dev/null; then
have_lock=1
LOCK_DIR="${CACHE_DIR}.lock"
fi
if [ ! -d "$CACHE_DIR/knowledge/technologies" ]; then
if [ "$have_lock" -eq 1 ] && clone_cache 2>/dev/null; then
stamp_synced
else
REASON="clone failed ($REPO_URL)"
return 1
fi
else
local age
age="$(cache_age)"
if [ "$FORCE_REFRESH" = true ] || [ "$age" -ge "$CACHE_TTL" ]; then
if [ "$have_lock" -eq 1 ] && refresh_cache 2>/dev/null; then
stamp_synced
else
MODE="stale"
REASON="refresh failed ($REPO_URL)"
SHA="$(git -C "$CACHE_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
SYNCED_AT="$(synced_at)"
return 0
fi
fi
fi
MODE="okf"
SHA="$(git -C "$CACHE_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
SYNCED_AT="$(synced_at)"
return 0
}
# Frontmatter (`title`, `quadrant`, `ring`, `include`) + `## Why` section of every
# technology document, as tab-separated records:
# title <TAB> quadrant <TAB> ring <TAB> include <TAB> path <TAB> why
build_index() {
local root="$1"
DOC_COUNT=0
: > "$INDEX_FILE"
local files=("$root"/knowledge/technologies/*/*.md)
[ -e "${files[0]}" ] || return 0
awk '
function trim(s) { gsub(/^[ \t]+|[ \t]+$/, "", s); return s }
function unquote(s) { if (s ~ /^".*"$/) { s = substr(s, 2, length(s) - 2) }; return s }
function flush() {
if (title == "") { return }
gsub(/[ \t]+/, " ", why)
why = trim(why)
gsub(/[\t\r\n]+/, " ", title)
gsub(/[\t\r\n]+/, " ", quadrant)
gsub(/[\t\r\n]+/, " ", ring)
gsub(/[\t\r\n]+/, " ", include)
printf "%s\t%s\t%s\t%s\t%s\t%s\n", title, quadrant, ring, include, curfile, why
}
function reset() { title = ""; quadrant = ""; ring = ""; include = ""; why = ""; in_fm = 0; fm_done = 0; in_why = 0 }
FNR == 1 { if (started) { flush() }; reset(); curfile = FILENAME; started = 1; skip = (FILENAME ~ /\/index\.md$/) }
{
if (skip == 1) { next }
line = $0
if (fm_done == 0) {
if (line == "---") { if (in_fm == 1) { fm_done = 1 } else { in_fm = 1 }; next }
if (in_fm == 1) {
i = index(line, ":")
if (i > 1) {
k = trim(substr(line, 1, i - 1))
v = unquote(trim(substr(line, i + 1)))
if (k == "title") { title = v }
else if (k == "quadrant") { quadrant = v }
else if (k == "ring") { ring = v }
else if (k == "include") { include = v }
}
next
}
}
if (in_why == 1) {
if (line ~ /^## /) { in_why = 0 }
else { why = why " " line; next }
}
if (line ~ /^## Why[ \t]*$/) { in_why = 1 }
}
END { flush() }
' "${files[@]}" > "$INDEX_FILE"
DOC_COUNT="$(wc -l < "$INDEX_FILE" | tr -d ' ')"
}
# Title matches for one canonical query. Emits records with a rank column.
match_title() {
local q="$1"
awk -F'\t' -v q="$q" -v fq="$FILTER_QUADRANT" -v fr="$FILTER_RING" -v inc="$INCLUDE_EXCLUDED" '
function lc(s) { return tolower(s) }
function emit(rank) { printf "%s\t%s\t%s\t%s\t%s\t%d\n", $1, $2, $3, $6, $5, rank }
{
if (inc == "false" && lc($4) == "no") { next }
if (fq != "" && lc($2) != lc(fq)) { next }
if (fr != "" && lc($3) != lc(fr)) { next }
t = lc($1)
if (t == q) { emit(3); next }
if (index(t, q) == 1) { emit(2); next }
if (index(t, q) > 0) { emit(1) }
}
' "$INDEX_FILE"
}
# Category queries ("we need an event streaming platform") fall back to body-term
# ranking — title hits weigh 3x — mirroring adlc-radar's tools/search.py.
match_body() {
local q="$1"
awk -F'\t' -v q="$q" -v fq="$FILTER_QUADRANT" -v fr="$FILTER_RING" -v inc="$INCLUDE_EXCLUDED" '
function lc(s) { return tolower(s) }
BEGIN { n = split(q, terms, /[^a-z0-9]+/) }
{
if (inc == "false" && lc($4) == "no") { next }
if (fq != "" && lc($2) != lc(fq)) { next }
if (fr != "" && lc($3) != lc(fr)) { next }
title = lc($1)
body = lc($1 " " $6)
score = 0
for (i = 1; i <= n; i++) {
t = terms[i]
if (length(t) < 2) { continue }
if (t ~ /^(no|not|such|the|and|or|for|with|need|want|using|use|of|to|in|on|at|by|is|are|be|we|our|a|an)$/) { continue }
ts = title
while ((p = index(ts, t)) > 0) { score += 3; ts = substr(ts, p + length(t)) }
bs = body
while ((p = index(bs, t)) > 0) { score += 1; bs = substr(bs, p + length(t)) }
}
if (score >= 2) { printf "%d\t%s\t%s\t%s\t%s\t%s\n", score, $1, $2, $3, $6, $5 }
}
' "$INDEX_FILE" | sort -rn | head -n "$LIMIT" \
| awk -F'\t' 'BEGIN { OFS = "\t" } { print $2, $3, $4, $5, $6, 0 }'
}
# Query -> canonical name (alias map kept from the radar.json era).
normalize_alias() {
local ql
ql="$(lc "$1")"
case "$ql" in
k8s|kube|kubernetes) echo "Kubernetes" ;;
postgres|postgresql|pg) echo "PostgreSQL" ;;
"gh actions"|github-actions|github_actions) echo "GitHub Actions" ;;
cra|create-react-app) echo "Create React App" ;;
next|nextjs) echo "Next.js" ;;
vue|vuejs) echo "Vue.js" ;;
node|nodejs) echo "Node.js" ;;
ts|typescript) echo "TypeScript" ;;
iac) echo "Infrastructure as Code (IaC)" ;;
mcp) echo "Model Context Protocol (MCP)" ;;
rsc) echo "React Server Components" ;;
rtk) echo "Redux Toolkit" ;;
idp) echo "Internal Developer Portals (IDPs) (e.g., Port.io. Backstage)" ;;
eso) echo "External Secrets Operator" ;;
gke) echo "GKE Workload Identity" ;;
*) echo "$1" ;;
esac
}
append_results() {
while IFS=$'\t' read -r name quadrant ring why path _rank; do
[ -n "$name" ] || continue
if [ ${#why} -gt 160 ]; then
why="$(printf '%s' "$why" | cut -c1-157 | sed 's/ [^ ]*$//')..."
fi
local key="$name|$quadrant|$ring"
if ! grep -Fxq -- "$key" "$SEEN_FILE"; then
printf '%s\n' "$key" >> "$SEEN_FILE"
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$quadrant" "$ring" "$why" "$path" >> "$RESULTS_FILE"
fi
done
}
search_okf() {
local query canonical q rows
for query in "${QUERIES[@]}"; do
[ -n "$query" ] || continue
canonical="$(normalize_alias "$query")"
q="$(lc "$canonical")"
rows="$(match_title "$q")"
if [ -z "$rows" ]; then
rows="$(match_body "$(lc "$query")")"
fi
append_results <<< "$rows"
done
}
fetch_fallback() {
case "$FALLBACK_URL" in
file://*) cat "${FALLBACK_URL#file://}" ;;
/*) cat "$FALLBACK_URL" ;;
*) curl -fsSL --proto '=https' --max-time "$CONNECT_TIMEOUT" "$FALLBACK_URL" ;;
esac
}
search_fallback_json() {
local data="$1"
local rows
rows="$(printf '%s' "$data" | jq -r --arg fq "$FILTER_QUADRANT" --arg fr "$FILTER_RING" '
def lc(s): (s | ascii_downcase);
.blips[]
| select(($fq == "") or (lc(.quadrant) == lc($fq)))
| select(($fr == "") or (lc(.ring) == lc($fr)))
| . as $b
| ($b.description // "") as $desc
| (if ($desc | test("<p>Why\\?</p>")) then
($desc | sub("<p>Why\\?</p>"; "") | split("<p>Description</p>")[0])
else $desc end) as $why_raw
| ($why_raw | gsub("<[^>]*>"; " ") | gsub("\\s+"; " ") | ltrimstr(" ") | rtrimstr(" ")) as $why
| [$b.name, $b.quadrant, $b.ring, $why] | @tsv
' 2>/dev/null)" || rows=""
local query canonical q
for query in "${QUERIES[@]}"; do
[ -n "$query" ] || continue
canonical="$(normalize_alias "$query")"
q="$(lc "$canonical")"
while IFS=$'\t' read -r name quadrant ring why; do
[ -n "$name" ] || continue
local nl
nl="$(lc "$name")"
case "$nl" in
"$q"*|*"$q"*)
local key="$name|$quadrant|$ring"
if ! grep -Fxq -- "$key" "$SEEN_FILE"; then
printf '%s\n' "$key" >> "$SEEN_FILE"
if [ ${#why} -gt 160 ]; then
why="$(printf '%s' "$why" | cut -c1-157 | sed 's/ [^ ]*$//')..."
fi
printf '%s\t%s\t%s\t%s\t%s\n' "$name" "$quadrant" "$ring" "$why" "public:radar.json" >> "$RESULTS_FILE"
fi
;;
esac
done <<< "$rows"
done
}
print_guidance() {
local names
names="$(cut -f1 "$RESULTS_FILE" | awk '!seen[$0]++')"
local tech rings placements
while IFS= read -r tech; do
[ -n "$tech" ] || continue
rings="$(awk -F'\t' -v t="$tech" '$1 == t { print $3 }' "$RESULTS_FILE" | sort -u | tr '\n' ',' | sed 's/,$//')"
placements="$(awk -F'\t' -v t="$tech" '$1 == t { print $2 ": " $3 }' "$RESULTS_FILE" | tr '\n' ',' | sed 's/,$//')"
if printf '%s' "$rings" | grep -q "Stop" && printf '%s' "$rings" | grep -qE "Keep|Start"; then
printf -- '- ⚡ **Conflicting Placements**: `%s` has multiple placements across categories (%s). Check specific quadrant context.\n' "$tech" "$placements"
elif printf '%s' "$rings" | grep -q "Stop"; then
printf -- '- ⚠️ **Stop**: `%s` — Tikal advises against usage; better alternatives exist. Seek Keep/Start alternatives in the same quadrant.\n' "$tech"
elif printf '%s' "$rings" | grep -qE "Keep|Start"; then
printf -- '- ✅ **Adopt**: `%s` — Recommended standard (%s).\n' "$tech" "$placements"
elif printf '%s' "$rings" | grep -q "Try"; then
printf -- '- 🧪 **Try**: `%s` — Seems promising on the surface; evaluate before broader adoption.\n' "$tech"
fi
done <<< "$names"
}
source_label() {
case "$MODE" in
local) printf 'adlc-radar OKF tree (local clone at %s%s)' "$LOCAL_REPO" "${SHA:+@$SHA}" ;;
stale) printf 'adlc-radar OKF tree (%s@%s, synced %s; STALE — %s)' "$REPO_BRANCH" "$SHA" "$SYNCED_AT" "$REASON" ;;
fallback) printf 'FALLBACK public radar.json (%s)' "$FALLBACK_URL" ;;
*) printf 'adlc-radar OKF tree (%s@%s, synced %s)' "$REPO_BRANCH" "$SHA" "$SYNCED_AT" ;;
esac
}
DATA_UNAVAILABLE=false
if ensure_data_source; then
build_index "${LOCAL_REPO:-$CACHE_DIR}"
search_okf
if [ "$MODE" = "stale" ]; then
notice "Radar knowledge repository unreachable ($REASON) — using the cached OKF tree from $SYNCED_AT. Rings may have moved since then."
fi
else
MODE="fallback"
notice "Radar knowledge repository unavailable ($REASON) — working with the public radar JSON at $FALLBACK_URL. It is generated from the same knowledge tree and may lag it."
if RADAR_JSON="$(fetch_fallback 2>/dev/null)" && printf '%s' "$RADAR_JSON" | jq -e '.blips' >/dev/null 2>&1; then
search_fallback_json "$RADAR_JSON"
else
DATA_UNAVAILABLE=true
printf 'radar-search: public radar JSON unavailable at %s\n' "$FALLBACK_URL" >&2
fi
fi
COUNT=0
[ -f "$RESULTS_FILE" ] && COUNT="$(wc -l < "$RESULTS_FILE" | tr -d ' ')"
if [ "$OUTPUT_JSON" = true ]; then
jq -R -s --arg mode "$MODE" --arg reason "$REASON" '
split("\n") | map(select(length > 0)) | map(split("\t"))
| map({name: .[0], quadrant: .[1], ring: .[2], why: .[3], path: .[4],
source: {mode: $mode, reason: $reason}})
' "$RESULTS_FILE"
exit 0
fi
echo "## Tikal Tech Radar Context"
echo ""
if [ "$COUNT" -gt 0 ]; then
echo "| Technology | Quadrant | Ring | Tikal's Opinion (Why?) |"
echo "|------------|----------|------|------------------------|"
while IFS=$'\t' read -r name quadrant ring why _path; do
[ -n "$name" ] || continue
printf '| %s | %s | %s | %s |\n' "$name" "$quadrant" "$ring" "$why"
done < "$RESULTS_FILE"
echo ""
echo "**Radar Guidance**"
print_guidance
echo ""
fi
if [ "$DATA_UNAVAILABLE" = true ]; then
printf '_Source: Tikal Israeli Tech Radar — radar unavailable (knowledge repository and %s both unreachable) · 0 technologies matched._\n' "$FALLBACK_URL"
elif [ "$COUNT" -eq 0 ]; then
printf '_Source: Tikal Israeli Tech Radar — %s · 0 technologies matched._\n' "$(source_label)"
elif [ "$MODE" = "fallback" ]; then
printf '_Source: Tikal Israeli Tech Radar — %s · %s blip placement(s) matched._\n' "$(source_label)" "$COUNT"
else
printf '_Source: Tikal Israeli Tech Radar — %s · %s documents scanned, %s placement(s) matched._\n' "$(source_label)" "${DOC_COUNT:-0}" "$COUNT"
fi
scripts/powershell/radar-search.ps1
# radar-search.ps1 — deterministic search over the Tikal Tech Radar OKF knowledge tree.
# PowerShell native. No Python/Node dependency.
#
# Knowledge source: gitlab.tikalk.dev/tikalk/engineering/agentic-sdlc/adlc-radar
# `knowledge/technologies/` is the system of record. A shallow clone is cached and
# refreshed after ADLC_RADAR_TTL seconds (default 3 days). When the repository is
# unreachable the helper falls back to the stale clone (with a warning), then to the
# public radar JSON (ConvertFrom-Json). JSON notices go to stderr; each JSON row
# includes source.mode and source.reason. Markdown notices stay on stdout.
#
# Usage:
# pwsh radar-search.ps1 <query1> [query2 ...] [-Json] [-Refresh]
#
# Options:
# -Json JSON array output instead of the markdown table
# -Refresh force a git refresh even inside the TTL
# -Quadrant <Q> filter: DevOps | Backend | AI/ML | Web/Mobile
# -Ring <R> filter: Try | Start | Keep | Stop
# -Limit <N> max rows per category (body-term) match (default 5)
# -IncludeExcluded include docs marked `include: no`
#
# Environment: ADLC_RADAR_URL, ADLC_RADAR_BRANCH, ADLC_RADAR_CACHE,
# ADLC_RADAR_REPO, ADLC_RADAR_TTL, ADLC_RADAR_FALLBACK_URL,
# ADLC_RADAR_CONNECT_TIMEOUT
[CmdletBinding(PositionalBinding = $false)]
param(
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$Queries,
[switch]$Json,
[switch]$Refresh,
[switch]$IncludeExcluded,
[string]$Quadrant = "",
[string]$Ring = "",
[int]$Limit = 5
)
$ErrorActionPreference = "Continue"
function Get-EnvOr([string]$Name, [string]$Default) {
$value = [Environment]::GetEnvironmentVariable($Name)
if ([string]::IsNullOrEmpty($value)) { return $Default }
return $value
}
$RepoUrl = Get-EnvOr "ADLC_RADAR_URL" "git@gitlab.tikalk.dev:tikalk/engineering/agentic-sdlc/adlc-radar.git"
$RepoBranch = Get-EnvOr "ADLC_RADAR_BRANCH" "main"
$CacheTtl = [int](Get-EnvOr "ADLC_RADAR_TTL" "259200")
$CacheDir = Get-EnvOr "ADLC_RADAR_CACHE" (Join-Path ([Environment]::GetFolderPath("UserProfile")) ".cache/adlc/radar")
$LocalRepo = Get-EnvOr "ADLC_RADAR_REPO" ""
$FallbackUrl = Get-EnvOr "ADLC_RADAR_FALLBACK_URL" "https://tikalk.com/radar.json"
$ConnectTimeout = Get-EnvOr "ADLC_RADAR_CONNECT_TIMEOUT" "10"
$SshCommand = "ssh -o ConnectTimeout=$ConnectTimeout -o BatchMode=yes"
function Get-UnixNow { return [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() }
function Format-UnixTime([long]$Epoch) {
return [DateTimeOffset]::FromUnixTimeSeconds($Epoch).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ")
}
function Write-Notice([string]$Message) {
if ($Json) {
[Console]::Error.WriteLine("> ⚠️ $Message`n")
}
else {
Write-Output "> ⚠️ $Message"
Write-Output ""
}
}
function Invoke-Git {
param([string[]]$Arguments)
$output = & git @Arguments 2>&1
return @{ Code = $LASTEXITCODE; Output = ($output -join "`n") }
}
function Get-CacheAge {
$stamp = Join-Path $CacheDir ".adlc-radar-synced"
if (Test-Path -LiteralPath $stamp) {
$raw = (Get-Content -LiteralPath $stamp -Raw).Trim()
$stamped = 0L
if ([long]::TryParse($raw, [ref]$stamped) -and $stamped -gt 0) {
return [long]((Get-UnixNow) - $stamped)
}
}
$fetchHead = Join-Path $CacheDir ".git/FETCH_HEAD"
if (Test-Path -LiteralPath $fetchHead) {
$age = (Get-UnixNow) - [long]((Get-Item -LiteralPath $fetchHead).LastWriteTimeUtc - [DateTime]::UnixEpoch).TotalSeconds
return $age
}
return 999999999L
}
function Get-SyncedAt {
$stamp = Join-Path $CacheDir ".adlc-radar-synced"
if (Test-Path -LiteralPath $stamp) {
$raw = (Get-Content -LiteralPath $stamp -Raw).Trim()
$stamped = 0L
if ([long]::TryParse($raw, [ref]$stamped) -and $stamped -gt 0) { return (Format-UnixTime $stamped) }
}
$fetchHead = Join-Path $CacheDir ".git/FETCH_HEAD"
if (Test-Path -LiteralPath $fetchHead) {
$epoch = [long]((Get-Item -LiteralPath $fetchHead).LastWriteTimeUtc - [DateTime]::UnixEpoch).TotalSeconds
return (Format-UnixTime $epoch)
}
return "unknown"
}
function Get-CacheSha {
if (Test-Path -LiteralPath $CacheDir) {
$result = Invoke-Git @("-C", $CacheDir, "rev-parse", "--short", "HEAD")
if ($result.Code -eq 0) { return $result.Output.Trim() }
}
return "unknown"
}
function Initialize-Cache {
$state = @{ Mode = "unavailable"; Reason = ""; Sha = ""; SyncedAt = "" }
if ($LocalRepo -ne "") {
if (Test-Path -LiteralPath (Join-Path $LocalRepo "knowledge/technologies")) {
$state.Mode = "local"
$shaResult = Invoke-Git @("-C", $LocalRepo, "rev-parse", "--short", "HEAD")
$state.Sha = if ($shaResult.Code -eq 0) { $shaResult.Output.Trim() } else { "unknown" }
$state.SyncedAt = "local clone"
return $state
}
$state.Reason = "ADLC_RADAR_REPO=$LocalRepo has no knowledge/technologies"
return $state
}
$knowledgePath = Join-Path $CacheDir "knowledge/technologies"
if (-not (Test-Path -LiteralPath $knowledgePath)) {
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $CacheDir) | Out-Null
$clone = Invoke-Git @("-c", "core.sshCommand=$SshCommand", "clone", "--depth", "1", "--quiet", "--branch", $RepoBranch, $RepoUrl, $CacheDir)
if ($clone.Code -ne 0) {
$state.Reason = "clone failed ($RepoUrl)"
return $state
}
Set-Content -LiteralPath (Join-Path $CacheDir ".adlc-radar-synced") -Value (Get-UnixNow) -NoNewline
}
else {
$age = Get-CacheAge
if ($Refresh -or $age -ge $CacheTtl) {
$fetch = Invoke-Git @("-C", $CacheDir, "-c", "core.sshCommand=$SshCommand", "fetch", "--depth", "1", "--quiet", "origin", $RepoBranch)
$reset = if ($fetch.Code -eq 0) { Invoke-Git @("-C", $CacheDir, "reset", "--hard", "--quiet", "FETCH_HEAD") } else { @{ Code = 1 } }
if ($reset.Code -eq 0) {
Set-Content -LiteralPath (Join-Path $CacheDir ".adlc-radar-synced") -Value (Get-UnixNow) -NoNewline
}
else {
$state.Mode = "stale"
$state.Reason = "refresh failed ($RepoUrl)"
$state.Sha = Get-CacheSha
$state.SyncedAt = Get-SyncedAt
return $state
}
}
}
$state.Mode = "okf"
$state.Sha = Get-CacheSha
$state.SyncedAt = Get-SyncedAt
return $state
}
function Get-DocRecord([System.IO.FileInfo]$File) {
$title = ""
$quadrant = ""
$ring = ""
$include = ""
$frontmatterOpen = $false
$frontmatterDone = $false
$inWhy = $false
$why = New-Object System.Collections.Generic.List[string]
foreach ($line in (Get-Content -LiteralPath $File.FullName)) {
if (-not $frontmatterDone) {
if ($line -eq "---") {
if ($frontmatterOpen) { $frontmatterDone = $true } else { $frontmatterOpen = $true }
continue
}
if ($frontmatterOpen) {
if ($line -match '^([A-Za-z_]+):\s*(.*)$') {
$key = $Matches[1]
$value = $Matches[2].Trim().Trim('"')
if ($key -eq "title") { $title = $value }
elseif ($key -eq "quadrant") { $quadrant = $value }
elseif ($key -eq "ring") { $ring = $value }
elseif ($key -eq "include") { $include = $value }
}
continue
}
}
if ($inWhy) {
if ($line -match '^## ') { $inWhy = $false }
else { $why.Add($line) | Out-Null; continue }
}
if ($line -match '^## Why\s*$') { $inWhy = $true }
}
if ([string]::IsNullOrWhiteSpace($title)) { return $null }
$whyText = (($why -join " ") -replace '\s+', ' ').Trim()
return [PSCustomObject]@{
Title = $title
Quadrant = $quadrant
Ring = $ring
Include = $include
Path = $File.FullName
Why = $whyText
}
}
function Get-KnowledgeDocs([string]$Root) {
$techDir = Join-Path $Root "knowledge/technologies"
if (-not (Test-Path -LiteralPath $techDir)) { return @() }
$files = Get-ChildItem -LiteralPath $techDir -Directory |
ForEach-Object { Get-ChildItem -LiteralPath $_.FullName -Filter "*.md" -File } |
Where-Object { $_.Name -ne "index.md" } |
Sort-Object FullName
$docs = @()
foreach ($file in $files) {
$record = Get-DocRecord $file
if ($null -ne $record) { $docs += $record }
}
return $docs
}
function Normalize-Alias([string]$Query) {
$q = $Query.Trim().ToLowerInvariant()
switch ($q) {
"k8s" { return "Kubernetes" }
"kube" { return "Kubernetes" }
"kubernetes" { return "Kubernetes" }
"postgres" { return "PostgreSQL" }
"postgresql" { return "PostgreSQL" }
"pg" { return "PostgreSQL" }
"gh actions" { return "GitHub Actions" }
"github-actions" { return "GitHub Actions" }
"cra" { return "Create React App" }
"create-react-app" { return "Create React App" }
"next" { return "Next.js" }
"nextjs" { return "Next.js" }
"vue" { return "Vue.js" }
"vuejs" { return "Vue.js" }
"node" { return "Node.js" }
"nodejs" { return "Node.js" }
"ts" { return "TypeScript" }
"typescript" { return "TypeScript" }
"iac" { return "Infrastructure as Code (IaC)" }
"mcp" { return "Model Context Protocol (MCP)" }
"rsc" { return "React Server Components" }
"rtk" { return "Redux Toolkit" }
"idp" { return "Internal Developer Portals (IDPs) (e.g., Port.io. Backstage)" }
"eso" { return "External Secrets Operator" }
"gke" { return "GKE Workload Identity" }
default { return $Query }
}
}
function Test-Filters($Doc) {
if ($Quadrant -ne "" -and $Doc.Quadrant.ToLowerInvariant() -ne $Quadrant.ToLowerInvariant()) { return $false }
if ($Ring -ne "" -and $Doc.Ring.ToLowerInvariant() -ne $Ring.ToLowerInvariant()) { return $false }
return $true
}
function Get-WhySnippet([string]$Why) {
if ($Why.Length -le 160) { return $Why }
$cut = $Why.Substring(0, 157)
$lastSpace = $cut.LastIndexOf(' ')
if ($lastSpace -gt 0) { $cut = $cut.Substring(0, $lastSpace) }
return "$cut..."
}
function Search-Docs($Docs, [string]$Query) { $results = @()
$canonical = Normalize-Alias $Query
$needle = $canonical.ToLowerInvariant()
foreach ($doc in $Docs) {
if (-not (Test-Filters $doc)) { continue }
if (-not $IncludeExcluded -and $doc.Include.ToLowerInvariant() -eq "no") { continue }
$title = $doc.Title.ToLowerInvariant()
if ($title -eq $needle -or $title.StartsWith($needle) -or $title.Contains($needle)) {
$results += $doc
}
}
if ($results.Count -gt 0) { return $results }
# Category query: rank body terms, title hits weigh 3x.
$terms = ($Query.ToLowerInvariant() -split '[^a-z0-9]+') | Where-Object { $_.Length -ge 2 -and $_ -notmatch '^(no|not|such|the|and|or|for|with|need|want|using|use|of|to|in|on|at|by|is|are|be|we|our|a|an)$' }
if ($terms.Count -eq 0) { return @() }
$scored = @()
foreach ($doc in $Docs) {
if (-not (Test-Filters $doc)) { continue }
if (-not $IncludeExcluded -and $doc.Include.ToLowerInvariant() -eq "no") { continue }
$title = $doc.Title.ToLowerInvariant()
$body = "$title $($doc.Why.ToLowerInvariant())"
$score = 0
foreach ($term in $terms) {
$score += 3 * ([regex]::Matches($title, [regex]::Escape($term))).Count
$score += ([regex]::Matches($body, [regex]::Escape($term))).Count
}
if ($score -ge 2) { $scored += [PSCustomObject]@{ Score = $score; Doc = $doc } }
}
return @($scored | Sort-Object Score -Descending | Select-Object -First $Limit | ForEach-Object { $_.Doc })
}
function Search-FallbackJson([string]$Data) {
$radar = $Data | ConvertFrom-Json -ErrorAction Stop
if (-not $radar.blips) { return @() }
$results = @()
foreach ($query in $Queries) {
$canonical = Normalize-Alias $query
$needle = $canonical.ToLowerInvariant()
foreach ($blip in $radar.blips) {
if ($Quadrant -ne "" -and $blip.quadrant.ToLowerInvariant() -ne $Quadrant.ToLowerInvariant()) { continue }
if ($Ring -ne "" -and $blip.ring.ToLowerInvariant() -ne $Ring.ToLowerInvariant()) { continue }
$name = $blip.name.ToLowerInvariant()
if (-not ($name -eq $needle -or $name.StartsWith($needle) -or $name.Contains($needle))) { continue }
$why = ""
if ($blip.description) {
$why = $blip.description
if ($why -match '(?s)<p>Why\?</p>') {
$why = $why -replace '(?s)<p>Why\?</p>', ''
$why = ($why -split '(?s)<p>Description</p>')[0]
}
$why = ($why -replace '<[^>]+>', ' ' -replace '\s+', ' ').Trim()
}
$results += [PSCustomObject]@{
Title = $blip.name; Quadrant = $blip.quadrant; Ring = $blip.ring
Include = "yes"; Path = "public:radar.json"; Why = (Get-WhySnippet $why)
}
}
}
return $results
}
if ($null -eq $Queries -or $Queries.Count -eq 0) {
Write-Output "Usage: radar-search.ps1 <tech1> [tech2 ...] [-Json] [-Refresh]"
exit 1
}
$state = Initialize-Cache
$docs = @()
$results = @()
$dataUnavailable = $false
if ($state.Mode -eq "unavailable") {
Write-Notice "Radar knowledge repository unavailable ($($state.Reason)) — working with the public radar JSON at $FallbackUrl. It is generated from the same knowledge tree and may lag it."
try {
if ($FallbackUrl -like "file://*") { $payload = Get-Content -LiteralPath $FallbackUrl.Substring(7) -Raw -ErrorAction Stop }
elseif ($FallbackUrl -match '^/') { $payload = Get-Content -LiteralPath $FallbackUrl -Raw -ErrorAction Stop }
else { $payload = (Invoke-WebRequest -Uri $FallbackUrl -TimeoutSec ([int]$ConnectTimeout) -UseBasicParsing -ErrorAction Stop).Content }
$results = @(Search-FallbackJson $payload)
}
catch {
$dataUnavailable = $true
[Console]::Error.WriteLine("radar-search: public radar JSON unavailable at $FallbackUrl")
}
}
else {
if ($state.Mode -eq "stale") {
Write-Notice "Radar knowledge repository unreachable ($($state.Reason)) — using the cached OKF tree from $($state.SyncedAt). Rings may have moved since then."
}
$root = if ($LocalRepo -ne "") { $LocalRepo } else { $CacheDir }
$docs = @(Get-KnowledgeDocs $root)
$seen = New-Object System.Collections.Generic.HashSet[string]
foreach ($query in $Queries) {
if ([string]::IsNullOrWhiteSpace($query)) { continue }
foreach ($doc in @(Search-Docs $docs $query)) {
$key = "$($doc.Title)|$($doc.Quadrant)|$($doc.Ring)"
if ($seen.Add($key)) {
$results += [PSCustomObject]@{
Title = $doc.Title; Quadrant = $doc.Quadrant; Ring = $doc.Ring
Include = $doc.Include; Path = $doc.Path; Why = (Get-WhySnippet $doc.Why)
}
}
}
}
}
switch ($state.Mode) {
"local" { $sourceLabel = "adlc-radar OKF tree (local clone at $LocalRepo@$($state.Sha))" }
"stale" { $sourceLabel = "adlc-radar OKF tree ($RepoBranch@$($state.Sha), synced $($state.SyncedAt); STALE — $($state.Reason))" }
"fallback" { $sourceLabel = "FALLBACK public radar.json ($FallbackUrl)" }
default { $sourceLabel = "adlc-radar OKF tree ($RepoBranch@$($state.Sha), synced $($state.SyncedAt))" }
}
if ($state.Mode -eq "unavailable") { $sourceLabel = "FALLBACK public radar.json ($FallbackUrl)" }
if ($Json) {
$sourceMode = if ($state.Mode -eq "unavailable") { "fallback" } else { $state.Mode }
$jsonRows = @($results | ForEach-Object {
[PSCustomObject]@{
name = $_.Title; quadrant = $_.Quadrant; ring = $_.Ring
why = $_.Why; path = $_.Path
source = [PSCustomObject]@{ mode = $sourceMode; reason = $state.Reason }
}
})
ConvertTo-Json -InputObject $jsonRows -Depth 3
exit 0
}
Write-Output "## Tikal Tech Radar Context"
Write-Output ""
if ($results.Count -gt 0) {
Write-Output "| Technology | Quadrant | Ring | Tikal's Opinion (Why?) |"
Write-Output "|------------|----------|------|------------------------|"
foreach ($r in $results) {
Write-Output "| $($r.Title) | $($r.Quadrant) | $($r.Ring) | $($r.Why) |"
}
Write-Output ""
Write-Output "**Radar Guidance**"
foreach ($group in ($results | Group-Object Title)) {
$rings = $group.Group.Ring | Sort-Object -Unique
$placements = ($group.Group | ForEach-Object { "$($_.Quadrant): $($_.Ring)" }) -join ","
$hasStop = $rings -contains "Stop"
$hasKeep = ($rings -contains "Keep") -or ($rings -contains "Start")
$hasTry = $rings -contains "Try"
if ($hasStop -and $hasKeep) {
Write-Output "- ⚡ **Conflicting Placements**: ``$($group.Name)`` has multiple placements across categories ($placements). Check specific quadrant context."
}
elseif ($hasStop) {
Write-Output "- ⚠️ **Stop**: ``$($group.Name)`` — Tikal advises against usage; better alternatives exist. Seek Keep/Start alternatives in the same quadrant."
}
elseif ($hasKeep) {
Write-Output "- ✅ **Adopt**: ``$($group.Name)`` — Recommended standard ($placements)."
}
elseif ($hasTry) {
Write-Output "- 🧪 **Try**: ``$($group.Name)`` — Seems promising on the surface; evaluate before broader adoption."
}
}
Write-Output ""
}
if ($dataUnavailable) {
Write-Output "_Source: Tikal Israeli Tech Radar — radar unavailable (knowledge repository and $FallbackUrl both unreachable) · 0 technologies matched._"
}
elseif ($results.Count -eq 0) {
Write-Output "_Source: Tikal Israeli Tech Radar — $sourceLabel · 0 technologies matched._"
}
elseif ($state.Mode -eq "unavailable") {
Write-Output "_Source: Tikal Israeli Tech Radar — $sourceLabel · $($results.Count) blip placement(s) matched._"
}
else {
Write-Output "_Source: Tikal Israeli Tech Radar — $sourceLabel · $($docs.Count) documents scanned, $($results.Count) placement(s) matched._"
}
SKILL.md
---
name: tech-radar-boot
description: Use when choosing or evaluating a technology (framework, database, library, cloud tool) — injects Tikal Israeli Tech Radar context (adoption ring, quadrant, opinion, Keep/Start alternatives for Stop items) from the adlc-radar OKF knowledge repository, and pairs the selection with ADR capture via /architect-specify; invoked from team-boot's Class Boots catalog (formerly tech-radar-context).
---
# tech-radar-boot
## Overview
One of the five **class boots** surfaced by `team-boot`'s Class Boots
catalog, this skill handles **technology selection**. It surfaces
**Tikal's opinion** on the technologies relevant to the current prompt so a
tech stack choice is informed by the Israeli Tech Radar, and it pairs the
selection with a capture suggestion: the chosen stack is an ADR-class
decision; the boot suggests `/architect-specify` and never writes drafts
itself.
The radar lookup works like `team-discover`, but its search surface is the
**adlc-radar OKF knowledge repository**
(`gitlab.tikalk.dev/tikalk/engineering/agentic-sdlc/adlc-radar`) instead of
the team CDR index: it extracts candidate technologies from the prompt,
matches them against radar **technology documents**, and injects a compact
**Tech Radar Context** table (ring, quadrant, Tikal's "Why" opinion) plus
Tikal-aligned alternatives for anything on `Stop`.
`knowledge/technologies/` is the **system of record** — one Markdown document
per technology with OKF frontmatter and `## Why` / `## Description` sections.
`radar.json` in that repository is generated output from those documents and
MUST NOT be treated as the knowledge source; the helper reads the Markdown.
The radar has four **quadrants** — `DevOps`, `Backend`, `AI/ML`,
`Web/Mobile` — and four adoption **rings**:
| Ring | Meaning | Guidance |
|------|---------|----------|
| `Try` | New stuff that on the surface seems good (good press, new solution) | Explore / evaluate; not yet endorsed for production use |
| `Start` | A good solution more companies should use; if in beta, active progress and contribution | Recommend adopting on new projects |
| `Keep` | Stable release (non-beta) with major supporter acceptance (large community, used by corporates) | Recommend by default for current & new work |
| `Stop` | Items we recommend companies stop using — better alternatives exist | Warn against; recommend a `Keep`/`Start` alternative |
Each technology document's `## Why` section carries Tikal's explicit stance and
rationale — that is the opinion to surface. A technology may appear more than
once (different quadrants) with different rings; report each relevant
placement.
## When to Use
Model-invoke this skill whenever the prompt involves **choosing or
evaluating technology**, for example:
- Selecting a framework, library, database, message broker, or cloud tool.
- Comparing options ("X vs Y", "should we use Z").
- Designing a system, service, or pipeline where stack decisions are implied.
- Reviewing an existing stack for modernization or replacement.
- A tech stack choice emerges mid-session (the ADR detection trigger).
Do **not** invoke it for pure business/product questions with no technology
selection, or when the user explicitly says to ignore the radar.
Invoke at the START of a matching task — before planning the todo list and
before implementation — so the radar context informs planning.
Never defer to session end.
Manual invocation:
```
/tech-radar-boot # inject radar context for the current prompt
/tech-radar-boot redis vs kafka
```
(`/tech-radar-context` still works as a deprecated alias for this skill.)
## Core Process
### Step 1: Extract Candidate Technologies
From the current prompt (or the description provided by an invoking skill),
extract every named or clearly implied technology: languages, frameworks,
libraries, databases, brokers, CI/CD tools, cloud services, AI/LLM tooling,
build tools, etc. Normalize obvious aliases (e.g. "postgres" → "PostgreSQL",
"k8s" → "Kubernetes", "GH Actions" → "GitHub Actions").
If the prompt implies a category without naming a product (e.g. "we need a
vector database", "pick a Python web framework"), treat the category as a query
and surface the radar's recommended options in that space.
### Step 2: Query the Radar Knowledge Tree
Execute the deterministic search helper script (relative to this skill directory):
POSIX (bash + jq):
```bash
bash scripts/bash/radar-search.sh <tech1> [tech2 ...]
```
Windows (PowerShell):
```powershell
pwsh scripts/powershell/radar-search.ps1 <tech1> [tech2 ...]
```
Or for JSON output (add `--json` for bash, `-Json` for PowerShell).
The helper:
1. keeps a **shallow git clone** of the adlc-radar repository in a cache
directory (`${XDG_CACHE_HOME:-$HOME/.cache}/adlc/radar` by default) and
refreshes it when the checkout is older than `ADLC_RADAR_TTL`
(default 3 days). `--refresh` (`-Refresh`) forces a refresh;
2. parses each `knowledge/technologies/<quadrant>/<slug>.md` document's
frontmatter (`title`, `quadrant`, `ring`, `include`) and its `## Why`
section — no HTML parsing, no `radar.json`;
3. handles alias mapping (`k8s` → `Kubernetes`, `postgres` → `PostgreSQL`,
`gh actions` → `GitHub Actions`, etc.);
4. formats the markdown table automatically.
Filters mirror the repository's own `tools/search.py`: `--quadrant <Q>`,
`--ring <R>`, `--limit <N>` (`-Limit`) for category matches, and
`--include-excluded` (`-IncludeExcluded`) to include documents marked
`include: no` (they are records excluded from the generated radar and are
skipped by default).
If a technology appears in multiple placements with conflicting rings, the
script detects and flags it with a **Conflicting Guidance** note.
Environment overrides: `ADLC_RADAR_URL`, `ADLC_RADAR_BRANCH`,
`ADLC_RADAR_CACHE`, `ADLC_RADAR_REPO` (use an existing local clone and skip git
network calls), `ADLC_RADAR_TTL`, `ADLC_RADAR_FALLBACK_URL`,
`ADLC_RADAR_CONNECT_TIMEOUT`.
### Step 3: Match Technology Documents
For each candidate from Step 1, the helper matches document titles
(case-insensitive, alias-aware, allowing minor version suffixes like
"Airflow 2" / "Airflow 3" and partial matches like "Redux" → "Redux Toolkit").
A candidate may match documents in multiple quadrants — keep them all.
For category queries (Step 1), the helper falls back to body-term ranking
(title hits weigh more than `Why` hits) and returns the strongest radar
recommendations in that space: prefer `Keep`/`Start` placements in the matching
quadrant, and note any `Stop` placements as things to avoid.
When you need the full rationale (user is weighing the technology, or the ring
is `Stop`), open the matched document by path and read its `## Why` section in
full. Follow the repository's navigation rule: `knowledge/index.md` →
quadrant index → the single technology document. Load only the documents the
current question needs; never bulk-load `knowledge/technologies/`.
### Step 4: Extract Tikal's Opinion
For every matched document, `## Why` is **Tikal's opinion / rationale** — the
primary signal. `## Description` is neutral background — use it only if
helpful.
Condense `## Why` to one or two sentences for the context table (quote it more
fully when the ring is `Stop` or when the user is directly weighing that
technology). The helper truncates the table cell; open the document when the
full text matters.
### Step 5: Recommend Alternatives for `Stop`
When a candidate matches a `Stop` document (or is a legacy technology the radar
clearly discourages), select Tikal-aligned replacements from the **same
quadrant** on `Keep` or `Start`, guided by the `## Why` text. Common examples
the dataset supports:
- Airflow 2 → Airflow 3 / Dagster
- Create React App → Vite / Next.js
- Jenkins → GitHub Actions / GitLab CI / Tekton
- requirements.txt / Poetry → uv
- Moment.js / Luxon → day.js / date-fns
- Redux / Redux Toolkit → Zustand / TanStack Query
- Ant Design / Styled Components → Tailwind CSS / shadcn/ui / Radix UI
Do not hardcode substitutions beyond what the loaded documents support —
derive alternatives from the radar's actual `Keep`/`Start` technologies in that
quadrant.
## Absent Context
If `team-boot` injected no team context this session (no Team Context & Decisions section in the first user message — unconfigured project or hook failure): say so in one line, emit the section heading with a `0 radar technologies matched (no team context injected — run /team-setup)` source line, and continue the task on the radar lookup. Never treat a missing injection as an empty record set. Recovery: run `/team-diagnose`.
### Step 6: Inject Tech Radar Context (Output Contract)
The script's stdout goes to the tool channel — invisible to the user. ALWAYS
re-emit the full Tikal Tech Radar Context section in your visible response,
before the task answer (or, when 0 matched, the heading + `_Source:` line).
Emit it as markdown blocks — heading, table rows, guidance bullets, and source
line each on their own lines; never collapse the section into a single line.
```markdown
## Tikal Tech Radar Context
| Technology | Quadrant | Ring | Tikal's Opinion (Why?) |
|------------|----------|------|------------------------|
| FastAPI | Backend | Keep | Better alternative to Flask; async, fast, big and growing community. |
| Jenkins | Backend | Stop | Plugin hell + XML config; legacy vs GitHub Actions / GitLab CI / Tekton. |
**Radar guidance**
- ✅ Keep/Start: FastAPI — safe to adopt.
- ⚠️ Stop: Jenkins → consider GitHub Actions, GitLab CI, or Tekton (see Why? above).
_Source: Tikal Israeli Tech Radar — adlc-radar OKF tree (main@abc1234, synced 2026-10-24T10:00:00Z) · 408 documents scanned, 2 placement(s) matched._
_Searched 408 documents, 2 matched._
```
- One row per matched placement (include duplicates across quadrants when relevant).
- If the helper printed a `> ⚠️` notice (stale cache or public-JSON fallback),
**re-emit that notice verbatim above the table** — the user must know which
data source answered.
- Group a short **Radar guidance** list: safe-to-adopt vs avoid-with-alternatives.
- Add a `_Source_` line naming the exact source the helper reported:
- OKF tree: `adlc-radar OKF tree (<branch>@<sha>, synced <timestamp>)`.
- Stale: the same line plus `; STALE — <reason>`.
- Fallback: `FALLBACK public radar.json (<url>)`.
- `N` = documents scanned (`blips` on the fallback path); `K` = rows shown in
the table. **K MUST equal the table rows shown.** 0 rows matched → emit the
section heading + the `_Source:` line only — no table, never a table header.
If no candidate technology matches any document, state that plainly with the
heading + `_Source:` line (e.g. `_Source: … · 0 technologies matched._`) — no
table — do not fabricate radar placements.
### Step 7: Capture the Selection (Decision Pairing)
A technology selection is an **ADR-class decision**. When no local memory index
exists in the current working directory and the directory sits inside a workspace
(detected via a `.gitmodules` marker in an ancestor), read the workspace root's
`docs/adlc/memory/` index instead (the dual-read order applies:
`docs/adlc/memory` first, legacy `.adlc/memory` fallback).
| Trigger | Action |
|---------|--------|
| Tech stack chosen ("we'll use X") | ADR → suggest `/architect-specify`, citing the radar rows that informed it |
| "X vs Y" comparison resolved | ADR → suggest `/architect-specify` with the comparison outcome |
| `Stop`-ring technology retained anyway | ADR → suggest `/architect-specify` documenting why the radar guidance was overridden |
Add/refresh rows in **Team Context & Decisions** (ID | Name | Type | Rel |
Status | Clarify) for the selection, mirrored as a task-list todo (suggest
`/architect-specify` → matching clarify skill at session end for drafts that
exist); after code-modifying tasks, add a
trailing todo to sweep Team Context & Decisions until _Unrecorded: 0 pending · Unclarified: 0 drafts_ (a draft leaves Unclarified only via its clarify skill or an explicit user handoff to a named clarify or execute skill). If
`architect-boot` was already invoked this session, extend its ledger rows
with the radar evidence; at session end, deliver the clarify prompt naming
each uncaptured tech-selection decision (ID + capture skill) and each
captured draft (ID + clarify skill); if the user defers clarify,
mark those rows handed off.
## Failure Handling
The helper resolves its data source in this order and always reports which one
answered:
1. **OKF knowledge tree** — cached shallow clone, refreshed inside the TTL.
2. **Stale OKF tree** — the repository is unreachable (no SSH key, offline,
transient failure) but a previous clone exists: the helper uses it and
prints a `> ⚠️` staleness notice. Re-emit that notice; never present stale
data as current.
3. **Public radar JSON** — no usable clone at all: the helper announces that it
is working with `https://tikalk.com/radar.json` (generated from the same
knowledge tree, possibly lagging it) and answers from it. Re-emit that
notice in your visible response.
4. **Unavailable** — both the repository and the public JSON fail: emit the
heading with `_Source: … radar unavailable … · 0 technologies matched._` and
continue the user's task without radar context.
Never substitute fabricated data, and never claim the OKF tree answered when
the notice says otherwise.
## Red Flags
- Fabricating a ring, quadrant, or "Why" opinion for a technology that is not
in the loaded dataset — report `0 matches` instead.
- Reading `radar.json` from the knowledge repository as if it were the system
of record — `knowledge/technologies/` is canonical and `radar.json` is
generated output.
- Silently hiding a stale-cache or public-JSON fallback notice.
- Hardcoding `Stop`→alternative substitutions not backed by the loaded documents.
- Reporting the neutral `## Description` text as Tikal's opinion — the opinion
lives in `## Why`.
- Ignoring duplicate placements: the same technology can sit in different
quadrants with different rings; surface each relevant placement.
- Bulk-loading `knowledge/technologies/` instead of following index → quadrant
→ technology document.
- Treating the skill load as the work — the Core Process must actually run and
produce the Tech Radar Context table.
- Reading radar findings via the script and answering without re-emitting the
context section (script stdout goes to the tool channel — invisible to the
user) — the visible response MUST carry the findings.
- Collapsing the context section into a single line — heading, table rows,
guidance, and source line each go on their own markdown lines.
- Injecting radar context but skipping the capture suggestion — a tech selection
that informed no ADR is a decision that evaporated.
- Writing a draft (or any file) yourself — the boot only suggests;
`/architect-specify` writes.
## Verification
- Candidate technologies were extracted from the prompt (or category queries
formed when no product was named).
- The helper read the **adlc-radar OKF knowledge tree** (or reported, with a
visible notice, that it used the stale clone or the public JSON fallback).
- A **Tikal Tech Radar Context** table was produced with columns Technology /
Quadrant / Ring / Tikal's Opinion (Why?), with the opinion sourced from the
document's `## Why` section.
- `Stop`-ring matches include Tikal-aligned `Keep`/`Start` alternatives from the
same quadrant, derived from the loaded documents.
- A `_Source_` line reports the resolved source (OKF tree SHA + sync time,
stale, or fallback) and the match count; a no-match run yields no table
(heading + source line only) rather than fabricated data. On total
unavailability, an error is reported and the skill continues without radar
context.
- Tech selections made this session appear in Team Context & Decisions with
`/architect-specify` as the capture skill, each carrying a
`_Suggested capture: … → run /architect-specify._` line; no files were
written by this skill.
## Configuration
- Knowledge source:
`git@gitlab.tikalk.dev:tikalk/engineering/agentic-sdlc/adlc-radar.git`
(`knowledge/technologies/`), cached as a shallow clone and refreshed after
3 days (`ADLC_RADAR_TTL`, default `259200` seconds; `--refresh` forces it).
- Cache location: `${XDG_CACHE_HOME:-$HOME/.cache}/adlc/radar`
(`ADLC_RADAR_CACHE`). `ADLC_RADAR_REPO` points the helper at an existing
local clone instead (no git network calls — useful when developing the
radar repository).
- Public fallback: `https://tikalk.com/radar.json`
(`ADLC_RADAR_FALLBACK_URL`), used only when no checkout is usable, always
with a visible notice.
- Fetch security: the SSH fetch is non-interactive
(`BatchMode=yes`, connect timeout 10s by default); the public fallback is
pinned to HTTPS (`--proto '=https'`) and fails closed. The fallback JSON is
only parsed, never executed. Radar content is treated as data for display.
- Git failure, timeout, or malformed frontmatter degrades to the next source in
the ladder; it never blocks the user's task.