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.
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
@@ -0,0 +1,75 @@
1
+ /** Matched experiment contract and descriptive supplied-data checks, not a runner. */
2
+ import { createHash } from 'node:crypto';
3
+
4
+ const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
5
+ const arms = ['legacy', 'adaptive'];
6
+ const metrics = ['activeMs', 'workerCalls', 'verifierCalls', 'humanPauses', 'actionableFindings', 'escapedDefects', 'actualCostUsd'];
7
+ export const scenarios = [
8
+ { id: 'docs-low-risk', cluster: 'content', tier: 'T0', task: 'Repair broken internal documentation links without code/config changes', checks: ['all target links resolve', 'no source/config drift'] },
9
+ { id: 'tenant-auth', cluster: 'identity', tier: 'T2', task: 'Fix cross-tenant object access and preserve valid tenant access', checks: ['cross-tenant deny', 'owner allow', 'unauthenticated deny', 'audit without secrets'] },
10
+ { id: 'billing-webhook', cluster: 'commerce', tier: 'T2', task: 'Make signed billing webhooks idempotent under duplicate and out-of-order delivery', checks: ['signature refusal', 'duplicate no-op', 'out-of-order reconciliation', 'no double charge'] },
11
+ { id: 'bounded-import', cluster: 'data', tier: 'T2', task: 'Implement dry-run import with validation, bounded history and rollback', checks: ['invalid data refused', 'dry run no mutation', 'bounded replacement', 'repeat import idempotent'] },
12
+ { id: 'prompt-injection', cluster: 'ai', tier: 'T2', task: 'Preserve citations and prevent retrieved text from changing tool authority', checks: ['injection rejected', 'supported citations', 'uncertainty retained', 'cross-user isolation'] },
13
+ { id: 'api-compatibility', cluster: 'platform', tier: 'T1', task: 'Add an optional API field while preserving older clients and explicit errors', checks: ['old client preserved', 'schema validation', 'no silent default on missing source'] },
14
+ { id: 'migration-safety', cluster: 'storage', tier: 'T2', task: 'Plan a backward-compatible schema change with concurrent readers and rollback', checks: ['rollback path', 'reader compatibility', 'bounded locks', 'least privilege'] },
15
+ { id: 'board-accessibility', cluster: 'ui', tier: 'T1', task: 'Repair keyboard navigation and narrow-screen layout without losing decision controls', checks: ['keyboard destinations', 'no horizontal overflow', 'decision target preserved', 'no console error'] },
16
+ ];
17
+
18
+ /** Both exact policies/artifacts and corpus must be pinned before observations. */
19
+ export function preregisterBenchmark({ artifacts, policies, repetitions = 3, hostModelPolicy }) {
20
+ if (!Number.isInteger(repetitions) || repetitions < 3 || repetitions > 10) throw Error('repetitions must be 3..10');
21
+ if (typeof hostModelPolicy !== 'string' || !hostModelPolicy.trim()) throw Error('explicit matched host/model policy required');
22
+ for (const arm of arms) {
23
+ if (!/^[a-f0-9]{40}$/.test(artifacts?.[arm]?.commit || '') || !/^[a-f0-9]{64}$/.test(artifacts?.[arm]?.artifactSha256 || '')) throw Error('exact artifact pins required for both arms');
24
+ if (!policies?.[arm] || typeof policies[arm] !== 'object' || Array.isArray(policies[arm])) throw Error('both full execution policies required');
25
+ }
26
+ if (JSON.stringify(artifacts.legacy) === JSON.stringify(artifacts.adaptive) && JSON.stringify(policies.legacy) === JSON.stringify(policies.adaptive)) throw Error('identical arms are not an adaptive comparison');
27
+ const blocks = scenarios.flatMap((scenario, index) => Array.from({ length: repetitions }, (_, repetition) => ({
28
+ task: scenario.id, cluster: scenario.cluster, repetition, order: (index + repetition) % 2 ? ['adaptive', 'legacy'] : ['legacy', 'adaptive'],
29
+ specificationDigest: hash(scenario), acceptanceDigest: hash(scenario.checks),
30
+ })));
31
+ const contract = { version: 1, kind: 'matched full-workflow preregistration', artifacts, policies, hostModelPolicy,
32
+ scenarios, repetitions, blocks, metrics, unitOfAnalysis: 'task cluster, not individual assertion or agent call',
33
+ gates: 'actual operator approvals required; never infer approval from exit zero or test fixture',
34
+ safety: 'same mandatory domain/security/compliance floors in both arms; no production targets',
35
+ stopping: 'record launch failures, incomplete runs and gates; no silent replacement of unsuccessful trials',
36
+ hiddenChecks: 'outside workers; fixed before first call; never fed back into repair prompts',
37
+ status: 'preregistered-not-run-ready', remaining: 'representative fixtures, independent scorer and attested controller observation collector' };
38
+ return { ...contract, digest: hash(contract) };
39
+ }
40
+
41
+ /** Validate observations without pretending supplied numbers are runtime attestation. */
42
+ export function describeMatchedObservations(protocol, observations) {
43
+ const { digest, ...contract } = protocol || {};
44
+ if (!digest || hash(contract) !== digest) throw Error('preregistration changed');
45
+ if (!Array.isArray(observations)) throw Error('observations must be an array');
46
+ const groups = new Map();
47
+ for (const row of observations) {
48
+ const block = protocol.blocks.find(b => b.task === row.task && b.repetition === row.repetition);
49
+ if (!block || !arms.includes(row.arm) || row.protocolDigest !== digest
50
+ || row.specificationDigest !== block.specificationDigest || row.acceptanceDigest !== block.acceptanceDigest
51
+ || row.artifactSha256 !== protocol.artifacts[row.arm].artifactSha256) throw Error('unmatched observation identity');
52
+ const key = `${row.task}:${row.repetition}`;
53
+ const group = groups.get(key) || {};
54
+ if (group[row.arm]) throw Error('duplicate arm observation');
55
+ if (!['completed', 'blocked', 'failed', 'unassessed'].includes(row.status)) throw Error('invalid observation status');
56
+ if (row.status === 'completed' ? typeof row.accepted !== 'boolean' : row.accepted !== null) throw Error('unassessed acceptance must be null');
57
+ for (const metric of metrics) {
58
+ if (row[metric] !== null && (!Number.isFinite(row[metric]) || row[metric] < 0)) throw Error('unavailable metric must be null, never fabricated zero');
59
+ if (row[metric] !== null && metric !== 'activeMs' && metric !== 'actualCostUsd' && !Number.isInteger(row[metric])) throw Error('count metric must be integral');
60
+ }
61
+ group[row.arm] = row; groups.set(key, group);
62
+ }
63
+ const paired = [...groups.values()].filter(g => arms.every(a => g[a]?.status === 'completed'));
64
+ const differences = Object.fromEntries(metrics.map(metric => {
65
+ const measured = paired.filter(g => arms.every(a => g[a][metric] !== null));
66
+ return [metric, { pairedMeasured: measured.length, meanAdaptiveMinusLegacy: measured.length
67
+ ? measured.reduce((sum, g) => sum + g.adaptive[metric] - g.legacy[metric], 0) / measured.length : null }];
68
+ }));
69
+ return { evidenceLevel: 'supplied-observations-not-runtime-attested', plannedBlocks: protocol.blocks.length,
70
+ observedRows: observations.length, completedPairs: paired.length,
71
+ incompleteBlocks: protocol.blocks.length - paired.length,
72
+ pairedAcceptance: Object.fromEntries(arms.map(a => [a, paired.length ? paired.filter(g => g[a].accepted).length / paired.length : null])),
73
+ differences, qualityUpliftPercent: null, defaultEnablementAllowed: false,
74
+ inference: 'descriptive only; no significance or product-quality conclusion without independent evidence and task-cluster uncertainty' };
75
+ }
@@ -0,0 +1,143 @@
1
+ /** Same-machine cross-host admission control, outside worker project scope. */
2
+ import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, rmdirSync, realpathSync, existsSync, lstatSync, openSync, fsyncSync, closeSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join, relative, isAbsolute, sep } from 'node:path';
5
+ import { randomUUID, createHash } from 'node:crypto';
6
+
7
+ const sha = value => createHash('sha256').update(value).digest('hex');
8
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
9
+ const outside = (root, path) => { const rel = relative(root, path); return rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel); };
10
+ const sleep = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
+
12
+ export function readExecutionBudget(root, { env = process.env } = {}) {
13
+ if (!env.GREAT_CTO_AGENT_BUDGET_FILE) return null;
14
+ root = realpathSync(root);
15
+ const file = realpathSync(env.GREAT_CTO_AGENT_BUDGET_FILE);
16
+ if (!outside(root, file)) throw Error('agent budget policy must be outside the worker workspace');
17
+ if ((lstatSync(file).mode & 0o077) !== 0) throw Error('agent budget policy must be private (0600)');
18
+ const policy = JSON.parse(readFileSync(file, 'utf8'));
19
+ for (const [key, min, max] of [['maxConcurrent', 1, 16], ['maxCallsPerRun', 2, 512]]) {
20
+ if (!Number.isInteger(policy[key]) || policy[key] < min || policy[key] > max) throw Error(`invalid agent budget ${key}`);
21
+ }
22
+ if (policy.maxDepth !== 1) throw Error('v1 requires maxDepth=1; nested delegation is not admitted');
23
+ if (!/^[a-zA-Z0-9_-]{1,100}$/.test(policy.runId || '')) throw Error('explicit shared budget runId required');
24
+ const limits = { maxConcurrent: policy.maxConcurrent, maxDepth: 1, maxCallsPerRun: policy.maxCallsPerRun };
25
+ const configured = env.GREAT_CTO_AGENT_BUDGET_STORE || join(homedir(), '.great_cto', 'agent-execution');
26
+ if (!isAbsolute(configured) || !outside(root, configured)) throw Error('agent budget store must be absolute and outside workspace');
27
+ mkdirSync(configured, { recursive: true, mode: 0o700 });
28
+ const store = realpathSync(configured);
29
+ if (!outside(root, store) || (lstatSync(store).mode & 0o077) !== 0) throw Error('agent budget store must be private and outside workspace');
30
+ return { store, limits, policyDigest: sha(JSON.stringify(limits)), runKey: sha(policy.runId) };
31
+ }
32
+
33
+ function atomicJson(path, value) {
34
+ const temp = `${path}.${randomUUID()}.tmp`;
35
+ const fd = openSync(temp, 'wx', 0o600);
36
+ try { writeFileSync(fd, JSON.stringify(value)); fsyncSync(fd); } finally { closeSync(fd); }
37
+ renameSync(temp, path);
38
+ }
39
+
40
+ /** A crashed lock stays closed. No TTL/PID assumption can prove a worker ended. */
41
+ function transaction(budget, operation) {
42
+ const lock = join(budget.store, 'lock'); const deadline = Date.now() + 1500;
43
+ while (true) {
44
+ try { mkdirSync(lock, { mode: 0o700 }); break; }
45
+ catch (e) { if (e.code !== 'EEXIST' || Date.now() >= deadline) throw Error('agent budget lock unavailable; inspect/reconcile, never bypass'); sleep(10); }
46
+ }
47
+ const lockToken = randomUUID();
48
+ try {
49
+ atomicJson(join(lock, 'owner.json'), { token: lockToken, pid: process.pid });
50
+ const path = join(budget.store, 'ledger.json');
51
+ const ledger = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { version: 1, policyDigest: budget.policyDigest, limits: budget.limits, nextFence: 0, leases: {}, calls: {}, retired: {}, events: [] };
52
+ // JSON arrays discard string-keyed properties on serialization. Treating
53
+ // them as maps can silently lose leases, call counts or replay fencing.
54
+ if (!record(ledger) || ledger.version !== 1 || ledger.policyDigest !== budget.policyDigest || JSON.stringify(ledger.limits) !== JSON.stringify(budget.limits) || !record(ledger.leases) || !record(ledger.calls) || !record(ledger.retired) || !Array.isArray(ledger.events) || !Number.isSafeInteger(ledger.nextFence) || ledger.nextFence < 0 || Object.values(ledger.calls).some(n => !Number.isSafeInteger(n) || n < 0)) throw Error('agent budget ledger/policy mismatch; admission refused');
55
+ const result = operation(ledger);
56
+ atomicJson(path, ledger);
57
+ return result;
58
+ } finally {
59
+ // Only the holder may unlock. Never delete somebody else's state recursively.
60
+ const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
61
+ if (owner.token !== lockToken) throw Error('agent budget lock ownership changed');
62
+ unlinkSync(join(lock, 'owner.json')); rmdirSync(lock);
63
+ }
64
+ }
65
+
66
+ const event = (ledger, entry) => {
67
+ ledger.events.push({ at: new Date().toISOString(), ...entry });
68
+ if (ledger.events.length > 2000) ledger.events.splice(0, ledger.events.length - 2000);
69
+ };
70
+
71
+ /** Allocate a whole wave atomically, so one host cannot consume half a quorum. */
72
+ export function reserveAgents(budget, requests) {
73
+ if (!budget) return requests.map(() => null);
74
+ return transaction(budget, ledger => {
75
+ const deny = reason => { event(ledger, { kind: 'denied', runKey: budget.runKey, reason }); return { error: reason }; };
76
+ if (!Array.isArray(requests) || !requests.length) return deny('empty agent reservation');
77
+ for (const r of requests) if (!/^[a-zA-Z0-9:_-]{1,200}$/.test(r.callId || '') || !['codex', 'claude-code'].includes(r.host) || r.depth !== 1) return deny('invalid agent identity or delegation depth');
78
+ const seen = new Set();
79
+ for (const r of requests) { if (seen.has(r.callId)) return deny('duplicate call identity'); seen.add(r.callId); }
80
+ const present = requests.map(r => Object.values(ledger.leases).find(l => l.runKey === budget.runKey && l.callId === r.callId));
81
+ if (present.some(Boolean)) {
82
+ if (present.every((lease, i) => lease && lease.host === requests[i].host && lease.role === (requests[i].role || 'worker') && lease.depth === requests[i].depth)) return present;
83
+ return deny('partial/conflicting reservation replay');
84
+ }
85
+ if (requests.some(r => ledger.retired[sha(`${budget.runKey}\0${r.callId}`)])) return deny('completed/reconciled call identity cannot be reused');
86
+ if (Object.keys(ledger.leases).length + requests.length > ledger.limits.maxConcurrent) return deny('global agent concurrency exhausted');
87
+ if ((ledger.calls[budget.runKey] || 0) + requests.length > ledger.limits.maxCallsPerRun) return deny('shared run agent-call budget exhausted');
88
+ if (requests.length > Number.MAX_SAFE_INTEGER - ledger.nextFence) return deny('agent lease fence exhausted; admission refused');
89
+ const leases = requests.map(r => {
90
+ const lease = { token: randomUUID(), fence: ++ledger.nextFence, runKey: budget.runKey, callId: r.callId, host: r.host, role: r.role || 'worker', depth: r.depth, acquiredAt: new Date().toISOString(), ownerPid: Object.hasOwn(r, 'ownerPid') ? r.ownerPid : process.pid };
91
+ ledger.leases[lease.token] = lease;
92
+ event(ledger, { kind: 'admitted', ...lease }); return lease;
93
+ });
94
+ ledger.calls[budget.runKey] = (ledger.calls[budget.runKey] || 0) + leases.length;
95
+ return leases;
96
+ });
97
+ }
98
+
99
+ export function requireAgents(budget, requests) {
100
+ const result = reserveAgents(budget, requests);
101
+ if (result.error) throw Error(result.error);
102
+ return result;
103
+ }
104
+
105
+ export function releaseAgent(budget, lease) {
106
+ if (!budget || !lease) return;
107
+ return transaction(budget, ledger => {
108
+ const current = ledger.leases[lease.token];
109
+ if (!current || current.fence !== lease.fence || current.runKey !== budget.runKey) throw Error('stale or foreign agent lease');
110
+ ledger.retired[sha(`${current.runKey}\0${current.callId}`)] = current.fence;
111
+ delete ledger.leases[lease.token]; event(ledger, { kind: 'released', token: lease.token, fence: lease.fence, runKey: lease.runKey });
112
+ });
113
+ }
114
+
115
+ /** Explicit reconciliation is not automatic expiry; caller must confirm work stopped. */
116
+ export function reconcileAgent(budget, { token, fence, confirmedStopped = false }) {
117
+ if (!confirmedStopped) throw Error('operator confirmation that execution stopped is required');
118
+ return releaseAgent(budget, { token, fence });
119
+ }
120
+
121
+ export function reconcileBudgetLock(budget, { token, confirmedStopped = false }) {
122
+ if (!confirmedStopped) throw Error('operator confirmation required for crashed lock recovery');
123
+ const lock = join(budget.store, 'lock');
124
+ const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
125
+ if (owner.token !== token || !Number.isInteger(owner.pid)) throw Error('lock recovery identity mismatch');
126
+ try { process.kill(owner.pid, 0); throw Error('lock owner still alive; recovery refused'); }
127
+ catch (e) { if (e.code !== 'ESRCH') throw e; }
128
+ const recovery = join(budget.store, 'recovery'); mkdirSync(recovery, { mode: 0o700 });
129
+ try {
130
+ if (JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8')).token !== token) throw Error('lock owner changed');
131
+ unlinkSync(join(lock, 'owner.json')); rmdirSync(lock);
132
+ } finally { rmdirSync(recovery); }
133
+ }
134
+
135
+ export function budgetSnapshot(budget) {
136
+ return transaction(budget, ledger => ({ active: Object.values(ledger.leases), calls: ledger.calls[budget.runKey] || 0, limits: ledger.limits, events: ledger.events.slice(-50) }));
137
+ }
138
+
139
+ export async function withAgentBudget(state, { callId, host, role }, run) {
140
+ const budget = state.executionBudget;
141
+ const [lease] = requireAgents(budget, [{ callId, host, role, depth: 1 }]);
142
+ try { return await run(); } finally { releaseAgent(budget, lease); }
143
+ }
@@ -0,0 +1,174 @@
1
+ // Which gates stop the human, for a given approval-level.
2
+ //
3
+ // The pipeline already chains itself: shared/pipeline.toml holds the transition
4
+ // map and pipeline-dispatcher.mjs injects the next agent after each verdict. The
5
+ // only question left is where it should PAUSE — and the existing levels could not
6
+ // express the one an operator most often wants: "ask me about the product, decide
7
+ // the technical parts yourself".
8
+ //
9
+ // `gates-only` (the default) stops at `arch` — an architectural question — and
10
+ // never at `product`. `auto` asks nothing at all, including what to build. So the
11
+ // operator had to either review architecture they did not want to review, or run
12
+ // with no human decision anywhere. `product-only` closes that: the two gates it
13
+ // keeps are exactly the two ADR-009 calls expensive to undo — choosing what to
14
+ // build (a wrong answer wastes the entire build) and shipping it (it escapes the
15
+ // machine and reaches users).
16
+ //
17
+ // This is a pure function on purpose. The rule used to live as prose in every
18
+ // agent's prompt, re-derived each time; one testable definition means the agents
19
+ // agree by construction.
20
+
21
+ /** Gate sets per level, in pipeline order. */
22
+ const LEVEL_GATES = Object.freeze({
23
+ auto: [],
24
+ 'product-only': ['product', 'ship'],
25
+ // `product` joined the default on 2026-08-19. The set was ['arch', 'ship'],
26
+ // which gated HOW to build and WHETHER to release, and left WHAT to build
27
+ // ungated — the one decision in the chain that is most expensive to reverse,
28
+ // because you learn it was wrong only after architect, pm, senior-dev, qa,
29
+ // security and devops have all run. ADR-009 puts gates on cost-of-undo rather
30
+ // than on position, and by that rule this was inverted: the cheap-to-undo
31
+ // decision stopped, the expensive one did not.
32
+ //
33
+ // It costs one pause per PRODUCT, not per feature. product-owner is a pipeline
34
+ // entry point — nothing transitions into it — so it runs only from `/start`.
35
+ // `/audit` enters at project-auditor, and the request classifier sends both
36
+ // SIMPLE and COMPLEX CODE straight to senior-dev or architect. The pause lands
37
+ // at the moment someone typed `/start` and is by definition at the keyboard.
38
+ // One pipeline gate, and a briefing where the second one was.
39
+ //
40
+ // Four stops were measured at `gates-only` — product, arch, import, ship — and
41
+ // they do not collapse, because they guard different failures: `arch` is cheap
42
+ // to undo, `import` destroys data, `product` wastes the whole build, `ship`
43
+ // escapes the machine. Only the last is irreversible in ADR-009's sense, so it
44
+ // is the one that stops you.
45
+ //
46
+ // `product` does not disappear. A decision about WHAT to build that nobody sees
47
+ // is how a pipeline spends a day on the wrong thing — and that is precisely why
48
+ // it was added to the default on 2026-08-19. Here it is surfaced as a mandatory
49
+ // one-screen console briefing instead: stay silent and it proceeds, speak and it
50
+ // stops. Not a gate, and not silence either.
51
+ //
52
+ // See briefedGatesFor() below, and the fallback in gatesForApprovalLevel: a
53
+ // brief that cannot be READ re-gates product, because "I could not show you"
54
+ // must never be delivered as "you were shown and said nothing".
55
+ 'ship-only': ['ship'],
56
+ 'gates-only': ['product', 'arch', 'ship'],
57
+ strict: ['arch', 'code', 'ship'],
58
+ expert: ['product', 'arch', 'plan', 'code', 'qa', 'security', 'ship'],
59
+ 'step-by-step': ['product', 'arch', 'plan', 'code', 'qa', 'security', 'ship'],
60
+ });
61
+
62
+ export const APPROVAL_LEVELS = Object.freeze(Object.keys(LEVEL_GATES));
63
+ export const DEFAULT_LEVEL = 'gates-only';
64
+
65
+ /**
66
+ * Archetypes where a compliance/security sign-off is not the operator's to skip.
67
+ * SKILL.md already states these carry a `strict` minimum; encoding it here means
68
+ * a new level cannot become a bypass by omission.
69
+ */
70
+ const REGULATED = new Set([
71
+ 'ai-system', 'agent-product', 'commerce', 'web3', 'iot-embedded', 'regulated',
72
+ 'fintech', 'healthcare', 'insurance', 'legal', 'gov-public', 'defense-govcon',
73
+ ]);
74
+ const REGULATED_FLOOR = ['security', 'compliance'];
75
+
76
+ /**
77
+ * Gates that apply at EVERY level, including `auto`.
78
+ *
79
+ * The levels above trade review depth for speed, which is the operator's call.
80
+ * This is a different axis, and ADR-009 states it: an operation that destroys
81
+ * evidence needs a human wherever it sits, regardless of position or policy.
82
+ *
83
+ * `import` is here because re-importing over records someone has since edited
84
+ * cannot be undone — `migration-import-engineer` says so in its own prompt. A
85
+ * level is a statement about how much review you want; it is not permission to
86
+ * overwrite a client's data unattended, and a floor of `[]` at `auto` would have
87
+ * made it exactly that.
88
+ */
89
+ const IRREVERSIBLE_FLOOR = ['import'];
90
+
91
+ export function isRegulated(archetype) {
92
+ return REGULATED.has(String(archetype || '').toLowerCase());
93
+ }
94
+
95
+ /**
96
+ * The gates that must stop a human for this level.
97
+ *
98
+ * @param {string} level
99
+ * @param {{archetype?: string}} [opts]
100
+ * @returns {string[]} gate names, pipeline order, deduped
101
+ */
102
+ /**
103
+ * Decisions this level surfaces as a non-blocking console briefing rather than a
104
+ * gate. Empty for every level that either gates the decision or never reaches it.
105
+ * @returns {string[]}
106
+ */
107
+ export function briefedGatesFor(level) {
108
+ return level === 'ship-only' ? ['product'] : [];
109
+ }
110
+
111
+ export function gatesForApprovalLevel(level, { archetype, briefReadable = true } = {}) {
112
+ // An unrecognised or missing level falls back to the default rather than to
113
+ // "no gates": a typo in PROJECT.md must not silently disable human review.
114
+ const key = LEVEL_GATES[level] ? level : DEFAULT_LEVEL;
115
+ const base = [...LEVEL_GATES[key]];
116
+
117
+ // Applies at every level, `auto` included — see IRREVERSIBLE_FLOOR.
118
+ for (const g of IRREVERSIBLE_FLOOR) if (!base.includes(g)) base.push(g);
119
+
120
+ // A briefing replaces a gate only while the briefing actually appears.
121
+ //
122
+ // `ship-only` trades the `product` gate for a mandatory console screen. If the
123
+ // brief cannot be read — absent, unreadable, written by a stage that failed —
124
+ // the trade is off and the gate comes back. Proceeding here would deliver "I
125
+ // could not show you" as "you were shown and said nothing", which is the exact
126
+ // substitution this project exists to refuse, arriving through a feature meant
127
+ // to reduce friction.
128
+ if (!briefReadable) {
129
+ for (const g of briefedGatesFor(key)) if (!base.includes(g)) base.push(g);
130
+ }
131
+
132
+ if (isRegulated(archetype)) {
133
+ for (const g of REGULATED_FLOOR) if (!base.includes(g)) base.push(g);
134
+ if (!base.includes('ship')) base.push('ship');
135
+ }
136
+
137
+ const order = ['product', 'arch', 'plan', 'code', 'import', 'qa', 'security', 'compliance', 'ship'];
138
+ return [...new Set(base)].sort((a, b) => order.indexOf(a) - order.indexOf(b));
139
+ }
140
+
141
+ /** Should this specific gate pause the human under this level? */
142
+ export function gateApplies(gate, level, opts) {
143
+ return gatesForApprovalLevel(level, opts).includes(String(gate).replace(/^gate:/, ''));
144
+ }
145
+
146
+ /** Read the level out of a PROJECT.md body. Missing → the default. */
147
+ export function levelFromProjectMd(text = '') {
148
+ const m = String(text).match(/^approval-level:\s*(\S+)/m);
149
+ const raw = m ? m[1].trim() : DEFAULT_LEVEL;
150
+ return LEVEL_GATES[raw] ? raw : DEFAULT_LEVEL;
151
+ }
152
+
153
+ /** One-line explanation for an operator asking why they were (not) asked. */
154
+ export function describeLevel(level, opts) {
155
+ const gates = gatesForApprovalLevel(level, opts);
156
+ const key = LEVEL_GATES[level] ? level : `${level} (unknown → ${DEFAULT_LEVEL})`;
157
+ return gates.length
158
+ ? `${key}: pauses at ${gates.map((g) => `gate:${g}`).join(', ')}`
159
+ : `${key}: no human gates — fully unattended`;
160
+ }
161
+
162
+ // ── CLI ─────────────────────────────────────────────────────────────────────
163
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
164
+ if (isMain) {
165
+ const argv = process.argv.slice(2);
166
+ const level = argv.find((a) => !a.startsWith('--')) || DEFAULT_LEVEL;
167
+ const i = argv.indexOf('--archetype');
168
+ const archetype = i > -1 ? argv[i + 1] : undefined;
169
+ if (argv.includes('--json')) {
170
+ process.stdout.write(JSON.stringify({ level, archetype: archetype ?? null, gates: gatesForApprovalLevel(level, { archetype }) }, null, 2) + '\n');
171
+ } else {
172
+ process.stdout.write(describeLevel(level, { archetype }) + '\n');
173
+ }
174
+ }
@@ -0,0 +1,52 @@
1
+ /** Read-only controller trace audit, not domain/package/provider attestation. */
2
+ import { dispatchEvidenceSummary } from './controller-dispatch-evidence.mjs';
3
+ import { completeScopeAttestation } from './scoped-review-reuse.mjs';
4
+
5
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
6
+ const mandatory = new Set(['code-reviewer', 'qa-engineer', 'security-officer', 'ai-eval-engineer']);
7
+
8
+ export function assertBenchmarkReviewDispatches(state, roles) {
9
+ if (!Array.isArray(roles) || !roles.length || new Set(roles).size !== roles.length
10
+ || roles.some(role => typeof role !== 'string' || !role)
11
+ || dispatchEvidenceSummary(state).evidenceLevel !== 'controller-invocations-recorded') throw Error('benchmark review dispatch: complete valid invocation history required');
12
+ const records = new Map(state.dispatchEvidence.records.map(record => [record.id, record]));
13
+ const used = new Set();
14
+ for (const role of roles) {
15
+ const result = state.results?.[role];
16
+ const matches = state.attempts?.filter(attempt => attempt.id === result?.attemptId) || [];
17
+ const attempt = matches[0];
18
+ const host = state.hostRoutes?.[role] || state.hostRoutes?.[state.specialistStages?.[role]?.role] || 'codex';
19
+ if (!result || typeof result.attemptId !== 'string' || !result.attemptId
20
+ || matches.length !== 1 || attempt.role !== role || attempt.status !== 'verified'
21
+ || attempt.host !== host || result.host !== host || !attempt.inputReceipt || attempt.inputReceipt.truncated
22
+ || !attempt.receipt || attempt.receipt.truncated || !same(result.receipt, attempt.receipt)
23
+ || attempt.verification?.state !== 'verified' || !Array.isArray(attempt.verification.checks)
24
+ || !attempt.verification.checks.length
25
+ || !same(result.verification, attempt.verification)) throw Error('benchmark review dispatch: required role attempt identity mismatch');
26
+ const verifier = records.get(`${attempt.id}:verifier`);
27
+ if (verifier?.kind !== 'verifier' || verifier.role !== 'codex-verifier' || verifier.host !== 'codex'
28
+ || verifier.outcome !== 'returned') throw Error('benchmark review dispatch: required independent verifier missing');
29
+ if (attempt.reuse) {
30
+ const prior = attempt.reuse, source = state.specialistPolicy?.reviewReuse?.sources?.[role];
31
+ if (mandatory.has(role) || attempt.workerCallId || records.has(`${attempt.id}:worker`)
32
+ || !source || !same(result.reuse, prior) || prior.role !== role || prior.runId === state.id
33
+ || typeof prior.runId !== 'string' || !prior.runId || typeof prior.attemptId !== 'string' || !prior.attemptId
34
+ || !/^[a-f0-9]{64}$/.test(prior.resultDigest || '') || !/^[a-f0-9]{64}$/.test(prior.sourceDigest || '') || prior.sourceDigest !== source.sha256
35
+ || !state.specialistPolicy.reviewReuse.scopes?.[role]
36
+ || !/^[a-f0-9]{64}$/.test(attempt.scopedInput?.digest || '')
37
+ || !completeScopeAttestation(attempt.verification, attempt.scopedInput)) throw Error('benchmark review dispatch: reuse lacks fresh scoped attestation');
38
+ continue;
39
+ }
40
+ const workerId = attempt.workerCallId || `${attempt.id}:worker`, worker = records.get(workerId);
41
+ if (attempt.workerCallId) {
42
+ const waves = state.waveHistory?.filter(wave => wave.status === 'verified'
43
+ && workerId === `${wave.id}:${role}` && wave.roles?.includes(role) && wave.hosts?.[role] === host) || [];
44
+ if (waves.length !== 1) throw Error('benchmark review dispatch: worker is not bound to a verified mixed-host wave');
45
+ }
46
+ if (used.has(workerId) || worker?.kind !== 'worker' || worker.role !== role || worker.host !== host
47
+ || worker.outcome !== 'returned' || Date.parse(worker.finishedAt) > Date.parse(verifier.startedAt)) throw Error('benchmark review dispatch: required worker missing, mismatched or after verification');
48
+ used.add(workerId);
49
+ }
50
+ return { version: 1, scope: 'required-controller-review-dispatches-only', roles: [...roles],
51
+ domainSelectionVerified: false, providerExecutionVerified: false };
52
+ }
@@ -0,0 +1,64 @@
1
+ /** A registered trusted-launcher assertion, not hardware/OS/provider attestation. */
2
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
3
+ const sha = bytes => createHash('sha256').update(bytes).digest('hex');
4
+ const hex = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
5
+ export const SIGNED_SCORER_SOURCE = 'trusted-launcher-signed-pinned-scorer';
6
+ function bytes(value, size) {
7
+ if (typeof value !== 'string') throw Error('invalid scorer signature encoding');
8
+ const decoded = Buffer.from(value, 'base64');
9
+ if (decoded.length !== size || decoded.toString('base64') !== value) throw Error('invalid scorer signature encoding');
10
+ return decoded;
11
+ }
12
+ function canonical(value) {
13
+ if (Array.isArray(value)) return value.map(canonical);
14
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(k => [k, canonical(value[k])]));
15
+ return value;
16
+ }
17
+ const message = payload => Buffer.from(JSON.stringify(canonical(payload)));
18
+
19
+ export function scorerAuthority(scorer) {
20
+ if (scorer?.authority == null) return null;
21
+ const a = scorer.authority;
22
+ if (!hex(scorer.oracleSha256) || a.algorithm !== 'ed25519' || !hex(a.publicKeySha256)
23
+ || Object.keys(a).sort().join(',') !== 'algorithm,publicKeySha256,publicKeySpki') throw Error('invalid registered scorer authority');
24
+ try {
25
+ const der = bytes(a.publicKeySpki, 44), key = createPublicKey({ key: der, format: 'der', type: 'spki' });
26
+ if (key.asymmetricKeyType !== 'ed25519' || sha(der) !== a.publicKeySha256
27
+ || !key.export({ format: 'der', type: 'spki' }).equals(der)) throw Error();
28
+ return key;
29
+ } catch { throw Error('invalid registered scorer authority'); }
30
+ }
31
+
32
+ /** Caller supplies already bounded private key bytes; no secret values in errors. */
33
+ export function signScorerPayload(payload, privateBytes, scorer) {
34
+ scorerAuthority(scorer);
35
+ if (!scorer.authority) throw Error('scorer signing authority was not preregistered');
36
+ let key;
37
+ try {
38
+ key = createPrivateKey(privateBytes);
39
+ const der = createPublicKey(key).export({ format: 'der', type: 'spki' });
40
+ if (key.asymmetricKeyType !== 'ed25519' || sha(der) !== scorer.authority.publicKeySha256) throw Error();
41
+ } catch { throw Error('private scorer signing key does not match registered authority'); }
42
+ return { version: 2, source: SIGNED_SCORER_SOURCE, payload,
43
+ signature: sign(null, message(payload), key).toString('base64') };
44
+ }
45
+
46
+ export function verifyScorerReport(report, scorer) {
47
+ const key = scorerAuthority(scorer);
48
+ if (!key || report?.version !== 2 || report.source !== SIGNED_SCORER_SOURCE
49
+ || Object.keys(report).sort().join(',') !== 'payload,signature,source,version') throw Error('signed scorer report required by registered authority');
50
+ const p = report.payload;
51
+ if (!p || Object.keys(p).sort().join(',') !== 'accepted,artifactSha256,candidateInputDigest,criteria,oracleSha256,process,receipt,registrationDigest,runId,scorerSha256,stateSha256'
52
+ || ![p.artifactSha256, p.candidateInputDigest, p.oracleSha256, p.registrationDigest, p.scorerSha256, p.stateSha256].every(hex)
53
+ || typeof p.runId !== 'string' || !p.runId || typeof p.accepted !== 'boolean'
54
+ || !Array.isArray(p.criteria) || !p.criteria.length || p.criteria.length > 100
55
+ || p.criteria.some(c => !c || typeof c.text !== 'string' || !c.text || c.text.length > 1024
56
+ || !['passed', 'failed'].includes(c.state) || typeof c.evidence !== 'string' || !c.evidence.trim() || c.evidence.length > 1024)
57
+ || p.accepted !== p.criteria.every(c => c.state === 'passed')
58
+ || !Number.isInteger(p.process?.pid) || p.process.pid <= 0 || p.process.exitCode !== 0
59
+ || !Number.isFinite(Date.parse(p.process.startedAt)) || !Number.isFinite(Date.parse(p.process.finishedAt))
60
+ || Date.parse(p.process.finishedAt) < Date.parse(p.process.startedAt)
61
+ || typeof p.process.node !== 'string' || !/^v\d+\./.test(p.process.node)) throw Error('invalid signed scorer payload');
62
+ if (!verify(null, message(p), key, bytes(report.signature, 64))) throw Error('scorer report signature mismatch');
63
+ return p;
64
+ }
@@ -45,7 +45,7 @@ export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.e
45
45
  '--permission-mode', 'dontAsk', '--permission-prompts', 'none', '--json-schema', PROPOSAL_SCHEMA];
