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.
- package/board/.claude-plugin/plugin.json +2 -2
- package/board/packages/board/lib/beads.mjs +41 -5
- package/board/packages/board/lib/fleet.mjs +6 -1
- package/board/packages/board/lib/projects.mjs +49 -5
- package/board/packages/board/lib/verdicts.mjs +23 -29
- package/board/packages/board/mcp-server.mjs +20 -8
- package/board/scripts/lib/verdict-record.mjs +238 -0
- package/dist/board-daemon.js +26 -0
- package/dist/main.js +59 -22
- package/dist/mcp.js +11 -3
- package/dist/self-upgrade.js +54 -0
- package/dist/telemetry.js +4 -1
- package/dist/update-check.js +29 -3
- package/package.json +1 -1
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
68
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
|
|
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:
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/board-daemon.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1388
|
+
await finish(2);
|
|
1352
1389
|
}
|
|
1353
1390
|
}
|
|
1354
1391
|
if (args.command === "task") {
|
|
1355
1392
|
const { runTask } = await import("./worker.js");
|
|
1356
|
-
|
|
1393
|
+
await finish(await runTask(args.taskArgs ?? []));
|
|
1357
1394
|
}
|
|
1358
1395
|
if (args.command === "worker") {
|
|
1359
1396
|
const { runWorker } = await import("./worker.js");
|
|
1360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/self-upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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/dist/update-check.js
CHANGED
|
@@ -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
|
-
|
|
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