great-cto 3.20.0 → 3.22.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.
@@ -1,11 +1,10 @@
1
1
  {
2
- "name": "great_cto",
3
- "id": "great_cto",
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": "3.20.0",
2
+ "name": "great-cto",
3
+ "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 70 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
+ "version": "3.22.0",
6
5
  "author": {
7
- "name": "Great CTO",
8
- "url": "https://github.com/avelikiy/great_cto"
6
+ "name": "Alexander Velikiy",
7
+ "url": "https://hashnode.com/@Greatcto"
9
8
  },
10
9
  "license": "MIT",
11
10
  "keywords": [
@@ -62,7 +61,7 @@
62
61
  },
63
62
  {
64
63
  "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 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",
64
+ "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 ==='; node \"${PLUGIN_DIR}/scripts/hooks/read-global-memory.mjs\" 2>&1 || 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
65
  "timeout": 30,
67
66
  "statusMessage": "Loading Great CTO context..."
68
67
  },
@@ -199,6 +198,12 @@
199
198
  {
200
199
  "type": "command",
201
200
  "command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/lesson-rules-check.mjs\" 2>/dev/null; true"
201
+ },
202
+ {
203
+ "type": "command",
204
+ "command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); node \"${PLUGIN_DIR}/scripts/hooks/docs-reference-sync.mjs\" 2>/dev/null; true",
205
+ "timeout": 25,
206
+ "async": true
202
207
  }
203
208
  ]
204
209
  },
@@ -283,5 +288,7 @@
283
288
  ]
284
289
  }
285
290
  ]
286
- }
291
+ },
292
+ "homepage": "https://greatcto.systems",
293
+ "repository": "https://github.com/avelikiy/great_cto"
287
294
  }
@@ -12,6 +12,7 @@ import { recurrence } from './alert-recurrence.mjs';
12
12
  import { waitingOnYou, dedupeKeyFor } from '../../../scripts/lib/waiting-on-you.mjs';
13
13
  import { listProjects, readProjectMd } from './projects.mjs';
14
14
  import { addNotification } from './notifications.mjs';
15
+ import { digestDecision } from './digest-policy.mjs';
15
16
  import { getMetrics } from './metrics.mjs';
16
17
  import { getCostHistory, getInbox } from './data-readers.mjs';
17
18
  // Every sweep below runs over EVERY registered project. `sweep: true` opts a
@@ -410,8 +411,17 @@ function startAlertCron() {
410
411
  if (!t.closed_at) return false;
411
412
  return t.closed_at.slice(0, 10) === yesterday;
412
413
  }).length;
413
- // Skip digest if nothing happened yesterday
414
- if (ySpend === 0 && doneYesterday === 0 && blocked === 0 && gates === 0) continue;
414
+ // Two reasons not to speak, and they are different. Nothing happened and
415
+ // nothing is open: silence. Nothing happened and the same gate is open as
416
+ // yesterday: that sentence was already sent, and sending it every morning
417
+ // is how 31 of 44 digests on this machine carried no new information and
418
+ // two P0s sank among them. Yesterday's reported state is read from
419
+ // alerts-fired.json; no record means UNKNOWN, which sends once — a
420
+ // comparison nobody could make is not a match. See digest-policy.mjs.
421
+ const stateKey = `digest.state:${proj.slug}`;
422
+ const prevState = readAlertsFired()[stateKey] ?? null;
423
+ const decision = digestDecision({ ySpend, doneYesterday, blocked, gates, prevState });
424
+ if (!decision.send) continue;
415
425
  const kvObj = {
416
426
  date: yesterday,
417
427
  'AI spend': `$${ySpend.toFixed(2)}`,
@@ -443,6 +453,12 @@ function startAlertCron() {
443
453
  await fireEmailAlert('digest.daily', dedupeKey, dailyPayload);
444
454
  addNotification('digest.daily', dailyPayload, dedupeKey);
445
455
  await firePushAlert('digest.daily', dedupeKey, dailyPayload);
456
+ // Record what was reported, so tomorrow can tell "unchanged" from "new".
457
+ // After the sends, not before: a digest that failed to go out is not one
458
+ // the operator has read.
459
+ const firedNow = readAlertsFired();
460
+ firedNow[stateKey] = decision.state;
461
+ writeAlertsFired(firedNow);
446
462
  }
447
463
  } catch (e) { log.warn('cron digest.daily failed:', e.message); }