46
46
  const group = process.platform !== 'win32';
47
47
  const proc = spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'], detached: group });
48
- let out = '', err = '', settled = false, timedOut = false;
48
+ let out = '', err = '', settled = false, timedOut = false, stdinError = null;
49
49
  const finish = result => { if (!settled) { settled = true; clearTimeout(timer); resolve(result); } };
50
50
  const kill = () => {
51
51
  try { if (group && proc.pid) process.kill(-proc.pid, 'SIGKILL'); else proc.kill('SIGKILL'); }
@@ -54,15 +54,23 @@ export function runClaudeExec({ prompt, cwd, timeoutMs = 300000, bin = process.e
54
54
  const timer = setTimeout(() => { timedOut = true; kill(); }, timeoutMs);
55
55
  proc.stdout.on('data', bytes => { out += String(bytes); });
56
56
  proc.stderr.on('data', bytes => { err += String(bytes); });
57
- proc.stdin.on('error', () => { /* early CLI exit is reported by close */ });
57
+ const inputFailed = error => {
58
+ stdinError ||= typeof error?.code === 'string' ? error.code : 'STDIN_ERROR';
59
+ kill(); // incomplete input must not produce an accepted proposal
60
+ };
61
+ proc.stdin.on('error', inputFailed);
58
62
  proc.on('error', error => finish({ state: 'unreadable', finalText: null, errors: [String(error.message)], usage: null, code: null }));
59
63
  proc.on('close', code => {
60
64
  if (group && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch { /* gone */ } }
61
65
  const parsed = parseClaudeResult(out);
62
66
  if (err.trim()) parsed.errors.push(err.trim().slice(-4000));
67
+ if (stdinError) {
68
+ parsed.state = 'unreadable'; parsed.text = null; parsed.finalText = null;
69
+ parsed.errors.push(`prompt transport failed: ${stdinError}`);
70
+ }
63
71
  if (timedOut) { parsed.state = 'unreadable'; parsed.errors.push(`timed out after ${timeoutMs}ms`); }
64
72
  finish({ ...parsed, code, timedOut });
65
73
  });
66
- proc.stdin.end(prompt);
74
+ try { proc.stdin.end(prompt); } catch (error) { inputFailed(error); }
67
75
  });
68
76
  }