great-cto 3.61.0 → 3.62.1

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.
Files changed (53) hide show
  1. package/board/.claude-plugin/plugin.json +23 -1
  2. package/board/packages/board/fixtures/beads-read-owner.mjs +3 -0
  3. package/board/packages/board/fixtures/fake-bd-controlled.mjs +49 -0
  4. package/board/packages/board/lib/alerts.mjs +21 -7
  5. package/board/packages/board/lib/bd-read-worker.mjs +64 -0
  6. package/board/packages/board/lib/beads.mjs +138 -35
  7. package/board/packages/board/lib/config.mjs +11 -4
  8. package/board/packages/board/lib/data-readers.mjs +13 -15
  9. package/board/packages/board/lib/metrics.mjs +1 -2
  10. package/board/packages/board/lib/projects.mjs +48 -17
  11. package/board/packages/board/lib/routes.mjs +46 -9
  12. package/board/packages/board/lib/share.mjs +4 -2
  13. package/board/packages/board/lib/sse.mjs +34 -5
  14. package/board/packages/board/lib/watchers.mjs +7 -18
  15. package/board/packages/board/public/index.html +26 -4
  16. package/board/packages/board/push-adapter.mjs +3 -3
  17. package/board/packages/cli/src/private-state.mjs +43 -0
  18. package/board/packages/cli/src/state-home.mjs +15 -0
  19. package/board/scripts/codex-pipeline.mjs +34 -5
  20. package/board/scripts/hooks/auto-attach-reviewers.mjs +449 -0
  21. package/board/scripts/lib/adaptive-benchmark-collector.mjs +202 -0
  22. package/board/scripts/lib/adaptive-benchmark-protocol.mjs +75 -0
  23. package/board/scripts/lib/agent-execution-budget.mjs +143 -0
  24. package/board/scripts/lib/approval-level.mjs +174 -0
  25. package/board/scripts/lib/benchmark-review-dispatch.mjs +52 -0
  26. package/board/scripts/lib/benchmark-scorer-signature.mjs +64 -0
  27. package/board/scripts/lib/claude-exec.mjs +11 -3
  28. package/board/scripts/lib/codex-checks.mjs +77 -6
  29. package/board/scripts/lib/codex-exec.mjs +13 -2
  30. package/board/scripts/lib/codex-host-state.mjs +2 -0
  31. package/board/scripts/lib/codex-pipeline.mjs +281 -38
  32. package/board/scripts/lib/codex-release.mjs +9 -5
  33. package/board/scripts/lib/codex-role-profiles.mjs +46 -1
  34. package/board/scripts/lib/controlled-specialists.mjs +150 -0
  35. package/board/scripts/lib/controller-dispatch-evidence.mjs +60 -0
  36. package/board/scripts/lib/pinned-benchmark-scorer.mjs +132 -0
  37. package/board/scripts/lib/receipt.mjs +31 -6
  38. package/board/scripts/lib/review-graph-floor.mjs +54 -0
  39. package/board/scripts/lib/runtime-gate-policy.mjs +84 -0
  40. package/board/scripts/lib/scoped-review-reuse.mjs +154 -0
  41. package/board/scripts/lib/specialist-plan.mjs +102 -0
  42. package/board/scripts/lib/turn-snapshot.mjs +24 -4
  43. package/dist/codex-host.js +1 -1
  44. package/dist/private-state.mjs +53 -0
  45. package/dist/serve.js +10 -5
  46. package/dist/state-home.mjs +15 -0
  47. package/dist/task-queue.js +2 -2
  48. package/dist/update-check.js +2 -5
  49. package/dist/webhook-cli.js +2 -2
  50. package/dist/webhook-config.js +18 -14
  51. package/dist/webhook-dispatch.js +5 -8
  52. package/dist/worker.js +3 -2
  53. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 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.61.0",
4
+ "version": "3.62.1",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -141,6 +141,17 @@
141
141
  }
142
142
  ],