448
464
  }, FIVE_MIN);
@@ -104,7 +104,12 @@ function bdEnv() {
104
104
  }
105
105
  // Centralized bd invocation — resolved binary + augmented PATH for every call site.
106
106
  function bd(args, opts = {}) {
107
- return spawnSync(BD_BIN, args, { encoding: 'utf8', timeout: 8000, ...opts, env: { ...bdEnv(), ...(opts.env || {}) } });
107
+ // 20s default. It was 8000, and `bd` was measured in this repository at up to
108
+ // 8.7s when ten test files spawn it at once (see tests/pipeline-contracts.test.mjs)
109
+ // — so the default cap sat BELOW the known worst case, and every call site that
110
+ // did not name its own timeout inherited a cap the machine could exceed while
111
+ // working correctly. What came back was indistinguishable from beads failing.
112
+ return spawnSync(BD_BIN, args, { encoding: 'utf8', timeout: 20000, ...opts, env: { ...bdEnv(), ...(opts.env || {}) } });
108
113
  }
109
114
  // Turn a failed bd result into an actionable message (the bare "bd update failed" hid ENOENT).
110
115
  function bdErr(r, what) {
@@ -313,7 +318,13 @@ function bdRefreshAsync(cwd, onDone = () => {}) {
313
318
  lastBdRunAt.set(cwd, startedAt);
314
319
  let out = '';
315
320
  try {
316
- const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
321
+ // Bounded like every other bd call. Without a timeout a hung bd never fires
322
+ // 'close', so `refreshing` stays held for this cwd forever and the cache
323
+ // silently serves stale tasks with no error anywhere — the async twin of the
324
+ // sync path's 500s. Node kills on timeout, which DOES fire 'close' with a
325
+ // non-zero code, so the failure handling below reports it rather than the
326
+ // board simply going quiet.
327
+ const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv(), timeout: 20000 });
317
328
  child.stdout?.on('data', (d) => { out += d; });
318
329
  child.on('error', (e) => {
319
330
  refreshing.delete(cwd);
@@ -1,3 +1,4 @@
1
+ import { reversibilityOf } from '../../../scripts/lib/gate-reversibility.mjs';
1
2
  import fs from 'fs';
2
3
  import path from 'path';
3
4
  import os from 'os';
@@ -8,6 +9,17 @@ import { readVerdicts, readSecStats } from './verdicts.mjs';
8
9
  import { datePlans } from './plan-date.mjs';
9
10
 
10
11
  // ── Memory: 4-layer file contents ─────────────────────────────────────────────
12
+ /**
13
+ * The gate's NAME out of a bead title. Titles are shaped `gate:ship — release X`,
14
+ * and the id is a bead id, so the name only ever lives in the title. A title that
15
+ * does not carry one yields '' — which reversibilityOf reports as unclassified,
16
+ * never as routine.
17
+ */
18
+ function gateNameOf(task) {
19
+ const m = /^\s*gate[:\-]\s*([a-z0-9-]+)/i.exec(String(task?.title ?? ''));
20
+ return m ? `gate:${m[1].toLowerCase()}` : '';
21
+ }
22
+
11
23
  function getMemory(cwd = process.cwd()) {
12
24
  const home = os.homedir();
13
25
  const layers = [
@@ -440,7 +452,15 @@ function getInbox(cwd = process.cwd()) {
440
452
  .map((t) => ({ ...t, also: alsoOf.get(t?.id ?? t?.title) || [] }))
441
453
  .slice(0, limit);
442
454
 
443
- const ownedGates = own('gate', pendingGates, 20);
455
+ // ADR-009 says gates follow cost-of-undo, and the pipeline obeys it — but every
456
+ // gate reaches this screen looking the same, so `gate:ship` to production and a
457
+ // brief approval are one purple chip apart at the moment somebody clicks. The
458
+ // doctrine lived in the ADR and in the map and never reached the pixel where
459
+ // the decision is taken. This attaches it; nothing here decides anything.
460
+ const ownedGates = own('gate', pendingGates, 20).map((g) => ({
461
+ ...g,
462
+ reversibility: reversibilityOf(gateNameOf(g)),
463
+ }));
444
464
  const ownedP0 = own('p0', p0, 10);
445
465
  const ownedBlocked = own('blocked', blocked, 10);
446
466
  const ownedStale = own('stale', stale, 10);
@@ -466,4 +486,41 @@ function getInbox(cwd = process.cwd()) {
466
486
  };
467
487
  }
468
488
 
469
- export { getMemory, getPipeline, getCostHistory, getInbox };
489
+ /**
490
+ * What is waiting on the PERSON in the projects this one is not.
491
+ *
492
+ * The inbox headline said "nothing is waiting on you" while two P0s sat open
493
+ * in another project of the same operator's. The sentence was true of the
494
+ * project and false of the person, and the person is who reads it. Every
495
+ * reader above is scoped to one cwd on purpose; this one walks the registry so
496
+ * the headline can be scoped to the human.
497
+ *
498
+ * Three states per project, and the third is not folded into the first:
499
+ * counted its inbox was read and its gate/P0 counts added.
500
+ * nothing read, and nothing there — contributes zero, honestly.
501
+ * unreadable its inbox threw. Listed by name in `unreadable`, NOT counted
502
+ * as zero: a project that could not be read may hold the P0.
503
+ *
504
+ * `readInbox` is injectable so the walk can be tested with stub projects
505
+ * without a beads store behind each one.
506
+ */
507
+ function inboxElsewhere(projects, currentPath, { readInbox = getInbox } = {}) {
508
+ const same = (a, b) => {
509
+ if (!a || !b) return false;
510
+ try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); }
511
+ };
512
+ const out = { p0: 0, gates: 0, projects: [], unreadable: [] };
513
+ for (const p of projects || []) {
514
+ if (!p?.path || same(p.path, currentPath)) continue;
515
+ let s;
516
+ try { s = readInbox(p.path)?.summary; } catch { out.unreadable.push(p.slug || p.path); continue; }
517
+ const p0 = Number(s?.p0) || 0;
518
+ const gates = Number(s?.gates) || 0;
519
+ if (p0 || gates) out.projects.push({ slug: p.slug || path.basename(p.path), p0, gates });
520
+ out.p0 += p0;
521
+ out.gates += gates;
522
+ }
523
+ return out;
524
+ }
525
+
526
+ export { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Should the morning digest speak today?
3
+ *
4
+ * The daily digest skipped a day on which nothing happened — no spend, nothing
5
+ * shipped, no gate, nothing blocked — and sent otherwise. So a project with one
6
+ * gate open for 26 days sent "$0.00 AI · 0 shipped · 🔒 1 gate awaiting
7
+ * approval" every morning for 26 mornings. Measured on the author's machine:
8
+ * 31 of the last 44 digests repeated the previous day's state exactly, and the
9
+ * two P0s in the same feed were buried under them.
10
+ *
11
+ * Two reasons not to speak, and they are different things:
12
+ *
13
+ * nothing-happened nothing moved AND nothing is open. Silence is right.
14
+ * unchanged-state nothing moved AND what is open is exactly what was
15
+ * reported yesterday. The operator has already read this
16
+ * sentence. A reminder is a different feature with a
17
+ * different cadence — see waiting-on-you — not a digest.
18
+ *
19
+ * And three states of "what was reported yesterday", because the record is on
20
+ * disk and disks are lossy:
21
+ *
22
+ * null no record. NOT "unchanged" — a comparison nobody could make is
23
+ * not a match. Send, once, and record.
24
+ * equal reported already. Suppress.
25
+ * different the world moved even though the numbers did not. Send.
26
+ *
27
+ * Activity always speaks: money was spent or work shipped, and a digest of
28
+ * that is the feature.
29
+ */
30
+
31
+ export function digestState({ gates = 0, blocked = 0 } = {}) {
32
+ return `gates:${Number(gates) || 0}|blocked:${Number(blocked) || 0}`;
33
+ }
34
+
35
+ /**
36
+ * @returns {{ send: boolean, reason: 'nothing-happened'|'unchanged-state'|'activity'|'first-report'|'state-changed', state: string }}
37
+ */
38
+ export function digestDecision({ ySpend = 0, doneYesterday = 0, blocked = 0, gates = 0, prevState = null } = {}) {
39
+ const state = digestState({ gates, blocked });
40
+ const moved = (Number(ySpend) || 0) > 0 || (Number(doneYesterday) || 0) > 0;
41
+ const open = (Number(gates) || 0) > 0 || (Number(blocked) || 0) > 0;
42
+
43
+ if (moved) return { send: true, reason: 'activity', state };
44
+ if (!open) return { send: false, reason: 'nothing-happened', state };
45
+ if (prevState == null) return { send: true, reason: 'first-report', state };
46
+ if (prevState === state) return { send: false, reason: 'unchanged-state', state };
47
+ return { send: true, reason: 'state-changed', state };
48
+ }
@@ -15,7 +15,7 @@ import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
15
15
  import { status as routerKeyStatus, writeKey as writeRouterKey } from '../../../scripts/lib/router-key.mjs';
16
16
  import { broadcastTasks } from './sse.mjs';
17
17
  import { saveNotifHistory } from './notifications.mjs';
18
- import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
18
+ import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from './data-readers.mjs';
19
19
  import { log } from './log.mjs';
20
20
  import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
21
21
  import { getMetrics } from './metrics.mjs';
@@ -480,7 +480,11 @@ async function dispatch(req, res, url, cwd) {
480
480
  const r = beadsErr
481
481
  ? { status: 1, error: { code: 'NO_BEADS' } }
482
482
  : bd(['update', id, '--status', status, ...(reason ? ['--notes', `[${action}] ${reason}`] : [])],
483
- { cwd: gateCwd, timeout: 5000 });
483
+ // 20s: this is the gate-approval write, and it failed in the full
484
+ // gate at 71s wall-clock with the gate simply absent from the
485
+ // inbox afterwards. bd was measured at up to 8.7s under the
486
+ // suite's own parallelism; 5s could not be met.
487
+ { cwd: gateCwd, timeout: 20000 });
484
488
  if (r.status !== 0) {
485
489
  if (!setTaskStatusInTasksMd(gateCwd, id, status)) {
486
490
  return { error: (beadsErr ? 'beads unavailable' : bdErr(r, 'bd update failed'))
@@ -489,13 +493,31 @@ async function dispatch(req, res, url, cwd) {
489
493
  via = 'tasks.md';
490
494
  }
491
495
  bdCacheInvalidate(gateCwd);
492
- // Append to global decisions log — still inside the lock window
496
+
497
+ // The decision log, and why this is not one try block any more.
498
+ //
499
+ // It used to be: look the gate's TITLE up through getTasks(), then write
500
+ // the line — all inside one `catch { /* best-effort */ }`, after which the
501
+ // handler returned `{ ok: true }` whatever had happened. getTasks() reads
502
+ // beads, beads takes a Dolt lock, and under a loaded test run that read
503
+ // throws. So the approval landed, the API answered 200 ok:true, and the
504
+ // decision was never recorded — a write that did not happen, wearing the
505
+ // response of one that did. It surfaced as a test failing one run in four
506
+ // with "Got 0 decisions", four layers away from the swallowed exception.
507
+ //
508
+ // The title is a NICETY — `id` was already its documented fallback. The
509
+ // line is the record. So the lookup gets its own try and cannot take the
510
+ // record down with it, and the caller is told which of the two happened.
511
+ const projectSlug = parsed.project || path.basename(gateCwd);
512
+ let title = id;
513
+ try {
514
+ const gateTask = getTasks(gateCwd).find((t) => t.id === id);
515
+ if (gateTask?.title) title = gateTask.title;
516
+ } catch { /* the id is a documented fallback; the record still gets written */ }
517
+ gateTitle = title;
518
+
519
+ let decision;
493
520
  try {
494
- const projectSlug = parsed.project || path.basename(gateCwd);
495
- const allTasks = getTasks(gateCwd);
496
- const gateTask = allTasks.find(t => t.id === id);
497
- const title = gateTask?.title || id;
498
- gateTitle = title;
499
521
  appendDecisionLog({
500
522
  ts: new Date().toISOString(),
501
523
  project: projectSlug,
@@ -505,8 +527,16 @@ async function dispatch(req, res, url, cwd) {
505
527
  reason: reason || '',
506
528
  cwd: gateCwd, // project-scoped (ADR-008) — never the global log
507
529
  });
508
- } catch { /* best-effort */ }
509
- return { ok: true, via };
530
+ decision = { logged: true };
531
+ } catch (err) {
532
+ // Named, not swallowed — the same rule the `wake` handler thirty lines
533
+ // below already states: a handler that reports nothing is only better
534
+ // than one that guesses. The approval itself stands; what is reported
535
+ // is that its record does not.
536
+ log.warn(`[decisions] approve ${id} landed but was not logged: ${err?.message || err}`);
537
+ decision = { logged: false, why: String(err?.message || err) };
538
+ }
539
+ return { ok: true, via, decision };
510
540
  });
511
541
  if (!result || result.error) {
512
542
  res.writeHead(500, { 'Content-Type': 'application/json' });
@@ -543,7 +573,11 @@ async function dispatch(req, res, url, cwd) {
543
573
  }
544
574
 
545
575
  res.writeHead(200, { 'Content-Type': 'application/json' });
546
- res.end(JSON.stringify({ ok: true, id, action, via: result.via, ...(wake ? { wake } : {}) }));
576
+ // `decision` rides out with the response for the same reason `wake` does:
577
+ // the caller asked for a gate to be approved and a record to be kept, and
578
+ // `ok: true` alone cannot say whether the second half happened.
579
+ res.end(JSON.stringify({ ok: true, id, action, via: result.via,
580
+ ...(result.decision ? { decision: result.decision } : {}), ...(wake ? { wake } : {}) }));
547
581
  broadcastTasks(gateCwd);
548
582
  // Auto-republish share report when a gate is approved (fire-and-forget)
549
583
  if (action === 'approve') {
@@ -560,8 +594,16 @@ async function dispatch(req, res, url, cwd) {
560
594
 
561
595
  // Inbox — what needs your attention right now
562
596
  if (pathname === '/api/inbox') {
597
+ const inbox = getInbox(cwd);
598
+ // What is waiting on the person in their OTHER projects. The headline and
599
+ // the badge are about the person, and the person is not scoped to `cwd`.
600
+ // If the registry itself cannot be walked, say so — `unreadable` is not
601
+ // `{p0: 0}`, and the page must not print "nothing elsewhere" from it.
602
+ let elsewhere;
603
+ try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
604
+ catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
563
605
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
564
- res.end(JSON.stringify(getInbox(cwd)));
606
+ res.end(JSON.stringify({ ...inbox, elsewhere }));
565
607
  return true;
566
608
  }
567
609
 
@@ -882,8 +924,20 @@ async function dispatch(req, res, url, cwd) {
882
924
  // Concurrent POST /api/tasks calls used to race on bd's file lock —
883
925
  // one crash would leave a stale .beads/.lock that froze ALL writes.
884
926
  const result = await bdWriteSerialised(() => {
885
- const r = bd(args, { cwd, timeout: 5000 });
886
- if (r.status !== 0) return { error: bdErr(r, 'bd create failed') };
927
+ // 20s, not 5s. `bd` was measured in this repository at 1.9s alone and
928
+ // up to 8.7s when ten test files spawn it at once (see the note in
929
+ // tests/pipeline-contracts.test.mjs), and bdWriteSerialised queues
930
+ // every write behind every other write on top of that. A 5s cap could
931
+ // not be met under the load the suite creates for itself, so "did bd
932
+ // succeed" was really asking "was the machine quiet" — and the answer
933
+ // came back as HTTP 500, indistinguishable from a malformed request.
934
+ const r = bd(args, { cwd, timeout: 20000 });
935
+ if (r.status !== 0) {
936
+ // A cap we imposed firing is not the same as beads refusing. The
937
+ // caller can retry one of them and not the other.
938
+ const timedOut = r.error && r.error.code === 'ETIMEDOUT';
939
+ return { error: bdErr(r, 'bd create failed'), timedOut };
940
+ }
887
941
  const idMatch = (r.stdout || '').match(/Created issue:\s*(\S+)/);
888
942
  const id = idMatch ? idMatch[1] : null;
889
943
 
@@ -897,14 +951,22 @@ async function dispatch(req, res, url, cwd) {
897
951
  if (lbl) { updateArgs.push('--add-label', lbl); needUpdate = true; }
898
952
  }
899
953
  if (agent && !lbls.includes(agent)) { updateArgs.push('--add-label', agent); needUpdate = true; }
900
- if (needUpdate) bd(updateArgs, { cwd, timeout: 5000 });
954
+ if (needUpdate) bd(updateArgs, { cwd, timeout: 20000 });
901
955
  }
902
956
  return { id };
903
957
  });
904
958
 
905
959
  if (!result || result.error) {
906
- res.writeHead(500, { 'Content-Type': 'application/json' });
907
- res.end(JSON.stringify({ error: (result && result.error) || 'bd create failed' }));
960
+ // 503 for a timeout: the request was fine, beads was busy, and coming
961
+ // back shortly is the right response. 500 told every caller — the UI,
962
+ // the tests, a person — that they had done something wrong.
963
+ const status = result && result.timedOut ? 503 : 500;
964
+ if (status === 503) res.setHeader('Retry-After', '5');
965
+ res.writeHead(status, { 'Content-Type': 'application/json' });
966
+ res.end(JSON.stringify({
967
+ error: (result && result.error) || 'bd create failed',
968
+ retryable: status === 503,
969
+ }));
908
970
  return;
909
971
  }
910
972
 
@@ -942,7 +1004,9 @@ async function dispatch(req, res, url, cwd) {
942
1004
  return;
943
1005
  }
944
1006
  const result = await bdWriteSerialised(() => {
945
- const r = bd(['update', id, '--status', status], { cwd, timeout: 5000 });
1007
+ // 20s for the same reason as the create path above: bd was measured at
1008
+ // up to 8.7s under `node --test` parallelism, and 5s could not be met.
1009
+ const r = bd(['update', id, '--status', status], { cwd, timeout: 20000 });
946
1010
  if (r.status !== 0) return { error: bdErr(r, 'bd update failed') };
947
1011
  bdCacheInvalidate(cwd);
948
1012
  return { ok: true };
@@ -980,7 +1044,7 @@ async function dispatch(req, res, url, cwd) {
980
1044
  return;
981
1045
  }
982
1046
  const result = await bdWriteSerialised(() => {
983
- const r = bd(['update', id, '--priority', String(priority)], { cwd, timeout: 5000 });
1047
+ const r = bd(['update', id, '--priority', String(priority)], { cwd, timeout: 20000 });
984
1048
  if (r.status !== 0) return { error: bdErr(r, 'bd update failed') };
985
1049
  bdCacheInvalidate(cwd);
986
1050
  return { ok: true };
@@ -1017,6 +1081,13 @@ async function dispatch(req, res, url, cwd) {
1017
1081
  try {
1018
1082
  const lines = fs.readFileSync(interactionsFile, 'utf8').split('\n').filter(Boolean);
1019
1083
  const events = [];
1084
+ // A row we could not parse is dropped — that is right, one bad line is
1085
+ // not evidence — but it was dropped SILENTLY, so a short history and a
1086
+ // complete one looked identical. Count them and send the count: the
1087
+ // client can then say "3 rows unreadable" instead of quietly showing less
1088
+ // than happened. Same rule as routes.mjs:661 one screen up, which does
1089
+ // report what it skipped.
1090
+ let unreadableRows = 0;
1020
1091
  for (const line of lines) {
1021
1092
  try {
1022
1093
  const obj = JSON.parse(line);
@@ -1030,10 +1101,10 @@ async function dispatch(req, res, url, cwd) {
1030
1101
  notes: obj.notes || null,
1031
1102
  });
1032
1103
  }
1033
- } catch {}
1104
+ } catch { unreadableRows++; }
1034
1105
  }
1035
1106
  res.writeHead(200, { 'Content-Type': 'application/json' });
1036
- res.end(JSON.stringify({ events }));
1107
+ res.end(JSON.stringify({ events, unreadable_rows: unreadableRows }));
1037
1108
  } catch (e) {
1038
1109
  res.writeHead(500, { 'Content-Type': 'application/json' });
1039
1110
  res.end(JSON.stringify({ error: String(e.message || e) }));
@@ -1459,6 +1530,8 @@ async function dispatch(req, res, url, cwd) {
1459
1530
  let budgetsDeprecatedKey = null;
1460
1531
  const budgetsMalformed = [];
1461
1532
  let goalAncestry = null;
1533
+ let budgetsRead = true;
1534
+ let budgetsWhy = null;
1462
1535
  try {
1463
1536
  const projectTxt = fs.readFileSync(projectMdPath, 'utf8');
1464
1537
  // One parser, in scripts/lib/agent-budget.mjs — the same one the pipeline
@@ -1476,15 +1549,30 @@ async function dispatch(req, res, url, cwd) {
1476
1549
  const phase = (projectTxt.match(/^phase:\s*(\S+)/m) || [])[1] || null;
1477
1550
  const complianceClean = compliance && !/^\[?none\]?$/i.test(compliance.trim()) ? compliance.trim() : null;
1478
1551
  if (archetype) goalAncestry = `[archetype:${archetype}]${complianceClean ? ` [compliance:${complianceClean}]` : ''}${phase ? ` [phase:${phase}]` : ''}`;
1479
- } catch { /* project not found */ }
1552
+ budgetsRead = true;
1553
+ } catch (err) {
1554
+ // "No PROJECT.md" and "PROJECT.md is there and we could not read it" are
1555
+ // different answers, and budgets={} renders as "no caps set" for both. A
1556
+ // cap its author believes they have, silently not shown, is the same
1557
+ // defect the caps exist to prevent — so say which happened.
1558
+ budgetsRead = false;
1559
+ budgetsWhy = err?.code === 'ENOENT' ? 'no PROJECT.md' : String(err?.message || err);
1560
+ }
1480
1561
 
1481
1562
  // Tool failure rate in last hour
1482
1563
  let toolFailureRate1h = 0;
1564
+ let failuresMeasured = true;
1565
+ let failuresWhy = null;
1483
1566
  try {
1484
1567
  const failLog = fs.readFileSync(path.join(cwd, '.great_cto', 'tool-failures.log'), 'utf8');
1485
1568
  const cutoff = new Date(nowMs - 3600000).toISOString();
1486
1569
  toolFailureRate1h = failLog.split('\n').filter(l => l > cutoff && l.trim()).length;
1487
- } catch { /* no log */ }
1570
+ } catch (err) {
1571
+ // Zero failures and an unreadable log render identically, and the
1572
+ // reassuring one is what the operator would see.
1573
+ failuresMeasured = false;
1574
+ failuresWhy = err?.code === 'ENOENT' ? 'no tool-failures.log' : String(err?.message || err);
1575
+ }
1488
1576
 
1489
1577
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1490
1578
  // `budgets_deprecated_key` and `budgets_malformed` travel with the caps.
@@ -1495,6 +1583,12 @@ async function dispatch(req, res, url, cwd) {
1495
1583
  stuck, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
1496
1584
  budgets_deprecated_key: budgetsDeprecatedKey,
1497
1585
  budgets_malformed: budgetsMalformed,
1586
+ // Three states, not two: read / absent / unreadable. Without these, a
1587
+ // failed read is indistinguishable from an empty result on the client.
1588
+ budgets_read: budgetsRead,
1589
+ budgets_why: budgetsWhy,
1590
+ tool_failures_measured: failuresMeasured,
1591
+ tool_failures_why: failuresWhy,
1498
1592
  }));
1499
1593
  return true;
1500
1594
  }