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.
- package/board/.claude-plugin/plugin.json +15 -8
- package/board/packages/board/lib/alerts.mjs +18 -2
- package/board/packages/board/lib/beads.mjs +13 -2
- package/board/packages/board/lib/data-readers.mjs +59 -2
- package/board/packages/board/lib/digest-policy.mjs +48 -0
- package/board/packages/board/lib/routes.mjs +117 -23
- package/board/packages/board/public/index.html +406 -200
- package/board/packages/board/server.mjs +7 -1
- package/board/scripts/lib/doc-links.mjs +6 -1
- package/board/scripts/lib/gate-reversibility.mjs +118 -0
- package/dist/board-path.js +14 -8
- package/package.json +1 -1
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name": "
|
|
3
|
-
"
|
|
4
|
-
"
|
|
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": "
|
|
8
|
-
"url": "https://
|
|
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 ===';
|
|
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
|
-
//
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
886
|
-
|
|
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:
|
|
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
|
-
|
|
907
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|