143
143
  "PreToolUse": [
144
+ {
145
+ "matcher": "Agent|Task",
146
+ "hooks": [
147
+ {
148
+ "type": "command",
149
+ "timeout": 5,
150
+ "command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && { [ \"$_gd\" = \"$HOME\" ] || [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; }; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; [ -n \"$GREAT_CTO_AGENT_BUDGET_FILE\" ] || exit 0; node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/agent-execution-budget.mjs\" pre; _budget_exit=$?; if [ $_budget_exit -ne 0 ]; then exit 2; fi",
151
+ "statusMessage": "Checking shared agent execution budget..."
152
+ }
153
+ ]
154
+ },
144
155
  {
145
156
  "matcher": "Bash",
146
157
  "hooks": [
@@ -195,6 +206,17 @@
195
206
  }
196
207
  ],
197
208
  "PostToolUse": [
209
+ {
210
+ "matcher": "Agent|Task",
211
+ "hooks": [
212
+ {
213
+ "type": "command",
214
+ "timeout": 5,
215
+ "command": "_gd=\"$PWD\"; while [ -n \"$_gd\" ] && { [ \"$_gd\" = \"$HOME\" ] || [ ! -f \"$_gd/.great_cto/PROJECT.md\" ]; }; do _gd=\"${_gd%/*}\"; done; [ -n \"$_gd\" ] && cd \"$_gd\"; [ -n \"$GREAT_CTO_AGENT_BUDGET_FILE\" ] || exit 0; node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/agent-execution-budget.mjs\" post; _budget_exit=$?; if [ $_budget_exit -ne 0 ]; then exit 2; fi",
216
+ "statusMessage": "Releasing completed foreground agent budget lease..."
217
+ }
218
+ ]
219
+ },
198
220
  {
199
221
  "matcher": "Write|Edit|MultiEdit",
200
222
  "hooks": [
@@ -0,0 +1,3 @@
1
+ // Fixed test-only parent, killed after the child-start handshake.
2
+ import { getTasksAsync } from '../lib/beads.mjs';
3
+ await getTasksAsync(process.cwd());
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+ // Fixed local test protocol. No shell commands or operator stores.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ const root = process.env.FAKE_BD_CONTROL_ROOT;
7
+ if (!root || !path.isAbsolute(root)) process.exit(2);
8
+ const key = path.basename(process.cwd());
9
+ const marker = name => path.join(root, `${key}.${name}`);
10
+ if (process.argv.includes('--descendant')) {
11
+ process.on('SIGTERM', () => {});
12
+ fs.writeFileSync(marker('descendant'), String(process.pid));
13
+ setInterval(() => {}, 1000);
14
+ } else {
15
+ fs.appendFileSync(marker('started'), `${process.pid}\n`);
16
+ fs.writeFileSync(marker('worker'), String(process.ppid));
17
+ if (process.env.FAKE_BD_CONTROL_MODE === 'fail') {
18
+ process.exit(1);
19
+ } else if (['hang', 'leader-exit'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
20
+ process.on('SIGTERM', () => {});
21
+ spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'inherit' });
22
+ if (process.env.FAKE_BD_CONTROL_MODE === 'leader-exit') {
23
+ setInterval(() => { if (fs.existsSync(marker('descendant'))) process.exit(0); }, 25);
24
+ }
25
+ setInterval(() => {}, 1000);
26
+ } else if (process.env.FAKE_BD_CONTROL_MODE === 'large-success') {
27
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'x'.repeat(131072), status: 'open', priority: 0 }]), () => {
28
+ fs.writeFileSync(marker('output-written'), 'ready');process.exit(0);
29
+ });
30
+ } else if (['closed-success', 'closed-fail'].includes(process.env.FAKE_BD_CONTROL_MODE)) {
31
+ const descendant = spawn(process.execPath, [new URL(import.meta.url).pathname, '--descendant'], { stdio: 'ignore' });
32
+ descendant.unref();
33
+ const ready = setInterval(() => {
34
+ if (!fs.existsSync(marker('descendant'))) return;
35
+ clearInterval(ready);
36
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]),
37
+ () => process.exit(process.env.FAKE_BD_CONTROL_MODE === 'closed-fail' ? 1 : 0));
38
+ }, 25);
39
+ } else if (process.env.FAKE_BD_CONTROL_MODE === 'oversize') {
40
+ process.stdout.write(Buffer.alloc(16 * 1024 * 1024 + 1, 120));
41
+ setInterval(() => {}, 1000);
42
+ } else {
43
+ const timer = setInterval(() => {
44
+ if (!fs.existsSync(marker('release'))) return;
45
+ clearInterval(timer);
46
+ process.stdout.write(JSON.stringify([{ id: 'FIXTURE-1', title: 'Fixture task', status: 'open', priority: 0 }]));
47
+ }, 25);
48
+ }
49
+ }
@@ -27,7 +27,7 @@ import { getCostHistory, getInbox } from './data-readers.mjs';
27
27
  // reporting "live · synced just now". `sweep: true` must NEVER be added to
28
28
  // an interactive read's opts: an ordinary request is someone waiting on
29
29
  // THIS project's answer, and the fill path answers `[]` instead.
30
- import { getTasks, SWEEP_MAX_AGE_MS } from './beads.mjs';
30
+ import { getTasks, getTasksAsync, getReadDegradation, SWEEP_MAX_AGE_MS } from './beads.mjs';
31
31
  const SWEEP = { maxAgeMs: SWEEP_MAX_AGE_MS, sweep: true };
32
32
  import { readVerdicts } from './verdicts.mjs';
33
33
  import { isFailure } from './fleet.mjs';
@@ -303,11 +303,16 @@ function startAlertCron() {
303
303
  }, FIVE_MIN);
304
304
 
305
305
  // cost.threshold: monthly LLM spend at 80% / 100% of budget
