great-cto 3.76.0 → 3.77.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.
@@ -0,0 +1,173 @@
1
+ // scripts/lib/gate-check.mjs — strict-mode evidence-blocking gate check (governance Phase 2).
2
+ //
3
+ // NaCl strict-mode: a gate REFUSES to pass while any in-scope task is in a terminal-fail
4
+ // state {BLOCKED, FAILED, UNVERIFIED, NOT_RUN} — instead of being downgraded to "explained".
5
+ // The ONLY sanctioned override is a valid signed exception (Phase 1). Every covered task is
6
+ // logged with the exception id, so a bypass is never silent.
7
+ //
8
+ // CLI: node scripts/lib/gate-check.mjs <gate> → exit 0 if the gate may pass, 1 if blocked
9
+ // (loads tasks from `bd list --json` and exceptions from the registry)
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { verify, list as listExceptions } from './exceptions.mjs';
13
+ import { reviewerStatus } from './required-reviewers.mjs';
14
+ import { latestVerdicts, lastCodeChange, shipBlockers } from './ship-evidence.mjs';
15
+ import { isMainModule } from './is-main.mjs';
16
+
17
+ // Terminal-fail states that block a gate (normalized, lowercase).
18
+ export const BLOCKING_STATES = new Set(['blocked', 'failed', 'unverified', 'not_run', 'not-run']);
19
+
20
+ /**
21
+ * Normalize a beads task to { id, title, state }. A task's effective state is its
22
+ * blocking status, else the first blocking label, else its status.
23
+ */
24
+ export function normalizeTask(task) {
25
+ const status = String(task.status || '').toLowerCase();
26
+ const labels = (task.labels || []).map((l) => String(l).toLowerCase().replace(/^state:/, ''));
27
+ let state = status;
28
+ if (!BLOCKING_STATES.has(status)) {
29
+ const bl = labels.find((l) => BLOCKING_STATES.has(l));
30
+ if (bl) state = bl;
31
+ }
32
+ return { id: task.id, title: task.title || '', state };
33
+ }
34
+
35
+ /**
36
+ * Does a signed exception cover this gate + task?
37
+ * - gate must match exactly or be '*'.
38
+ * - scope empty or '*' → gate-wide (covers every blocking task for that gate, e.g. a
39
+ * "CI billing-locked" exception).
40
+ * - otherwise scope is a comma/space-separated list of task ids; coverage requires an
41
+ * EXACT id match (no substring — "uncovered" must not be covered by scope "covered").
42
+ */
43
+ export function covers(exc, gate, taskId, now) {
44
+ if (!verify(exc, { now }).valid) return false;
45
+ if (!(exc.gate === gate || exc.gate === '*')) return false;
46
+ const scope = String(exc.scope || '').trim();
47
+ if (!scope || scope === '*') return true;
48
+ return scope.split(/[\s,]+/).includes(String(taskId));
49
+ }
50
+
51
+ /**
52
+ * Evaluate a gate against a list of (already-normalized) tasks + exceptions.
53
+ * @returns {{ pass: boolean, blocking: Array, covered: Array }}
54
+ */
55
+ export function evaluateGate(tasks, exceptions, { gate, now } = {}) {
56
+ const blocking = [];
57
+ const covered = [];
58
+ for (const t of tasks) {
59
+ if (!BLOCKING_STATES.has(String(t.state).toLowerCase())) continue;
60
+ const exc = (exceptions || []).find((e) => covers(e, gate, t.id, now));
61
+ if (exc) covered.push({ id: t.id, state: t.state, exception: exc.id });
62
+ else blocking.push({ id: t.id, state: t.state, title: t.title });
63
+ }
64
+ return { pass: blocking.length === 0, blocking, covered };
65
+ }
66
+
67
+ /**
68
+ * Required domain reviewers for gate:ship. A reviewer the project's archetype, packs
69
+ * or compliance imply (required-reviewers.mjs) must have a verdict, or a signed
70
+ * exception whose scope names `reviewer:<agent>`. On the measuring machine
71
+ * pci-reviewer was needed by 7 of 22 projects and ran once — nothing required it.
72
+ */
73
+ export function evaluateReviewers(status, exceptions, { gate, now } = {}) {
74
+ const blocking = [];
75
+ const covered = [];
76
+ if (gate !== 'gate:ship' || !status || status.state !== 'read') return { pass: true, blocking, covered };
77
+ for (const r of status.reviewers) {
78
+ if (r.verdict) continue;
79
+ const exc = (exceptions || []).find((e) => covers(e, gate, `reviewer:${r.agent}`, now));
80
+ if (exc) covered.push({ agent: r.agent, exception: exc.id });
81
+ else blocking.push({ agent: r.agent, why: r.why });
82
+ }
83
+ return { pass: blocking.length === 0, blocking, covered };
84
+ }
85
+
86
+ /**
87
+ * What the verdict logs say about gate:ship (ship-evidence.mjs): an open negative
88
+ * verdict, a missing or non-passing QA / security verdict, or a QA verdict older
89
+ * than the last code change. Each is waived only by a signed exception naming
90
+ * `reviewer:<agent>` — the same scope the required-reviewer check uses.
91
+ */
92
+ export function evaluateShipEvidence(blockers, exceptions, { gate, now } = {}) {
93
+ const blocking = [];
94
+ const covered = [];
95
+ if (gate !== 'gate:ship') return { pass: true, blocking, covered };
96
+ for (const b of blockers || []) {
97
+ const exc = (exceptions || []).find((e) => covers(e, gate, `reviewer:${b.agent}`, now));
98
+ if (exc) covered.push({ ...b, exception: exc.id });
99
+ else blocking.push(b);
100
+ }
101
+ return { pass: blocking.length === 0, blocking, covered };
102
+ }
103
+
104
+ // ── CLI ──────────────────────────────────────────────────────────────────────
105
+
106
+ function loadTasks() {
107
+ try {
108
+ // 20s: bd has been measured at up to 8.7s under this repo's own test
109
+ // parallelism, and an unbounded call can wait forever on a stale
110
+ // .beads/.lock. The catch below already returns null for "bd unavailable",
111
+ // which is the right three-state answer — it just never got to run.
112
+ const out = execFileSync('bd', ['list', '--json'], { encoding: 'utf8', timeout: 20000, killSignal: 'SIGKILL', stdio: ['ignore', 'pipe', 'ignore'] });
113
+ const data = JSON.parse(out);
114
+ const arr = Array.isArray(data) ? data : (data.issues || data.tasks || []);
115
+ return arr.map(normalizeTask);
116
+ } catch {
117
+ return null; // bd unavailable
118
+ }
119
+ }
120
+
121
+ function main() {
122
+ const argv = process.argv.slice(2);
123
+ // --as <agent>: the agent about to write its own verdict runs this check, and its
124
+ // own previous verdict (or absence) must not block it — security-officer calls
125
+ // `gate-check gate:ship --as security-officer` before it approves.
126
+ const asIdx = argv.indexOf('--as');
127
+ const as = asIdx >= 0 ? argv[asIdx + 1] : null;
128
+ const gate = argv.find((a, i) => !a.startsWith('--') && !(asIdx >= 0 && i === asIdx + 1));
129
+ if (!gate) { process.stderr.write('Usage: gate-check.mjs <gate> [--as <agent>]\n'); process.exit(2); }
130
+
131
+ const exceptions = listExceptions({});
132
+
133
+ // Reviewers first: they read files, so a missing bd is no reason to skip them.
134
+ const rev = evaluateReviewers(reviewerStatus(process.cwd()), exceptions, { gate });
135
+ for (const c of rev.covered) process.stdout.write(` ⚠ ${c.agent} has no verdict — sanctioned by signed exception ${c.exception}\n`);
136
+ if (!rev.pass) {
137
+ process.stdout.write(`✗ ${gate} BLOCKED — ${rev.blocking.length} required reviewer(s) never ran for this project:\n`);
138
+ for (const b of rev.blocking) process.stdout.write(` ${b.agent} — required by ${b.why}\n`);
139
+ process.stdout.write(` Run the reviewer, or sign an exception: /exception create --gate ${gate} --scope reviewer:<agent> --reason "…"\n`);
140
+ }
141
+
142
+ const cwd = process.cwd();
143
+ const ev = evaluateShipEvidence(shipBlockers(latestVerdicts(cwd), lastCodeChange(cwd), { as }), exceptions, { gate });
144
+ for (const c of ev.covered) process.stdout.write(` ⚠ ${c.agent}: ${c.why} — sanctioned by signed exception ${c.exception}\n`);
145
+ if (!ev.pass) {
146
+ process.stdout.write(`✗ ${gate} BLOCKED — ${ev.blocking.length} verdict problem(s):\n`);
147
+ for (const b of ev.blocking) process.stdout.write(` ${b.agent} — ${b.why}\n`);
148
+ process.stdout.write(` Re-run the agent, or sign an exception: /exception create --gate ${gate} --scope reviewer:<agent> --reason "…"\n`);
149
+ }
150
+ const filesPass = rev.pass && ev.pass;
151
+
152
+ const tasks = loadTasks();
153
+ if (tasks === null) {
154
+ process.stdout.write('gate-check: beads (bd) unavailable — task states not evaluated; treat them as a manual check.\n');
155
+ process.exit(filesPass ? 0 : 1); // bd absent skips the task check, never the file checks
156
+ }
157
+ const r = evaluateGate(tasks, exceptions, { gate });
158
+
159
+ for (const c of r.covered) {
160
+ process.stdout.write(` ⚠ ${c.id} [${c.state}] — sanctioned by signed exception ${c.exception}\n`);
161
+ }
162
+ if (r.pass) {
163
+ process.stdout.write(`✓ ${gate}: no blocking tasks (${r.covered.length} covered by exception).\n`);
164
+ process.exit(filesPass ? 0 : 1);
165
+ }
166
+ process.stdout.write(`✗ ${gate} BLOCKED — ${r.blocking.length} task(s) in a terminal-fail state with no signed exception:\n`);
167
+ for (const b of r.blocking) process.stdout.write(` ${b.id} [${b.state}] ${b.title}\n`);
168
+ process.stdout.write(` Fix the task, or create a signed exception: /exception create --gate ${gate} --reason "…"\n`);
169
+ process.exit(1);
170
+ }
171
+
172
+ const isMain = isMainModule(import.meta.url);
173
+ if (isMain) main();
@@ -54,6 +54,10 @@ const GATE_COST = Object.freeze({
54
54
  categories: ['destroys-evidence'],
55
55
  why: 'guards migration-import-engineer; the map\'s own comment: an approval level "is not permission to overwrite a client\'s data unattended"',
56
56
  },
57
+ release: {
58
+ categories: ['escapes-the-machine'],
59
+ why: 'guards release-manager\'s second run — production promote, store submission, phased-rollout increase, production OTA; a submitted build cannot be called back',
60
+ },
57
61
  product: {
58
62
  categories: ['sunk-work'],
59
63
  why: 'the WHAT decision — approval-level.mjs put it in the default set for exactly this reason',
@@ -42,7 +42,7 @@
42
42
  * "it scored well" is not the same as "a human decided".
43
43
  */
44
44
  import { isMainModule } from './is-main.mjs';
45
- export const CLASS_A = Object.freeze(['devops', 'infra-provisioner']);
45
+ export const CLASS_A = Object.freeze(['devops', 'infra-provisioner', 'release-manager']);
46
46
 
47
47
  /** The run shape a tier decision may be based on. Anything else is not evidence. */
48
48
  export const REQUIRED_SPLIT = 'holdout';
@@ -0,0 +1,174 @@
1
+ import { readFileSync, lstatSync, realpathSync } from 'node:fs';
2
+ import { resolve, dirname, join, relative, isAbsolute, sep } from 'node:path';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { devNull } from 'node:os';
6
+ import { stableDigest } from './ship-evidence-identity.mjs';
7
+ import { requiredReviewers, REVIEWERS_BY_ARCHETYPE, PACK_REVIEWERS, COMPLIANCE_REVIEWERS } from './required-reviewers.mjs';
8
+
9
+ const fail = code => { throw Object.assign(new Error(code), {code}); };
10
+ const sha = s => createHash('sha256').update(s).digest('hex');
11
+ const id = s => typeof s === 'string' && Buffer.byteLength(s) > 0 && Buffer.byteLength(s) <= 256 && !/[\x00-\x1f\x7f-\x9f]/u.test(s);
12
+ const digest = s => typeof s === 'string' && /^[a-f0-9]{64}$/.test(s);
13
+ const exact = (x, keys) => x && Object.getPrototypeOf(x) === Object.prototype && Object.keys(x).sort().join('|') === [...keys].sort().join('|');
14
+ const inside = (root, path) => { const rel = relative(root, path); return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`)); };
15
+ function git(root, args) {
16
+ const config = ['-c', 'core.fsmonitor=false', '-c', `core.hooksPath=${devNull}`];
17
+ const options = {cwd: root, encoding: 'utf8', timeout: 10000, maxBuffer: 16777216, stdio: ['ignore', 'pipe', 'ignore'], env: {...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0'}};
18
+ if (args[0] === 'status') {
19
+ let definitions;
20
+ try {
21
+ definitions = execFileSync('git', [...config, 'config', '--null', '--name-only', '--get-regexp', '^filter\\..*\\.(clean|process|required)$'], {...options, timeout: 5000, maxBuffer: 65536});
22
+ } catch (error) {
23
+ if (error.status !== 1 || error.signal || error.error) fail('SHIP_EVIDENCE_UNKNOWN');
24
+ definitions = '';
25
+ }
26
+ const names = new Set();
27
+ for (const key of definitions.split('\0').filter(Boolean)) {
28
+ const match = key.match(/^filter\.([^\x00-\x1f\x7f=]+)\.(?:clean|process|required)$/);
29
+ if (!match) fail('SHIP_EVIDENCE_UNKNOWN');
30
+ names.add(match[1]);
31
+ if (names.size > 128) fail('SHIP_EVIDENCE_UNKNOWN');
32
+ }
33
+ // Status may execute repository-configured clean/process filters while comparing files.
34
+ for (const name of names) config.push('-c', `filter.${name}.clean=`, '-c', `filter.${name}.process=`, '-c', `filter.${name}.required=false`);
35
+ }
36
+ return execFileSync('git', [...config, ...args], options);
37
+ }
38
+ function canonicalRoot(root) {
39
+ const canonical = realpathSync(root);
40
+ if (!lstatSync(canonical).isDirectory()) fail('SHIP_PATH_UNSAFE');
41
+ return canonical;
42
+ }
43
+ function privatePath(path, kind) {
44
+ if (!isAbsolute(path) || resolve(path) !== path || realpathSync(path) !== path) fail('SHIP_PATH_UNSAFE');
45
+ const stat = lstatSync(path);
46
+ if (stat.isSymbolicLink() || (kind === 'dir' ? !stat.isDirectory() : !stat.isFile()) || stat.uid !== process.getuid() || (stat.mode & 0o777) !== (kind === 'dir' ? 0o700 : 0o600)) fail('SHIP_PATH_UNSAFE');
47
+ if (kind === 'file' && stat.size > 262144) fail('SHIP_POLICY_INVALID');
48
+ return path;
49
+ }
50
+ function registrationValid(r) {
51
+ return exact(r, ['version', 'repositoryId', 'projectId', 'gitCommonDirDigest', 'journalOriginId', 'journalRootDigest']) && r.version === 'ship-registration/v1' && [r.repositoryId, r.projectId, r.journalOriginId].every(id) && digest(r.gitCommonDirDigest) && digest(r.journalRootDigest);
52
+ }
53
+ function bindRegistration(root, registration, actualCommon = null) {
54
+ if (!registrationValid(registration)) fail('SHIP_REGISTRATION_MISMATCH');
55
+ const common = actualCommon || realpathSync(resolve(root, git(root, ['rev-parse', '--git-common-dir']).trim()));
56
+ if (sha(common) !== registration.gitCommonDirDigest) fail('SHIP_REGISTRATION_MISMATCH');
57
+ return common;
58
+ }
59
+ function sourceTuple(root) {
60
+ const lines = git(root, ['rev-parse', '--git-common-dir', 'HEAD', 'HEAD^{tree}']).trim().split('\n');
61
+ if (lines.length !== 3) fail('SHIP_EVIDENCE_UNKNOWN');
62
+ const [commonPath, head, tree] = lines;
63
+ if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(head) || tree.length !== head.length || !/^[a-f0-9]+$/.test(tree)) fail('SHIP_EVIDENCE_UNKNOWN');
64
+ return {common: realpathSync(resolve(root, commonPath)), head, tree};
65
+ }
66
+ function mergedRequirements(root, policy) {
67
+ let text;
68
+ try { text = readFileSync(join(root, '.great_cto/PROJECT.md'), 'utf8'); } catch { fail('SHIP_REQUIREMENTS_UNKNOWN'); }
69
+ if (Buffer.byteLength(text) > 262144) fail('SHIP_REQUIREMENTS_UNKNOWN');
70
+ const field = name => {
71
+ const rows = text.split('\n').filter(line => line.startsWith(`${name}:`));
72
+ if (rows.length > 1) fail('SHIP_REQUIREMENTS_UNKNOWN');
73
+ return rows.length ? rows[0].slice(name.length + 1).trim() : null;
74
+ };
75
+ const archetypes = ['primary', 'archetype'].map(field).filter(Boolean);
76
+ if (!archetypes.some(a => Object.hasOwn(REVIEWERS_BY_ARCHETYPE, a))) fail('SHIP_REQUIREMENTS_UNKNOWN');
77
+ if (new Set(archetypes.filter(a => Object.hasOwn(REVIEWERS_BY_ARCHETYPE, a))).size > 1) fail('SHIP_REQUIREMENTS_UNKNOWN');
78
+ const parseList = name => {
79
+ const value = field(name);
80
+ if (value === null) return [];
81
+ if (!/^\[[^\[\]\n]*\]$/.test(value)) fail('SHIP_REQUIREMENTS_UNKNOWN');
82
+ return value.slice(1, -1).split(',').map(s => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
83
+ };
84
+ for (const pack of parseList('packs')) if (!Object.hasOwn(PACK_REVIEWERS, pack)) fail('SHIP_REQUIREMENTS_UNKNOWN');
85
+ for (const token of parseList('compliance')) if (!COMPLIANCE_REVIEWERS.some(r => r.token.test(token))) fail('SHIP_REQUIREMENTS_UNKNOWN');
86
+ const merged = new Map(['qa-engineer', 'security-officer', ...requiredReviewers(text).map(r => r.agent)].map(role => [role, {role, requiredChecks: ['independent-verifier'], independentOf: []}]));
87
+ for (const row of policy.requiredReviewers) {
88
+ const prior = merged.get(row.role);
89
+ merged.set(row.role, {role: row.role, requiredChecks: [...new Set([...(prior?.requiredChecks || ['independent-verifier']), ...row.requiredChecks])].sort(), independentOf: []});
90
+ }
91
+ if (merged.size > 128) fail('SHIP_REQUIREMENTS_UNKNOWN');
92
+ return [...merged.values()].sort((a, b) => a.role.localeCompare(b.role));
93
+ }
94
+ export function readShipPolicy({root, path}) {
95
+ try {
96
+ root = canonicalRoot(root);
97
+ const store = dirname(path), common = realpathSync(resolve(root, git(root, ['rev-parse', '--git-common-dir']).trim()));
98
+ privatePath(store, 'dir'); privatePath(path, 'file');
99
+ if (path !== join(store, 'policy.json') || inside(root, store) || inside(common, store)) fail('SHIP_PATH_UNSAFE');
100
+ const registrationPath = privatePath(join(store, 'registration.json'), 'file');
101
+ const policy = JSON.parse(readFileSync(path, 'utf8')), registration = JSON.parse(readFileSync(registrationPath, 'utf8'));
102
+ stableDigest(policy); stableDigest(registration);
103
+ if (!exact(policy, ['version', 'registration', 'requiredReviewers', 'assignmentLifetimeMs', 'journalRoot']) || policy.version !== 'ship-controller-policy/v1' || policy.registration !== 'registration.json' || !Number.isSafeInteger(policy.assignmentLifetimeMs) || policy.assignmentLifetimeMs <= 0 || !Array.isArray(policy.requiredReviewers) || policy.requiredReviewers.length > 128 || new Set(policy.requiredReviewers.map(r => r.role)).size !== policy.requiredReviewers.length) fail('SHIP_POLICY_INVALID');
104
+ for (const row of policy.requiredReviewers) if (!exact(row, ['role', 'requiredChecks']) || !/^[a-z][a-z0-9-]{0,63}$/.test(row.role) || !Array.isArray(row.requiredChecks) || !row.requiredChecks.length || row.requiredChecks.length > 256 || new Set(row.requiredChecks).size !== row.requiredChecks.length || row.requiredChecks.some(c => !['independent-verifier', 'configured-checks'].includes(c))) fail('SHIP_POLICY_INVALID');
105
+ bindRegistration(root, registration, common);
106
+ privatePath(policy.journalRoot, 'dir');
107
+ if (inside(store, policy.journalRoot) || sha(policy.journalRoot) !== registration.journalRootDigest) fail('SHIP_REGISTRATION_MISMATCH');
108
+ const requirements = mergedRequirements(root, policy);
109
+ return {version: 'ship-policy-descriptor/v1', root, store, policyPath: path, registrationPath, registration, policy, registrationDigest: stableDigest(registration), policyDigest: stableDigest(policy), requirements, requirementsDigest: stableDigest(requirements)};
110
+ } catch (error) {
111
+ if (error.code?.startsWith('SHIP_')) throw error;
112
+ fail('SHIP_POLICY_INVALID');
113
+ }
114
+ }
115
+ export function captureShipContext({root, registration, taskId, runId, gateId, policy, requirements}) {
116
+ try {
117
+ root = canonicalRoot(root);
118
+ const {common, head, tree} = sourceTuple(root); bindRegistration(root, registration, common);
119
+ if (!id(taskId) || !id(runId) || gateId !== 'gate:ship' || !Array.isArray(requirements) || sha(realpathSync(policy.journalRoot)) !== registration.journalRootDigest) fail('SHIP_REGISTRATION_MISMATCH');
120
+ const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none']);
121
+ if (status.length) fail('SHIP_CONTEXT_DRIFT');
122
+ const expected = {version: 'ship-context/v1', repositoryId: registration.repositoryId, projectId: registration.projectId, source: {head, tree, clean: true, dirtyDigest: null, exclusionsDigest: stableDigest([])}, taskId, runId, gateId, policyDigest: stableDigest(policy), requirementsDigest: stableDigest(requirements), evidenceClass: 'SOURCE_REVIEW', requiredReviewers: structuredClone(requirements)};
123
+ return {state: 'COMPLETE', expected, contextDigest: stableDigest(expected), diagnostics: []};
124
+ } catch { return {state: 'UNKNOWN', expected: null, contextDigest: null, diagnostics: [{code: 'CONTEXT_UNAVAILABLE', subject: 'source'}]}; }
125
+ }
126
+ export function recheckShipContext({root, registration, expected}) {
127
+ try {
128
+ root = canonicalRoot(root);
129
+ const {common, head, tree} = sourceTuple(root); bindRegistration(root, registration, common);
130
+ if (!expected || expected.repositoryId !== registration.repositoryId || expected.projectId !== registration.projectId || expected.version !== 'ship-context/v1' || expected.source.exclusionsDigest !== stableDigest([])) fail('SHIP_REGISTRATION_MISMATCH');
131
+ const status = git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=none']);
132
+ const current = {...expected, source: {...expected.source, head, tree, clean: !status, dirtyDigest: status ? sha(status) : null}};
133
+ const contextDigest = stableDigest(current);
134
+ return {state: !status && contextDigest === stableDigest(expected) ? 'MATCH' : 'DRIFT', contextDigest, diagnostics: []};
135
+ } catch { return {state: 'UNKNOWN', contextDigest: null, diagnostics: [{code: 'CONTEXT_UNAVAILABLE', subject: 'source'}]}; }
136
+ }
137
+
138
+ /** Internal refusal check; this does not issue review or controller authority. */
139
+ export function assertCommittedShipArtifacts({root, allowed, rule, proposal}) {
140
+ try {
141
+ root = canonicalRoot(root);
142
+ if (!Array.isArray(allowed) || !Array.isArray(proposal?.files) || proposal.files.length) fail('UNSUPPORTED_REVIEW_OUTPUT');
143
+ const prefix = git(root, ['rev-parse', '--show-prefix']).trim();
144
+ const names = new Set();
145
+ const inspect = name => {
146
+ if (typeof name !== 'string' || !name || isAbsolute(name) || /[\\\x00-\x1f\x7f]/.test(name) || !allowed.some(p => name === p || name.startsWith(`${p}/`))) fail('UNSUPPORTED_REVIEW_OUTPUT');
147
+ const parts = name.split('/');
148
+ if (parts.some(p => !p || p === '.' || p === '..' || /^(?:\.git|\.codex|\.claude|\.agents|\.beads|\.great_cto|node_modules)$/i.test(p) || /^(?:AGENTS\.md|CLAUDE\.md|SKILL\.md|\.env(?:\..*)?)$/i.test(p))) fail('UNSUPPORTED_REVIEW_OUTPUT');
149
+ let path = root;
150
+ for (const part of parts) { path = join(path, part); if (lstatSync(path).isSymbolicLink()) fail('UNSUPPORTED_REVIEW_OUTPUT'); }
151
+ return {path, stat: lstatSync(path)};
152
+ };
153
+ const committedFile = name => {
154
+ if (names.has(name)) return;
155
+ if (names.size >= 200) fail('UNSUPPORTED_REVIEW_OUTPUT');
156
+ const {path, stat} = inspect(name);
157
+ if (!stat.isFile() || stat.size === 0) fail('UNSUPPORTED_REVIEW_OUTPUT');
158
+ const entry = git(root, ['--literal-pathspecs', 'ls-tree', '-z', '--full-name', 'HEAD', '--', name]);
159
+ const match = entry.match(/^(100644|100755) blob ((?:[a-f0-9]{40}|[a-f0-9]{64}))\t([^\0]+)\0$/);
160
+ if (!match || match[3] !== prefix + name) fail('UNSUPPORTED_REVIEW_OUTPUT');
161
+ const data = readFileSync(path);
162
+ const actual = createHash(match[2].length === 64 ? 'sha256' : 'sha1').update(`blob ${data.length}\0`).update(data).digest('hex');
163
+ if (actual !== match[2] || !data.toString('utf8').trim()) fail('UNSUPPORTED_REVIEW_OUTPUT');
164
+ names.add(name);
165
+ };
166
+ const produces = rule?.produces == null ? [] : Array.isArray(rule.produces) ? rule.produces : [rule.produces];
167
+ for (const key of produces) {
168
+ if (key === 'receipt') continue;
169
+ const name = proposal.meta?.[key];
170
+ if (typeof name !== 'string' || name.endsWith('/')) fail('UNSUPPORTED_REVIEW_OUTPUT');
171
+ committedFile(name);
172
+ }
173
+ } catch { fail('UNSUPPORTED_REVIEW_OUTPUT'); }
174
+ }