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.
- package/board/.claude-plugin/plugin.json +23 -1
- package/board/packages/board/fixtures/beads-read-owner.mjs +3 -0
- package/board/packages/board/fixtures/fake-bd-controlled.mjs +49 -0
- package/board/packages/board/lib/alerts.mjs +21 -7
- package/board/packages/board/lib/bd-read-worker.mjs +64 -0
- package/board/packages/board/lib/beads.mjs +138 -35
- package/board/packages/board/lib/config.mjs +11 -4
- package/board/packages/board/lib/data-readers.mjs +13 -15
- package/board/packages/board/lib/metrics.mjs +1 -2
- package/board/packages/board/lib/projects.mjs +48 -17
- package/board/packages/board/lib/routes.mjs +46 -9
- package/board/packages/board/lib/share.mjs +4 -2
- package/board/packages/board/lib/sse.mjs +34 -5
- package/board/packages/board/lib/watchers.mjs +7 -18
- package/board/packages/board/public/index.html +26 -4
- package/board/packages/board/push-adapter.mjs +3 -3
- package/board/packages/cli/src/private-state.mjs +43 -0
- package/board/packages/cli/src/state-home.mjs +15 -0
- package/board/scripts/codex-pipeline.mjs +34 -5
- package/board/scripts/hooks/auto-attach-reviewers.mjs +449 -0
- package/board/scripts/lib/adaptive-benchmark-collector.mjs +202 -0
- package/board/scripts/lib/adaptive-benchmark-protocol.mjs +75 -0
- package/board/scripts/lib/agent-execution-budget.mjs +143 -0
- package/board/scripts/lib/approval-level.mjs +174 -0
- package/board/scripts/lib/benchmark-review-dispatch.mjs +52 -0
- package/board/scripts/lib/benchmark-scorer-signature.mjs +64 -0
- package/board/scripts/lib/claude-exec.mjs +11 -3
- package/board/scripts/lib/codex-checks.mjs +77 -6
- package/board/scripts/lib/codex-exec.mjs +13 -2
- package/board/scripts/lib/codex-host-state.mjs +2 -0
- package/board/scripts/lib/codex-pipeline.mjs +281 -38
- package/board/scripts/lib/codex-release.mjs +9 -5
- package/board/scripts/lib/codex-role-profiles.mjs +46 -1
- package/board/scripts/lib/controlled-specialists.mjs +150 -0
- package/board/scripts/lib/controller-dispatch-evidence.mjs +60 -0
- package/board/scripts/lib/pinned-benchmark-scorer.mjs +132 -0
- package/board/scripts/lib/receipt.mjs +31 -6
- package/board/scripts/lib/review-graph-floor.mjs +54 -0
- package/board/scripts/lib/runtime-gate-policy.mjs +84 -0
- package/board/scripts/lib/scoped-review-reuse.mjs +154 -0
- package/board/scripts/lib/specialist-plan.mjs +102 -0
- package/board/scripts/lib/turn-snapshot.mjs +24 -4
- package/dist/codex-host.js +1 -1
- package/dist/private-state.mjs +53 -0
- package/dist/serve.js +10 -5
- package/dist/state-home.mjs +15 -0
- package/dist/task-queue.js +2 -2
- package/dist/update-check.js +2 -5
- package/dist/webhook-cli.js +2 -2
- package/dist/webhook-config.js +18 -14
- package/dist/webhook-dispatch.js +5 -8
- package/dist/worker.js +3 -2
- 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.
|
|
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,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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
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(
|
|
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
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
401
|
+
finish({ ok: false });
|
|
333
402
|
});
|
|
334
403
|
child.on('close', (code) => {
|
|
335
|
-
|
|
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}`);
|
|
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
|
-
|
|
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 = (
|
|
430
|
+
const supersededByInvalidation = (invalidationGeneration.get(cwd) || 0) !== generation;
|
|
357
431
|
const supersededByFresherEntry = (bdCache.get(cwd)?.ts || 0) >= startedAt;
|
|
358
432
|
if (supersededByInvalidation || supersededByFresherEntry) {
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
37
|
-
{ id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(
|
|
38
|
-
{ id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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.
|