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
|
@@ -204,7 +204,15 @@ function getChangeTier(dir) {
|
|
|
204
204
|
return { tier: 'T2', error: e.message }; // fail-safe: unknown → full gates
|
|
205
205
|
}
|
|
206
206
|
}
|
|
207
|
+
function isGlobalStateProject(dir) {
|
|
208
|
+
const canonical = value => { try { return fs.realpathSync(value); } catch { return path.resolve(value); } };
|
|
209
|
+
const resolved = canonical(dir);
|
|
210
|
+
const state = canonical(GREAT_CTO_DIR);
|
|
211
|
+
return isInsideDir(state, resolved) || canonical(path.join(resolved, '.great_cto')) === state;
|
|
212
|
+
}
|
|
213
|
+
|
|
207
214
|
function autoRegisterProject(dir) {
|
|
215
|
+
if (isGlobalStateProject(dir)) return null;
|
|
208
216
|
// The home directory is never a project.
|
|
209
217
|
//
|
|
210
218
|
// `~/.great_cto/` is the GLOBAL store — cross-project verdicts, decisions,
|
|
@@ -228,18 +236,29 @@ function autoRegisterProject(dir) {
|
|
|
228
236
|
// the board there added it as a separate project reading great_cto's beads —
|
|
229
237
|
// 228 tasks counted twice, under two names, in a fleet of seventeen.
|
|
230
238
|
try {
|
|
239
|
+
// Automatic discovery/cwd must not bypass the raw-path registration
|
|
240
|
+
// boundary. An explicit isolated discovery scope replaces HOME, never
|
|
241
|
+
// falls back to it, and is canonicalized before containment checks.
|
|
242
|
+
const scope = getDiscoveryScope();
|
|
243
|
+
const boundary = fs.realpathSync(scope.includeClaudeProjects ? os.homedir() : scope.roots[0]);
|
|
244
|
+
const canonical = fs.realpathSync(dir);
|
|
245
|
+
if (!isInsideDir(boundary, canonical)) return null;
|
|
231
246
|
const resolved = path.resolve(dir);
|
|
232
247
|
if (resolved === path.resolve(os.homedir())) return null;
|
|
233
248
|
if (isInsideDir(path.join(os.homedir(), '.claude', 'plugins'), resolved)) return null;
|
|
234
249
|
const enclosing = readProjectsRegistry().projects
|
|
235
250
|
.find((e) => e.path && path.resolve(e.path) !== resolved && isInsideDir(path.resolve(e.path), resolved));
|
|
236
251
|
if (enclosing) return null;
|
|
237
|
-
} catch {
|
|
252
|
+
} catch { return null; } // Unknown scope is not permission to register it.
|
|
238
253
|
|
|
239
254
|
const meta = readProjectMd(dir);
|
|
240
255
|
if (!meta) return null;
|
|
241
256
|
const reg = readProjectsRegistry();
|
|
242
|
-
const existingByPath = reg.projects.find(p =>
|
|
257
|
+
const existingByPath = reg.projects.find(p => {
|
|
258
|
+
if (p.path === meta.path) return true;
|
|
259
|
+
try { return fs.realpathSync(p.path) === fs.realpathSync(meta.path); }
|
|
260
|
+
catch { return false; }
|
|
261
|
+
});
|
|
243
262
|
if (existingByPath) return meta; // already registered at this path, nothing to do
|
|
244
263
|
// Only an entry whose path is GONE is this repo having moved. An entry whose
|
|
245
264
|
// path still exists is a different project that happens to share a name, and
|
|
@@ -264,20 +283,40 @@ function autoRegisterProject(dir) {
|
|
|
264
283
|
// project that ran /audit or /start (which writes .great_cto/PROJECT.md) gets
|
|
265
284
|
// auto-registered without the user having to do anything.
|
|
266
285
|
// Fully async — never blocks the event loop.
|
|
286
|
+
function getDiscoveryScope() {
|
|
287
|
+
const scoped = process.env.GREAT_CTO_DISCOVERY_ROOT;
|
|
288
|
+
if (scoped) {
|
|
289
|
+
if (!path.isAbsolute(scoped)) throw new Error('GREAT_CTO_DISCOVERY_ROOT must be absolute');
|
|
290
|
+
const root = fs.realpathSync(scoped);
|
|
291
|
+
if (!fs.statSync(root).isDirectory()) throw new Error('GREAT_CTO_DISCOVERY_ROOT must be a directory');
|
|
292
|
+
return { roots: [root], includeClaudeProjects: false };
|
|
293
|
+
}
|
|
294
|
+
const home = os.homedir();
|
|
295
|
+
return { roots: [
|
|
296
|
+
path.join(home, 'work'), path.join(home, 'dev'),
|
|
297
|
+
path.join(home, 'development'), path.join(home, 'code'),
|
|
298
|
+
path.join(home, 'projects'), path.join(home, 'src'),
|
|
299
|
+
path.join(home, 'Documents', 'projects'), home,
|
|
300
|
+
], includeClaudeProjects: true };
|
|
301
|
+
}
|
|
302
|
+
|
|
267
303
|
async function discoverProjects() {
|
|
268
304
|
const fsAsync = fs.promises;
|
|
269
305
|
const HOME = os.homedir();
|
|
270
306
|
const seen = new Set();
|
|
271
307
|
const found = [];
|
|
308
|
+
// Resolve before scanning. A broken explicit scope must never fall back to
|
|
309
|
+
// scanning the operator's home or Claude project cache.
|
|
310
|
+
const scope = getDiscoveryScope();
|
|
272
311
|
|
|
273
312
|
async function scanDir(dir, depth) {
|
|
274
|
-
if (depth < 0 || seen.has(dir)) return;
|
|
313
|
+
if (depth < 0 || seen.has(dir) || isInsideDir(GREAT_CTO_DIR, dir)) return;
|
|
275
314
|
seen.add(dir);
|
|
276
315
|
try {
|
|
277
316
|
// Check the dir itself first — but NEVER treat HOME's own .great_cto as a
|
|
278
317
|
// project: ~/.great_cto is the global config dir, not a project. Without
|
|
279
318
|
// this guard, $HOME gets registered as a bogus project (great_cto-…).
|
|
280
|
-
if (dir !== HOME) {
|
|
319
|
+
if (dir !== HOME && !isGlobalStateProject(dir)) {
|
|
281
320
|
try {
|
|
282
321
|
await fsAsync.access(path.join(dir, '.great_cto', 'PROJECT.md'));
|
|
283
322
|
found.push(dir);
|
|
@@ -298,22 +337,13 @@ async function discoverProjects() {
|
|
|
298
337
|
}
|
|
299
338
|
|
|
300
339
|
// 1) Common dev folders — top-level scan, 1-level deep
|
|
301
|
-
const roots =
|
|
302
|
-
path.join(HOME, 'work'),
|
|
303
|
-
path.join(HOME, 'dev'),
|
|
304
|
-
path.join(HOME, 'development'),
|
|
305
|
-
path.join(HOME, 'code'),
|
|
306
|
-
path.join(HOME, 'projects'),
|
|
307
|
-
path.join(HOME, 'src'),
|
|
308
|
-
path.join(HOME, 'Documents', 'projects'),
|
|
309
|
-
HOME,
|
|
310
|
-
];
|
|
340
|
+
const roots = scope.roots;
|
|
311
341
|
for (const root of roots) {
|
|
312
342
|
try { await fsAsync.access(root); await scanDir(root, 1); } catch {}
|
|
313
343
|
}
|
|
314
344
|
|
|
315
345
|
// 2) Claude Code's known project list (~/.claude/projects/<encoded-path>/)
|
|
316
|
-
try {
|
|
346
|
+
if (scope.includeClaudeProjects) try {
|
|
317
347
|
const ccProj = path.join(HOME, '.claude', 'projects');
|
|
318
348
|
await fsAsync.access(ccProj);
|
|
319
349
|
const entries = await fsAsync.readdir(ccProj);
|
|
@@ -322,7 +352,7 @@ async function discoverProjects() {
|
|
|
322
352
|
const decoded = '/' + dir.replace(/^-+/, '').replace(/-/g, '/');
|
|
323
353
|
try {
|
|
324
354
|
await fsAsync.access(path.join(decoded, '.great_cto', 'PROJECT.md'));
|
|
325
|
-
found.push(decoded);
|
|
355
|
+
if (!isGlobalStateProject(decoded)) found.push(decoded);
|
|
326
356
|
} catch {}
|
|
327
357
|
}
|
|
328
358
|
} catch {}
|
|
@@ -342,7 +372,7 @@ function listProjects() {
|
|
|
342
372
|
// .great_cto has no project marker or task source left (no PROJECT.md,
|
|
343
373
|
// no tasks.md, no .beads → nothing to show, just clutters the switcher).
|
|
344
374
|
reg.projects = reg.projects.filter(p =>
|
|
345
|
-
p.path !== HOME &&
|
|
375
|
+
p.path !== HOME && !isGlobalStateProject(p.path) &&
|
|
346
376
|
fs.existsSync(p.path) &&
|
|
347
377
|
(fs.existsSync(path.join(p.path, '.great_cto', 'PROJECT.md')) ||
|
|
348
378
|
fs.existsSync(path.join(p.path, '.great_cto', 'tasks.md')) ||
|
|
@@ -457,6 +487,7 @@ export {
|
|
|
457
487
|
getChangeTier,
|
|
458
488
|
autoRegisterProject,
|
|
459
489
|
getRegistryDegradation,
|
|
490
|
+
getDiscoveryScope,
|
|
460
491
|
discoverProjects,
|
|
461
492
|
listProjects,
|
|
462
493
|
resolveProjectCwd,
|
|
@@ -27,9 +27,9 @@ import { status as routerKeyStatus, writeKey as writeRouterKey, verifyKey as ver
|
|
|
27
27
|
let routerKeyCheck = null;
|
|
28
28
|
import { broadcastTasks } from './sse.mjs';
|
|
29
29
|
import { saveNotifHistory } from './notifications.mjs';
|
|
30
|
-
import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from './data-readers.mjs';
|
|
30
|
+
import { getMemory, getPipeline, getCostHistory, getInbox, getInboxAsync, inboxElsewhere } from './data-readers.mjs';
|
|
31
31
|
import { log } from './log.mjs';
|
|
32
|
-
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
|
|
32
|
+
import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, getTasksAsync, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
|
|
33
33
|
import { getMetrics } from './metrics.mjs';
|
|
34
34
|
import { agentActivity, agentActivitySince } from './agent-activity.mjs';
|
|
35
35
|
import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
|
|
@@ -124,6 +124,32 @@ async function dispatch(req, res, url, cwd) {
|
|
|
124
124
|
const scopedProject = ['/api/skills', '/api/usage', '/api/outcomes'].includes(pathname)
|
|
125
125
|
? scopedReadProject(cwd, serverCwd, requestedProject) : null;
|
|
126
126
|
|
|
127
|
+
// Existing synchronous projections may consume a completed snapshot, but
|
|
128
|
+
// HTTP cold reads must await async I/O instead of freezing unrelated clients.
|
|
129
|
+
const taskRead = ['/api/work', '/api/sse', '/api/tasks', '/api/metrics',
|
|
130
|
+
'/api/memory', '/api/pipeline', '/api/heartbeat', '/api/share', '/api/cost', '/api/resume'].includes(pathname)
|
|
131
|
+
|| /^\/api\/tasks\/[^/]+\/history$/.test(pathname);
|
|
132
|
+
const unknownWork = pathname === '/api/work' && requestedProject
|
|
133
|
+
&& resolveProjectInfo(requestedProject).resolved === 'fallback';
|
|
134
|
+
let sseClosed = false;
|
|
135
|
+
if (pathname === '/api/sse') {
|
|
136
|
+
// Install lifecycle ownership before the cold snapshot await. Request
|
|
137
|
+
// 'close' also occurs for normally completed GET bodies; response close
|
|
138
|
+
// is the actual stream lifetime signal.
|
|
139
|
+
res.once('close', () => { sseClosed = true; sseClients.delete(res); });
|
|
140
|
+
}
|
|
141
|
+
const closedSse = () => pathname === '/api/sse'
|
|
142
|
+
&& (sseClosed || req.aborted || res.destroyed || res.writableEnded);
|
|
143
|
+
if (req.method === 'GET' && taskRead && !unknownWork) {
|
|
144
|
+
try { await getTasksAsync(cwd); }
|
|
145
|
+
catch {
|
|
146
|
+
if (closedSse()) return true;
|
|
147
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
148
|
+
res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
127
153
|
if (pathname === '/api/work/publication') {
|
|
128
154
|
const respond = (status, body) => { res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(body)); };
|
|
129
155
|
if (requestedProject && resolveProjectInfo(requestedProject).resolved === 'fallback') { respond(404, { error: 'Unknown project' }); return true; }
|
|
@@ -222,6 +248,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
222
248
|
|
|
223
249
|
// SSE
|
|
224
250
|
if (pathname === '/api/sse') {
|
|
251
|
+
if (closedSse()) return true;
|
|
225
252
|
res.writeHead(200, {
|
|
226
253
|
'Content-Type': 'text/event-stream',
|
|
227
254
|
'Cache-Control': 'no-cache',
|
|
@@ -229,7 +256,11 @@ async function dispatch(req, res, url, cwd) {
|
|
|
229
256
|
});
|
|
230
257
|
res._gctoCwd = cwd; // remember which project this client wants
|
|
231
258
|
sseClients.add(res);
|
|
232
|
-
|
|
259
|
+
if (getReadDegradation(cwd)) {
|
|
260
|
+
res.write(`event: task-read-state\ndata: ${JSON.stringify({ ok: false, state: 'unreadable' })}\n\n`);
|
|
261
|
+
} else {
|
|
262
|
+
res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
|
|
263
|
+
}
|
|
233
264
|
// ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
|
|
234
265
|
// Phase 2: a client that hands back the cursor of the last frame it saw — the
|
|
235
266
|
// Last-Event-ID header, or ?since= from a page that had to recreate its
|
|
@@ -243,7 +274,6 @@ async function dispatch(req, res, url, cwd) {
|
|
|
243
274
|
}
|
|
244
275
|
res._gctoAgentCursor = a.cursor;
|
|
245
276
|
res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
|
|
246
|
-
req.on('close', () => sseClients.delete(res));
|
|
247
277
|
return true;
|
|
248
278
|
}
|
|
249
279
|
|
|
@@ -598,7 +628,8 @@ async function dispatch(req, res, url, cwd) {
|
|
|
598
628
|
let transitions = null;
|
|
599
629
|
try {
|
|
600
630
|
const { pipelineMapFor } = await import('../../../scripts/lib/pipeline-health.mjs');
|
|
601
|
-
|
|
631
|
+
// Use the pure parser, not the native hook that merely reexports it.
|
|
632
|
+
const { parsePipelineToml } = await import('../../../scripts/lib/pipeline-toml.mjs');
|
|
602
633
|
const map = pipelineMapFor(cwd);
|
|
603
634
|
if (map.path) transitions = parsePipelineToml(fs.readFileSync(map.path, 'utf8'));
|
|
604
635
|
} catch { transitions = null; }
|
|
@@ -717,7 +748,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
717
748
|
// Without this, concurrent approve+reject on the same gate produced
|
|
718
749
|
// TWO appendDecisionLog entries (one wrong) — log says approved AND
|
|
719
750
|
// rejected. bdWriteSerialised guarantees one-at-a-time semantics.
|
|
720
|
-
const result = await bdWriteSerialised(() => {
|
|
751
|
+
const result = await bdWriteSerialised(async () => {
|
|
721
752
|
const status = action === 'approve' ? 'closed' : 'blocked';
|
|
722
753
|
let via = 'beads';
|
|
723
754
|
// Try beads first (unless there's no store at all); on any bd failure
|
|
@@ -760,7 +791,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
760
791
|
const projectSlug = parsed.project && !/^[\/~]/.test(parsed.project) ? parsed.project : path.basename(gateCwd);
|
|
761
792
|
let title = id;
|
|
762
793
|
try {
|
|
763
|
-
const gateTask =
|
|
794
|
+
const gateTask = (await getTasksAsync(gateCwd)).find((t) => t.id === id);
|
|
764
795
|
if (gateTask?.title) title = gateTask.title;
|
|
765
796
|
} catch { /* the id is a documented fallback; the record still gets written */ }
|
|
766
797
|
gateTitle = title;
|
|
@@ -846,7 +877,13 @@ async function dispatch(req, res, url, cwd) {
|
|
|
846
877
|
|
|
847
878
|
// Inbox — what needs your attention right now
|
|
848
879
|
if (pathname === '/api/inbox') {
|
|
849
|
-
|
|
880
|
+
let inbox;
|
|
881
|
+
try { inbox = await getInboxAsync(cwd); }
|
|
882
|
+
catch {
|
|
883
|
+
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
884
|
+
res.end(JSON.stringify({ error: 'Task snapshot is unavailable; retry', state: 'unreadable' }));
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
850
887
|
// BRD-R3: the Decisions row shows both reviewers. The second opinion is a
|
|
851
888
|
// fact about the TREE, not about a gate — every pending gate on this tree
|
|
852
889
|
// shares it — so it is resolved once: the newest cross-review line whose
|
|
@@ -860,7 +897,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
860
897
|
// If the registry itself cannot be walked, say so — `unreadable` is not
|
|
861
898
|
// `{p0: 0}`, and the page must not print "nothing elsewhere" from it.
|
|
862
899
|
let elsewhere;
|
|
863
|
-
try { elsewhere = inboxElsewhere(listProjects(), cwd
|
|
900
|
+
try { elsewhere = inboxElsewhere(listProjects(), cwd); }
|
|
864
901
|
catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
|
|
865
902
|
// ADR-024 §1: each pending gate carries the token an approval must present,
|
|
866
903
|
// bound to the project as it is now. The typed name stored with it is the one
|
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import { GREAT_CTO_DIR, PUBLIC, SHARE_ENDPOINT } from './config.mjs';
|
|
4
4
|
import { readProjectMd } from './projects.mjs';
|
|
5
5
|
import { getMetrics } from './metrics.mjs';
|
|
6
|
-
import { getTasks } from './beads.mjs';
|
|
6
|
+
import { getTasks, getTasksAsync, getReadDegradation } from './beads.mjs';
|
|
7
7
|
import { readVerdicts } from './verdicts.mjs';
|
|
8
8
|
import { readDecisionsLog } from './fleet.mjs';
|
|
9
9
|
|
|
@@ -87,8 +87,10 @@ async function toggleShare(enable, cwd = process.cwd(), force = false) {
|
|
|
87
87
|
// Generate and publish
|
|
88
88
|
// Share report is a marketing artifact — show LIFETIME numbers, not a
|
|
89
89
|
// rolling window. 365 days × 100 = effectively-lifetime cap for any project.
|
|
90
|
-
const html = generateShareHTML(getTasks(cwd), getMetrics(cwd, 36500), cwd);
|
|
91
90
|
try {
|
|
91
|
+
const tasks = await getTasksAsync(cwd);
|
|
92
|
+
if (getReadDegradation(cwd)) return { error: 'Task snapshot is unavailable; report not published' };
|
|
93
|
+
const html = generateShareHTML(tasks, getMetrics(cwd, 36500, { tasks }), cwd);
|
|
92
94
|
const result = await publishReport(html);
|
|
93
95
|
const newState = { enabled: true, url: result.url, hash: result.hash, published_at: new Date().toISOString() };
|
|
94
96
|
saveShareState(newState, cwd);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { sseClients } from './state.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import { getTasksAsync, getReadDegradation, warmTasksAsync, checkBeadsAvailable } from './beads.mjs';
|
|
3
|
+
import { getPipeline, getInbox } from './data-readers.mjs';
|
|
3
4
|
|
|
4
5
|
function broadcast(event, data) {
|
|
5
6
|
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
@@ -8,13 +9,41 @@ function broadcast(event, data) {
|
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
function broadcastTasks(cwd) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
async function broadcastTasks(cwd, { fresh = false, projections = false } = {}) {
|
|
13
|
+
// A completed write invalidates its cache. Reading it synchronously here
|
|
14
|
+
// froze every HTTP client even though the write response was already sent.
|
|
15
|
+
// With no subscriber there is no projection to refresh at all.
|
|
16
|
+
for (const res of sseClients) if (res.destroyed || res.writableEnded) sseClients.delete(res);
|
|
17
|
+
const clients = [...sseClients].filter(res => res._gctoCwd === cwd);
|
|
18
|
+
if (!clients.length) return { ok: true, subscribers: 0 };
|
|
19
|
+
let msg, result;
|
|
20
|
+
try {
|
|
21
|
+
if (fresh && !checkBeadsAvailable(cwd)) {
|
|
22
|
+
const read = await warmTasksAsync(cwd);
|
|
23
|
+
if (!read.ok || read.discarded) throw Error('fresh snapshot unavailable');
|
|
24
|
+
}
|
|
25
|
+
const tasks = await getTasksAsync(cwd);
|
|
26
|
+
if (getReadDegradation(cwd)) throw Error('snapshot unavailable');
|
|
27
|
+
msg = `event: tasks\ndata: ${JSON.stringify(tasks)}\n\n`;
|
|
28
|
+
if (projections) {
|
|
29
|
+
// All projections describe the same snapshot, without another Beads read.
|
|
30
|
+
msg += `event: pipeline\ndata: ${JSON.stringify(getPipeline(cwd, { tasks }))}\n\n`;
|
|
31
|
+
msg += `event: inbox\ndata: ${JSON.stringify(getInbox(cwd, { tasks }))}\n\n`;
|
|
32
|
+
}
|
|
33
|
+
result = { ok: true };
|
|
34
|
+
} catch {
|
|
35
|
+
// Preserve the last visible tasks instead of publishing a fabricated [].
|
|
36
|
+
// Explicit transport state; clients retain the last visible snapshot.
|
|
37
|
+
result = { ok: false, state: 'unreadable' };
|
|
38
|
+
msg = `event: task-read-state\ndata: ${JSON.stringify(result)}\n\n`;
|
|
39
|
+
}
|
|
40
|
+
for (const res of clients) {
|
|
41
|
+
// A client may have disconnected or changed project while the read ran.
|
|
42
|
+
if (sseClients.has(res) && res._gctoCwd === cwd && !res.destroyed && !res.writableEnded) {
|
|
15
43
|
try { res.write(msg); } catch { sseClients.delete(res); }
|
|
16
44
|
}
|
|
17
45
|
}
|
|
46
|
+
return result;
|
|
18
47
|
}
|
|
19
48
|
|
|
20
49
|
export { broadcast, broadcastTasks };
|
|
@@ -3,8 +3,8 @@ import path from 'path';
|
|
|
3
3
|
import { GREAT_CTO_DIR } from './config.mjs';
|
|
4
4
|
import { sseClients } from './state.mjs';
|
|
5
5
|
import { listProjects } from './projects.mjs';
|
|
6
|
-
import { bdCacheStale,
|
|
7
|
-
import {
|
|
6
|
+
import { bdCacheStale, isSelfInflictedTouch } from './beads.mjs';
|
|
7
|
+
import { broadcastTasks } from './sse.mjs';
|
|
8
8
|
import { agentActivitySince, activityStamp } from './agent-activity.mjs';
|
|
9
9
|
|
|
10
10
|
// ── File watcher ───────────────────────────────────────────────────────────────
|
|
@@ -51,17 +51,9 @@ function watchBeads() {
|
|
|
51
51
|
if (isSelfInflictedTouch(dir)) return;
|
|
52
52
|
// Stale, not deleted. A change we NOTICED must not make the next reader pay
|
|
53
53
|
// for it — they asked for something else. The broadcast below still reads
|
|
54
|
-
// fresh, because
|
|
54
|
+
// fresh, because it explicitly awaits the shared asynchronous refresh.
|
|
55
55
|
bdCacheStale(dir);
|
|
56
|
-
|
|
57
|
-
if (res._gctoCwd === dir) {
|
|
58
|
-
try {
|
|
59
|
-
res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(dir))}\n\n`);
|
|
60
|
-
res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
|
|
61
|
-
res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
|
|
62
|
-
} catch {}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
56
|
+
void broadcastTasks(dir, { fresh: true, projections: true });
|
|
65
57
|
};
|
|
66
58
|
|
|
67
59
|
// Debounce per-dir: dolt writes can fire 3-5 events in <50ms during a single
|
|
@@ -130,12 +122,9 @@ function watchVerdicts() {
|
|
|
130
122
|
if (pushTimer) clearTimeout(pushTimer);
|
|
131
123
|
// debounce: collapse a burst of writes (multiple agents finishing within ~150ms)
|
|
132
124
|
pushTimer = setTimeout(() => {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
res.write(`event: pipeline\ndata: ${JSON.stringify(getPipeline(dir))}\n\n`);
|
|
137
|
-
res.write(`event: inbox\ndata: ${JSON.stringify(getInbox(dir))}\n\n`);
|
|
138
|
-
} catch { sseClients.delete(res); }
|
|
125
|
+
const dirs = new Set([...sseClients].map(res => res._gctoCwd || process.cwd()));
|
|
126
|
+
for (const dir of dirs) {
|
|
127
|
+
void broadcastTasks(dir, { projections: true });
|
|
139
128
|
}
|
|
140
129
|
}, 150);
|
|
141
130
|
};
|
|
@@ -1768,8 +1768,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1768
1768
|
.pl-gate.active .pl-msg, .pl-gate.active .pl-meta { opacity: 1; color: var(--text); }
|
|
1769
1769
|
.pl-gate .gate-badge {
|
|
1770
1770
|
margin-left: auto;
|
|
1771
|
-
background: var(--status-blocked); color: var(--on-
|
|
1772
|
-
font-size: var(--fs-eyebrow); font-weight:
|
|
1771
|
+
background: var(--status-blocked); color: var(--on-status);
|
|
1772
|
+
font-size: var(--fs-eyebrow); font-weight: 500;
|
|
1773
1773
|
min-width: 16px; height: 16px; padding: 0 4px;
|
|
1774
1774
|
border-radius: 0;
|
|
1775
1775
|
display: inline-flex; align-items: center; justify-content: center;
|
|
@@ -8117,6 +8117,12 @@ function connectSSE() {
|
|
|
8117
8117
|
};
|
|
8118
8118
|
es.addEventListener('tasks', e => {
|
|
8119
8119
|
const newTasks = JSON.parse(e.data);
|
|
8120
|
+
delete BOARD_DEGRADED['/api/tasks'];
|
|
8121
|
+
renderDegradedBanner('degraded-read', null, '');
|
|
8122
|
+
if (es.readyState === EventSource.OPEN) {
|
|
8123
|
+
document.getElementById('live-dot').className = 'live-dot pulse';
|
|
8124
|
+
document.getElementById('live-label').textContent = 'live · synced just now';
|
|
8125
|
+
}
|
|
8120
8126
|
// A transient bd failure (dolt lock, timeout) can produce an empty
|
|
8121
8127
|
// snapshot indistinguishable from "no tasks" (great_cto-e2ew). Don't
|
|
8122
8128
|
// blindly wipe a populated board — refetch once via the regular API
|
|
@@ -8130,6 +8136,20 @@ function connectSSE() {
|
|
|
8130
8136
|
}
|
|
8131
8137
|
applyTasksSnapshot(newTasks);
|
|
8132
8138
|
});
|
|
8139
|
+
es.addEventListener('task-read-state', e => {
|
|
8140
|
+
try {
|
|
8141
|
+
const state = JSON.parse(e.data);
|
|
8142
|
+
if (state.state !== 'unreadable') return;
|
|
8143
|
+
BOARD_DEGRADED['/api/tasks'] = 'Task snapshot is unavailable. Showing the last loaded tasks.';
|
|
8144
|
+
document.getElementById('live-dot').className = 'live-dot error';
|
|
8145
|
+
document.getElementById('live-label').textContent = 'connected · task data unavailable';
|
|
8146
|
+
// Re-evaluate existing healthy claims as well as adding the banner.
|
|
8147
|
+
// A previously painted "All clear" must not survive a failed refresh.
|
|
8148
|
+
if (window.__inboxData) renderInbox(window.__inboxData);
|
|
8149
|
+
renderDegradedBanner('degraded-read', degradedFor('/api/tasks'),
|
|
8150
|
+
'Project data could not be refreshed. Counts may be out of date.');
|
|
8151
|
+
} catch { /* Malformed transport data must not erase the last snapshot. */ }
|
|
8152
|
+
});
|
|
8133
8153
|
es.addEventListener('pipeline', e => {
|
|
8134
8154
|
try {
|
|
8135
8155
|
const stages = JSON.parse(e.data);
|
|
@@ -8165,8 +8185,10 @@ function connectSSE() {
|
|
|
8165
8185
|
});
|
|
8166
8186
|
es.onopen = () => {
|
|
8167
8187
|
const d = document.getElementById('live-dot');
|
|
8168
|
-
|
|
8169
|
-
|
|
8188
|
+
const unreadable = degradedFor('/api/tasks');
|
|
8189
|
+
d.className = unreadable ? 'live-dot error' : 'live-dot pulse';
|
|
8190
|
+
document.getElementById('live-label').textContent = unreadable
|
|
8191
|
+
? 'connected · task data unavailable' : 'live · synced just now';
|
|
8170
8192
|
};
|
|
8171
8193
|
es.onerror = () => {
|
|
8172
8194
|
document.getElementById('live-dot').className = 'live-dot error';
|
|
@@ -12,6 +12,7 @@ import http from 'node:http';
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { URL } from 'node:url';
|
|
15
|
+
import { readPrivateState, writePrivateState } from '../cli/src/private-state.mjs';
|
|
15
16
|
|
|
16
17
|
// ── Helpers: base64url ────────────────────────────────────────────────────
|
|
17
18
|
|
|
@@ -74,7 +75,7 @@ export function getVapidKeys(keyPath) {
|
|
|
74
75
|
// Load existing keys from disk
|
|
75
76
|
if (fs.existsSync(keyPath)) {
|
|
76
77
|
try {
|
|
77
|
-
const stored = JSON.parse(
|
|
78
|
+
const stored = JSON.parse(readPrivateState(keyPath));
|
|
78
79
|
if (stored.publicKey && stored.privateKey) return stored;
|
|
79
80
|
} catch { /* regenerate if corrupt */ }
|
|
80
81
|
}
|
|
@@ -93,8 +94,7 @@ export function getVapidKeys(keyPath) {
|
|
|
93
94
|
const keys = { publicKey, privateKey: jwk };
|
|
94
95
|
|
|
95
96
|
// Persist — best effort
|
|
96
|
-
|
|
97
|
-
try { fs.writeFileSync(keyPath, JSON.stringify(keys, null, 2)); } catch { /* best-effort */ }
|
|
97
|
+
writePrivateState(keyPath, JSON.stringify(keys, null, 2));
|
|
98
98
|
|
|
99
99
|
return keys;
|
|
100
100
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** @param {string} file */
|
|
5
|
+
function regularOrAbsent(file) {
|
|
6
|
+
try {
|
|
7
|
+
const stat = fs.lstatSync(file);
|
|
8
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('private state must be a regular file, not a symlink');
|
|
9
|
+
} catch (error) {
|
|
10
|
+
if (error.code !== 'ENOENT') throw error;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Tighten existing secrets as well as new files; never follow file symlinks.
|
|
15
|
+
* @param {string} file
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function readPrivateState(file) {
|
|
19
|
+
regularOrAbsent(file);
|
|
20
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
21
|
+
try {
|
|
22
|
+
if (!fs.fstatSync(fd).isFile()) throw new Error('private state is not a regular file');
|
|
23
|
+
const stat = fs.fstatSync(fd);
|
|
24
|
+
if ((stat.mode & 0o777) !== 0o600) fs.fchmodSync(fd, 0o600);
|
|
25
|
+
return fs.readFileSync(fd, 'utf8');
|
|
26
|
+
} finally { fs.closeSync(fd); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** @param {string} file @param {string} text @param {boolean} [append] */
|
|
30
|
+
export function writePrivateState(file, text, append = false) {
|
|
31
|
+
regularOrAbsent(file);
|
|
32
|
+
fs.mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
33
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_NOFOLLOW
|
|
34
|
+
| fs.constants.O_NONBLOCK | (append ? fs.constants.O_APPEND : 0);
|
|
35
|
+
const fd = fs.openSync(file, flags, 0o600);
|
|
36
|
+
try {
|
|
37
|
+
if (!fs.fstatSync(fd).isFile()) throw new Error('private state is not a regular file');
|
|
38
|
+
fs.fchmodSync(fd, 0o600);
|
|
39
|
+
// Refuse permission failures before modifying existing bytes.
|
|
40
|
+
if (!append) fs.ftruncateSync(fd, 0);
|
|
41
|
+
fs.writeFileSync(fd, text);
|
|
42
|
+
} finally { fs.closeSync(fd); }
|
|
43
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** One resolver for CLI and board, independent of their working directories.
|
|
5
|
+
* @param {Record<string, string | undefined>} [env]
|
|
6
|
+
* @param {string} [home]
|
|
7
|
+
*/
|
|
8
|
+
export function stateHome(env = process.env, home = homedir()) {
|
|
9
|
+
const configured = env.GREAT_CTO_HOME;
|
|
10
|
+
if (configured === undefined || configured === '') return join(home, '.great_cto');
|
|
11
|
+
if (typeof configured !== 'string' || !isAbsolute(configured) || configured.includes('\0')) {
|
|
12
|
+
throw new Error('GREAT_CTO_HOME must be an absolute dedicated state directory');
|
|
13
|
+
}
|
|
14
|
+
return resolve(configured);
|
|
15
|
+
}
|
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync, renameSync, rmdirSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { join, resolve, relative, sep, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { newRun, runStage, runParallelWave, parallelPair, approve, recover, cancel } from './lib/codex-pipeline.mjs';
|
|
5
|
+
import { newRun, runStage, runParallelWave, parallelPair, approve, reject, recover, cancel } from './lib/codex-pipeline.mjs';
|
|
6
6
|
import { approveRelease } from './lib/codex-release.mjs';
|
|
7
7
|
import { codexRunStore, listCodexRuns, codexHostDoctor } from './lib/codex-host-state.mjs';
|
|
8
8
|
import { beginWork, finishWork, acquireProjectLease, readWorkTask, linkWork, observeWorkRun, controlledDecisions } from './lib/work-tasks.mjs';
|
|
9
9
|
import { detectClaude } from './lib/claude-exec.mjs';
|
|
10
|
+
import { dispatchEvidenceSummary } from './lib/controller-dispatch-evidence.mjs';
|
|
11
|
+
import { readBenchmarkRegistration, bindBenchmarkTrial } from './lib/adaptive-benchmark-collector.mjs';
|
|
10
12
|
|
|
11
13
|
// State is outside the worker workspace. A per-run exclusive lock covers the entire subprocess lifetime.
|
|
12
14
|
const args = process.argv.slice(2);
|
|
@@ -81,11 +83,31 @@ try {
|
|
|
81
83
|
if (rel !== '..' && !rel.startsWith(`..${sep}`)) throw Error('release policy must be operator-owned outside the target workspace');
|
|
82
84
|
releasePolicy = JSON.parse(readFileSync(policyPath, 'utf8'));
|
|
83
85
|
}
|
|
84
|
-
|
|
86
|
+
let gatePolicy = null;
|
|
87
|
+
if (args.includes('--gate-policy')) {
|
|
88
|
+
if (!value('--gate-policy')) throw Error('--gate-policy requires an operator-owned policy file');
|
|
89
|
+
const policyPath = realpathSync(value('--gate-policy'));
|
|
90
|
+
const rel = relative(root, policyPath);
|
|
91
|
+
if (rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`))) throw Error('gate policy must be operator-owned outside the target workspace');
|
|
92
|
+
gatePolicy = JSON.parse(readFileSync(policyPath, 'utf8'));
|
|
93
|
+
}
|
|
94
|
+
let specialistPolicy = null;
|
|
95
|
+
if (args.includes('--specialist-policy')) {
|
|
96
|
+
if (!value('--specialist-policy')) throw Error('--specialist-policy requires an operator-owned policy file');
|
|
97
|
+
const policyPath = realpathSync(value('--specialist-policy'));
|
|
98
|
+
const rel = relative(root, policyPath);
|
|
99
|
+
if (rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`))) throw Error('specialist policy must be operator-owned outside the target workspace');
|
|
100
|
+
specialistPolicy = JSON.parse(readFileSync(policyPath, 'utf8'));
|
|
101
|
+
}
|
|
102
|
+
state = newRun({ root, prompt: value('--prompt'), checkPolicy, releasePolicy, specialistPolicy,
|
|
85
103
|
allowed, intent, entry: intent === 'research' ? 'project-auditor' : value('--entry') || 'product-owner',
|
|
86
|
-
maxAttempts, hostRoutes });
|
|
104
|
+
maxAttempts, hostRoutes, gatePolicy });
|
|
87
105
|
state.taskId = taskId || work.task.taskId;
|
|
88
106
|
state.acceptance = acceptance;
|
|
107
|
+
if (args.includes('--benchmark-trial')) {
|
|
108
|
+
if (!value('--benchmark-trial')) throw Error('--benchmark-trial requires an operator-owned trial registration');
|
|
109
|
+
bindBenchmarkTrial(state, readBenchmarkRegistration(root, value('--benchmark-trial')));
|
|
110
|
+
}
|
|
89
111
|
linkWork(state.taskId, 'runs', state.id, { root, host: 'codex' });
|
|
90
112
|
save(state);
|
|
91
113
|
} else {
|
|
@@ -100,7 +122,7 @@ try {
|
|
|
100
122
|
} else projectLease = acquireProjectLease(state.root, { reuseToken: inheritedLease });
|
|
101
123
|
}
|
|
102
124
|
}
|
|
103
|
-
if (!['start', 'resume', 'status', 'approve', 'approve-release', 'recover', 'cancel', 'approve-task'].includes(command)) throw Error('expected start, resume, status, approve, approve-release, recover, cancel, list or doctor');
|
|
125
|
+
if (!['start', 'resume', 'status', 'approve', 'reject', 'approve-release', 'recover', 'cancel', 'approve-task'].includes(command)) throw Error('expected start, resume, status, approve, reject, approve-release, recover, cancel, list or doctor');
|
|
104
126
|
if (command !== 'status') {
|
|
105
127
|
const lock = join(store, `${state.id}.lock`);
|
|
106
128
|
mkdirSync(lock); locked = lock;
|
|
@@ -115,6 +137,7 @@ try {
|
|
|
115
137
|
if (decision.kind === 'release') approveRelease(state, state.release.token); else approve(state, state.pending.token);
|
|
116
138
|
save(state);
|
|
117
139
|
} else if (command === 'approve') { approve(state, value('--token')); save(state); }
|
|
140
|
+
else if (command === 'reject') { reject(state, value('--token'), value('--reason')); save(state); }
|
|
118
141
|
else if (command === 'approve-release') { approveRelease(state, value('--token')); save(state); }
|
|
119
142
|
else if (command === 'recover') { recover(state); save(state); }
|
|
120
143
|
else if (command === 'cancel') { cancel(state); save(state); }
|
|
@@ -126,10 +149,16 @@ try {
|
|
|
126
149
|
}
|
|
127
150
|
}
|
|
128
151
|
console.log(JSON.stringify({ id: state.id, status: state.status, reason: state.reason,
|
|
152
|
+
controllerDispatch: dispatchEvidenceSummary(state),
|
|
129
153
|
release: state.release ? { status: state.release.status, token: state.release.token, adapter: state.release.adapter,
|
|
130
154
|
artifactDigest: state.release.artifactDigest, target: state.release.target, path: state.release.path, url: state.release.url,
|
|
131
155
|
activation: state.release.activation, rollback: state.release.rollback } : null,
|
|
132
|
-
pending: state.pending, queue: state.queue,
|
|
156
|
+
pending: state.pending, queue: state.queue, specialistPreparation: state.specialistPreparation ? {
|
|
157
|
+
roles: state.specialistPreparation.roles, status: state.specialistPreparation.status,
|
|
158
|
+
fingerprint: state.specialistPreparation.fingerprint, reusablePass: false } : null,
|
|
159
|
+
specialistReview: state.specialistReview ? {
|
|
160
|
+
roles: state.specialistReview.roles, fingerprint: state.specialistReview.fingerprint, reusablePass: false } : null,
|
|
161
|
+
hostRoutes: state.hostRoutes || {}, wave: state.wave ? {
|
|
133
162
|
id: state.wave.id, roles: state.wave.roles, hosts: state.wave.hosts, status: state.wave.status } : null,
|
|
134
163
|
rolesCompleted: Object.keys(state.results), stateFile: join(store, `${state.id}.json`) }, null, 2));
|
|
135
164
|
process.exitCode = ['blocked', 'manual-action', 'join-wait'].includes(state.status) ? 2 : 0;
|