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
@@ -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 { /* if we cannot resolve it, fall through to the normal checks */ }
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 => p.path === meta.path);
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
- res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
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
- const { parsePipelineToml } = await import('../../../scripts/hooks/pipeline-dispatcher.mjs');
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 = getTasks(gateCwd).find((t) => t.id === id);
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
- const inbox = getInbox(cwd);
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, { readInbox: getInbox }); }
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 { getTasks } from './beads.mjs';
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
- const msg = `event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`;
13
- for (const res of sseClients) {
14
- if (res._gctoCwd === cwd) {
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, getTasks, isSelfInflictedTouch } from './beads.mjs';
7
- import { getPipeline, getInbox } from './data-readers.mjs';
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 a stale entry refreshes on read.
54
+ // fresh, because it explicitly awaits the shared asynchronous refresh.
55
55
  bdCacheStale(dir);
56
- for (const res of sseClients) {
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
- for (const res of sseClients) {
134
- const dir = res._gctoCwd || process.cwd();
135
- try {
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-solid);
1772
- font-size: var(--fs-eyebrow); font-weight: 700;
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
- d.className = 'live-dot pulse';
8169
- document.getElementById('live-label').textContent = 'live · synced just now';
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(fs.readFileSync(keyPath, 'utf8'));
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
- try { fs.mkdirSync(path.dirname(keyPath), { recursive: true }); } catch { /* ignore */ }
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
- state = newRun({ root, prompt: value('--prompt'), checkPolicy, releasePolicy,
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, hostRoutes: state.hostRoutes || {}, wave: state.wave ? {
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;