Skills › Compound Engineering › ce-setup › check-health
check-health
926 lines · skills/ce-setup/scripts/check-health at 3449230
1#!/usr/bin/env bash
2# Compound Engineering environment health check.
3# Reports optional tool capabilities and repo-local config safety in one pass.
5set -o pipefail
7# =====================================================
8# Optional capability config
9# =====================================================
10# Format: name|install_cmd|url|capability
12deps=(
13 "agent-browser|CI=true npm install -g agent-browser --no-audit --no-fund --loglevel=error && agent-browser install|https://github.com/vercel-labs/agent-browser|browser testing and dogfood QA"
14 "gh|NONINTERACTIVE=1 HOMEBREW_NO_AUTO_UPDATE=1 brew install -q gh|https://cli.github.com|GitHub PR, issue, and review workflows"
15 "jq|NONINTERACTIVE=1 HOMEBREW_NO_AUTO_UPDATE=1 brew install -q jq|https://jqlang.github.io/jq/|JSON inspection in shell-based workflows"
16 "ast-grep|NONINTERACTIVE=1 HOMEBREW_NO_AUTO_UPDATE=1 brew install -q ast-grep|https://ast-grep.github.io|syntax-aware structural code search"
17 "ffmpeg|NONINTERACTIVE=1 HOMEBREW_NO_AUTO_UPDATE=1 brew install -q ffmpeg|https://ffmpeg.org/download.html|media chunking and screenshot extraction for Riffrec analysis"
18)
20# Bounds match the peer workers; adapter ranges track the ceiling release.
21# The canary advances the ceiling and adapter specs, leaving the minimum stable.
22ACPX_VERSION="0.21.0"
23ACPX_MIN_VERSION="0.18.0"
24ACPX_ADAPTER_SPECS="@agentclientprotocol/codex-acp@^1.1.5 @agentclientprotocol/claude-agent-acp@^0.81.2"
26# =====================================================
27# Args
28# =====================================================
30plugin_version=""
31warm_acpx="no"
32while [ $# -gt 0 ]; do
33 case "$1" in
34 --version) [ -n "$2" ] && plugin_version="$2" && shift 2 || shift ;;
35 --warm-acpx) warm_acpx="yes"; shift ;;
36 *) shift ;;
37 esac
38done
40# =====================================================
41# Helpers
42# =====================================================
44ok() { echo " 🟢 $1"; }
45warn() { echo " 🟡 $1"; }
46skip() { echo " ➖ $1"; }
47detail() { echo " $1"; }
48section() { echo ""; echo " $1"; }
50# Read one top-level scalar from the repo's flat local-config convention. This is
51# intentionally not a general YAML parser: comments and absent/empty keys are
52# ignored, surrounding quotes are removed, and values are never evaluated.
53read_flat_config_value() {
54 local config_file="$1"
55 local config_key="$2"
56 local value
57 value=$(awk -v key="$config_key" '
58 $0 ~ "^" key "[[:space:]]*:" {
59 sub("^" key "[[:space:]]*:[[:space:]]*", "")
60 sub(/[[:space:]]+#.*$/, "")
61 sub(/^[[:space:]]+/, "")
62 sub(/[[:space:]]+$/, "")
63 print
64 exit
65 }
66 ' "$config_file")
68 case "$value" in
69 \"*\") value=${value#\"}; value=${value%\"} ;;
70 \'*\') value=${value#\'}; value=${value%\'} ;;
71 esac
72 printf '%s' "$value"
73}
75# Resolve the symlink-followed absolute path of $1 without relying on `realpath -m`
76# (absent on stock macOS/BSD). Resolves the existing prefix with `cd`/`pwd -P`,
77# then re-appends any not-yet-created tail — enough to catch a symlink in the
78# existing portion escaping the repository, which is the only way a relative
79# value can escape.
80resolve_real_best_effort() {
81 local target="$1" tail="" existing base
82 existing="$target"
83 while [ ! -e "$existing" ] && [ "$existing" != "/" ] && [ "$existing" != "." ] && [ "$existing" != "$(dirname "$existing")" ]; do
84 tail="/$(basename "$existing")$tail"
85 existing="$(dirname "$existing")"
86 done
87 if [ -d "$existing" ]; then
88 base="$(cd "$existing" 2>/dev/null && pwd -P)" || return 1
89 elif [ -e "$existing" ]; then
90 base="$(cd "$(dirname "$existing")" 2>/dev/null && pwd -P)/$(basename "$existing")" || return 1
91 else
92 return 1
93 fi
94 printf '%s' "${base}${tail}"
95}
97# Resolve docs_root from tracked config.yaml only. A docs_root in
98# config.local.yaml is ignored. Unset -> default root `docs`. A configured
99# value is validated and rejected (never silently defaulted) when it is
100# absolute, escapes the repository via path or symlink, resolves to the repo
101# root or inside .git/, or names an existing non-directory. Sets
102# docs_root_status (default|configured|invalid), docs_root_value,
103# docs_root_source, docs_root_error, and docs_root_local_ignored.
104resolve_docs_root() {
105 local repo_root="$1"
106 local local_cfg="$repo_root/.compound-engineering/config.local.yaml"
107 local tracked_cfg="$repo_root/.compound-engineering/config.yaml"
108 local value="" source="" repo_real real local_docs probe
110 docs_root_error=""
111 docs_root_local_ignored=""
112 if [ -f "$local_cfg" ]; then
113 local_docs=$(read_flat_config_value "$local_cfg" "docs_root")
114 [ -n "$local_docs" ] && docs_root_local_ignored="$local_docs"
115 fi
116 if [ -f "$tracked_cfg" ]; then
117 value=$(read_flat_config_value "$tracked_cfg" "docs_root")
118 [ -n "$value" ] && source="config.yaml"
119 fi
121 if [ -z "$value" ]; then
122 docs_root_status="default"; docs_root_value="docs"; docs_root_source="default"; return
123 fi
125 docs_root_value="$value"; docs_root_source="$source"; docs_root_status="invalid"
126 case "$value" in
127 /*) docs_root_error="absolute paths are not allowed; use a repo-relative path"; return ;;
128 esac
129 # Reject any `..` path component up front. resolve_real_best_effort only
130 # collapses `..` within the existing prefix (via cd/pwd -P); a `..` that
131 # traverses a not-yet-created segment would otherwise survive literally and
132 # escape the repo, hit the repo root, or reach .git/ while still string-prefix
133 # matching. A legitimate repo-relative artifact root never needs `..`.
134 case "/$value/" in
135 */../*) docs_root_error="path traversal ('..') is not allowed; use a plain repo-relative path"; return ;;
136 esac
137 repo_real="$(cd "$repo_root" 2>/dev/null && pwd -P)" || { docs_root_error="could not resolve the repository root"; return; }
138 real="$(resolve_real_best_effort "$repo_root/$value")" || { docs_root_error="could not resolve the path"; return; }
139 case "$real/" in
140 "$repo_real/"*) ;;
141 *) docs_root_error="resolves outside the repository ($real)"; return ;;
142 esac
143 [ "$real" = "$repo_real" ] && { docs_root_error="resolves to the repository root itself"; return; }
144 case "$real/" in
145 "$repo_real/.git/"*) docs_root_error="resolves inside .git/"; return ;;
146 esac
147 if [ -e "$real" ] && [ ! -d "$real" ]; then
148 docs_root_error="names an existing non-directory"; return
149 fi
150 # Reject when an intermediate component is an existing non-directory (e.g.
151 # `afile/nested` where `afile` is a file): the leaf-only check above passes
152 # because `$real` itself does not exist, but `mkdir -p` would fail. Walk up to
153 # the deepest existing ancestor and require it be a directory.
154 probe="$real"
155 while [ ! -e "$probe" ] && [ "$probe" != "/" ] && [ "$probe" != "$(dirname "$probe")" ]; do
156 probe="$(dirname "$probe")"
157 done
158 if [ -e "$probe" ] && [ ! -d "$probe" ]; then
159 docs_root_error="an intermediate path component is not a directory"; return
160 fi
161 docs_root_status="configured"
162}
164# Report whether a retired top-level key is still present without interpreting
165# its value. Detection is intentionally separate from routing so legacy scalar
166# settings can receive a migration diagnostic without remaining supported.
167has_flat_config_key() {
168 local config_file="$1"
169 local config_key="$2"
170 awk -v key="$config_key" '
171 $0 ~ "^" key "[[:space:]]*:" { found = 1 }
172 END { exit(found ? 0 : 1) }
173 ' "$config_file"
174}
176# First non-empty ordinary scalar across local then tracked. Empty/commented
177# local continues to tracked. Sets ordinary_value and ordinary_source.
178resolve_ordinary_scalar() {
179 local local_file="$1"
180 local tracked_file="$2"
181 local config_key="$3"
182 local value=""
183 ordinary_value=""
184 ordinary_source=""
185 if [ -f "$local_file" ]; then
186 value=$(read_flat_config_value "$local_file" "$config_key")
187 if [ -n "$value" ]; then
188 ordinary_value="$value"
189 ordinary_source="config.local.yaml"
190 return
191 fi
192 fi
193 if [ -f "$tracked_file" ]; then
194 value=$(read_flat_config_value "$tracked_file" "$config_key")
195 if [ -n "$value" ]; then
196 ordinary_value="$value"
197 ordinary_source="config.yaml"
198 fi
199 fi
200}
202# Present list/map key (including `[]`) wins that layer. Sets
203# structured_file, or empty when neither layer sets the key.
204resolve_structured_layer() {
205 local local_file="$1"
206 local tracked_file="$2"
207 local config_key="$3"
208 structured_file=""
209 if [ -f "$local_file" ] && has_flat_config_key "$local_file" "$config_key"; then
210 structured_file="$local_file"
211 return
212 fi
213 if [ -f "$tracked_file" ] && has_flat_config_key "$tracked_file" "$config_key"; then
214 structured_file="$tracked_file"
215 fi
216}
218# Read the narrow YAML sequence used by work_engine_preferences. The local
219# config remains human-authored YAML, but setup health needs only this one
220# deterministic shape rather than a general parser.
221read_work_engine_preferences() {
222 local config_file="$1"
223 awk '
224 function trim(value) {
225 sub(/^[[:space:]]+/, "", value)
226 sub(/[[:space:]]+$/, "", value)
227 return value
228 }
229 function scalar(value) {
230 sub(/[[:space:]]+#.*$/, "", value)
231 value = trim(value)
232 if (value ~ /^".*"$/ || value ~ /^\047.*\047$/) {
233 value = substr(value, 2, length(value) - 2)
234 }
235 return value
236 }
237 function emit_item() {
238 if (!have_item) return
239 if (harness == "" && model != "") {
240 print "ERROR\tmodel \047" model "\047 has no harness in work_engine_preferences"
241 } else if (harness == "") {
242 print "ERROR\tempty item in work_engine_preferences"
243 } else {
244 print "ITEM\t" harness "\t" (model == "" ? "default" : model)
245 }
246 have_item = 0
247 harness = ""
248 model = ""
249 }
250 /^[^[:space:]#-][^:]*[[:space:]]*:/ {
251 if (active) {
252 emit_item()
253 active = 0
254 }
255 }
256 /^work_engine_preferences[[:space:]]*:/ {
257 active = 1
258 next
259 }
260 active {
261 line = $0
262 if (line ~ /^[[:space:]]*($|#)/) next
263 if (line ~ /^[[:space:]]*-[[:space:]]*harness[[:space:]]*:/) {
264 emit_item()
265 sub(/^[[:space:]]*-[[:space:]]*harness[[:space:]]*:[[:space:]]*/, "", line)
266 harness = scalar(line)
267 have_item = 1
268 next
269 }
270 if (line ~ /^[[:space:]]*-[[:space:]]*model[[:space:]]*:/) {
271 emit_item()
272 sub(/^[[:space:]]*-[[:space:]]*model[[:space:]]*:[[:space:]]*/, "", line)
273 model = scalar(line)
274 have_item = 1
275 next
276 }
277 if (line ~ /^[[:space:]]+model[[:space:]]*:/) {
278 sub(/^[[:space:]]+model[[:space:]]*:[[:space:]]*/, "", line)
279 model = scalar(line)
280 if (!have_item) have_item = 1
281 next
282 }
283 if (line ~ /^[[:space:]]+harness[[:space:]]*:/) {
284 sub(/^[[:space:]]+harness[[:space:]]*:[[:space:]]*/, "", line)
285 harness = scalar(line)
286 if (!have_item) have_item = 1
287 next
288 }
289 print "ERROR\tunsupported work_engine_preferences entry: " trim(line)
290 }
291 END {
292 if (active) emit_item()
293 }
294 ' "$config_file"
295}
297# Emits KEY<TAB>harness<TAB>effort per map entry, or ERROR when the value is not
298# a map. An entry is exactly `harness: token`, optionally quoted, because an
299# effort must be a plain token; this is the data contract, not a YAML parser.
300# Only Antigravity's levels are checked here: its three thinking levels are
301# fixed, while other harnesses' levels belong to the ce-work adapter.
302read_work_engine_effort_keys() {
303 local config_file="$1"
304 awk '
305 function emit_entry(entry) {
306 sub(/[[:space:]]+#.*$/, "", entry)
307 gsub(/^[[:space:]]+|[[:space:]]+$/, "", entry)
308 # A key or value is a bare token or one wrapped in a matching quote pair.
309 token = "[A-Za-z0-9][A-Za-z0-9_-]*"
310 quoted = "(" token "|\"" token "\"|\047" token "\047)"
311 if (entry !~ ("^" quoted "[[:space:]]*:[[:space:]]*" quoted "$")) not_a_map()
312 gsub(/["\047]/, "", entry)
313 key = entry
314 sub(/[[:space:]]*:.*$/, "", key)
315 sub(/^[^:]*:[[:space:]]*/, "", entry)
316 print "KEY\t" key "\t" entry
317 entries++
318 }
319 function not_a_map() {
320 print "ERROR"
321 reported = 1
322 exit
323 }
324 # A block that ends with no entries is a null value, not the explicit empty map {}.
325 function close_block() {
326 if (active && !entries) not_a_map()
327 active = 0
328 }
329 /^[^[:space:]#-][^:]*[[:space:]]*:/ { close_block() }
330 /^work_engine_effort[[:space:]]*:/ {
331 active = 1
332 entries = 0
333 line = $0
334 sub(/^work_engine_effort[[:space:]]*:/, "", line)
335 sub(/[[:space:]]+#.*$/, "", line)
336 gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
337 if (line == "") next
338 if (line !~ /^\{.*\}$/) not_a_map()
339 line = substr(line, 2, length(line) - 2)
340 count = split(line, pairs, ",")
341 for (i = 1; i <= count; i++) {
342 if (pairs[i] ~ /^[[:space:]]*$/) {
343 if (i < count) not_a_map()
344 continue
345 }
346 emit_entry(pairs[i])
347 }
348 active = 0
349 next
350 }
351 active {
352 line = $0
353 if (line ~ /^[[:space:]]*($|#)/) next
354 if (line !~ /^[[:space:]]/) not_a_map()
355 emit_entry(line)
356 }
357 END { if (!reported) close_block() }
358 ' "$config_file"
359}
361# Same prerequisite the peer workers check before launching acpx. Sets
362# node_version and, when unmet, node_problem.
363check_node_prerequisite() {
364 local major minor
365 node_version=""; node_problem=""
366 if ! command -v node >/dev/null 2>&1; then
367 node_problem="node not found; cross-model peers need Node 22.13 or newer"; return 1
368 fi
369 node_version="$(node -p 'process.versions.node' 2>/dev/null | tr -d '\r')"
370 node_version="${node_version#v}"
371 major="${node_version%%.*}"; minor="${node_version#*.}"; minor="${minor%%.*}"
372 case "$major$minor" in
373 ''|*[!0-9]*) node_problem="cannot read the Node version; cross-model peers need Node 22.13 or newer"; return 1 ;;
374 esac
375 if [ "$major" -lt 22 ] || { [ "$major" -eq 22 ] && [ "$minor" -lt 13 ]; }; then
376 node_problem="Node $node_version is too old; cross-model peers need Node 22.13 or newer"; return 1
377 fi
378 if ! command -v npx >/dev/null 2>&1; then
379 node_problem="npx not found; cross-model peers run acpx through npx"; return 1
380 fi
381}
383# Cache packages without starting their CLIs. npm still runs install lifecycle
384# scripts; node --version only prevents launching the agent afterward.
385if [ "$warm_acpx" = "yes" ]; then
386 echo ""
387 if ! check_node_prerequisite; then
388 warn "Cannot warm the npm cache: $node_problem"
389 echo ""
390 exit 1
391 fi
392 warm_failed=0
393 # Run from an empty directory, as the peer workers do: npx resolves packages
394 # from its working directory's node_modules and .npmrc first.
395 warm_dir="$(mktemp -d "${TMPDIR:-/tmp}/ce-acpx-warm-XXXXXX")" || { warn "Cannot warm the npm cache: no temporary directory"; echo ""; exit 1; }
396 for spec in "acpx@>=$ACPX_MIN_VERSION <=$ACPX_VERSION" $ACPX_ADAPTER_SPECS; do
397 if (cd "$warm_dir" && npx --yes --package="$spec" -- node --version >/dev/null); then
398 ok "cached $spec"
399 else
400 warn "could not fetch $spec"
401 warm_failed=1
402 fi
403 done
404 rmdir "$warm_dir" 2>/dev/null
405 echo ""
406 exit "$warm_failed"
407fi
409has_brew=$(command -v brew >/dev/null 2>&1 && echo "yes" || echo "no")
410in_repo=$(git rev-parse --is-inside-work-tree >/dev/null 2>&1 && echo "yes" || echo "no")
412# Retired Bun-era Codex tool map (#1099). The block is present only when a
413# standalone BEGIN line is followed by a standalone END line.
414has_codex_tool_map() {
415 awk '
416 { sub(/\r$/, "") }
417 $0 == "<!-- BEGIN COMPOUND CODEX TOOL MAP -->" { seen = 1 }
418 seen && $0 == "<!-- END COMPOUND CODEX TOOL MAP -->" { found = 1; exit }
419 END { exit found ? 0 : 1 }
420 ' "$1"
421}
423codex_agents_file="${CODEX_HOME:-$HOME/.codex}/AGENTS.md"
424legacy_codex_map="no"
425if [ -f "$codex_agents_file" ] && has_codex_tool_map "$codex_agents_file"; then
426 legacy_codex_map="yes"
427fi
429# =====================================================
430# Check optional capabilities
431# =====================================================
433capability_ok=0
434capability_total=0
435capability_missing=0
436results=()
438for entry in "${deps[@]}"; do
439 IFS='|' read -r name install_cmd url capability < <(printf '%s\n' "$entry")
440 capability_total=$((capability_total + 1))
441 if command -v "$name" >/dev/null 2>&1; then
442 capability_ok=$((capability_ok + 1))
443 results+=("$name|ok|$install_cmd|$url|$capability")
444 else
445 capability_missing=$((capability_missing + 1))
446 results+=("$name|missing|$install_cmd|$url|$capability")
447 fi
448done
450capability_total=$((capability_total + 1))
451if check_node_prerequisite; then
452 capability_ok=$((capability_ok + 1))
453 node_row="ok"
454else
455 capability_missing=$((capability_missing + 1))
456 node_row="missing"
457fi
459# =====================================================
460# Project checks (repo only)
461# =====================================================
463legacy_cfg="skip"
464repo_cfg_gitignore="skip"
465scratch_gitignore="skip"
466local_cfg="skip"
467example_cfg="skip"
468retired_keys=()
469work_engine_cfg="skip"
470work_engine_detail=""
471work_engine_preferences_note=""
472work_engine_preferences_note_level="detail"
473work_engine_migration_note=""
474docs_root_status="skip"
475docs_root_value=""
476docs_root_source=""
477docs_root_error=""
478docs_root_local_ignored=""
479tracked_cfg="skip"
480project_issues=0
481antigravity_configured="no"
483if [ "$in_repo" = "yes" ]; then
484 repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
485 local_config_path="$repo_root/.compound-engineering/config.local.yaml"
486 tracked_config_path="$repo_root/.compound-engineering/config.yaml"
487 resolve_docs_root "$repo_root"
488 [ "$docs_root_status" = "invalid" ] && project_issues=$((project_issues + 1))
489 legacy_cfg="missing"
490 [ -f "$repo_root/compound-engineering.local.md" ] && legacy_cfg="present"
492 local_cfg="missing"
493 if [ -e "$local_config_path" ]; then
494 local_cfg="present"
495 if git check-ignore -q "$local_config_path" 2>/dev/null; then
496 repo_cfg_gitignore="ok"
497 else
498 repo_cfg_gitignore="missing"
499 fi
500 fi
502 # Trailing slash is load-bearing: without it an existing directory-only rule
503 # is missed before the directory exists, and a configured repo reads as bare.
504 scratch_gitignore="missing"
505 if git -C "$repo_root" check-ignore -q .context/compound-engineering/ 2>/dev/null; then
506 scratch_gitignore="ok"
507 fi
509 tracked_cfg="missing"
510 [ -f "$tracked_config_path" ] && tracked_cfg="present"
512 resolve_ordinary_scalar "$local_config_path" "$tracked_config_path" "cross_model_peer"
513 [ "$ordinary_value" = "antigravity" ] && antigravity_configured="yes"
515 resolve_ordinary_scalar "$local_config_path" "$tracked_config_path" "work_engine_mode"
516 work_engine_mode="$ordinary_value"
517 case "$work_engine_mode" in
518 ""|off|prefer|require) ;;
519 *)
520 if [ "$ordinary_source" = "config.local.yaml" ]; then
521 if [ -f "$tracked_config_path" ]; then
522 work_engine_mode=$(read_flat_config_value "$tracked_config_path" "work_engine_mode")
523 else
524 work_engine_mode=""
525 fi
526 fi
527 ;;
528 esac
530 resolve_structured_layer "$local_config_path" "$tracked_config_path" "work_engine_preferences"
531 work_engine_preferences_count=0
532 work_engine_preferences_detail=""
533 work_engine_preferences_error=""
534 retired_work_engine_keys=""
535 for retired_key in work_engine_target work_engine_model; do
536 if { [ -f "$local_config_path" ] && has_flat_config_key "$local_config_path" "$retired_key"; } || \
537 { [ -f "$tracked_config_path" ] && has_flat_config_key "$tracked_config_path" "$retired_key"; }; then
538 if [ -n "$retired_work_engine_keys" ]; then
539 retired_work_engine_keys="${retired_work_engine_keys}, ${retired_key}"
540 else
541 retired_work_engine_keys="$retired_key"
542 fi
543 fi
544 done
545 if [ -n "$retired_work_engine_keys" ]; then
546 work_engine_migration_note="retired config key(s) ${retired_work_engine_keys} detected; migrate routing to work_engine_preferences entries with harness and optional model fields, then remove the retired keys"
547 project_issues=$((project_issues + 1))
548 fi
549 if [ -n "$structured_file" ]; then
550 while IFS=$'\t' read -r preference_kind preference_harness preference_model; do
551 case "$preference_kind" in
552 ITEM)
553 work_engine_preferences_count=$((work_engine_preferences_count + 1))
554 case "$preference_harness" in
555 codex|claude|grok|cursor|opencode) ;;
556 antigravity) antigravity_configured="yes" ;;
557 *)
558 [ -z "$work_engine_preferences_error" ] && work_engine_preferences_error="invalid harness '$preference_harness' in work_engine_preferences"
559 ;;
560 esac
561 if [ "$preference_model" != "default" ]; then
562 case "$preference_model" in
563 [A-Za-z0-9]*)
564 case "$preference_model" in
565 *[!A-Za-z0-9._:/-]*)
566 [ -z "$work_engine_preferences_error" ] && work_engine_preferences_error="invalid model '$preference_model' in work_engine_preferences"
567 ;;
568 esac
569 ;;
570 *)
571 [ -z "$work_engine_preferences_error" ] && work_engine_preferences_error="invalid model '$preference_model' in work_engine_preferences"
572 ;;
573 esac
574 fi
575 preference_display="${preference_harness}@${preference_model}"
576 if [ -n "$work_engine_preferences_detail" ]; then
577 work_engine_preferences_detail="${work_engine_preferences_detail}, ${preference_display}"
578 else
579 work_engine_preferences_detail="$preference_display"
580 fi
581 ;;
582 ERROR)
583 [ -z "$work_engine_preferences_error" ] && work_engine_preferences_error="$preference_harness"
584 ;;
585 esac
586 done < <(read_work_engine_preferences "$structured_file")
587 fi
589 work_engine_effort_notes=()
590 resolve_structured_layer "$local_config_path" "$tracked_config_path" "work_engine_effort"
591 if [ -n "$structured_file" ]; then
592 while IFS=$'\t' read -r effort_kind effort_harness effort_value; do
593 case "$effort_kind:$effort_harness" in
594 ERROR:*) effort_problem="is not a map of harness to effort; ce-work ignores the value and keeps each harness default" ;;
595 KEY:cursor) effort_problem="names 'cursor', which takes no effort; ce-work treats Cursor entries as unavailable while it is set" ;;
596 KEY:codex|KEY:claude|KEY:grok|KEY:opencode) continue ;;
597 KEY:antigravity)
598 case "$effort_value" in low|medium|high) continue ;; esac
599 effort_problem="sets antigravity to '${effort_value}'; Antigravity takes low, medium, or high, so ce-work treats Antigravity entries as unavailable while it is set" ;;
600 KEY:*) effort_problem="names unknown harness '${effort_harness}'; ce-work ignores that entry" ;;
601 *) continue ;;
602 esac
603 work_engine_effort_notes+=("work_engine_effort in $(basename "$structured_file") ${effort_problem}")
604 done < <(read_work_engine_effort_keys "$structured_file")
605 fi
607 case "$work_engine_mode" in
608 ""|off)
609 work_engine_cfg="native"
610 if [ -n "$work_engine_mode" ]; then
611 work_engine_detail="standing preference is off"
612 else
613 work_engine_detail="setting is commented or missing"
614 fi
615 if [ -n "$work_engine_preferences_error" ]; then
616 work_engine_preferences_note="invalid dormant work_engine_preferences: ${work_engine_preferences_error}"
617 work_engine_preferences_note_level="warn"
618 project_issues=$((project_issues + 1))
619 elif [ "$work_engine_preferences_count" -gt 0 ]; then
620 work_engine_preferences_note="ordered preferences ignored while standing mode is off"
621 fi
622 ;;
623 prefer|require)
624 if [ -n "$work_engine_preferences_error" ]; then
625 work_engine_cfg="unavailable"
626 work_engine_detail="$work_engine_preferences_error"
627 project_issues=$((project_issues + 1))
628 elif [ "$work_engine_preferences_count" -eq 0 ] && [ -n "$retired_work_engine_keys" ]; then
629 work_engine_cfg="unavailable"
630 work_engine_detail="$work_engine_mode cannot use retired scalar routing; migrate ${retired_work_engine_keys} to work_engine_preferences"
631 elif [ "$work_engine_preferences_count" -eq 0 ]; then
632 work_engine_cfg="unavailable"
633 work_engine_detail="$work_engine_mode requires work_engine_preferences"
634 project_issues=$((project_issues + 1))
635 else
636 work_engine_cfg="$work_engine_mode"
637 work_engine_detail="$work_engine_preferences_detail"
638 fi
639 ;;
640 *)
641 work_engine_cfg="invalid"
642 work_engine_detail="invalid mode '$work_engine_mode' ignored; native is the default"
643 project_issues=$((project_issues + 1))
644 ;;
645 esac
647 script_dir="$(cd "$(dirname "$0")" && pwd)"
648 template="$script_dir/../references/config-template.yaml"
649 example="$repo_root/.compound-engineering/config.example.yaml"
650 if [ ! -f "$example" ]; then
651 example_cfg="missing"
652 elif [ -f "$template" ] && ! diff -q "$template" "$example" >/dev/null 2>&1; then
653 example_cfg="outdated"
654 else
655 example_cfg="ok"
656 fi
658 # Retired keys set as ACTIVE (non-commented) in either layer. A
659 # commented `# plan_use_fable:` line does not match `^[[:space:]]*<key>:`.
660 for cfg_file in "$local_config_path" "$tracked_config_path"; do
661 [ -f "$cfg_file" ] || continue
662 for key in plan_use_fable brainstorm_use_fable fable_nudge; do
663 if grep -Eq "^[[:space:]]*${key}[[:space:]]*:" "$cfg_file" 2>/dev/null; then
664 already=""
665 for seen in "${retired_keys[@]}"; do
666 [ "$seen" = "$key" ] && already=1
667 done
668 [ -z "$already" ] && retired_keys+=("$key")
669 fi
670 done
671 done
672fi
674# Same runtime and helper the peer workers resolve before launching the
675# antigravity route. Reported only when the user configured it or installed it.
676antigravity_row="skip"
677antigravity_problem=""
678antigravity_runtime="$(command -v agy_acp_server.par 2>/dev/null)"
679if [ "$antigravity_configured" = "yes" ] || [ -n "$antigravity_runtime" ]; then
680 capability_total=$((capability_total + 1))
681 antigravity_helper="${ANTIGRAVITY_HARNESS_PATH:-$(dirname "${antigravity_runtime:-.}")/localharness_external}"
682 if [ -z "$antigravity_runtime" ]; then
683 antigravity_problem="agy_acp_server.par not found on PATH; install Antigravity's ACP runtime"
684 elif [ ! -x "$antigravity_helper" ]; then
685 antigravity_problem="localharness_external not found at ${antigravity_helper}; install it from the same release as the runtime"
686 fi
687 if [ -z "$antigravity_problem" ]; then
688 capability_ok=$((capability_ok + 1))
689 antigravity_row="ok"
690 else
691 capability_missing=$((capability_missing + 1))
692 antigravity_row="missing"
693 fi
694fi
696# =====================================================
697# Output
698# =====================================================
700echo ""
701if [ -n "$plugin_version" ]; then
702 ok "Plugin version v${plugin_version}"
703fi
705section "Optional capabilities ${capability_ok}/${capability_total}"
707for result in "${results[@]}"; do
708 IFS='|' read -r name status install_cmd url capability < <(printf '%s\n' "$result")
709 if [ "$status" = "ok" ]; then
710 ok "$name -- $capability"
711 else
712 warn "$name -- unavailable: $capability"
713 if [[ "$install_cmd" == *"brew install"* ]] && [ "$has_brew" != "yes" ]; then
714 detail "$url"
715 else
716 detail "$install_cmd"
717 detail "$url"
718 fi
719 fi
720done
722if [ "$node_row" = "ok" ]; then
723 ok "node -- cross-model peers (Node $node_version, npx)"
724else
725 warn "node -- unavailable: cross-model peers"
726 detail "$node_problem"
727 detail "https://nodejs.org/en/download"
728fi
730if [ "$antigravity_row" = "ok" ]; then
731 ok "antigravity -- Gemini cross-model peer (runtime and helper found)"
732elif [ "$antigravity_row" = "missing" ]; then
733 warn "antigravity -- unavailable: Gemini cross-model peer"
734 detail "$antigravity_problem"
735 detail "https://github.com/agentclientprotocol/registry/blob/main/antigravity-acp/agent.json"
736fi
738if [ "$legacy_codex_map" = "yes" ]; then
739 section "Codex instructions"
740 warn "Legacy Compound Codex tool map in ${codex_agents_file}"
741 detail "Retired block from a pre-native Bun install; one line tells Codex to collapse subagent dispatch."
742 detail "Remove it -- see this skill's references/legacy-codex-tool-map.md"
743fi
745if [ "$in_repo" = "yes" ]; then
746 section "Project config"
748 if [ "$legacy_cfg" = "present" ]; then
749 warn "Obsolete compound-engineering.local.md exists"
750 project_issues=$((project_issues + 1))
751 else
752 ok "No obsolete compound-engineering.local.md"
753 fi
755 if [ "$tracked_cfg" = "present" ]; then
756 ok ".compound-engineering/config.yaml exists"
757 else
758 skip "No repo config yet (.compound-engineering/config.yaml)"
759 fi
761 if [ "$local_cfg" = "present" ]; then
762 ok ".compound-engineering/config.local.yaml exists"
763 if [ "$repo_cfg_gitignore" = "ok" ]; then
764 ok "Local config is gitignored"
765 else
766 warn "Local config is not safely gitignored"
767 project_issues=$((project_issues + 1))
768 fi
769 else
770 skip "No local override yet (.compound-engineering/config.local.yaml)"
771 fi
773 if [ "$scratch_gitignore" = "ok" ]; then
774 ok "CE scratch space is gitignored"
775 else
776 skip "CE scratch space is not gitignored"
777 detail "Skills that keep local scratch write it under .context/compound-engineering/"
778 fi
780 if [ "$docs_root_status" = "default" ]; then
781 ok "Artifact root: docs/ (default — docs_root not set)"
782 elif [ "$docs_root_status" = "configured" ]; then
783 ok "Artifact root: ${docs_root_value}/ (from ${docs_root_source})"
784 elif [ "$docs_root_status" = "invalid" ]; then
785 warn "Invalid docs_root '${docs_root_value}' in ${docs_root_source}: ${docs_root_error}"
786 detail "CE artifacts will not be written until docs_root is corrected or removed."
787 fi
788 if [ -n "$docs_root_local_ignored" ]; then
789 skip "Local docs_root '${docs_root_local_ignored}' is ignored; set docs_root only in config.yaml"
790 fi
792 if [ "$work_engine_cfg" = "native" ]; then
793 ok "CE Work implementation engine: native (${work_engine_detail})"
794 if [ -n "$work_engine_preferences_note" ]; then
795 if [ "$work_engine_preferences_note_level" = "warn" ]; then
796 warn "$work_engine_preferences_note"
797 else
798 detail "$work_engine_preferences_note"
799 fi
800 fi
801 elif [ "$work_engine_cfg" = "prefer" ] || [ "$work_engine_cfg" = "require" ]; then
802 ok "CE Work implementation engine: ${work_engine_cfg} -> ${work_engine_detail}"
803 elif [ "$work_engine_cfg" = "unavailable" ]; then
804 warn "CE Work implementation engine unavailable: ${work_engine_detail}"
805 elif [ "$work_engine_cfg" = "invalid" ]; then
806 warn "CE Work implementation engine: ${work_engine_detail}"
807 fi
808 [ -n "$work_engine_migration_note" ] && warn "$work_engine_migration_note"
809 for effort_note in ${work_engine_effort_notes[@]+"${work_engine_effort_notes[@]}"}; do
810 warn "$effort_note"
811 project_issues=$((project_issues + 1))
812 done
814 if [ "$example_cfg" = "ok" ]; then
815 ok "Example config is current"
816 elif [ "$example_cfg" = "missing" ]; then
817 warn "Example config missing (.compound-engineering/config.example.yaml)"
818 project_issues=$((project_issues + 1))
819 elif [ "$example_cfg" = "outdated" ]; then
820 warn "Example config outdated (new settings available)"
821 project_issues=$((project_issues + 1))
822 fi
824 for key in "${retired_keys[@]}"; do
825 case "$key" in
826 plan_use_fable) warn "Retired config key '$key' — removed; use 'plan_model' instead" ;;
827 brainstorm_use_fable) warn "Retired config key '$key' — removed; use 'brainstorm_model' instead" ;;
828 fable_nudge) warn "Retired config key '$key' — removed; model elevation is always available" ;;
829 esac
830 project_issues=$((project_issues + 1))
831 done
832 # --- Compound Packs (packs: config key) -------------------------------------
833 section "Compound Packs"
834 packs_python="$(for c in python3 python py; do command -v "$c" >/dev/null 2>&1 && "$c" -c '' >/dev/null 2>&1 && { echo "$c"; break; }; done)"
835 packs_resolver="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/packs-resolve.py"
836 if [ -z "$packs_python" ]; then
837 skip "No Python interpreter found; pack health not checked"
838 elif [ ! -f "$packs_resolver" ]; then
839 skip "packs-resolve.py not found beside check-health; pack health not checked"
840 else
841 packs_json="$("$packs_python" "$packs_resolver" 2>/dev/null)"
842 if [ -z "$packs_json" ]; then
843 warn "packs-resolve.py failed to run"
844 project_issues=$((project_issues + 1))
845 else
846 packs_report="$(printf '%s' "$packs_json" | "$packs_python" -c '
847import json, os, re, subprocess, sys
848data = json.load(sys.stdin)
849roots, errors, warns = data.get("roots", []), data.get("errors", []), data.get("warnings", [])
850if not roots and not errors and not warns:
851 print("SKIP\tNo packs configured")
852for r in roots:
853 line = "OK\tpack " + r["id"]
854 url, ref = r.get("url"), r.get("ref")
855 if url and ref:
856 line += " (" + ref + ")"
857 # Only a full commit id is immutable. A branch tip advances and a tag can
858 # be force-moved upstream, so for either compare the cached HEAD with the
859 # remote target of that name -- the peeled commit for an annotated tag,
860 # which is why the ls-remote asks for `<tag>^{}` too.
861 if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", ref):
862 try:
863 genv = dict(os.environ)
864 genv["GIT_TERMINAL_PROMPT"] = "0"
865 genv.setdefault("GIT_ASKPASS", "true")
866 _ssh = genv.get("GIT_SSH_COMMAND") or "ssh"
867 if "BatchMode" not in _ssh:
868 genv["GIT_SSH_COMMAND"] = _ssh + " -o BatchMode=yes"
869 names = ["refs/tags/" + ref + "^{}", "refs/tags/" + ref, "refs/heads/" + ref]
870 remote = subprocess.run(["git", "ls-remote", "--end-of-options", url, *names],
871 capture_output=True, text=True, timeout=10, env=genv)
872 targets = {}
873 for row in remote.stdout.splitlines() if remote.returncode == 0 else []:
874 sha, _, name = row.partition("\t")
875 if name:
876 targets[name] = sha
877 rsha = next((targets[n] for n in names if n in targets), "")
878 local = subprocess.run(["git", "-C", r["dir"], "rev-parse", "HEAD"], capture_output=True, text=True, timeout=10)
879 lsha = local.stdout.strip() if local.returncode == 0 else ""
880 if rsha and lsha and rsha != lsha:
881 line += " -- cached resolution is behind upstream"
882 except Exception:
883 pass # offline or slow remote: silently skip the drift note
884 # Subdirectories are storage, so rule-shaped files there are a note on the
885 # OK line, never a warning: the author may keep drafts and evidence with frontmatter.
886 nested = r.get("nested_rule_shaped", 0)
887 if nested:
888 line += " -- " + str(nested) + " rule-shaped file(s) in subfolders kept as storage"
889 print(line)
890for w in warns:
891 print("WARN\t" + w)
892for e in errors:
893 print("ERROR\t" + e)
894')"
895 while IFS=$'\t' read -r kind msg; do
896 [ -z "$kind" ] && continue
897 case "$kind" in
898 OK) ok "$msg" ;;
899 SKIP) skip "$msg" ;;
900 WARN) warn "$msg" ;;
901 ERROR) warn "Pack config error: $msg"; project_issues=$((project_issues + 1)) ;;
902 esac
903 done < <(printf '%s\n' "$packs_report")
904 fi
905 fi
906else
907 section "Project config"
908 skip "Not inside a git repository"
909fi
911echo ""
912if [ "$project_issues" -eq 0 ]; then
913 echo " ✅ Project config healthy. Optional capabilities available: ${capability_ok}/${capability_total}"
914else
915 echo " ⚠️ ${project_issues} project issue(s) found. Optional capabilities available: ${capability_ok}/${capability_total}"
916fi
918if [ "$legacy_codex_map" = "yes" ]; then
919 echo " Environment: the legacy Codex tool map above still needs attention."
920fi
922if [ "$capability_missing" -gt 0 ]; then
923 echo " Missing optional tools do not block setup; install them only for the workflows you use."
924fi
926echo ""