great-cto 3.61.0 → 3.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/board/.claude-plugin/plugin.json +23 -1
  2. package/board/packages/board/lib/config.mjs +11 -4
  3. package/board/packages/board/lib/data-readers.mjs +4 -6
  4. package/board/packages/board/lib/projects.mjs +48 -17
  5. package/board/packages/board/lib/routes.mjs +2 -1
  6. package/board/packages/board/push-adapter.mjs +3 -3
  7. package/board/packages/cli/src/private-state.mjs +43 -0
  8. package/board/packages/cli/src/state-home.mjs +15 -0
  9. package/board/scripts/codex-pipeline.mjs +34 -5
  10. package/board/scripts/hooks/auto-attach-reviewers.mjs +449 -0
  11. package/board/scripts/lib/adaptive-benchmark-collector.mjs +202 -0
  12. package/board/scripts/lib/adaptive-benchmark-protocol.mjs +75 -0
  13. package/board/scripts/lib/agent-execution-budget.mjs +143 -0
  14. package/board/scripts/lib/approval-level.mjs +174 -0
  15. package/board/scripts/lib/benchmark-review-dispatch.mjs +52 -0
  16. package/board/scripts/lib/benchmark-scorer-signature.mjs +64 -0
  17. package/board/scripts/lib/claude-exec.mjs +11 -3
  18. package/board/scripts/lib/codex-checks.mjs +77 -6
  19. package/board/scripts/lib/codex-exec.mjs +13 -2
  20. package/board/scripts/lib/codex-host-state.mjs +2 -0
  21. package/board/scripts/lib/codex-pipeline.mjs +281 -38
  22. package/board/scripts/lib/codex-release.mjs +9 -5
  23. package/board/scripts/lib/codex-role-profiles.mjs +46 -1
  24. package/board/scripts/lib/controlled-specialists.mjs +150 -0
  25. package/board/scripts/lib/controller-dispatch-evidence.mjs +60 -0
  26. package/board/scripts/lib/pinned-benchmark-scorer.mjs +132 -0
  27. package/board/scripts/lib/receipt.mjs +31 -6
  28. package/board/scripts/lib/review-graph-floor.mjs +54 -0
  29. package/board/scripts/lib/runtime-gate-policy.mjs +84 -0
  30. package/board/scripts/lib/scoped-review-reuse.mjs +154 -0
  31. package/board/scripts/lib/specialist-plan.mjs +102 -0
  32. package/board/scripts/lib/turn-snapshot.mjs +24 -4
  33. package/dist/codex-host.js +1 -1
  34. package/dist/private-state.mjs +53 -0
  35. package/dist/serve.js +10 -5
  36. package/dist/state-home.mjs +15 -0
  37. package/dist/task-queue.js +2 -2
  38. package/dist/update-check.js +2 -5
  39. package/dist/webhook-cli.js +2 -2
  40. package/dist/webhook-config.js +18 -14
  41. package/dist/webhook-dispatch.js +5 -8
  42. package/dist/worker.js +3 -2
  43. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.61.0",
4
+ "version": "3.62.0",
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": [
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { fileURLToPath } from 'url';
4
- import os from 'os';
4
+ import { stateHome } from '../../cli/src/state-home.mjs';
5
5
 
6
6
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
7
  // `--port N` (or `--port=N`) first — server.mjs's usage line documents it, and
@@ -28,17 +28,24 @@ const HOST = (() => {
28
28
  const i = process.argv.indexOf('--host');
29
29
  return String(i > -1 ? process.argv[i + 1] : process.env.GREAT_CTO_HOST || '127.0.0.1');
30
30
  })();
31
- const GREAT_CTO_DIR = path.join(os.homedir(), '.great_cto');
31
+ // Shared dedicated namespace with the CLI; unset/empty keeps the operator default.
32
+ const GREAT_CTO_DIR = stateHome();
32
33
  const SHARE_STATE_FILE = path.join(GREAT_CTO_DIR, 'board-share.json');
33
34
  // Test seam: honor an explicit override so tests can point the registry at a
34
35
  // tmp fixture without touching the real ~/.great_cto/projects.json (same
35
36
  // convention as GREAT_CTO_BD_BIN in lib/beads.mjs). Unset in production —
36
37
  // zero behavior change at runtime.
37
- const PROJECTS_FILE = process.env.GREAT_CTO_PROJECTS_FILE || path.join(GREAT_CTO_DIR, 'projects.json');
38
+ function stateFileOverride(name, fallback) {
39
+ const value = process.env[name];
40
+ if (!value) return path.join(GREAT_CTO_DIR, fallback);
41
+ if (!path.isAbsolute(value) || value.includes('\0')) throw new Error(`${name} must be absolute and contain no NUL`);
42
+ return path.resolve(value);
43
+ }
44
+ const PROJECTS_FILE = stateFileOverride('GREAT_CTO_PROJECTS_FILE', 'projects.json');
38
45
  const SHARE_ENDPOINT = 'https://greatcto.systems/r/';
