great-cto 2.90.0 → 2.92.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "2.90.0",
5
+ "version": "2.92.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -62,7 +62,7 @@
62
62
  },
63
63
  {
64
64
  "type": "command",
65
- "command": "export PATH=\"/opt/homebrew/bin:$HOME/.local/bin:/usr/local/bin:$PATH\"; PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); if [ -z \"$PLUGIN_DIR\" ]; then echo 'GREAT_CTO: plugin dir not found \u2014 run /update'; else mkdir -p ~/.claude/commands ~/.claude/agents ~/.great_cto .great_cto; for STALE in triage gates dora investigate threat-model sbom security-incident update status capture revisit board-report; do STALE_F=~/.claude/commands/${STALE}.md; [ -f \"$STALE_F\" ] && grep -q 'great_cto-managed' \"$STALE_F\" 2>/dev/null && rm -f \"$STALE_F\" 2>/dev/null; done; for CMD in start audit inbox board digest review recall ownership oncall rfc release doctor burn cost sec poc promote crystallize migrate resume save learn agent-review agent-retire help voice-compliance aedt-bias-audit api-contract-review discover gen-evals prd prompt-evolve skillify spec ccr exception trace coding-audit gov-metrics upl-check procurement-review close-review msp-review tax-review; do if cp \"${PLUGIN_DIR}/commands/${CMD}.md\" ~/.claude/commands/${CMD}.md 2>/dev/null; then grep -q 'great_cto-managed' ~/.claude/commands/${CMD}.md 2>/dev/null || echo '<!-- great_cto-managed -->' >> ~/.claude/commands/${CMD}.md; fi; done; for STALE_AGENT in tech-lead; do STALE_AF=~/.claude/agents/great_cto-${STALE_AGENT}.md; [ -f \"$STALE_AF\" ] && grep -q 'great_cto-managed' \"$STALE_AF\" 2>/dev/null && rm -f \"$STALE_AF\" 2>/dev/null; done; for AGENT in product-owner architect senior-dev code-reviewer qa-engineer e2e-test-engineer security-officer devops l3-support project-auditor ai-prompt-architect ai-eval-engineer ai-security-reviewer web-store-reviewer pci-reviewer oracle-reviewer firmware-reviewer regulated-reviewer performance-engineer db-migration-reviewer pm mobile-store-reviewer library-reviewer infra-reviewer cli-reviewer game-reviewer data-platform-reviewer devtools-reviewer enterprise-saas-reviewer mlops-reviewer streaming-reviewer marketplace-reviewer cms-reviewer continuous-learner edtech-reviewer gov-reviewer insurance-reviewer voice-ai-reviewer hr-ai-reviewer api-platform-reviewer gdpr-reviewer us-privacy-reviewer dpdpa-reviewer adtech-privacy-reviewer cmmc-reviewer us-ai-reviewer legal-reviewer rcm-reviewer procurement-reviewer accounting-reviewer msp-reviewer tax-reviewer coordinator; do if cp \"${PLUGIN_DIR}/agents/${AGENT}.md\" ~/.claude/agents/great_cto-${AGENT}.md 2>/dev/null; then grep -q 'great_cto-managed' ~/.claude/agents/great_cto-${AGENT}.md 2>/dev/null || echo '<!-- great_cto-managed -->' >> ~/.claude/agents/great_cto-${AGENT}.md; fi; done; cp \"${PLUGIN_DIR}/skills/great_cto/ARCHETYPES.md\" .great_cto/ARCHETYPES.md 2>/dev/null || true; cp \"${PLUGIN_DIR}/skills/great_cto/SKILL.md\" .great_cto/SKILL.md 2>/dev/null || true; { printf \"export PATH=/opt/homebrew/bin:$HOME/.local/bin:/usr/local/bin:$PATH\\nexport ARCHETYPES_MD=.great_cto/ARCHETYPES.md\\n\"; } > .great_cto/env.sh; fi; if [ ! -d ~/.great_cto/catalog ]; then git clone --depth=1 https://github.com/davila7/claude-code-templates.git ~/.great_cto/catalog 2>/dev/null & fi; if [ ! -d ~/.great_cto/anthropic-skills ]; then git clone --depth=1 https://github.com/anthropics/skills.git ~/.great_cto/anthropic-skills 2>/dev/null & elif [ $(($(date +%s) - $(stat -f %m ~/.great_cto/anthropic-skills/.git 2>/dev/null || stat -c %Y ~/.great_cto/anthropic-skills/.git 2>/dev/null || echo 0))) -gt 604800 ]; then (cd ~/.great_cto/anthropic-skills && git pull --quiet 2>/dev/null) & fi; if [ ! -d ~/.great_cto/personal-skills ]; then git clone --depth=1 https://github.com/avelikiy/ai-agent-skills.git ~/.great_cto/personal-skills 2>/dev/null & elif [ $(($(date +%s) - $(stat -f %m ~/.great_cto/personal-skills/.git 2>/dev/null || stat -c %Y ~/.great_cto/personal-skills/.git 2>/dev/null || echo 0))) -gt 86400 ]; then (cd ~/.great_cto/personal-skills && git pull --quiet 2>/dev/null) & fi; if [ ! -f ~/.great_cto/skills-registry.json ] || [ $(($(date +%s) - $(stat -f %m ~/.great_cto/skills-registry.json 2>/dev/null || stat -c %Y ~/.great_cto/skills-registry.json 2>/dev/null || echo 0))) -gt 86400 ]; then [ -x \"${PLUGIN_DIR}/scripts/skill-discover.sh\" ] && bash \"${PLUGIN_DIR}/scripts/skill-discover.sh\" >/dev/null 2>&1 & fi; [ -x \"${PLUGIN_DIR}/scripts/hooks/welcome.sh\" ] && bash \"${PLUGIN_DIR}/scripts/hooks/welcome.sh\" 2>/dev/null || true; bash \"${PLUGIN_DIR}/scripts/apply-model-override.sh\" 2>/dev/null || true; bd prime 2>/dev/null || true; echo '=== PREFERENCES ==='; cat ~/.great_cto/preferences.md 2>/dev/null || true; echo '=== PROJECT ==='; cat .great_cto/PROJECT.md 2>/dev/null || echo 'GREAT_CTO: No PROJECT.md \u2014 run /start'; PHASE=$(grep '^phase:' .great_cto/PROJECT.md 2>/dev/null | awk '{print $2}' || echo implementation); echo '=== PHASE ==='; echo \"$PHASE\"; { [ \"$PHASE\" != review ] && [ \"$PHASE\" != release ]; } && { echo '=== BRAIN ==='; head -30 .great_cto/brain.md 2>/dev/null; } || true; [ \"$PHASE\" = implementation ] && { echo '=== CODEBASE ==='; head -20 .great_cto/CODEBASE.md 2>/dev/null; } || true; echo '=== LOCAL ==='; cat .great_cto/local.md 2>/dev/null || true; [ \"$PHASE\" != planning ] && { echo '=== HANDOFF ==='; cat .great_cto/HANDOFF.md 2>/dev/null || echo 'No handoff state \u2014 fresh session'; } || true; [ \"$PHASE\" = review ] && { echo '=== LATEST QA ==='; ls docs/qa-reports/QA-*.md 2>/dev/null | sort -V | tail -1 | xargs head -20 2>/dev/null; echo '=== LATEST CSO ==='; ls docs/security/CSO-*.md 2>/dev/null | sort -V | tail -1 | xargs head -20 2>/dev/null; } || true; [ \"$PHASE\" = release ] && { echo '=== PERF BASELINE ==='; tail -10 .great_cto/perf-baseline.log 2>/dev/null; } || true; echo '=== STATUS ==='; OPEN_GATES=$(bd list --label gate --status open 2>/dev/null | wc -l | tr -d ' '); OPEN_TASKS=$(bd list --status open 2>/dev/null | wc -l | tr -d ' '); BRANCH=$(git branch --show-current 2>/dev/null || echo '?'); LAST_AGENT=$(ls -t .great_cto/verdicts/*.log 2>/dev/null | head -1 | xargs tail -1 2>/dev/null | awk '{print $2}' || echo 'none'); printf 'branch=%s | gates=%s | tasks=%s | last_agent=%s\\n' \"$BRANCH\" \"${OPEN_GATES:-0}\" \"${OPEN_TASKS:-0}\" \"${LAST_AGENT:-none}\"; [ \"${OPEN_GATES:-0}\" -gt 0 ] && echo '\u2192 run /inbox to see pending decisions' || true; P0_OPEN=$(bd list --status open 2>/dev/null | grep -c 'P0'); P0_OPEN=${P0_OPEN:-0}; if [ \"$P0_OPEN\" -gt 0 ]; then echo ''; printf '\\033[41;97m \ud83d\udea8 P0 OPEN: %s \u2014 run /inbox NOW \\033[0m\\n' \"$P0_OPEN\"; bd list --status open 2>/dev/null | grep 'P0' | head -3 | sed 's/^/ /'; fi; NOW_EPOCH=$(date +%s); LAST_AUDIT_FILE=$(find docs/audit -maxdepth 1 -name 'AUDIT-*.md' 2>/dev/null | sort | tail -1); if [ -n \"$LAST_AUDIT_FILE\" ]; then AUD_M=$(stat -f %m \"$LAST_AUDIT_FILE\" 2>/dev/null || stat -c %Y \"$LAST_AUDIT_FILE\" 2>/dev/null || echo $NOW_EPOCH); AUD_AGE=$(( (NOW_EPOCH - AUD_M) / 86400 )); [ \"$AUD_AGE\" -gt 30 ] && echo \"\u26a0 audit $AUD_AGE days old \u2014 run /audit\"; else echo '\u26a0 no audit artefact \u2014 run /audit or /doctor'; fi; if [ -f .great_cto/digest-latest.md ]; then DIG_M=$(stat -f %m .great_cto/digest-latest.md 2>/dev/null || stat -c %Y .great_cto/digest-latest.md 2>/dev/null || echo $NOW_EPOCH); DIG_AGE=$(( (NOW_EPOCH - DIG_M) / 86400 )); [ \"$DIG_AGE\" -gt 8 ] && echo \"\u26a0 digest $DIG_AGE days old \u2014 run /digest 7\"; fi; [ \"$PHASE\" != implementation ] && cat .great_cto/digest-latest.md 2>/dev/null | head -5 || true; [ -f \"${PLUGIN_DIR}/scripts/hooks/auto-attach-reviewers.mjs\" ] && node \"${PLUGIN_DIR}/scripts/hooks/auto-attach-reviewers.mjs\" 2>/dev/null; [ -n \"$PLUGIN_DIR\" ] && [ -f .great_cto/PROJECT.md ] && [ ! -f .great_cto/CODEBASE.md ] && node \"${PLUGIN_DIR}/scripts/first-run-codebase.mjs\" >/dev/null 2>&1 & node \"${PLUGIN_DIR}/scripts/hooks/quota-check.mjs\" 2>/dev/null || true; echo '=== PATTERNS ==='; GP_DIR=\"$HOME/.great_cto/global-patterns\"; ARCH=$(grep '^primary:' .great_cto/PROJECT.md 2>/dev/null | awk '{print $2}' | head -1); if [ -d \"$GP_DIR\" ] && ls \"$GP_DIR\"/GP-*.md >/dev/null 2>&1; then ACTIVE_COUNT=$(grep -rl 'status: active' \"$GP_DIR\" 2>/dev/null | wc -l | tr -d ' '); echo \"Active patterns: ${ACTIVE_COUNT:-0}\"; if [ -n \"$ARCH\" ] && [ \"${ACTIVE_COUNT:-0}\" -gt 0 ]; then grep -rl 'status: active' \"$GP_DIR\" 2>/dev/null | xargs grep -lE \"applies_to:.*$ARCH\" 2>/dev/null | sort -V | tail -5 | while read f; do SLUG=$(basename \"$f\" .md); SYMPT=$(grep -m1 '^symptom:' \"$f\" 2>/dev/null | sed 's/symptom: //'); DETECT=$(grep -A 2 '^detection_order:' \"$f\" 2>/dev/null | grep '^ - ' | head -1 | sed 's/^ - //'); HITS=$(grep -m1 '^hits:' \"$f\" 2>/dev/null | awk '{print $2}'); printf ' %s (hits=%s): %s\\n \\u2192 Check first: %s\\n' \"$SLUG\" \"${HITS:-0}\" \"$SYMPT\" \"$DETECT\"; done || echo \" no patterns match archetype=$ARCH yet \u2014 /crystallize builds the library\"; fi; else echo 'no global patterns yet \u2014 run /crystallize after first incident'; fi; [ -x \"${PLUGIN_DIR}/scripts/check-update.sh\" ] && bash \"${PLUGIN_DIR}/scripts/check-update.sh\" \"${PLUGIN_DIR}/.claude-plugin/plugin.json\" 2>/dev/null || true ; # great_cto-cache-cleanup v2.5.8: keep only 3 most recent cached versions to prevent disk bloat. ls -d \"$HOME\"/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | awk -v k=3 '{a[NR]=$0} END {for (i=1; i<=NR-k; i++) print a[i]}' | xargs -I{} rm -rf {} 2>/dev/null || true",
65
+ "command": "export PATH=\"/opt/homebrew/bin:$HOME/.local/bin:/usr/local/bin:$PATH\"; PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); if [ -z \"$PLUGIN_DIR\" ]; then echo 'GREAT_CTO: plugin dir not found \u2014 run /update'; else mkdir -p ~/.claude/commands ~/.claude/agents ~/.great_cto .great_cto; for STALE in triage gates dora investigate threat-model sbom security-incident update status capture revisit board-report; do STALE_F=~/.claude/commands/${STALE}.md; [ -f \"$STALE_F\" ] && grep -q 'great_cto-managed' \"$STALE_F\" 2>/dev/null && rm -f \"$STALE_F\" 2>/dev/null; done; for CMD in start audit inbox board digest review recall ownership oncall rfc release doctor burn cost sec poc promote crystallize migrate resume save learn agent-review agent-retire help voice-compliance aedt-bias-audit api-contract-review discover gen-evals prd prompt-evolve skillify spec ccr exception trace coding-audit gov-metrics upl-check procurement-review close-review msp-review tax-review; do if cp \"${PLUGIN_DIR}/commands/${CMD}.md\" ~/.claude/commands/${CMD}.md 2>/dev/null; then grep -q 'great_cto-managed' ~/.claude/commands/${CMD}.md 2>/dev/null || echo '<!-- great_cto-managed -->' >> ~/.claude/commands/${CMD}.md; fi; done; for STALE_AGENT in tech-lead; do STALE_AF=~/.claude/agents/great_cto-${STALE_AGENT}.md; [ -f \"$STALE_AF\" ] && grep -q 'great_cto-managed' \"$STALE_AF\" 2>/dev/null && rm -f \"$STALE_AF\" 2>/dev/null; done; for AGENT in product-owner architect senior-dev code-reviewer qa-engineer e2e-test-engineer security-officer devops l3-support project-auditor ai-prompt-architect ai-eval-engineer ai-security-reviewer web-store-reviewer pci-reviewer oracle-reviewer firmware-reviewer regulated-reviewer healthcare-reviewer performance-engineer db-migration-reviewer pm mobile-store-reviewer library-reviewer infra-reviewer cli-reviewer mcp-server-reviewer game-reviewer data-platform-reviewer devtools-reviewer enterprise-saas-reviewer mlops-reviewer streaming-reviewer marketplace-reviewer cms-reviewer continuous-learner edtech-reviewer gov-reviewer insurance-reviewer voice-ai-reviewer hr-ai-reviewer api-platform-reviewer gdpr-reviewer us-privacy-reviewer dpdpa-reviewer adtech-privacy-reviewer cmmc-reviewer us-ai-reviewer legal-reviewer rcm-reviewer procurement-reviewer accounting-reviewer msp-reviewer tax-reviewer coordinator; do if cp \"${PLUGIN_DIR}/agents/${AGENT}.md\" ~/.claude/agents/great_cto-${AGENT}.md 2>/dev/null; then grep -q 'great_cto-managed' ~/.claude/agents/great_cto-${AGENT}.md 2>/dev/null || echo '<!-- great_cto-managed -->' >> ~/.claude/agents/great_cto-${AGENT}.md; fi; done; cp \"${PLUGIN_DIR}/skills/great_cto/ARCHETYPES.md\" .great_cto/ARCHETYPES.md 2>/dev/null || true; cp \"${PLUGIN_DIR}/skills/great_cto/SKILL.md\" .great_cto/SKILL.md 2>/dev/null || true; { printf \"export PATH=/opt/homebrew/bin:$HOME/.local/bin:/usr/local/bin:$PATH\\nexport ARCHETYPES_MD=.great_cto/ARCHETYPES.md\\n\"; } > .great_cto/env.sh; fi; if [ ! -d ~/.great_cto/catalog ]; then git clone --depth=1 https://github.com/davila7/claude-code-templates.git ~/.great_cto/catalog 2>/dev/null & fi; if [ ! -d ~/.great_cto/anthropic-skills ]; then git clone --depth=1 https://github.com/anthropics/skills.git ~/.great_cto/anthropic-skills 2>/dev/null & elif [ $(($(date +%s) - $(stat -f %m ~/.great_cto/anthropic-skills/.git 2>/dev/null || stat -c %Y ~/.great_cto/anthropic-skills/.git 2>/dev/null || echo 0))) -gt 604800 ]; then (cd ~/.great_cto/anthropic-skills && git pull --quiet 2>/dev/null) & fi; if [ ! -d ~/.great_cto/personal-skills ]; then git clone --depth=1 https://github.com/avelikiy/ai-agent-skills.git ~/.great_cto/personal-skills 2>/dev/null & elif [ $(($(date +%s) - $(stat -f %m ~/.great_cto/personal-skills/.git 2>/dev/null || stat -c %Y ~/.great_cto/personal-skills/.git 2>/dev/null || echo 0))) -gt 86400 ]; then (cd ~/.great_cto/personal-skills && git pull --quiet 2>/dev/null) & fi; if [ ! -f ~/.great_cto/skills-registry.json ] || [ $(($(date +%s) - $(stat -f %m ~/.great_cto/skills-registry.json 2>/dev/null || stat -c %Y ~/.great_cto/skills-registry.json 2>/dev/null || echo 0))) -gt 86400 ]; then [ -x \"${PLUGIN_DIR}/scripts/skill-discover.sh\" ] && bash \"${PLUGIN_DIR}/scripts/skill-discover.sh\" >/dev/null 2>&1 & fi; [ -x \"${PLUGIN_DIR}/scripts/hooks/welcome.sh\" ] && bash \"${PLUGIN_DIR}/scripts/hooks/welcome.sh\" 2>/dev/null || true; bash \"${PLUGIN_DIR}/scripts/apply-model-override.sh\" 2>/dev/null || true; bd prime 2>/dev/null || true; echo '=== PREFERENCES ==='; cat ~/.great_cto/preferences.md 2>/dev/null || true; echo '=== PROJECT ==='; cat .great_cto/PROJECT.md 2>/dev/null || echo 'GREAT_CTO: No PROJECT.md \u2014 run /start'; PHASE=$(grep '^phase:' .great_cto/PROJECT.md 2>/dev/null | awk '{print $2}' || echo implementation); echo '=== PHASE ==='; echo \"$PHASE\"; { [ \"$PHASE\" != review ] && [ \"$PHASE\" != release ]; } && { echo '=== BRAIN ==='; head -30 .great_cto/brain.md 2>/dev/null; } || true; [ \"$PHASE\" = implementation ] && { echo '=== CODEBASE ==='; head -20 .great_cto/CODEBASE.md 2>/dev/null; } || true; echo '=== LOCAL ==='; cat .great_cto/local.md 2>/dev/null || true; [ \"$PHASE\" != planning ] && { echo '=== HANDOFF ==='; cat .great_cto/HANDOFF.md 2>/dev/null || echo 'No handoff state \u2014 fresh session'; } || true; [ \"$PHASE\" = review ] && { echo '=== LATEST QA ==='; ls docs/qa-reports/QA-*.md 2>/dev/null | sort -V | tail -1 | xargs head -20 2>/dev/null; echo '=== LATEST CSO ==='; ls docs/security/CSO-*.md 2>/dev/null | sort -V | tail -1 | xargs head -20 2>/dev/null; } || true; [ \"$PHASE\" = release ] && { echo '=== PERF BASELINE ==='; tail -10 .great_cto/perf-baseline.log 2>/dev/null; } || true; echo '=== STATUS ==='; OPEN_GATES=$(bd list --label gate --status open 2>/dev/null | wc -l | tr -d ' '); OPEN_TASKS=$(bd list --status open 2>/dev/null | wc -l | tr -d ' '); BRANCH=$(git branch --show-current 2>/dev/null || echo '?'); LAST_AGENT=$(ls -t .great_cto/verdicts/*.log 2>/dev/null | head -1 | xargs tail -1 2>/dev/null | awk '{print $2}' || echo 'none'); printf 'branch=%s | gates=%s | tasks=%s | last_agent=%s\\n' \"$BRANCH\" \"${OPEN_GATES:-0}\" \"${OPEN_TASKS:-0}\" \"${LAST_AGENT:-none}\"; [ \"${OPEN_GATES:-0}\" -gt 0 ] && echo '\u2192 run /inbox to see pending decisions' || true; P0_OPEN=$(bd list --status open 2>/dev/null | grep -c 'P0'); P0_OPEN=${P0_OPEN:-0}; if [ \"$P0_OPEN\" -gt 0 ]; then echo ''; printf '\\033[41;97m \ud83d\udea8 P0 OPEN: %s \u2014 run /inbox NOW \\033[0m\\n' \"$P0_OPEN\"; bd list --status open 2>/dev/null | grep 'P0' | head -3 | sed 's/^/ /'; fi; NOW_EPOCH=$(date +%s); LAST_AUDIT_FILE=$(find docs/audit -maxdepth 1 -name 'AUDIT-*.md' 2>/dev/null | sort | tail -1); if [ -n \"$LAST_AUDIT_FILE\" ]; then AUD_M=$(stat -f %m \"$LAST_AUDIT_FILE\" 2>/dev/null || stat -c %Y \"$LAST_AUDIT_FILE\" 2>/dev/null || echo $NOW_EPOCH); AUD_AGE=$(( (NOW_EPOCH - AUD_M) / 86400 )); [ \"$AUD_AGE\" -gt 30 ] && echo \"\u26a0 audit $AUD_AGE days old \u2014 run /audit\"; else echo '\u26a0 no audit artefact \u2014 run /audit or /doctor'; fi; if [ -f .great_cto/digest-latest.md ]; then DIG_M=$(stat -f %m .great_cto/digest-latest.md 2>/dev/null || stat -c %Y .great_cto/digest-latest.md 2>/dev/null || echo $NOW_EPOCH); DIG_AGE=$(( (NOW_EPOCH - DIG_M) / 86400 )); [ \"$DIG_AGE\" -gt 8 ] && echo \"\u26a0 digest $DIG_AGE days old \u2014 run /digest 7\"; fi; [ \"$PHASE\" != implementation ] && cat .great_cto/digest-latest.md 2>/dev/null | head -5 || true; [ -f \"${PLUGIN_DIR}/scripts/hooks/auto-attach-reviewers.mjs\" ] && node \"${PLUGIN_DIR}/scripts/hooks/auto-attach-reviewers.mjs\" 2>/dev/null; [ -n \"$PLUGIN_DIR\" ] && [ -f .great_cto/PROJECT.md ] && [ ! -f .great_cto/CODEBASE.md ] && node \"${PLUGIN_DIR}/scripts/first-run-codebase.mjs\" >/dev/null 2>&1 & node \"${PLUGIN_DIR}/scripts/hooks/quota-check.mjs\" 2>/dev/null || true; echo '=== PATTERNS ==='; GP_DIR=\"$HOME/.great_cto/global-patterns\"; ARCH=$(grep '^primary:' .great_cto/PROJECT.md 2>/dev/null | awk '{print $2}' | head -1); if [ -d \"$GP_DIR\" ] && ls \"$GP_DIR\"/GP-*.md >/dev/null 2>&1; then ACTIVE_COUNT=$(grep -rl 'status: active' \"$GP_DIR\" 2>/dev/null | wc -l | tr -d ' '); echo \"Active patterns: ${ACTIVE_COUNT:-0}\"; if [ -n \"$ARCH\" ] && [ \"${ACTIVE_COUNT:-0}\" -gt 0 ]; then grep -rl 'status: active' \"$GP_DIR\" 2>/dev/null | xargs grep -lE \"applies_to:.*$ARCH\" 2>/dev/null | sort -V | tail -5 | while read f; do SLUG=$(basename \"$f\" .md); SYMPT=$(grep -m1 '^symptom:' \"$f\" 2>/dev/null | sed 's/symptom: //'); DETECT=$(grep -A 2 '^detection_order:' \"$f\" 2>/dev/null | grep '^ - ' | head -1 | sed 's/^ - //'); HITS=$(grep -m1 '^hits:' \"$f\" 2>/dev/null | awk '{print $2}'); printf ' %s (hits=%s): %s\\n \\u2192 Check first: %s\\n' \"$SLUG\" \"${HITS:-0}\" \"$SYMPT\" \"$DETECT\"; done || echo \" no patterns match archetype=$ARCH yet \u2014 /crystallize builds the library\"; fi; else echo 'no global patterns yet \u2014 run /crystallize after first incident'; fi; [ -x \"${PLUGIN_DIR}/scripts/check-update.sh\" ] && bash \"${PLUGIN_DIR}/scripts/check-update.sh\" \"${PLUGIN_DIR}/.claude-plugin/plugin.json\" 2>/dev/null || true ; # great_cto-cache-cleanup v2.5.8: keep only 3 most recent cached versions to prevent disk bloat. ls -d \"$HOME\"/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | awk -v k=3 '{a[NR]=$0} END {for (i=1; i<=NR-k; i++) print a[i]}' | xargs -I{} rm -rf {} 2>/dev/null || true",
66
66
  "timeout": 30,
67
67
  "statusMessage": "Loading Great CTO context..."
68
68
  },
