great-cto 2.90.0 → 2.91.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.91.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
  },
@@ -201,7 +201,19 @@ const TITLE_MAX = 160;
201
201
  function capTitle(title, description) {
202
202
  const t = String(title || '').trim();
203
203
  if (t.length <= TITLE_MAX) return { title: t, description };
204
- const cut = t.lastIndexOf(' ', TITLE_MAX);
204
+ let cut = t.lastIndexOf(' ', TITLE_MAX);
205
+ // Cutting inside a markdown link leaves `see [the design…` in the title and
206
+ // the URL stranded in the description — the board renders a broken link and
207
+ // the reader cannot tell the title was truncated rather than malformed. Back
208
+ // up to before the link when the cut lands inside one.
209
+ const linkStart = t.lastIndexOf('[', cut);
210
+ if (linkStart !== -1) {
211
+ const linkEnd = t.indexOf(')', linkStart);
212
+ if (linkEnd === -1 || linkEnd >= cut) {
213
+ const before = t.lastIndexOf(' ', linkStart);
214
+ if (before > 0) cut = before;
215
+ }
216
+ }
205
217
  const at = cut > TITLE_MAX * 0.6 ? cut : TITLE_MAX;
206
218
  const overflow = t.slice(at).trim();
207
219
  return {
@@ -264,11 +276,23 @@ function setTaskStatusInTasksMd(cwd, id, newStatus) {
264
276
  if (!cols) {
265
277
  const lower = cells.map(c => c.toLowerCase());
266
278
  if (lower.indexOf('id') !== -1 && lower.indexOf('status') !== -1) {
267
- cols = { id: lower.indexOf('id'), status: lower.indexOf('status') };
279
+ cols = { id: lower.indexOf('id'), status: lower.indexOf('status'), width: cells.length };
268
280
  }
269
281
  continue;
270
282
  }
271
283
  if ((cells[cols.id] || '') === id) {
284
+ // A title containing an unescaped `|` — "deploy A | B decision" — splits
285
+ // into an extra cell, so every column after it is shifted. Writing by
286
+ // index then put the new status INSIDE the title and left the real status
287
+ // cell untouched: the row lost text and the gate stayed open while the
288
+ // board reported it approved. A row that does not match the header's
289
+ // width is not one we can address by index, so it is refused instead.
290
+ if (cells.length !== cols.width) {
291
+ readDegradation.set(cwd,
292
+ `tasks.md row '${id}' has ${cells.length} cells but the table header has ${cols.width} ` +
293
+ '(an unescaped `|` in a cell?) — status not written, as writing by column would corrupt the row');
294
+ return false;
295
+ }
272
296
  cells[cols.status] = newStatus;
273
297
  // Re-escape pipes we unescaped on the way in, then rebuild the row.
274
298
  lines[i] = '| ' + cells.map(c => c.replace(/\|/g, '\\|')).join(' | ') + ' |';
@@ -344,8 +368,13 @@ function parseTasksMd(cwd) {
344
368
  source: 'tasks.md',
345
369
  });
346
370
  }
347
- // No checkbox tasks try the table dialect before giving up.
348
- if (tasks.length === 0) return parseTableTasks(r.text);
371
+ // Both dialects, always. Parsing the table only when the checkbox pass came
372
+ // back empty meant a single stray `- [ ] …` line hid every table row behind
373
+ // it — and a gate row that never reaches the board is a gate nobody can
374
+ // approve. The checkbox pass wins a duplicate id: it is the older dialect,
375
+ // so a file carrying both is one being migrated away from it.
376
+ const seen = new Set(tasks.map((t) => t.id));
377
+ for (const t of parseTableTasks(r.text)) if (!seen.has(t.id)) tasks.push(t);
349
378
  return tasks;
350
379
  } catch (e) {
351
380
  // The file was readable but we could not make sense of it. Record it: an
@@ -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();
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
  }
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "2.90.0",
3
+ "version": "2.91.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",