39
46
  const VAPID_KEYS_FILE = path.join(GREAT_CTO_DIR, 'vapid-keys.json');
40
47
  const PUSH_SUBS_FILE = path.join(GREAT_CTO_DIR, 'push-subscriptions.json');
41
- const NOTIF_HISTORY_FILE = process.env.GREAT_CTO_NOTIF_HISTORY_FILE || path.join(GREAT_CTO_DIR, 'notif-history.json');
48
+ const NOTIF_HISTORY_FILE = stateFileOverride('GREAT_CTO_NOTIF_HISTORY_FILE', 'notif-history.json');
42
49
  const VAPID_SUBJECT = 'mailto:hi@updates.greatcto.systems';
43
50
 
44
51
  export {
@@ -1,7 +1,6 @@
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
6
  import { getTasks } from './beads.mjs';
@@ -21,7 +20,6 @@ function gateNameOf(task) {
21
20
  }
22
21
 
23
22
  function getMemory(cwd = process.cwd()) {
24
- const home = os.homedir();
25
23
  const layers = [
26
24
  // Project-local (.great_cto/) — L1 archetype + L2 codebase + L3 retros
27
25
  { id: 'project', scope: 'project', layer: 'L1', name: 'PROJECT.md', desc: 'Archetype, size, compliance, owners', path: path.join(cwd, '.great_cto', 'PROJECT.md') },
@@ -33,9 +31,9 @@ function getMemory(cwd = process.cwd()) {
33
31
  { id: 'handoff', scope: 'project', layer: 'L3', name: 'HANDOFF.md', desc: 'Auto-written on context compaction', path: path.join(cwd, '.great_cto', 'HANDOFF.md') },
34
32
  { id: 'local', scope: 'project', layer: 'L3', name: 'local.md', desc: 'Project-local notes (gitignored)', path: path.join(cwd, '.great_cto', 'local.md') },
35
33
  // Cross-project (~/.great_cto/) — L4 global memory shared across all projects
36
- { id: 'g-decisions', scope: 'global', layer: 'L4', name: 'decisions.md', desc: 'Append-only ADR log — every gate approval', path: path.join(home, '.great_cto', 'decisions.md') },
37
- { id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(home, '.great_cto', 'preferences.md') },
38
- { id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(home, '.great_cto', 'lessons.md') },
34
+ { id: 'g-decisions', scope: 'global', layer: 'L4', name: 'decisions.md', desc: 'Append-only ADR log — every gate approval', path: path.join(GREAT_CTO_DIR, 'decisions.md') },
35
+ { id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(GREAT_CTO_DIR, 'preferences.md') },
36
+ { id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(GREAT_CTO_DIR, 'lessons.md') },
39
37
  ];
40
38
  const result = layers.map(l => ({
41
39
  ...l,
@@ -45,7 +43,7 @@ function getMemory(cwd = process.cwd()) {
45
43
  // is photographed. The layer already knows its scope, so the display form is
46
44
  // derived rather than guessed.
47
45
  displayPath: l.scope === 'global'
48
- ? path.join('~', path.relative(home, l.path)).split(path.sep).join('/')
46
+ ? path.join(process.env.GREAT_CTO_HOME ? '$GREAT_CTO_HOME' : path.join('~', '.great_cto'), path.relative(GREAT_CTO_DIR, l.path)).split(path.sep).join('/')
49
47
  : path.relative(cwd, l.path).split(path.sep).join('/'),
50
48
  content: readFileSafe(l.path),
51
49
  exists: fs.existsSync(l.path),
@@ -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,
@@ -598,7 +598,8 @@ async function dispatch(req, res, url, cwd) {
598
598
  let transitions = null;
599
599
  try {
600
600
  const { pipelineMapFor } = await import('../../../scripts/lib/pipeline-health.mjs');
601
- const { parsePipelineToml } = await import('../../../scripts/hooks/pipeline-dispatcher.mjs');
601
+ // Use the pure parser, not the native hook that merely reexports it.
602
+ const { parsePipelineToml } = await import('../../../scripts/lib/pipeline-toml.mjs');
602
603
  const map = pipelineMapFor(cwd);
603
604
  if (map.path) transitions = parsePipelineToml(fs.readFileSync(map.path, 'utf8'));
604
605
  } catch { transitions = null; }
@@ -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;