@@ -132,7 +132,14 @@ function tasksMdStatus(rawStatus, id, title) {
132
132
  else if (s === 'in_progress' || s === 'in-progress' || s === 'wip' || s === 'doing') status = 'in_progress';
133
133
  else if (s === 'blocked') status = 'blocked';
134
134
  else status = isGate ? 'gate' : 'backlog';
135
- return { status, raw_status: status === 'done' ? 'closed' : 'open', isGate };
135
+ // raw_status is what the inbox filters on, because mapStatus() rewrites any
136
+ // gate-labelled task to 'gate' and filtering on the mapped value would leave
137
+ // closed gates in the inbox forever. That fix covered `done`; it did not cover
138
+ // `blocked`, so a gate marked blocked in tasks.md reported raw_status 'open'
139
+ // and never left the inbox — for exactly the bd-less projects this parser
140
+ // exists to serve.
141
+ const raw = status === 'done' ? 'closed' : status === 'blocked' ? 'blocked' : 'open';
142
+ return { status, raw_status: raw, isGate };
136
143
  }
137
144
 
138
145
  // Build the full task record both parsers emit — one shape so getTasks can
@@ -201,7 +208,19 @@ const TITLE_MAX = 160;
201
208
  function capTitle(title, description) {
202
209
  const t = String(title || '').trim();
203
210
  if (t.length <= TITLE_MAX) return { title: t, description };
204
- const cut = t.lastIndexOf(' ', TITLE_MAX);
211
+ let cut = t.lastIndexOf(' ', TITLE_MAX);
212
+ // Cutting inside a markdown link leaves `see [the design…` in the title and
213
+ // the URL stranded in the description — the board renders a broken link and
214
+ // the reader cannot tell the title was truncated rather than malformed. Back
215
+ // up to before the link when the cut lands inside one.
216
+ const linkStart = t.lastIndexOf('[', cut);
217
+ if (linkStart !== -1) {
218
+ const linkEnd = t.indexOf(')', linkStart);
219
+ if (linkEnd === -1 || linkEnd >= cut) {
220
+ const before = t.lastIndexOf(' ', linkStart);
221
+ if (before > 0) cut = before;
222
+ }
223
+ }
205
224
  const at = cut > TITLE_MAX * 0.6 ? cut : TITLE_MAX;
206
225
  const overflow = t.slice(at).trim();
207
226
  return {
@@ -264,11 +283,23 @@ function setTaskStatusInTasksMd(cwd, id, newStatus) {
264
283
  if (!cols) {
265
284
  const lower = cells.map(c => c.toLowerCase());
266
285
  if (lower.indexOf('id') !== -1 && lower.indexOf('status') !== -1) {
267
- cols = { id: lower.indexOf('id'), status: lower.indexOf('status') };
286
+ cols = { id: lower.indexOf('id'), status: lower.indexOf('status'), width: cells.length };
268
287
  }
269
288
  continue;
270
289
  }
271
290
  if ((cells[cols.id] || '') === id) {
291
+ // A title containing an unescaped `|` — "deploy A | B decision" — splits
292
+ // into an extra cell, so every column after it is shifted. Writing by
293
+ // index then put the new status INSIDE the title and left the real status
294
+ // cell untouched: the row lost text and the gate stayed open while the
295
+ // board reported it approved. A row that does not match the header's
296
+ // width is not one we can address by index, so it is refused instead.
297
+ if (cells.length !== cols.width) {
298
+ readDegradation.set(cwd,
299
+ `tasks.md row '${id}' has ${cells.length} cells but the table header has ${cols.width} ` +
300
+ '(an unescaped `|` in a cell?) — status not written, as writing by column would corrupt the row');
301
+ return false;
302
+ }
272
303
  cells[cols.status] = newStatus;
273
304
  // Re-escape pipes we unescaped on the way in, then rebuild the row.
274
305
  lines[i] = '| ' + cells.map(c => c.replace(/\|/g, '\\|')).join(' | ') + ' |';
@@ -344,8 +375,13 @@ function parseTasksMd(cwd) {
344
375
  source: 'tasks.md',
345
376
  });
346
377
  }
347
- // No checkbox tasks try the table dialect before giving up.
348
- if (tasks.length === 0) return parseTableTasks(r.text);
378
+ // Both dialects, always. Parsing the table only when the checkbox pass came
379
+ // back empty meant a single stray `- [ ] …` line hid every table row behind
380
+ // it — and a gate row that never reaches the board is a gate nobody can
381
+ // approve. The checkbox pass wins a duplicate id: it is the older dialect,
382
+ // so a file carrying both is one being migrated away from it.
383
+ const seen = new Set(tasks.map((t) => t.id));
384
+ for (const t of parseTableTasks(r.text)) if (!seen.has(t.id)) tasks.push(t);
349
385
  return tasks;
350
386
  } catch (e) {
351
387
  // The file was readable but we could not make sense of it. Record it: an
@@ -23,7 +23,12 @@ function deriveDomain(slug) {
23
23
  const s = slug.toLowerCase();
24
24
  if (/architect|adr|design|prompt/.test(s)) return 'arch';
25
25
  if (/security|sec-|threat|pci|gdpr|hipaa/.test(s)) return 'security';
26
- if (/qa|test|eval|review/.test(s)) return 'qa';
26
+ // `review` used to be in this alternation, and it matched every `*-reviewer`
27
+ // slug — so 37 of the 40 agents in `qa` were domain reviewers, the `domain`
28
+ // bucket below was permanently empty, and the panel grouped 58% of the fleet
29
+ // under one heading. The rule that was supposed to separate them sat two lines
30
+ // later and could never be reached.
31
+ if (/\bqa\b|test|eval|^code-reviewer$/.test(s)) return 'qa';
27
32
  if (/devops|deploy|infra|l3|support|oncall/.test(s)) return 'ops';
28
33
  if (/reviewer$/.test(s)) return 'domain';
29
34
  if (/pm|plan|product/.test(s)) return 'pm';
@@ -64,18 +64,59 @@ function pickBestBySlug(candidates) {
64
64
  function existsSafe(p) {
65
65
  try { return !!p && fs.existsSync(p); } catch { return false; }
66
66
  }
67
- // Dedupe registry entries by slug before writing, so the registry self-heals
68
- // on every write regardless of how the duplicate got in (great_cto-7xfc).
67
+ // Two directories that share a basename ~/work/api and ~/personal/api — read
68
+ // as the same slug. Collapsing them lost one project outright, so a colliding
69
+ // name is qualified by its parent instead: api → api@work, api@personal. The
70
+ // slug stays the lookup key, so it still has to be unique; what changed is that
71
+ // uniqueness is now bought by renaming rather than by dropping a project.
72
+ function qualifiedSlug(project, taken) {
73
+ const parts = String(project.path || '').split(path.sep).filter(Boolean).slice(0, -1).reverse();
74
+ let candidate = project.slug;
75
+ for (const parent of parts) {
76
+ if (!taken.has(candidate)) return candidate;
77
+ candidate = `${project.slug}@${parent}`;
78
+ }
79
+ let n = 2;
80
+ while (taken.has(candidate)) candidate = `${project.slug}@${n++}`;
81
+ return candidate;
82
+ }
83
+
84
+ // Dedupe registry entries before writing, so the registry self-heals on every
85
+ // write regardless of how the duplicate got in (great_cto-7xfc).
86
+ //
87
+ // Identity is the PATH. Entries sharing a slug are only the same project when
88
+ // at most one of their paths still exists — that is a repo that moved, and
89
+ // collapsing it is the original fix. Two live paths are two projects.
69
90
  function dedupeProjects(projects) {
70
- const bySlug = new Map();
91
+ // Same slug AND same path is one entry written twice. A shared path under two
92
+ // different slugs is left alone — someone registered an alias deliberately.
93
+ const exact = new Map();
71
94
  for (const p of projects) {
72
95
  if (!p || !p.slug) continue;
96
+ const key = `${p.slug}\u0000${p.path || ''}`;
97
+ const prior = exact.get(key);
98
+ exact.set(key, prior ? pickBestBySlug([prior, p]) : p);
99
+ }
100
+
101
+ const bySlug = new Map();
102
+ for (const p of exact.values()) {
73
103
  const group = bySlug.get(p.slug) || [];
74
104
  group.push(p);
75
105
  bySlug.set(p.slug, group);
76
106
  }
107
+
77
108
  const out = [];
78
- for (const group of bySlug.values()) out.push(pickBestBySlug(group));
109
+ const taken = new Set();
110
+ for (const group of bySlug.values()) {
111
+ const live = group.filter((p) => existsSafe(p.path));
112
+ // 0 or 1 live path → one project, possibly one that moved.
113
+ const keep = live.length > 1 ? live : [pickBestBySlug(group)];
114
+ for (const p of keep) {
115
+ const slug = qualifiedSlug(p, taken);
116
+ taken.add(slug);
117
+ out.push(slug === p.slug ? p : { ...p, slug });
118
+ }
119
+ }
79
120
  return out;
80
121
  }
81
122
  function writeProjectsRegistry(reg) {
@@ -169,7 +210,10 @@ function autoRegisterProject(dir) {
169
210
  const reg = readProjectsRegistry();
170
211
  const existingByPath = reg.projects.find(p => p.path === meta.path);
171
212
  if (existingByPath) return meta; // already registered at this path, nothing to do
172
- const existingBySlug = reg.projects.find(p => p.slug === meta.slug);
213
+ // Only an entry whose path is GONE is this repo having moved. An entry whose
214
+ // path still exists is a different project that happens to share a name, and
215
+ // overwriting its path deleted it from the switcher silently.
216
+ const existingBySlug = reg.projects.find(p => p.slug === meta.slug && !existsSafe(p.path));
173
217
  if (existingBySlug) {
174
218
  // Repo moved: update the existing entry's path in place instead of
175
219
  // inserting a second entry for the same slug (great_cto-7xfc).
@@ -1,6 +1,8 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { GREAT_CTO_DIR } from './config.mjs';
4
+ import { parseVerdictLine } from '../../../scripts/lib/verdict-record.mjs';
5
+
4
6
 
5
7
  function readVerdicts(cwd = null) {
6
8
  // Verdict attribution model:
@@ -44,37 +46,23 @@ function readVerdicts(cwd = null) {
44
46
  const lines = fs.readFileSync(path.join(verdictDir, file), 'utf8')
45
47
  .split('\n').filter(Boolean);
46
48
  for (const line of lines) {
47
- // When reading global with a project filter, only include lines tagged
48
- // with this project's slug.
49
- if (projectTagFilter) {
50
- const tagMatch = line.match(/\bproject=([^\s|]+)/);
51
- if (!tagMatch || tagMatch[1] !== projectTagFilter) continue;
52
- }
53
- // Two formats agents emit in the wild:
54
- // space-separated: "<ts> <verdict> <details> cost=$X"
55
- // pipe-separated: "<ts> | <agent> | <verdict> | <details> | cost=$X"
56
- // Pre-2026-05: parts[1] always took the 2nd whitespace token, which
57
- // for the pipe form is "|", breaking /api/pipeline status mapping
58
- // (verdicts displayed as "|" instead of APPROVED/DONE/BLOCKED).
59
- // Now we detect the pipe form and parse it differently.
60
- let ts, verdict;
61
- if (line.includes(' | ')) {
62
- const pipeParts = line.split('|').map(s => s.trim());
63
- ts = pipeParts[0].trim();
64
- // Pipe form: [ts, agent, verdict, details, cost]
65
- // Verdict is at index 2 (after ts and agent name).
66
- verdict = pipeParts[2] || '';
67
- } else {
68
- const parts = line.split(' ');
69
- ts = parts[0];
70
- verdict = parts[1] || '';
71
- }
72
- const costMatch = line.match(/\bcost=\$?(\d+\.?\d*)\b/i);
49
+ const parsed = parseVerdictLine(line);
50
+ if (!parsed.ok) continue; // unreadable counted by the CLI, not shown as a verdict
51
+
52
+ // When reading global with a project filter, only include records for this
53
+ // project. Read from the parsed record, not from a `project=` substring:
54
+ // that tag only exists in the legacy text form, so matching on it would
55
+ // have dropped every new NDJSON record from the global view.
56
+ if (projectTagFilter && parsed.rec.project !== projectTagFilter) continue;
57
+ // Format handling lives in scripts/lib/verdict-record.mjs: v1 NDJSON for
58
+ // anything written from 2026-08 on, plus both legacy text dialects so the
59
+ // history keeps reading.
60
+ const { ts, verdict } = parsed.rec;
73
61
  results.push({
74
62
  ts,
75
63
  agent,
76
64
  verdict,
77
- cost_usd: costMatch ? parseFloat(costMatch[1]) : null,
65
+ cost_usd: parsed.rec.cost_usd ?? null,
78
66
  raw: line.replace(/\s*\bcost=\$?\d+\.?\d*\b/i, ''),
79
67
  });
80
68
  }
@@ -169,8 +157,14 @@ function readSecStats(cwd = process.cwd()) {
169
157
  if (!fs.existsSync(secDir)) return { approved: 0, blocked: 0 };
170
158
  for (const file of fs.readdirSync(secDir).filter(f => f.endsWith('.md'))) {
171
159
  const content = fs.readFileSync(path.join(secDir, file), 'utf8');
172
- if (/APPROVED/i.test(content)) approved++;
173
- if (/BLOCKED/i.test(content)) blocked++;
160
+ // One report is one outcome. Testing for each word separately counted a
161
+ // report that says "initially BLOCKED, now APPROVED" as both an approval
162
+ // and a block — the two counters summed to more than the number of reports,
163
+ // and a resolved finding kept inflating the blocked count forever. The LAST
164
+ // verdict word in the file is the one that stands.
165
+ const last = [...content.matchAll(/\b(APPROVED|BLOCKED)\b/gi)].pop();
166
+ if (!last) continue;
167
+ if (last[1].toUpperCase() === 'APPROVED') approved++; else blocked++;
174
168
  }
175
169
  return { approved, blocked };
176
170
  }
@@ -118,13 +118,22 @@ const TOOLS = [
118
118
  ];
119
119
 
120
120
  // ── HTTP helpers ───────────────────────────────────────────────────────────
121
+ // An MCP tool call blocks the model until it answers. Without a deadline, a
122
+ // board that accepts the connection and then never replies — paused process,
123
+ // half-open socket after a laptop sleep — hangs the caller indefinitely with no
124
+ // way to tell it apart from slow work.
125
+ const FETCH_TIMEOUT_MS = 10_000;
126
+
121
127
  async function boardFetch(path) {
122
128
  try {
123
- const res = await fetch(`${BASE_URL}${path}`);
129
+ const res = await fetch(`${BASE_URL}${path}`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
124
130
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
125
131
  return res.json();
126
132
  } catch (e) {
127
- throw new Error(`Board unreachable at ${BASE_URL} is great-cto board running? (${e.message})`);
133
+ const why = e.name === 'TimeoutError' || e.name === 'AbortError'
134
+ ? `no response within ${FETCH_TIMEOUT_MS / 1000}s`
135
+ : e.message;
136
+ throw new Error(`Board unreachable at ${BASE_URL} — is great-cto board running? (${why})`);
128
137
  }
129
138
  }
130
139
 
@@ -163,17 +172,20 @@ async function callTool(name, args = {}) {
163
172
  lines.push(`- **${b.id}** ${b.title}`);
164
173
  }
165
174
  }
166
- if (metrics) {
167
- const done = metrics.done ?? 0;
168
- const total = metrics.total ?? 0;
169
- lines.push('', `**Tasks:** ${done}/${total} done`);
170
- }
175
+ // /api/metrics nests these under `tasks` — reading metrics.done/.total off
176
+ // the root gave undefined every time, so this line reported "0/0 done" for
177
+ // every project regardless of how much had shipped.
178
+ const t = metrics && metrics.tasks;
179
+ if (t) lines.push('', `**Tasks:** ${t.done ?? 0}/${t.total ?? 0} done`);
171
180
  return lines.join('\n');
172
181
  }
173
182
 
174
183
  if (name === 'cost_summary') {
175
184
  const days = Math.min(365, Math.max(1, args.days || 30));
176
- const d = await boardFetch(`/api/cost${pqs(proj)}&days=${days}`);
185
+ // With no project, pqs() is empty and this built `/api/cost&days=30` — a
186
+ // path the router never matches, so cost_summary answered "board
187
+ // unreachable" for anyone who did not pass a project.
188
+ const d = await boardFetch(`/api/cost${pqs(proj)}${pqs(proj) ? '&' : '?'}days=${days}`);
177
189
  const lines = [
178
190
  `## Cost summary${proj ? ` — ${proj}` : ''} (last ${days} days)`,
179
191
  '',
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verdict-record — one schema for the lines agents write and the board reads.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * Verdicts were free text in two dialects:
8
+ *
9
+ * "<ts> <verdict> <details> cost=$X"
10
+ * "<ts> | <agent> | <verdict> | <details> | cost=$X"
11
+ *
12
+ * The reader chose between them by asking whether the line contained ' | '.
13
+ * Agents write prose in the details field and prose contains pipes, so
14
+ * "BLOCKED 3 findings | all in the auth path" was read as the second dialect and
15
+ * the verdict came back as "all in the auth path" — which the board then showed
16
+ * as the agent's verdict. That was fixed by guessing more carefully. This
17
+ * removes the guess: a line is JSON, the fields are named, and no amount of
18
+ * punctuation in a human sentence can move one field into another.
19
+ *
20
+ * The format carries its own version. A reader that meets a version it does not
21
+ * know says so instead of interpreting the fields it happens to recognise —
22
+ * silently half-reading a newer record is how a format change becomes a data
23
+ * bug six weeks later.
24
+ *
25
+ * Reading stays permissive: the two legacy dialects are still parsed, because
26
+ * every verdict log written before today is in them and a reader that drops
27
+ * history to enforce a schema has destroyed the thing the schema protects.
28
+ * Writing is strict: everything new is v1 NDJSON.
29
+ */
30
+
31
+ export const VERDICT_FORMAT_VERSION = 1;
32
+
33
+ /** Verdict values with a defined meaning. Anything else is kept but flagged. */
34
+ export const KNOWN_VERDICTS = Object.freeze([
35
+ 'APPROVED', 'BLOCKED', 'DONE', 'FAIL', 'PASS', 'REJECTED', 'SKIPPED', 'ESCALATED',
36
+ ]);
37
+
38
+ /**
39
+ * The v1 record. Every field is either present and typed, or absent — there is
40
+ * no "empty string means unknown", because that is the ambiguity the text
41
+ * format had.
42
+ *
43
+ * @typedef {object} VerdictRecord
44
+ * @property {number} v format version
45
+ * @property {string} ts ISO-8601 UTC
46
+ * @property {string} agent agent slug
47
+ * @property {string} verdict see KNOWN_VERDICTS
48
+ * @property {string} [project] project slug the run belongs to
49
+ * @property {number} [cost_usd]
50
+ * @property {object} [meta] free-form key/value from the caller
51
+ */
52
+
53
+ const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
54
+
55
+ /**
56
+ * Is this a well-formed v1 record?
57
+ * @returns {{valid: boolean, errors: string[]}}
58
+ */
59
+ export function validateVerdict(rec) {
60
+ const errors = [];
61
+ if (!rec || typeof rec !== 'object') return { valid: false, errors: ['not an object'] };
62
+ if (rec.v !== VERDICT_FORMAT_VERSION) errors.push(`v must be ${VERDICT_FORMAT_VERSION}, got ${JSON.stringify(rec.v)}`);
63
+ if (typeof rec.ts !== 'string' || !ISO.test(rec.ts)) errors.push('ts must be an ISO-8601 UTC timestamp');
64
+ if (typeof rec.agent !== 'string' || !rec.agent) errors.push('agent is required');
65
+ if (typeof rec.verdict !== 'string' || !rec.verdict) errors.push('verdict is required');
66
+ if ('cost_usd' in rec && (typeof rec.cost_usd !== 'number' || !Number.isFinite(rec.cost_usd) || rec.cost_usd < 0)) {
67
+ errors.push('cost_usd must be a non-negative number');
68
+ }
69
+ if ('project' in rec && typeof rec.project !== 'string') errors.push('project must be a string');
70
+ if ('meta' in rec && (typeof rec.meta !== 'object' || rec.meta === null || Array.isArray(rec.meta))) {
71
+ errors.push('meta must be an object');
72
+ }
73
+ return { valid: errors.length === 0, errors };
74
+ }
75
+
76
+ /** Build a v1 record, normalising what the caller passed. Throws if unusable. */
77
+ export function makeVerdict({ ts, agent, verdict, project, cost_usd, meta } = {}) {
78
+ const rec = {
79
+ v: VERDICT_FORMAT_VERSION,
80
+ ts: String(ts ?? '').trim(),
81
+ agent: String(agent ?? '').trim(),
82
+ verdict: String(verdict ?? '').trim().toUpperCase(),
83
+ };
84
+ if (project) rec.project = String(project).trim();
85
+ if (cost_usd !== undefined && cost_usd !== null && cost_usd !== '') rec.cost_usd = Number(cost_usd);
86
+ if (meta && Object.keys(meta).length) rec.meta = meta;
87
+
88
+ const { valid, errors } = validateVerdict(rec);
89
+ // Refusing here is the point: a malformed record that reaches the log is read
90
+ // by something later, and by then nobody knows which agent wrote it.
91
+ if (!valid) throw new Error(`invalid verdict record: ${errors.join('; ')}`);
92
+ return rec;
93
+ }
94
+
95
+ /** One record → one NDJSON line (no trailing newline). */
96
+ export function formatVerdict(rec) {
97
+ return JSON.stringify(rec);
98
+ }
99
+
100
+ /**
101
+ * `k=v k2=v2` → object. Values may not contain spaces; that is what the text
102
+ * format allowed and all it ever allowed.
103
+ */
104
+ function parseMetaPairs(s) {
105
+ const meta = {};
106
+ for (const m of String(s ?? '').matchAll(/(^|\s)([A-Za-z_][\w-]*)=([^\s|]*)/g)) meta[m[2]] = m[3];
107
+ return meta;
108
+ }
109
+
110
+ /**
111
+ * One log line → a record, whatever dialect it is in.
112
+ *
113
+ * @returns {{ok: true, rec: VerdictRecord, legacy: boolean} | {ok: false, reason: string, line: string}}
114
+ */
115
+ export function parseVerdictLine(line) {
116
+ const raw = String(line ?? '').trim();
117
+ if (!raw) return { ok: false, reason: 'blank', line: raw };
118
+
119
+ if (raw.startsWith('{')) {
120
+ let obj;
121
+ try { obj = JSON.parse(raw); } catch { return { ok: false, reason: 'malformed JSON', line: raw }; }
122
+ // A version we do not know: report it rather than reading the fields we
123
+ // happen to recognise and pretending we understood the record.
124
+ if (obj && obj.v !== VERDICT_FORMAT_VERSION) {
125
+ return { ok: false, reason: `unsupported format version ${JSON.stringify(obj.v)} (this reader knows ${VERDICT_FORMAT_VERSION})`, line: raw };
126
+ }
127
+ const { valid, errors } = validateVerdict(obj);
128
+ if (!valid) return { ok: false, reason: errors[0], line: raw };
129
+ return { ok: true, rec: obj, legacy: false };
130
+ }
131
+
132
+ // ── legacy ────────────────────────────────────────────────────────────────
133
+ // Pipe form is recognised by SHAPE — a timestamp, a separator, then fields —
134
+ // not by containing ' | ' anywhere, which ordinary prose does.
135
+ const cost = raw.match(/\bcost=\$?(\d+\.?\d*)/i);
136
+ const project = raw.match(/\bproject=([^\s|]+)/);
137
+ const piped = raw.match(/^(\S+)\s+\|\s+(.*)$/);
138
+
139
+ let ts, agent, verdict;
140
+ if (piped) {
141
+ const fields = piped[2].split('|').map((s) => s.trim());
142
+ ts = piped[1];
143
+ agent = fields.length >= 2 ? fields[0] : '';
144
+ verdict = fields.length >= 2 ? fields[1] : fields[0];
145
+ } else {
146
+ const parts = raw.split(/\s+/);
147
+ ts = parts[0] || '';
148
+ verdict = parts[1] || '';
149
+ agent = ''; // the space form never carried one; the filename did
150
+ }
151
+
152
+ const rec = { v: VERDICT_FORMAT_VERSION, ts, agent, verdict: String(verdict || '').toUpperCase() };
153
+ if (project) rec.project = project[1];
154
+ if (cost) rec.cost_usd = parseFloat(cost[1]);
155
+ const meta = parseMetaPairs(raw);
156
+ delete meta.cost; delete meta.project;
157
+ if (Object.keys(meta).length) rec.meta = meta;
158
+
159
+ return { ok: true, rec, legacy: true };
160
+ }
161
+
162
+ /**
163
+ * A whole log file → records, plus the lines that could not be read.
164
+ *
165
+ * Unreadable lines are RETURNED, not skipped. A parser that silently drops what
166
+ * it cannot understand reports a clean file and a short list, and the gap
167
+ * between them is invisible.
168
+ */
169
+ export function parseVerdictLog(text, { agent } = {}) {
170
+ const records = [];
171
+ const rejected = [];
172
+ for (const line of String(text ?? '').split('\n')) {
173
+ if (!line.trim()) continue;
174
+ const r = parseVerdictLine(line);
175
+ if (!r.ok) { rejected.push({ line: line.trim(), reason: r.reason }); continue; }
176
+ // The space dialect carried the agent in the filename, not the line.
177
+ if (!r.rec.agent && agent) r.rec.agent = agent;
178
+ records.push(r.rec);
179
+ }
180
+ return { records, rejected };
181
+ }
182
+
183
+ // ── CLI ─────────────────────────────────────────────────────────────────────
184
+
185
+ async function main(argv) {
186
+ const { readFileSync } = await import('node:fs');
187
+ const { basename } = await import('node:path');
188
+ // `--emit` builds one line from the environment. It exists so log-verdict.sh
189
+ // can produce a valid record without embedding a JSON writer in shell, where
190
+ // quoting a user-supplied meta value correctly is a losing game.
191
+ if (argv.includes('--emit')) {
192
+ const meta = {};
193
+ for (const kv of String(process.env.META || '').split(/\s+/).filter(Boolean)) {
194
+ const i = kv.indexOf('=');
195
+ if (i > 0) meta[kv.slice(0, i)] = kv.slice(i + 1);
196
+ }
197
+ try {
198
+ process.stdout.write(formatVerdict(makeVerdict({
199
+ ts: process.env.TS,
200
+ agent: process.env.AGENT,
201
+ verdict: process.env.VERDICT,
202
+ project: process.env.PROJECT_SLUG || undefined,
203
+ cost_usd: process.env.COST === '' || process.env.COST === undefined ? undefined : Number(process.env.COST),
204
+ meta,
205
+ })) + '\n');
206
+ return 0;
207
+ } catch (e) {
208
+ console.error(`verdict-record: ${e.message}`);
209
+ return 1;
210
+ }
211
+ }
212
+
213
+ const files = argv.filter((a) => !a.startsWith('--'));
214
+ if (!files.length) {
215
+ console.error('usage: verdict-record.mjs <verdicts/*.log>... [--json] [--strict]');
216
+ console.error(' verdict-record.mjs --emit (fields from TS/AGENT/VERDICT/COST/PROJECT_SLUG/META)');
217
+ return 2;
218
+ }
219
+ let bad = 0;
220
+ const all = [];
221
+ for (const f of files) {
222
+ let text;
223
+ try { text = readFileSync(f, 'utf8'); } catch { console.error(`cannot read ${f}`); bad++; continue; }
224
+ const { records, rejected } = parseVerdictLog(text, { agent: basename(f).replace(/\.log$/, '') });
225
+ all.push(...records);
226
+ for (const r of rejected) { bad++; console.error(`${f}: ${r.reason} — ${r.line.slice(0, 80)}`); }
227
+ }
228
+ if (argv.includes('--json')) console.log(JSON.stringify(all, null, 2));
229
+ else {
230
+ const legacyish = all.filter((r) => !r.agent).length;
231
+ console.log(`${all.length} record(s), ${bad} unreadable line(s)${legacyish ? `, ${legacyish} without an agent` : ''}`);
232
+ }
233
+ return argv.includes('--strict') && bad ? 1 : 0;
234
+ }
235
+
236
+ if (import.meta.url === `file://${process.argv[1]}`) {
237
+ main(process.argv.slice(2)).then((c) => { process.exitCode = c; });
238
+ }
@@ -141,3 +141,29 @@ export function decideEnsureAction(s) {
141
141
  return "restart";
142
142
  return "noop";
143
143
  }
144
+ /**
145
+ * Is this HTTP response the great_cto board, or just something on that port?
146
+ *
147
+ * The probe used to accept ANY response, including a 404 — so a dev server, a
148
+ * Docker proxy, or an unrelated app squatting on 3141 made `board ensure` print
149
+ * "board already running" and exit 0. The supervisor then never started the
150
+ * board, and the user got a healthy-looking line pointing at someone else's app.
151
+ *
152
+ * `/api/version` answers with the board's own build identity, which nothing else
153
+ * on the machine has a reason to serve.
154
+ */
155
+ export function isBoardResponse(status, body) {
156
+ if (status !== 200)
157
+ return false;
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(body);
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ if (!parsed || typeof parsed !== "object")
166
+ return false;
167
+ const o = parsed;
168
+ return typeof o.version === "string" && typeof o.surface === "string";
169
+ }
package/dist/main.js CHANGED
@@ -23,7 +23,7 @@ import { shouldUseLlmFallback, suggestArchetypeFromLlm } from "./llm-fallback.js
23
23
  import { sendUsagePing, sendInstallPing, telemetrySubcommand, isTelemetryEnabled, computeAnonId } from "./telemetry.js";
24
24
  import { checkForUpdate } from "./update-check.js";
25
25
  import { findBoardServerPath } from "./board-path.js";
26
- import { daemonSpec, decideEnsureAction } from "./board-daemon.js";
26
+ import { daemonSpec, decideEnsureAction, isBoardResponse } from "./board-daemon.js";
27
27
  import { readFileSync, writeFileSync, copyFileSync, chmodSync, mkdirSync, unlinkSync, existsSync as fsExistsSync } from "node:fs";
28
28
  import { dirname, join } from "node:path";
29
29
  import { fileURLToPath } from "node:url";
@@ -370,11 +370,27 @@ function isPidAlive(pid) {
370
370
  return false;
371
371
  }
372
372
  }
373
- /** Does the board answer HTTP on this port? Any response (even 4xx) = healthy. */
373
+ /**
374
+ * Is OUR board answering on this port?
375
+ *
376
+ * Any-response-is-healthy was the bug: whatever happened to hold 3141 — a dev
377
+ * server, a proxy, an unrelated app — made `ensure` print "board already
378
+ * running" and never start the board. `/api/version` returns the board's own
379
+ * identity, so a stranger on the port now reads as "not running".
380
+ */
374
381
  async function probeBoardPort(port) {
375
382
  const http = await import("node:http");
376
383
  return new Promise(resolve => {
377
- const req = http.request({ host: "127.0.0.1", port, path: "/", method: "GET", timeout: 1200 }, res => { res.resume(); resolve(true); });
384
+ const req = http.request({ host: "127.0.0.1", port, path: "/api/version", method: "GET", timeout: 1200 }, res => {
385
+ const status = res.statusCode ?? 0;
386
+ let body = "";
387
+ res.setEncoding("utf8");
388
+ // A stranger could stream forever; the identity fits in well under 4 KB.
389
+ res.on("data", (c) => { body += c; if (body.length > 4096)
390
+ req.destroy(); });
391
+ res.on("end", () => resolve(isBoardResponse(status, body)));
392
+ res.on("error", () => resolve(false));
393
+ });
378
394
  req.on("error", () => resolve(false));
379
395
  req.on("timeout", () => { req.destroy(); resolve(false); });
380
396
  req.end();
@@ -1180,6 +1196,23 @@ async function runSelfUpgrade() {
1180
1196
  return result.exitCode;
1181
1197
  }
1182
1198
  success(result.message);
1199
+ // The CLI is upgraded; the plugin Claude Code actually loads may not be.
1200
+ // `npm i -g` never touches the plugin cache, so without this the user is told
1201
+ // the upgrade succeeded and keeps running the previous release's hooks and
1202
+ // agent prompts with nothing anywhere saying so.
1203
+ try {
1204
+ const { pluginCacheLagWarning } = await import("./self-upgrade.js");
1205
+ const { readdirSync } = await import("node:fs");
1206
+ const { join } = await import("node:path");
1207
+ const cacheRoot = join(homedir(), ".claude", "plugins", "cache", "local", "great_cto");
1208
+ const dirs = readdirSync(cacheRoot);
1209
+ const warning = pluginCacheLagWarning(result.newVersion ?? "", dirs);
1210
+ if (warning) {
1211
+ log("");
1212
+ log(dim(warning));
1213
+ }
1214
+ }
1215
+ catch { /* no plugin cache is not a lag — the plugin is simply not installed */ }
1183
1216
  return 0;
1184
1217
  }
1185
1218
  async function runUpgrade(rawArgv, args) {
@@ -1226,6 +1259,10 @@ async function main() {
1226
1259
  // is the single exit funnel: it fires one fire-and-forget event (command + node + os
1227
1260
  // + exit_code + duration, no PII) only when the user has opted in, then exits.
1228
1261
  const __tStart = Date.now();
1262
+ // Promise<never>, not Promise<void>: this always exits, and typing it as such
1263
+ // is what lets `await finish(2)` narrow a nullable the way `process.exit(2)`
1264
+ // did. Without it, routing an exit through the update check would silently
1265
+ // widen types at every call site.
1229
1266
  const finish = async (code) => {
1230
1267
  try {
1231
1268
  await sendUsagePing({
@@ -1254,7 +1291,7 @@ async function main() {
1254
1291
  if (args.command === "telemetry") {
1255
1292
  const { exitCode, output } = telemetrySubcommand(args.positional[0]);
1256
1293
  process.stdout.write(output);
1257
- process.exit(exitCode);
1294
+ await finish(exitCode);
1258
1295
  }
1259
1296
  if (args.command === "help") {
1260
1297
  printHelp();
@@ -1265,7 +1302,7 @@ async function main() {
1265
1302
  error(`great-cto: unknown command or flag '${tok}'`);
1266
1303
  log("");
1267
1304
  log(`Run ${cyan("great-cto --help")} for usage.`);
1268
- process.exit(2);
1305
+ await finish(2);
1269
1306
  }
1270
1307
  if (args.command === "board") {
1271
1308
  try {
@@ -1288,7 +1325,7 @@ async function main() {
1288
1325
  }
1289
1326
  catch (e) {
1290
1327
  error(e.message);
1291
- process.exit(1);
1328
+ await finish(1);
1292
1329
  }
1293
1330
  }
1294
1331
  if (args.command === "console") {
@@ -1298,7 +1335,7 @@ async function main() {
1298
1335
  }
1299
1336
  catch (e) {
1300
1337
  error(e.message);
1301
- process.exit(1);
1338
+ await finish(1);
1302
1339
  }
1303
1340
  }
1304
1341
  if (args.command === "register") {
@@ -1308,7 +1345,7 @@ async function main() {
1308
1345
  }
1309
1346
  catch (e) {
1310
1347
  error(e.message);
1311
- process.exit(1);
1348
+ await finish(1);
1312
1349
  }
1313
1350
  }
1314
1351
  if (args.command === "ci") {
@@ -1319,7 +1356,7 @@ async function main() {
1319
1356
  }
1320
1357
  catch (e) {
1321
1358
  error(e.message);
1322
- process.exit(2);
1359
+ await finish(2);
1323
1360
  }
1324
1361
  }
1325
1362
  if (args.command === "mcp") {
@@ -1333,7 +1370,7 @@ async function main() {
1333
1370
  }
1334
1371
  catch (e) {
1335
1372
  error(e.message);
1336
- process.exit(2);
1373
+ await finish(2);
1337
1374
  }
1338
1375
  }
1339
1376
  if (args.command === "adapt") {
@@ -1348,16 +1385,16 @@ async function main() {
1348
1385
  }
1349
1386
  catch (e) {
1350
1387
  error(e.message);
1351
- process.exit(2);
1388
+ await finish(2);
1352
1389
  }
1353
1390
  }
1354
1391
  if (args.command === "task") {
1355
1392
  const { runTask } = await import("./worker.js");
1356
- process.exit(await runTask(args.taskArgs ?? []));
1393
+ await finish(await runTask(args.taskArgs ?? []));
1357
1394
  }
1358
1395
  if (args.command === "worker") {
1359
1396
  const { runWorker } = await import("./worker.js");
1360
- process.exit(await runWorker(args.taskArgs ?? []));
1397
+ await finish(await runWorker(args.taskArgs ?? []));
1361
1398
  }
1362
1399
  if (args.command === "serve") {
1363
1400
  try {
@@ -1374,7 +1411,7 @@ async function main() {
1374
1411
  }
1375
1412
  catch (e) {
1376
1413
  error(e.message);
1377
- process.exit(2);
1414
+ await finish(2);
1378
1415
  }
1379
1416
  }
1380
1417
  if (args.command === "webhook") {
@@ -1383,14 +1420,14 @@ async function main() {
1383
1420
  const parsed = parseWebhookArgs(rawArgv);
1384
1421
  if (!parsed) {
1385
1422
  error("usage: great-cto webhook list | add-incoming <name> --secret <s> | add-outgoing <name> --url <u> --format <f> --triggers <t1,t2> | remove <name> | test <name>");
1386
- process.exit(2);
1423
+ return finish(2);
1387
1424
  }
1388
1425
  const code = await runWebhookCli(parsed);
1389
1426
  await finish(code);
1390
1427
  }
1391
1428
  catch (e) {
1392
1429
  error(e.message);
1393
- process.exit(2);
1430
+ await finish(2);
1394
1431
  }
1395
1432
  }
1396
1433
  if (args.command === "upgrade") {
@@ -1400,7 +1437,7 @@ async function main() {
1400
1437
  }
1401
1438
  catch (e) {
1402
1439
  error(e.message);
1403
- process.exit(2);
1440
+ await finish(2);
1404
1441
  }
1405
1442
  }
1406
1443
  if (args.command === "chat-only-hint") {
@@ -1417,21 +1454,21 @@ async function main() {
1417
1454
  log(` ${cyan("serve")} · ${cyan("webhook")} · ${cyan("report")} · ${cyan("board")} · ${cyan("register")} · ${cyan("upgrade")}`);
1418
1455
  log("");
1419
1456
  log(`Run ${cyan("npx great-cto --help")} for the full CLI reference.`);
1420
- process.exit(2);
1457
+ await finish(2);
1421
1458
  }
1422
1459
  if (args.command === "report") {
1423
1460
  try {
1424
1461
  const { runReport, parseReportArgs } = await import("./report.js");
1425
1462
  const parsed = parseReportArgs(rawArgv, args.dir);
1426
1463
  if (!parsed) {
1427
- process.exit(2);
1464
+ return finish(2);
1428
1465
  }
1429
1466
  const code = await runReport(parsed);
1430
1467
  await finish(code);
1431
1468
  }
1432
1469
  catch (e) {
1433
1470
  error(e.message);
1434
- process.exit(2);
1471
+ await finish(2);
1435
1472
  }
1436
1473
  }
1437
1474
  if (args.command === "version") {
@@ -1448,7 +1485,7 @@ async function main() {
1448
1485
  const pkg = JSON.parse(readFileSync(p, "utf-8"));
1449
1486
  if (pkg.name === "great-cto" && pkg.version) {
1450
1487
  log(pkg.version);
1451
- process.exit(0);
1488
+ await finish(0);
1452
1489
  }
1453
1490
  }
1454
1491
  catch { /* keep searching */ }
@@ -1458,7 +1495,7 @@ async function main() {
1458
1495
  catch {
1459
1496
  log("0.0.0");
1460
1497
  }
1461
- process.exit(0);
1498
+ await finish(0);
1462
1499
  }
1463
1500
  try {
1464
1501
  const code = await runInit(args);
package/dist/mcp.js CHANGED
@@ -99,13 +99,19 @@ const BOARD_PORT = parseInt(process.env.GREAT_CTO_PORT ?? "3141", 10);
99
99
  const BOARD_BASE = `http://127.0.0.1:${BOARD_PORT}`;
100
100
  async function boardFetch(path) {
101
101
  try {
102
- const res = await fetch(`${BOARD_BASE}${path}`);
102
+ // A tool call blocks the model until it answers; a board that accepts the
103
+ // connection and never replies would hang it with no timeout to end the wait.
104
+ const res = await fetch(`${BOARD_BASE}${path}`, { signal: AbortSignal.timeout(10_000) });
103
105
  if (!res.ok)
104
106
  throw new Error(`HTTP ${res.status}`);
105
107
  return res.json();
106
108
  }
107
109
  catch (e) {
108
- throw new Error(`Board unreachable at ${BOARD_BASE} — run \`great-cto board\` first. (${e.message})`);
110
+ const err = e;
111
+ const why = err.name === "TimeoutError" || err.name === "AbortError"
112
+ ? "no response within 10s"
113
+ : err.message;
114
+ throw new Error(`Board unreachable at ${BOARD_BASE} — run \`great-cto board\` first. (${why})`);
109
115
  }
110
116
  }
111
117
  function boardPqs(project) {
@@ -139,7 +145,9 @@ async function toolProjectStatus(args) {
139
145
  }
140
146
  }
141
147
  if (metrics) {
142
- lines.push("", `**Tasks:** ${metrics.done ?? 0}/${metrics.total ?? 0} done`);
148
+ // /api/metrics nests these under `tasks`; off the root they are undefined,
149
+ // so this reported "0/0 done" for every project.
150
+ lines.push("", `**Tasks:** ${metrics.tasks?.done ?? 0}/${metrics.tasks?.total ?? 0} done`);
143
151
  }
144
152
  return lines.join("\n");
145
153
  }
@@ -154,3 +154,57 @@ export function performSelfUpgrade(opts) {
154
154
  }
155
155
  // Re-export for callers that only need the prefix-derivation logic directly.
156
156
  export { derivePrefixFromBinPath as _derivePrefixFromBinPath };
157
+ /**
158
+ * Newest version present in the Claude Code plugin cache, or null.
159
+ *
160
+ * Takes the directory names so the rule is testable without a filesystem.
161
+ */
162
+ export function newestCachedPlugin(dirNames) {
163
+ const versions = dirNames.filter((d) => /^\d+\.\d+\.\d+$/.test(d));
164
+ if (!versions.length)
165
+ return null;
166
+ const key = (v) => v.split(".").map(Number);
167
+ return versions.sort((a, b) => {
168
+ const [A, B] = [key(a), key(b)];
169
+ for (let i = 0; i < 3; i++)
170
+ if (A[i] !== B[i])
171
+ return A[i] - B[i];
172
+ return 0;
173
+ })[versions.length - 1];
174
+ }
175
+ /**
176
+ * Warn when the npm package has moved ahead of the plugin Claude Code loads.
177
+ *
178
+ * `npm i -g` replaces the CLI. It does not touch
179
+ * ~/.claude/plugins/cache/local/great_cto/<version>/, which is where the hooks
180
+ * and agent prompts are actually read from — so a user can take the update
181
+ * prompt, be told the upgrade succeeded, and keep running the previous release's
182
+ * agents with nothing anywhere saying so. That is the same defect this codebase
183
+ * keeps finding: a step reported as done while the thing it was meant to change
184
+ * stayed as it was.
185
+ *
186
+ * Returns null when they match, when nothing is cached (the plugin is simply not
187
+ * installed), or when the cache is somehow ahead — none of those are the failure
188
+ * this exists to catch.
189
+ */
190
+ export function pluginCacheLagWarning(npmVersion, cachedDirs) {
191
+ const cached = newestCachedPlugin(cachedDirs);
192
+ if (!cached || !npmVersion)
193
+ return null;
194
+ if (cached === npmVersion)
195
+ return null;
196
+ const key = (v) => v.split(".").map(Number);
197
+ const [c, n] = [key(cached), key(npmVersion)];
198
+ for (let i = 0; i < 3; i++) {
199
+ if (c[i] > n[i])
200
+ return null; // cache ahead — not the case we guard
201
+ if (c[i] < n[i])
202
+ break;
203
+ }
204
+ return [
205
+ `note: the npm package is ${npmVersion}, but the plugin Claude Code loads is ${cached}.`,
206
+ " Hooks and agent prompts come from the plugin cache, not from npm — until it is",
207
+ " refreshed, this session keeps running the previous release's agents.",
208
+ " Refresh it with: bash scripts/install-local.sh --prune",
209
+ ].join("\n");
210
+ }
package/dist/telemetry.js CHANGED
@@ -82,7 +82,10 @@ export function computeAnonId() {
82
82
  /** Resolve telemetry-enabled state. Pure function, no side effects. */
83
83
  export function isTelemetryEnabled(cliFlag = false) {
84
84
  // Opt-out wins, in priority order:
85
- if (process.env.DO_NOT_TRACK === "1" || process.env.DO_NOT_TRACK === "true")
85
+ // consoledonottrack.com specifies ANY non-empty value as opt-out. Accepting
86
+ // only "1"/"true" meant `DO_NOT_TRACK=yes` — which a user sets believing it
87
+ // works, and which the spec says does work — silently left telemetry on.
88
+ if ((process.env.DO_NOT_TRACK ?? "") !== "")
86
89
  return false;
87
90
  if (process.env.GREAT_CTO_TELEMETRY === "off")
88
91
  return false;
@@ -58,6 +58,29 @@ export function cachePath() {
58
58
  }
59
59
  /** Pure function: is a suppression condition active? Fail-open to "suppressed" on any ambiguity. */
60
60
  export function isSuppressed(opts = {}) {
61
+ if (isRefreshSuppressed(opts))
62
+ return true;
63
+ // Nothing may be printed when stderr is not a terminal — the output is being
64
+ // read by something. This is about PRINTING only; see isRefreshSuppressed.
65
+ if (opts.stderrIsTTY === false)
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Should the background cache refresh be suppressed too?
71
+ *
72
+ * Deliberately narrower than isSuppressed. Not printing and not learning are
73
+ * different things, and conflating them had a consequence: the refresh sits
74
+ * behind the same non-TTY check as the hint, so a user whose runs are mostly
75
+ * non-interactive never warmed the cache — and their occasional interactive run
76
+ * therefore had no cache to read and showed nothing, every time. The cache file
77
+ * was simply absent on this machine, which is how this was found.
78
+ *
79
+ * A detached refresh with stdio ignored pollutes no output. What genuinely must
80
+ * not spawn: the protocol commands, whose children would outlive a parsed
81
+ * stream, and anyone who asked us not to check at all.
82
+ */
83
+ export function isRefreshSuppressed(opts = {}) {
61
84
  const env = opts.env ?? process.env;
62
85
  if (env.CI != null && env.CI !== "" && env.CI !== "0" && env.CI !== "false")
63
86
  return true;
@@ -65,8 +88,6 @@ export function isSuppressed(opts = {}) {
65
88
  return true;
66
89
  if (opts.command && PROTOCOL_SENSITIVE_COMMANDS.has(opts.command))
67
90
  return true;
68
- if (opts.stderrIsTTY === false)
69
- return true;
70
91
  return false;
71
92
  }
72
93
  /**
@@ -213,10 +234,15 @@ export async function checkForUpdate(opts) {
213
234
  const env = opts.env ?? process.env;
214
235
  const stderrIsTTY = opts.stderrIsTTY ?? Boolean(process.stderr.isTTY);
215
236
  const stdinIsTTY = opts.stdinIsTTY ?? Boolean(process.stdin.isTTY);
216
- if (isSuppressed({ env, command: opts.command, stderrIsTTY }))
237
+ // Warm the cache even when nothing may be printed. A run that cannot show a
238
+ // hint can still learn the version for the run that can.
239
+ if (isRefreshSuppressed({ env, command: opts.command }))
217
240
  return;
241
+ const quiet = isSuppressed({ env, command: opts.command, stderrIsTTY });
218
242
  const cache = readCache();
219
243
  if (isCacheFresh(cache, opts.now)) {
244
+ if (quiet)
245
+ return; // fresh cache, but this run may not speak
220
246
  if (!cache || !isNewerVersion(opts.currentVersion, cache.latest))
221
247
  return;
222
248
  if (shouldPrompt({ currentVersion: opts.currentVersion, cache, env, command: opts.command, stderrIsTTY, stdinIsTTY })) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.90.0",
3
+ "version": "2.92.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",