great-cto 2.89.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.
- package/board/.claude-plugin/plugin.json +2 -2
- package/board/packages/board/lib/beads.mjs +33 -4
- 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 +19 -3
- package/dist/mcp.js +11 -3
- package/dist/telemetry.js +4 -1
- 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.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
|
-
|
|
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
|
-
//
|
|
348
|
-
|
|
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
|
-
//
|
|
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();
|
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/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/package.json
CHANGED