great-cto 3.60.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.
- package/board/.claude-plugin/plugin.json +23 -1
- package/board/packages/board/lib/config.mjs +11 -4
- package/board/packages/board/lib/data-readers.mjs +4 -6
- package/board/packages/board/lib/projects.mjs +48 -17
- package/board/packages/board/lib/routes.mjs +2 -1
- package/board/packages/board/push-adapter.mjs +3 -3
- package/board/packages/cli/src/private-state.mjs +43 -0
- package/board/packages/cli/src/state-home.mjs +15 -0
- package/board/scripts/codex-pipeline.mjs +34 -5
- package/board/scripts/hooks/auto-attach-reviewers.mjs +449 -0
- package/board/scripts/lib/adaptive-benchmark-collector.mjs +202 -0
- package/board/scripts/lib/adaptive-benchmark-protocol.mjs +75 -0
- package/board/scripts/lib/agent-execution-budget.mjs +143 -0
- package/board/scripts/lib/approval-level.mjs +174 -0
- package/board/scripts/lib/benchmark-review-dispatch.mjs +52 -0
- package/board/scripts/lib/benchmark-scorer-signature.mjs +64 -0
- package/board/scripts/lib/claude-exec.mjs +11 -3
- package/board/scripts/lib/codex-checks.mjs +77 -6
- package/board/scripts/lib/codex-exec.mjs +13 -2
- package/board/scripts/lib/codex-host-state.mjs +2 -0
- package/board/scripts/lib/codex-pipeline.mjs +281 -38
- package/board/scripts/lib/codex-release.mjs +9 -5
- package/board/scripts/lib/codex-role-profiles.mjs +46 -1
- package/board/scripts/lib/controlled-specialists.mjs +150 -0
- package/board/scripts/lib/controller-dispatch-evidence.mjs +60 -0
- package/board/scripts/lib/pinned-benchmark-scorer.mjs +132 -0
- package/board/scripts/lib/receipt.mjs +31 -6
- package/board/scripts/lib/review-graph-floor.mjs +54 -0
- package/board/scripts/lib/runtime-gate-policy.mjs +84 -0
- package/board/scripts/lib/scoped-review-reuse.mjs +154 -0
- package/board/scripts/lib/specialist-plan.mjs +102 -0
- package/board/scripts/lib/turn-snapshot.mjs +24 -4
- package/dist/codex-host.js +1 -1
- package/dist/private-state.mjs +53 -0
- package/dist/serve.js +10 -5
- package/dist/state-home.mjs +15 -0
- package/dist/task-queue.js +2 -2
- package/dist/update-check.js +2 -5
- package/dist/webhook-cli.js +2 -2
- package/dist/webhook-config.js +18 -14
- package/dist/webhook-dispatch.js +5 -8
- package/dist/worker.js +3 -2
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "great-cto",
|
|
3
3
|
"description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.62.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
|
|
4
|
+
import { stateHome } from '../../cli/src/state-home.mjs';
|
|
5
5
|
|
|
6
6
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
// `--port N` (or `--port=N`) first — server.mjs's usage line documents it, and
|
|
@@ -28,17 +28,24 @@ const HOST = (() => {
|
|
|
28
28
|
const i = process.argv.indexOf('--host');
|
|
29
29
|
return String(i > -1 ? process.argv[i + 1] : process.env.GREAT_CTO_HOST || '127.0.0.1');
|
|
30
30
|
})();
|
|
31
|
-
|
|
31
|
+
// Shared dedicated namespace with the CLI; unset/empty keeps the operator default.
|
|
32
|
+
const GREAT_CTO_DIR = stateHome();
|
|
32
33
|
const SHARE_STATE_FILE = path.join(GREAT_CTO_DIR, 'board-share.json');
|
|
33
34
|
// Test seam: honor an explicit override so tests can point the registry at a
|
|
34
35
|
// tmp fixture without touching the real ~/.great_cto/projects.json (same
|
|
35
36
|
// convention as GREAT_CTO_BD_BIN in lib/beads.mjs). Unset in production —
|
|
36
37
|
// zero behavior change at runtime.
|
|
37
|
-
|
|
38
|
+
function stateFileOverride(name, fallback) {
|
|
39
|
+
const value = process.env[name];
|
|
40
|
+
if (!value) return path.join(GREAT_CTO_DIR, fallback);
|
|
41
|
+
if (!path.isAbsolute(value) || value.includes('\0')) throw new Error(`${name} must be absolute and contain no NUL`);
|
|
42
|
+
return path.resolve(value);
|
|
43
|
+
}
|
|
44
|
+
const PROJECTS_FILE = stateFileOverride('GREAT_CTO_PROJECTS_FILE', 'projects.json');
|
|
38
45
|
const SHARE_ENDPOINT = 'https://greatcto.systems/r/';
|
|
39
46
|
const VAPID_KEYS_FILE = path.join(GREAT_CTO_DIR, 'vapid-keys.json');
|
|
40
47
|
const PUSH_SUBS_FILE = path.join(GREAT_CTO_DIR, 'push-subscriptions.json');
|
|
41
|
-
const NOTIF_HISTORY_FILE =
|
|
48
|
+
const NOTIF_HISTORY_FILE = stateFileOverride('GREAT_CTO_NOTIF_HISTORY_FILE', 'notif-history.json');
|
|
42
49
|
const VAPID_SUBJECT = 'mailto:hi@updates.greatcto.systems';
|
|
43
50
|
|
|
44
51
|
export {
|
|
@@ -1,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(
|
|
37
|
-
{ id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(
|
|
38
|
-
{ id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(
|
|
34
|
+
{ id: 'g-decisions', scope: 'global', layer: 'L4', name: 'decisions.md', desc: 'Append-only ADR log — every gate approval', path: path.join(GREAT_CTO_DIR, 'decisions.md') },
|
|
35
|
+
{ id: 'g-prefs', scope: 'global', layer: 'L4', name: 'preferences.md',desc: 'User-level CTO preferences (style, defaults)', path: path.join(GREAT_CTO_DIR, 'preferences.md') },
|
|
36
|
+
{ id: 'g-lessons', scope: 'global', layer: 'L4', name: 'lessons.md', desc: 'Cross-project lessons promoted from L3', path: path.join(GREAT_CTO_DIR, 'lessons.md') },
|
|
39
37
|
];
|
|
40
38
|
const result = layers.map(l => ({
|
|
41
39
|
...l,
|
|
@@ -45,7 +43,7 @@ function getMemory(cwd = process.cwd()) {
|
|
|
45
43
|
// is photographed. The layer already knows its scope, so the display form is
|
|
46
44
|
// derived rather than guessed.
|
|
47
45
|
displayPath: l.scope === 'global'
|
|
48
|
-
? path.join('~', path.relative(
|
|
46
|
+
? path.join(process.env.GREAT_CTO_HOME ? '$GREAT_CTO_HOME' : path.join('~', '.great_cto'), path.relative(GREAT_CTO_DIR, l.path)).split(path.sep).join('/')
|
|
49
47
|
: path.relative(cwd, l.path).split(path.sep).join('/'),
|
|
50
48
|
content: readFileSafe(l.path),
|
|
51
49
|
exists: fs.existsSync(l.path),
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
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(
|
|
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;
|