306
- setInterval(() => {
306
+ setInterval(async () => {
307
307
  try {
308
308
  const projects = listProjects();
309
309
  for (const proj of projects) {
310
- const m = getMetrics(proj.path);
310
+ let tasks;
311
+ try {
312
+ tasks = await getTasksAsync(proj.path);
313
+ if (getReadDegradation(proj.path)) continue;
314
+ } catch { continue; } // Unknown spend must not be treated as zero.
315
+ const m = getMetrics(proj.path, 30, { tasks });
311
316
  const meta = readProjectMd(proj.path) || {};
312
317
  const budget = parseFloat(meta['monthly-budget']?.replace?.(/[$\s]/g, '') || '0');
313
318
  if (!budget) continue;
@@ -356,7 +361,12 @@ function startAlertCron() {
356
361
  // next project reads alerts-fired.json. Prevents the race condition where
357
362
  // all N projects read an empty map simultaneously and all fire.
358
363
  for (const proj of projects) {
359
- const m = getMetrics(proj.path);
364
+ let tasks;
365
+ try {
366
+ tasks = await getTasksAsync(proj.path);
367
+ if (getReadDegradation(proj.path)) continue;
368
+ } catch { continue; } // Unknown is not a zero-activity weekly report.
369
+ const m = getMetrics(proj.path, 30, { tasks });
360
370
  const dedupeKey = `digest.weekly:${proj.slug}:${isoWeek}`;
361
371
  const weeklyPayload = {
362
372
  title: `${proj.slug} weekly — ${m.tasks?.done || 0} shipped, $${(m.cost?.llm_usd || 0).toFixed(2)} spent`,
@@ -395,18 +405,22 @@ function startAlertCron() {
395
405
  const projects = listProjects();
396
406
  for (const proj of projects) {
397
407
  const dedupeKey = `digest.daily:${proj.slug}:${isoDay}`;
408
+ let tasks;
409
+ try {
410
+ tasks = await getTasksAsync(proj.path);
411
+ if (getReadDegradation(proj.path)) continue;
412
+ } catch { continue; } // Do not send or record a fabricated quiet day.
398
413
  // Pull last 2 days so "yesterday" is always in the window
399
- const cost = getCostHistory(proj.path, 2);
414
+ const cost = getCostHistory(proj.path, 2, { tasks });
400
415
  const yBucket = (cost.series || []).find(s => s.date === yesterday);
401
416
  const ySpend = yBucket ? yBucket.llm : 0;
402
417
  // Feature breakdown for yesterday
403
418
  const topFeatures = (cost.by_feature || []).slice(0, 3);
404
419
  // Inbox for blocked + gates
405
- const inbox = (() => { try { return getInbox(proj.path); } catch { return {}; } })();
420
+ const inbox = getInbox(proj.path, { tasks });
406
421
  const blocked = inbox.summary?.blocked ?? 0;
407
422
  const gates = inbox.summary?.gates ?? 0;
408
423
  // Tasks closed yesterday
409
- const tasks = (() => { try { return getTasks(proj.path, SWEEP); } catch { return []; } })();
410
424
  const doneYesterday = tasks.filter(t => {
411
425
  if (!t.closed_at) return false;
412
426
  return t.closed_at.slice(0, 10) === yesterday;
@@ -0,0 +1,64 @@
1
+ // A read's process-group lifetime must not depend on the HTTP event loop.
2
+ // Forked into a newly owned group; ordinary module imports do not start work.
3
+ import { fork, spawn } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ const entry = fileURLToPath(import.meta.url);
6
+ export function spawnBdRead(binary, { readDeadlineMs = 20250, ...options }) {
7
+ return fork(entry, [binary, String(readDeadlineMs)], { ...options, silent: true });
8
+ }
9
+ if (process.argv[1] === entry) {
10
+ const grouped = process.platform !== 'win32';
11
+ let child;
12
+ function killOwnedGroup() {
13
+ // POSIX fork is detached: the worker PID is exclusively this read's PGID.
14
+ if (grouped) {
15
+ try { process.kill(-process.pid, 'SIGKILL'); } catch { process.exit(1); }
16
+ } else {
17
+ try { child?.kill('SIGKILL'); } catch { /* already gone */ }
18
+ process.exit(1);
19
+ }
20
+ }
21
+ process.on('disconnect', killOwnedGroup);
22
+ process.on('SIGTERM', () => {}); // keep the independent KILL deadline alive
23
+ process.stdout.on('error', killOwnedGroup);
24
+ process.stderr.on('error', killOwnedGroup);
25
+ // Independent upper bound even if the parent's event loop is stalled.
26
+ const budget = Number(process.argv[3]);
27
+ if (!Number.isFinite(budget) || budget < 1 || budget > 20250) process.exit(2);
28
+ setTimeout(killOwnedGroup, budget);
29
+ let completing = false;
30
+ function complete(code) {
31
+ if (completing) return;
32
+ completing = true;
33
+ // A successful leader can leave descendants with closed/ignored pipes.
34
+ // Flush the result over IPC before killing the whole owned group. Keep
35
+ // the independent deadline alive until cleanup, even if IPC stalls.
36
+ if (!process.connected) return killOwnedGroup();
37
+ // child 'close' means its pipes ended, not that this worker's output
38
+ // queues reached the parent. Flush before announcing success or KILL
39
+ // can truncate an otherwise valid snapshot under pipe backpressure.
40
+ try { process.stdout.write('', stdoutError => {
41
+ if (stdoutError) return killOwnedGroup();
42
+ process.stderr.write('', stderrError => {
43
+ if (stderrError || !process.connected) return killOwnedGroup();
44
+ try { process.send({ type: 'bd-read-result', code }, () => killOwnedGroup()); }
45
+ catch { killOwnedGroup(); }
46
+ });
47
+ }); }
48
+ catch { killOwnedGroup(); }
49
+ }
50
+ try {
51
+ child = spawn(process.argv[2], ['list', '--json', '--all', '--include-gates'], {
52
+ stdio: ['ignore', 'pipe', 'pipe']
53
+ });
54
+ child.stdout.pipe(process.stdout, { end: false });
55
+ child.stderr.pipe(process.stderr, { end: false });
56
+ child.on('error', () => complete(1));
57
+ child.on('close', code => {
58
+ // No background descendants are part of a completed read contract.
59
+ // Wait for inherited pipes too: exiting on leader 'exit' would remove
60
+ // the IPC watchdog while a descendant was still holding those pipes.
61
+ complete(Number.isInteger(code) ? code : 1);
62
+ });
63
+ } catch { complete(1); }
64
+ }
@@ -5,6 +5,7 @@ import { spawnSync, spawn } from 'child_process';
5
5
  import { readSafe } from './util.mjs';
6
6
  import { bdCache } from './state.mjs';
7
7
  import { log } from './log.mjs';
8
+ import { spawnBdRead } from './bd-read-worker.mjs';
8
9
 
9
10
  // ── Beads data ─────────────────────────────────────────────────────────────────
10
11
  // Cache bdList output per cwd for BD_CACHE_TTL_MS. Invalidated when the project's
@@ -39,13 +40,11 @@ import { log } from './log.mjs';
39
40
  const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
40
41
 
41
42
  /**
42
- * When a cwd was last told "your cache entry is wrong" — by an invalidate or
43
- * a stale-mark, whichever most recently ran. A background fetch that STARTED
44
- * before this timestamp answers a question nobody is asking anymore: see
45
- * `bdRefreshAsync`, which compares its own start time against this map before
46
- * committing its result, so a slow fetch racing a write always loses to it.
43
+ * Per-project invalidation generation. A background fetch may publish only
44
+ * when its generation is still current. Wall-clock timestamps cannot order
45
+ * an invalidate followed by a read within the same millisecond.
47
46
  */
48
- const lastInvalidatedAt = new Map();
47
+ const invalidationGeneration = new Map();
49
48
 
50
49
  /**
51
50
  * Drop a directory's entry so the next read is guaranteed fresh.
@@ -56,7 +55,7 @@ const lastInvalidatedAt = new Map();
56
55
  */
57
56
  function bdCacheInvalidate(cwd) {
58
57
  clearSelfTouch(cwd);
59
- lastInvalidatedAt.set(cwd, Date.now());
58
+ invalidationGeneration.set(cwd, (invalidationGeneration.get(cwd) || 0) + 1);
60
59
  bdCache.delete(cwd);
61
60
  }
62
61
 
@@ -73,7 +72,7 @@ function bdCacheInvalidate(cwd) {
73
72
  */
74
73
  function bdCacheStale(cwd) {
75
74
  clearSelfTouch(cwd);
76
- lastInvalidatedAt.set(cwd, Date.now());
75
+ invalidationGeneration.set(cwd, (invalidationGeneration.get(cwd) || 0) + 1);
77
76
  const cached = bdCache.get(cwd);
78
77
  if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
79
78
  }
@@ -297,6 +296,17 @@ const EMPTY_TTL_MS = Number(process.env.GREAT_CTO_BD_EMPTY_TTL_MS || 5000);
297
296
  * Without this, ten requests arriving while one refresh runs start ten more.
298
297
  */
299
298
  const refreshing = new Set();
299
+ const refreshWaiters = new Map();
300
+ const refreshQueue = [];
301
+ let activeReads = 0;
302
+ const READ_TIMEOUT_MS = 20000;
303
+ const READ_MAX_BYTES = 16 * 1024 * 1024;
304
+ function drainRefreshQueue() {
305
+ while (activeReads < 4 && refreshQueue.length) {
306
+ activeReads++;
307
+ refreshQueue.shift()();
308
+ }
309
+ }
300
310
 
301
311
  /**
302
312
  * Refresh a directory's entry WITHOUT holding the event loop.
@@ -304,44 +314,108 @@ const refreshing = new Set();
304
314
  * `spawnSync` is what made this board unanswerable: `bd list` costs seconds and
305
315
  * blocks everything for the whole of it — /api/version, one readdirSync,
306
316
  * measured at 1-10 s because it was queued behind a task read. Warming at boot
307
- * moved the first stall out of sight; this removes the rest.
317
+ * moved the first stall out of sight; HTTP cold reads now await this path.
318
+ * Direct synchronous write broadcasts and background readers remain separate.
308
319
  *
309
- * `onDone`, if given, fires exactly once at every exit point (success,
310
- * failure, or "already refreshing"). Every existing caller omits it and stays
311
- * fire-and-forget — the ONE caller that wants to know when the fill finished
312
- * is the boot warm-up (see `warmTasksAsync`), which logs how long it took.
320
+ * `onDone`, if given, fires exactly once when the shared read finishes. A
321
+ * concurrent reader joins it rather than receiving a premature "skipped".
322
+ * Background callers omit it and stay fire-and-forget. Boot warm-up and
323
+ * interactive cold reads join the same completion through `warmTasksAsync`.
313
324
  */
314
- function bdRefreshAsync(cwd, onDone = () => {}) {
315
- if (refreshing.has(cwd)) { onDone({ ok: false, skipped: true }); return; }
325
+ function bdRefreshAsync(cwd, onDone = null) {
326
+ if (refreshing.has(cwd)) { if (onDone) refreshWaiters.get(cwd).push(onDone); return; }
316
327
  refreshing.add(cwd);
328
+ refreshWaiters.set(cwd, onDone ? [onDone] : []);
329
+ const due = performance.now() + READ_TIMEOUT_MS;
330
+ const run = () => {
331
+ clearTimeout(queuedDeadline);
332
+ startRefresh(cwd, Math.max(1, due - performance.now()));
333
+ };
334
+ const queuedDeadline = setTimeout(() => {
335
+ const index = refreshQueue.indexOf(run);
336
+ if (index < 0) return;
337
+ refreshQueue.splice(index, 1);
338
+ refreshing.delete(cwd);
339
+ bdFailures.set(cwd, 'bd read timed out waiting for a slot');
340
+ const waiters = refreshWaiters.get(cwd) || [];
341
+ refreshWaiters.delete(cwd);
342
+ for (const done of waiters) done({ ok: false });
343
+ }, READ_TIMEOUT_MS);
344
+ refreshQueue.push(run);
345
+ drainRefreshQueue();
346
+ }
347
+
348
+ function startRefresh(cwd, remainingMs) {
317
349
  const startedAt = Date.now();
350
+ const generation = invalidationGeneration.get(cwd) || 0;
318
351
  lastBdRunAt.set(cwd, startedAt);
319
- let out = '';
352
+ let out = '', outBytes = 0;
353
+ let child, deadline, killDeadline, finished = false, workerResult;
354
+ const grouped = process.platform !== 'win32';
355
+ function signalOwned(signal) {
356
+ if (!child?.pid) return;
357
+ try { if (grouped) process.kill(-child.pid, signal); else child.kill(signal); } catch { /* already exited */ }
358
+ }
359
+ function finish(result) {
360
+ if (finished) return;
361
+ finished = true;
362
+ clearTimeout(deadline); clearTimeout(killDeadline);
363
+ refreshing.delete(cwd);
364
+ const waiters = refreshWaiters.get(cwd) || [];
365
+ refreshWaiters.delete(cwd);
366
+ activeReads--;
367
+ for (const done of waiters) done(result);
368
+ drainRefreshQueue();
369
+ }
370
+ function stop(reason) {
371
+ if (finished || killDeadline) return;
372
+ bdFailures.set(cwd, reason);
373
+ signalOwned('SIGTERM');
374
+ // TERM alone can leave a read and inherited pipes alive indefinitely.
375
+ killDeadline = setTimeout(() => {
376
+ signalOwned('SIGKILL');
377
+ child.stdout?.destroy(); child.stderr?.destroy();
378
+ finish({ ok: false });
379
+ }, 250);
380
+ }
320
381
  try {
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 });
328
- child.stdout?.on('data', (d) => { out += d; });
382
+ // At most four reads run concurrently. The fixed deadline escalates TERM
383
+ // to KILL for this newly owned process group; Node's TERM-only timeout did
384
+ // not release the slot when a child ignored it or inherited pipes stayed open.
385
+ child = spawnBdRead(BD_BIN, { cwd, env: bdEnv(), detached: grouped, readDeadlineMs: Math.ceil(remainingMs) + 250 });
386
+ child.on('message', message => {
387
+ if (message?.type === 'bd-read-result' && Number.isInteger(message.code)) workerResult = message.code;
388
+ });
389
+ deadline = setTimeout(() => stop('bd read timed out'), remainingMs);
390
+ child.stdout?.setEncoding('utf8');
391
+ child.stdout?.on('data', (d) => {
392
+ if (finished || killDeadline) return;
393
+ outBytes += Buffer.byteLength(d);
394
+ if (outBytes > READ_MAX_BYTES) { stop('bd output exceeded limit'); return; }
395
+ out += d;
396
+ });
397
+ // Drain stderr without retaining arbitrary private subprocess output.
398
+ child.stderr?.resume();
329
399
  child.on('error', (e) => {
330
- refreshing.delete(cwd);
331
400
  bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
332
- onDone({ ok: false });
401
+ finish({ ok: false });
333
402
  });
334
403
  child.on('close', (code) => {
335
- refreshing.delete(cwd);
404
+ if (finished) return;
405
+ if (killDeadline) { signalOwned('SIGKILL'); finish({ ok: false }); return; }
406
+ // The worker reports its leader result, then terminates its entire
407
+ // owned group, including itself. A bare signal without that result
408
+ // remains a failed read, never a fabricated successful empty list.
409
+ code = workerResult ?? code;
336
410
  lastBdRunAt.set(cwd, Date.now());
337
- if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); onDone({ ok: false }); return; }
411
+ if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); finish({ ok: false }); return; }
338
412
  try {
339
413
  const parsed = JSON.parse(out || '[]');
340
414
  // Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
341
415
  // and a non-array rendered as no tasks is the silent zero again.
342
416
  if (!Array.isArray(parsed)) {
343
417
  bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
344
- onDone({ ok: false });
418
+ finish({ ok: false });
345
419
  return;
346
420
  }
347
421
  // This fetch answers "what was true at `startedAt`?" — if anyone has
@@ -353,24 +427,23 @@ function bdRefreshAsync(cwd, onDone = () => {}) {
353
427
  // `tests/pipeline-e2e.test.mjs`'s gate-approval assertions flake once
354
428
  // this function started running from a COLD cache (the boot warm-up)
355
429
  // instead of only ever refreshing an already-populated one.
356
- const supersededByInvalidation = (lastInvalidatedAt.get(cwd) || 0) >= startedAt;
430
+ const supersededByInvalidation = (invalidationGeneration.get(cwd) || 0) !== generation;
357
431
  const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
358
432
  if (supersededByInvalidation || supersededByFresherEntry) {
359
- onDone({ ok: true, count: parsed.length, discarded: true });
433
+ finish({ ok: true, count: parsed.length, discarded: true });
360
434
  return;
361
435
  }
362
436
  bdFailures.delete(cwd);
363
437
  bdCache.set(cwd, { ts: Date.now(), data: parsed });
364
- onDone({ ok: true, count: parsed.length });
438
+ finish({ ok: true, count: parsed.length });
365
439
  } catch (e) {
366
440
  bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
367
- onDone({ ok: false });
441
+ finish({ ok: false });
368
442
  }
369
443
  });
370
444
  } catch (e) {
371
- refreshing.delete(cwd);
372
445
  bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
373
- onDone({ ok: false });
446
+ finish({ ok: false });
374
447
  }
375
448
  }
376
449
 
@@ -410,6 +483,34 @@ function warmTasksAsync(cwd) {
410
483
  });
411
484
  }
412
485
 
486
+ /** Wait for an actual cold snapshot without blocking Node or inventing []. */
487
+ async function getTasksAsync(cwd = process.cwd()) {
488
+ if (!bdCache.has(cwd)) {
489
+ const result = await warmTasksAsync(cwd);
490
+ if (!bdCache.has(cwd)) {
491
+ // tasks.md-only projects remain supported, but never hide a failed
492
+ // initialized Beads store or a snapshot invalidated during this read.
493
+ if (!result.ok && checkBeadsAvailable(cwd)) {
494
+ bdCache.set(cwd, { ts: Date.now(), data: [] });
495
+ return getTasks(cwd);
496
+ }
497
+ throw new Error(bdFailureFor(cwd) || 'Task snapshot changed while being read; retry');
498
+ }
499
+ }
500
+ return getTasks(cwd);
501
+ }
502
+
503
+ /** Other-project badges may be incomplete, but must never block HTTP. */
504
+ function getTasksCached(cwd = process.cwd()) {
505
+ if (!bdCache.has(cwd)) {
506
+ bdRefreshAsync(cwd);
507
+ throw new Error('Task snapshot is loading');
508
+ }
509
+ const tasks = getTasks(cwd);
510
+ if (getReadDegradation(cwd)) throw new Error('Task snapshot is unavailable');
511
+ return tasks;
512
+ }
513
+
413
514
  function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
414
515
  const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
415
516
  const cached = bdCache.get(cwd);
@@ -864,6 +965,8 @@ export {
864
965
  getReadDegradation,
865
966
  setTaskStatusInTasksMd,
866
967
  getTasks,
968
+ getTasksAsync,
969
+ getTasksCached,
867
970
  mapStatus,
868
971
  detectAgent,
869
972
  };
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import os from 'os';
4
+ import { stateHome } from '../../cli/src/state-home.mjs';
5
5
 
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
7
  // `--port N` (or `--port=N`) first — server.mjs's usage line documents it, and
@@ -28,17 +28,24 @@ const HOST = (() => {
28
28
  const i = process.argv.indexOf('--host');
29
29
  return String(i > -1 ? process.argv[i + 1] : process.env.GREAT_CTO_HOST || '127.0.0.1');
30
30
  })();
31
- const GREAT_CTO_DIR = path.join(os.homedir(), '.great_cto');
31
+ // Shared dedicated namespace with the CLI; unset/empty keeps the operator default.
32
+ const GREAT_CTO_DIR = stateHome();
32
33
  const SHARE_STATE_FILE = path.join(GREAT_CTO_DIR, 'board-share.json');
33
34
  // Test seam: honor an explicit override so tests can point the registry at a
34
35
  // tmp fixture without touching the real ~/.great_cto/projects.json (same
35
36
  // convention as GREAT_CTO_BD_BIN in lib/beads.mjs). Unset in production —
36
37
  // zero behavior change at runtime.
37
- const PROJECTS_FILE = process.env.GREAT_CTO_PROJECTS_FILE || path.join(GREAT_CTO_DIR, 'projects.json');
38
+ function stateFileOverride(name, fallback) {
39
+ const value = process.env[name];
40
+ if (!value) return path.join(GREAT_CTO_DIR, fallback);
41
+ if (!path.isAbsolute(value) || value.includes('\0')) throw new Error(`${name} must be absolute and contain no NUL`);
42
+ return path.resolve(value);
43
+ }
44
+ const PROJECTS_FILE = stateFileOverride('GREAT_CTO_PROJECTS_FILE', 'projects.json');
38
45
  const SHARE_ENDPOINT = 'https://greatcto.systems/r/';
39
46
  const VAPID_KEYS_FILE = path.join(GREAT_CTO_DIR, 'vapid-keys.json');
40
47
  const PUSH_SUBS_FILE = path.join(GREAT_CTO_DIR, 'push-subscriptions.json');
41
- const NOTIF_HISTORY_FILE = process.env.GREAT_CTO_NOTIF_HISTORY_FILE || path.join(GREAT_CTO_DIR, 'notif-history.json');
48
+ const NOTIF_HISTORY_FILE = stateFileOverride('GREAT_CTO_NOTIF_HISTORY_FILE', 'notif-history.json');
42
49
  const VAPID_SUBJECT = 'mailto:hi@updates.greatcto.systems';
43
50
 
44
51
  export {
@@ -1,10 +1,9 @@
1
1
  import { reversibilityOf } from '../../../scripts/lib/gate-reversibility.mjs';
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
- import os from 'os';
5
4
  import { GREAT_CTO_DIR } from './config.mjs';
6
5
  import { readFileSafe } from './util.mjs';
7
- import { getTasks } from './beads.mjs';
6
+ import { getTasks, getTasksAsync, getTasksCached } from './beads.mjs';
8
7
  import { readVerdicts, readSecStats } from './verdicts.mjs';
9
8
  import { datePlans } from './plan-date.mjs';
10
9
 
@@ -21,7 +20,6 @@ function gateNameOf(task) {
21
20
  }
22
21
 
23
22
  function getMemory(cwd = process.cwd()) {
24
- const home = os.homedir();
25
23
  const layers = [
26
24
  // Project-local (.great_cto/) — L1 archetype + L2 codebase + L3 retros
27
25
  { id: 'project', scope: 'project', layer: 'L1', name: 'PROJECT.md', desc: 'Archetype, size, compliance, owners', path: path.join(cwd, '.great_cto', 'PROJECT.md') },
@@ -33,9 +31,9 @@ function getMemory(cwd = process.cwd()) {
33
31
  { id: 'handoff', scope: 'project', layer: 'L3', name: 'HANDOFF.md', desc: 'Auto-written on context compaction', path: path.join(cwd, '.great_cto', 'HANDOFF.md') },
34
32
  { id: 'local', scope: 'project', layer: 'L3', name: 'local.md', desc: 'Project-local notes (gitignored)', path: path.join(cwd, '.great_cto', 'local.md') },
35
33
  // Cross-project (~/.great_cto/) — L4 global memory shared across all projects
36
- { id: 'g-decisions', scope: 'global', layer: 'L4', name: 'decisions.md', desc: 'Append-only ADR log — every gate approval', path: path.join(home, '.great_cto', 'decisions.md') },
37
- { id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(home, '.great_cto', 'preferences.md') },
38
- { id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(home, '.great_cto', 'lessons.md') },
34
+ { id: 'g-decisions', scope: 'global', layer: 'L4', name: 'decisions.md', desc: 'Append-only ADR log — every gate approval', path: path.join(GREAT_CTO_DIR, 'decisions.md') },
35
+ { id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(GREAT_CTO_DIR, 'preferences.md') },
36
+ { id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(GREAT_CTO_DIR, 'lessons.md') },
39
37
  ];
40
38
  const result = layers.map(l => ({
41
39
  ...l,
@@ -45,7 +43,7 @@ function getMemory(cwd = process.cwd()) {
45
43
  // is photographed. The layer already knows its scope, so the display form is
46
44
  // derived rather than guessed.
47
45
  displayPath: l.scope === 'global'
48
- ? path.join('~', path.relative(home, l.path)).split(path.sep).join('/')
46
+ ? path.join(process.env.GREAT_CTO_HOME ? '$GREAT_CTO_HOME' : path.join('~', '.great_cto'), path.relative(GREAT_CTO_DIR, l.path)).split(path.sep).join('/')
49
47
  : path.relative(cwd, l.path).split(path.sep).join('/'),
50
48
  content: readFileSafe(l.path),
51
49
  exists: fs.existsSync(l.path),
@@ -75,7 +73,7 @@ function getMemory(cwd = process.cwd()) {
75
73
  }
76
74
 
77
75
  // ── Pipeline state ────────────────────────────────────────────────────────────
78
- function getPipeline(cwd = process.cwd()) {
76
+ function getPipeline(cwd = process.cwd(), { tasks = getTasks(cwd) } = {}) {
79
77
  const stages = ['product-owner', 'architect', 'pm', 'senior-dev', 'reviewers', 'qa-engineer', 'security-officer', 'devops', 'l3-support'];
80
78
  const verdicts = readVerdicts(cwd);
81
79
  const now = Date.now();
@@ -89,7 +87,6 @@ function getPipeline(cwd = process.cwd()) {
89
87
  }
90
88
 
91
89
  // Tasks in_progress give us "active" agents
92
- const tasks = getTasks(cwd);
93
90
  const activeAgents = new Set(
94
91
  tasks.filter(t => t.status === 'in_progress').map(t => (t.agent || '').toLowerCase()).filter(Boolean)
95
92
  );
@@ -181,7 +178,7 @@ function getPipeline(cwd = process.cwd()) {
181
178
  }
182
179
 
183
180
  // ── Cost history (daily LLM burn) ────────────────────────────────────────────
184
- function getCostHistory(cwd = process.cwd(), days = 30) {
181
+ function getCostHistory(cwd = process.cwd(), days = 30, { tasks: suppliedTasks } = {}) {
185
182
  // Build a map<dateISO, { llm, human, plans, verdictCost }>.
186
183
  // Inclusive window: `days=30` means [today-30 ... today] = 31 buckets.
187
184
  // Previous behaviour (days buckets only) created a one-day gap on the
@@ -317,7 +314,7 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
317
314
  const LLM_RATE_PER_HR = parseFloat(process.env.GREATCTO_LLM_RATE_PER_HR || '0.30');
318
315
  const DEFAULT_TASK_MIN = 30;
319
316
  try {
320
- const tasks = getTasks(cwd);
317
+ const tasks = suppliedTasks ?? getTasks(cwd);
321
318
  for (const t of tasks) {
322
319
  if (!t.closed_at) continue;
323
320
  if (!t.agent) continue;
@@ -403,8 +400,7 @@ function getCostHistory(cwd = process.cwd(), days = 30) {
403
400
  }
404
401
 
405
402
  // ── Inbox: what needs the user's decision right now ──────────────────────────
406
- function getInbox(cwd = process.cwd()) {
407
- const tasks = getTasks(cwd);
403
+ function getInbox(cwd = process.cwd(), { tasks = getTasks(cwd) } = {}) {
408
404
  // Use raw_status here: mapStatus() rewrites status to 'gate' for any task with the
409
405
  // 'gate' label, regardless of bd-native state. Filtering on the mapped value would
410
406
  // leave closed/blocked gates in the inbox forever.
@@ -504,7 +500,7 @@ function getInbox(cwd = process.cwd()) {
504
500
  * `readInbox` is injectable so the walk can be tested with stub projects
505
501
  * without a beads store behind each one.
506
502
  */
507
- function inboxElsewhere(projects, currentPath, { readInbox = getInbox } = {}) {
503
+ function inboxElsewhere(projects, currentPath, { readInbox = getInboxCached } = {}) {
508
504
  const same = (a, b) => {
509
505
  if (!a || !b) return false;
510
506
  try { return fs.realpathSync(a) === fs.realpathSync(b); } catch { return path.resolve(a) === path.resolve(b); }
@@ -523,4 +519,6 @@ function inboxElsewhere(projects, currentPath, { readInbox = getInbox } = {}) {
523
519
  return out;
524
520
  }
525
521
 
526
- export { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere };
522
+ async function getInboxAsync(cwd) { return getInbox(cwd, { tasks: await getTasksAsync(cwd) }); }
523
+ function getInboxCached(cwd) { return getInbox(cwd, { tasks: getTasksCached(cwd) }); }
524
+ export { getMemory, getPipeline, getCostHistory, getInbox, getInboxAsync, getInboxCached, inboxElsewhere };
@@ -43,11 +43,10 @@ function acceptanceMetrics(verdicts = [], windowCostUsd = null) {
43
43
  }
44
44
 
45
45
  // ── Metrics ────────────────────────────────────────────────────────────────────
46
- function getMetrics(cwd = process.cwd(), days = 30) {
46
+ function getMetrics(cwd = process.cwd(), days = 30, { tasks = getTasks(cwd) } = {}) {
47
47
  // `days` controls the window for cost/agents_cost and for "shipped in window".
48
48
  // Tasks shipped within the window are returned in `tasks.done_in_window`.
49
49
  // Full lifetime `tasks.done` is still returned for backwards compatibility.
50
- const tasks = getTasks(cwd);
51
50
  // BH-28: resolved gates (approve→closed, reject→blocked) get mapped to
52
51
  // status='done' by mapStatus(). Those are governance decisions, not shipped
53
52
  // features — counting them inflates velocity / tasks-shipped on the report.