@hellraisercenobit/ai-engineering-gate 0.8.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/README.md +82 -0
- package/agents/design-pattern-reviewer.md +40 -0
- package/agents/modern-typescript-reviewer.md +24 -0
- package/agents/testing-pattern-reviewer.md +23 -0
- package/bin/ai-engineering-gate.mjs +6 -0
- package/contracts/axi/axi-standard.md +273 -0
- package/contracts/axi/pin.json +36 -0
- package/contracts/members.json +58 -0
- package/contracts/schemas/arbitration.schema.json +45 -0
- package/contracts/schemas/decision-envelope.schema.json +215 -0
- package/contracts/schemas/declaration.schema.json +190 -0
- package/contracts/schemas/dispute.schema.json +67 -0
- package/contracts/schemas/evidence-append.schema.json +72 -0
- package/contracts/schemas/gate-output.schema.json +296 -0
- package/contracts/schemas/marker.schema.json +75 -0
- package/contracts/schemas/review-envelope.schema.json +378 -0
- package/contracts/schemas/verdict-record.schema.json +191 -0
- package/contracts/suite-contract.md +270 -0
- package/dist/ai-engineering-gate.mjs +2762 -0
- package/package.json +47 -0
- package/skills/engineering/review-design-patterns/LICENSE +6 -0
- package/skills/engineering/review-design-patterns/SKILL.md +196 -0
- package/skills/engineering/review-design-patterns/agents/openai.yaml +3 -0
- package/skills/engineering/review-design-patterns/references/smell-signatures.md +117 -0
- package/skills/engineering/review-design-patterns/review-design-patterns.yaml +25 -0
- package/skills/engineering/review-modern-typescript/LICENSE +21 -0
- package/skills/engineering/review-modern-typescript/SKILL.md +88 -0
- package/skills/engineering/review-modern-typescript/agents/openai.yaml +3 -0
- package/skills/engineering/review-modern-typescript/references/smell-signatures.md +36 -0
- package/skills/engineering/review-modern-typescript/review-modern-typescript.yaml +22 -0
- package/skills/engineering/review-testing-patterns/SKILL.md +108 -0
- package/skills/engineering/review-testing-patterns/agents/openai.yaml +4 -0
- package/skills/engineering/review-testing-patterns/references/compiler-profile.mjs +103 -0
- package/skills/engineering/review-testing-patterns/references/smell-signatures.md +30 -0
- package/skills/engineering/review-testing-patterns/review-testing-patterns.yaml +23 -0
- package/skills/engineering/transpose-design-patterns/LICENSE +6 -0
- package/skills/engineering/transpose-design-patterns/SKILL.md +231 -0
- package/skills/engineering/transpose-design-patterns/agents/openai.yaml +3 -0
- package/skills/engineering/transpose-design-patterns/references/arbitration.schema.json +45 -0
- package/skills/engineering/transpose-design-patterns/references/decision-envelope.schema.json +215 -0
- package/skills/engineering/transpose-design-patterns/references/declaration.schema.json +190 -0
- package/skills/engineering/transpose-design-patterns/references/design-decision-record.schema.json +731 -0
- package/skills/engineering/transpose-design-patterns/references/dispute.schema.json +67 -0
- package/skills/engineering/transpose-design-patterns/references/evidence-append.schema.json +72 -0
- package/skills/engineering/transpose-design-patterns/references/marker.schema.json +75 -0
- package/skills/engineering/transpose-design-patterns/references/pattern-catalog.md +305 -0
- package/skills/engineering/transpose-design-patterns/references/record.example.json +120 -0
- package/skills/engineering/transpose-design-patterns/references/review-envelope.schema.json +378 -0
- package/skills/engineering/transpose-design-patterns/references/suite-contract.md +270 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-angular.md +224 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-php.md +561 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-quarkus.md +283 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-react.md +256 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-vanilla.md +204 -0
- package/skills/engineering/transpose-design-patterns/references/transpose-vue.md +234 -0
- package/skills/engineering/transpose-design-patterns/transpose-design-patterns.yaml +24 -0
- package/skills/engineering/transpose-modern-typescript/LICENSE +21 -0
- package/skills/engineering/transpose-modern-typescript/SKILL.md +90 -0
- package/skills/engineering/transpose-modern-typescript/agents/openai.yaml +3 -0
- package/skills/engineering/transpose-modern-typescript/references/arbitration.schema.json +45 -0
- package/skills/engineering/transpose-modern-typescript/references/catalog.md +38 -0
- package/skills/engineering/transpose-modern-typescript/references/collections.md +50 -0
- package/skills/engineering/transpose-modern-typescript/references/compatibility.md +61 -0
- package/skills/engineering/transpose-modern-typescript/references/decision-envelope.schema.json +215 -0
- package/skills/engineering/transpose-modern-typescript/references/decision-record.schema.json +830 -0
- package/skills/engineering/transpose-modern-typescript/references/declaration.schema.json +190 -0
- package/skills/engineering/transpose-modern-typescript/references/dispute.schema.json +67 -0
- package/skills/engineering/transpose-modern-typescript/references/evidence-append.schema.json +72 -0
- package/skills/engineering/transpose-modern-typescript/references/idioms.md +74 -0
- package/skills/engineering/transpose-modern-typescript/references/marker.schema.json +75 -0
- package/skills/engineering/transpose-modern-typescript/references/platform.md +147 -0
- package/skills/engineering/transpose-modern-typescript/references/record.example.json +172 -0
- package/skills/engineering/transpose-modern-typescript/references/record.md +37 -0
- package/skills/engineering/transpose-modern-typescript/references/review-envelope.schema.json +378 -0
- package/skills/engineering/transpose-modern-typescript/references/suite-contract.md +270 -0
- package/skills/engineering/transpose-modern-typescript/references/types.md +97 -0
- package/skills/engineering/transpose-modern-typescript/transpose-modern-typescript.yaml +22 -0
- package/skills/engineering/transpose-testing-patterns/SKILL.md +96 -0
- package/skills/engineering/transpose-testing-patterns/agents/openai.yaml +4 -0
- package/skills/engineering/transpose-testing-patterns/references/arbitration.schema.json +45 -0
- package/skills/engineering/transpose-testing-patterns/references/catalog.md +218 -0
- package/skills/engineering/transpose-testing-patterns/references/decision-envelope.schema.json +215 -0
- package/skills/engineering/transpose-testing-patterns/references/decision-record.schema.json +857 -0
- package/skills/engineering/transpose-testing-patterns/references/declaration.schema.json +190 -0
- package/skills/engineering/transpose-testing-patterns/references/detect-adapter.mjs +98 -0
- package/skills/engineering/transpose-testing-patterns/references/dispute.schema.json +67 -0
- package/skills/engineering/transpose-testing-patterns/references/doubles-data.md +45 -0
- package/skills/engineering/transpose-testing-patterns/references/evidence-append.schema.json +72 -0
- package/skills/engineering/transpose-testing-patterns/references/journal-event.schema.json +115 -0
- package/skills/engineering/transpose-testing-patterns/references/marker.schema.json +75 -0
- package/skills/engineering/transpose-testing-patterns/references/record.example.json +230 -0
- package/skills/engineering/transpose-testing-patterns/references/record.md +25 -0
- package/skills/engineering/transpose-testing-patterns/references/review-envelope.schema.json +378 -0
- package/skills/engineering/transpose-testing-patterns/references/suite-contract.md +270 -0
- package/skills/engineering/transpose-testing-patterns/references/tdd.md +62 -0
- package/skills/engineering/transpose-testing-patterns/references/transpose-codeception.md +84 -0
- package/skills/engineering/transpose-testing-patterns/references/transpose-karma-jasmine-angular.md +73 -0
- package/skills/engineering/transpose-testing-patterns/references/transpose-vitest.md +111 -0
- package/skills/engineering/transpose-testing-patterns/references/typescript.md +42 -0
- package/skills/engineering/transpose-testing-patterns/transpose-testing-patterns.yaml +23 -0
|
@@ -0,0 +1,2762 @@
|
|
|
1
|
+
// Generated by scripts/build-gate-bundle.mjs from packages/ai-engineering-gate/src. Do not edit.
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
// packages/ai-engineering-gate/src/answer.mjs
|
|
11
|
+
// Every command answers in one of two shapes, which the renderer and the exit code both read: a refusal
|
|
12
|
+
// carrying the contract's code, or an acceptance carrying the line a human reads plus the payload the
|
|
13
|
+
// JSON surface publishes.
|
|
14
|
+
const refuse = (code, reason, details = [], cause = null) => ({
|
|
15
|
+
ok: false,
|
|
16
|
+
code,
|
|
17
|
+
reason,
|
|
18
|
+
details,
|
|
19
|
+
...(cause ? { cause } : {}),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const accept = (text, payload = {}) => ({ ok: true, text, payload });
|
|
23
|
+
|
|
24
|
+
// packages/ai-engineering-gate/src/hash.mjs
|
|
25
|
+
// Content hashes only. A fingerprint never carries a revision, a timestamp or a path's mtime, so a
|
|
26
|
+
// content-neutral rebase and a re-checkout leave every verdict current.
|
|
27
|
+
|
|
28
|
+
function hashText(text) {
|
|
29
|
+
return `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hashFile(path) {
|
|
33
|
+
try {
|
|
34
|
+
if (!statSync(path).isFile()) return null;
|
|
35
|
+
return `sha256:${createHash('sha256').update(readFileSync(path)).digest('hex')}`;
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Canonical JSON with sorted keys, so two equal documents hash equally whatever their key order.
|
|
42
|
+
function canonicalJson(value) {
|
|
43
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
44
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
45
|
+
const entries = Object.keys(value).sort()
|
|
46
|
+
.filter(key => value[key] !== undefined)
|
|
47
|
+
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`);
|
|
48
|
+
return `{${entries.join(',')}}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hashJson(value) {
|
|
52
|
+
return hashText(canonicalJson(value));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One fingerprint from a set of named content hashes. `null` marks a member that does not exist,
|
|
56
|
+
// so a deleted file changes the fingerprint instead of disappearing from it.
|
|
57
|
+
function hashEntries(entries) {
|
|
58
|
+
const ordered = [...entries].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
|
|
59
|
+
return hashText(ordered.map(([name, digest]) => `${name}\u0000${digest ?? 'absent'}`).join('\u0001'));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// packages/ai-engineering-gate/src/repo.mjs
|
|
63
|
+
const MARKER_FILE = '.ai-engineering-suite.json';
|
|
64
|
+
const DEFAULT_EVIDENCE_ROOT = join(homedir(), '.ai-engineering-gate', 'evidence');
|
|
65
|
+
|
|
66
|
+
function git(args, cwd) {
|
|
67
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8' });
|
|
68
|
+
return {
|
|
69
|
+
ok: result.status === 0,
|
|
70
|
+
stdout: (result.stdout ?? '').trim(),
|
|
71
|
+
stderr: (result.stderr ?? '').trim(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function gitLines(args, cwd) {
|
|
76
|
+
const result = git(args, cwd);
|
|
77
|
+
if (!result.ok) return [];
|
|
78
|
+
return result.stdout.split('\n').map(line => line.trim()).filter(Boolean);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function findRepoRoot(cwd) {
|
|
82
|
+
const result = git(['rev-parse', '--show-toplevel'], cwd);
|
|
83
|
+
return result.ok ? result.stdout : cwd;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readMarker(repoRoot) {
|
|
87
|
+
const path = join(repoRoot, MARKER_FILE);
|
|
88
|
+
if (!existsSync(path)) return null;
|
|
89
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function expandHome(path) {
|
|
93
|
+
if (path === '~') return homedir();
|
|
94
|
+
if (path.startsWith('~/')) return join(homedir(), path.slice(2));
|
|
95
|
+
return path;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveEvidenceRoot({ flag, env, marker, repoRoot }) {
|
|
99
|
+
const chosen = flag ?? env ?? marker?.evidenceRoot;
|
|
100
|
+
if (!chosen) return DEFAULT_EVIDENCE_ROOT;
|
|
101
|
+
const expanded = expandHome(chosen);
|
|
102
|
+
return isAbsolute(expanded) ? expanded : resolve(repoRoot, expanded);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// `feature/REF-15-something` names task REF-15, matching the delivery convention. A branch with no
|
|
106
|
+
// ticket key falls back to a slug of the whole branch name, so the gate never goes silent for want
|
|
107
|
+
// of a key, and an explicit flag or environment variable serves a detached pipeline head.
|
|
108
|
+
function resolveTaskKey({ flag, env, repoRoot }) {
|
|
109
|
+
if (flag) return flag;
|
|
110
|
+
if (env) return env;
|
|
111
|
+
const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot).stdout;
|
|
112
|
+
if (!branch || branch === 'HEAD') return 'detached-head';
|
|
113
|
+
const ticket = /(?<![A-Z0-9])([A-Z][A-Z0-9]+-\d+)(?![0-9])/.exec(branch.toUpperCase());
|
|
114
|
+
if (ticket) return ticket[1];
|
|
115
|
+
return branch.replaceAll(/[^A-Za-z0-9]+/g, '-').replaceAll(/^-+|-+$/g, '').toLowerCase() || 'unnamed';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `*` matches inside one segment, `**` across segments, and a bare directory name matches
|
|
119
|
+
// everything under it. Nothing else, so an ignore pattern cannot quietly widen.
|
|
120
|
+
function matchesPattern(path, pattern) {
|
|
121
|
+
const clean = pattern.replace(/^\.\//, '').replace(/\/$/, '');
|
|
122
|
+
if (path === clean || path.startsWith(`${clean}/`)) return true;
|
|
123
|
+
const expression = clean.split('**').map(part => part
|
|
124
|
+
.replaceAll(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
125
|
+
.replaceAll('*', '[^/]*')
|
|
126
|
+
.replaceAll('?', '[^/]')).join('.*');
|
|
127
|
+
return new RegExp(`^${expression}$`, 'u').test(path);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isInside(parent, child) {
|
|
131
|
+
const rel = relative(parent, child);
|
|
132
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toRepoRelative(repoRoot, path) {
|
|
136
|
+
const absolute = isAbsolute(path) ? path : resolve(repoRoot, path);
|
|
137
|
+
const rel = relative(repoRoot, absolute);
|
|
138
|
+
return rel.split(sep).join('/');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// packages/ai-engineering-gate/src/changeset.mjs
|
|
142
|
+
// A path the gate owns or publishes is never part of the state a verdict binds to: the export
|
|
143
|
+
// directory carries the verdicts themselves, an evidence root inside the checkout is the index, and
|
|
144
|
+
// the marker configures enforcement rather than describing the change.
|
|
145
|
+
function excludedFromChangeSet(context, path) {
|
|
146
|
+
return excluded(context, path);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function excluded(context, path) {
|
|
150
|
+
const { marker, repoRoot, evidenceRoot } = context;
|
|
151
|
+
if (path === MARKER_FILE) return true;
|
|
152
|
+
if (marker.exportDirectory && matchesPattern(path, marker.exportDirectory)) return true;
|
|
153
|
+
if (isInside(repoRoot, evidenceRoot) && matchesPattern(path, toRepoRelative(repoRoot, evidenceRoot))) return true;
|
|
154
|
+
return (marker.ignore ?? []).some(pattern => matchesPattern(path, pattern));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function mergeBase(repoRoot, base) {
|
|
158
|
+
if (!base) return null;
|
|
159
|
+
const found = git(['merge-base', base, 'HEAD'], repoRoot);
|
|
160
|
+
if (found.ok && found.stdout) return found.stdout;
|
|
161
|
+
const resolved = git(['rev-parse', '--verify', base], repoRoot);
|
|
162
|
+
return resolved.ok ? resolved.stdout : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The tracked diff between the merge-base and the worktree, staged, unstaged and untracked files
|
|
166
|
+
// included. A clean HEAD never identifies a dirty worktree, so the untracked half is not optional.
|
|
167
|
+
function changeSet(context, base) {
|
|
168
|
+
const { repoRoot } = context;
|
|
169
|
+
const point = mergeBase(repoRoot, base);
|
|
170
|
+
const names = new Set();
|
|
171
|
+
if (point) {
|
|
172
|
+
for (const name of gitLines(['diff', '--name-only', point], repoRoot)) names.add(name);
|
|
173
|
+
for (const name of gitLines(['diff', '--name-only', '--cached', point], repoRoot)) names.add(name);
|
|
174
|
+
} else {
|
|
175
|
+
for (const name of gitLines(['diff', '--name-only', 'HEAD'], repoRoot)) names.add(name);
|
|
176
|
+
}
|
|
177
|
+
for (const name of gitLines(['ls-files', '--others', '--exclude-standard'], repoRoot)) names.add(name);
|
|
178
|
+
return [...names].filter(name => !excluded(context, name)).sort();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function filesUnder(context, repoRoot, relativePath, into) {
|
|
182
|
+
const absolute = join(repoRoot, relativePath);
|
|
183
|
+
if (!existsSync(absolute)) {
|
|
184
|
+
into.set(relativePath, null);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (statSync(absolute).isFile()) {
|
|
188
|
+
if (!excluded(context, relativePath)) into.set(relativePath, hashFile(absolute));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const entry of readdirSync(absolute, { withFileTypes: true })) {
|
|
192
|
+
if (entry.name === '.git') continue;
|
|
193
|
+
filesUnder(context, repoRoot, `${relativePath.replace(/\/$/, '')}/${entry.name}`, into);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// The union of the declared scope paths, the declared configuration files and the change set. A
|
|
198
|
+
// scope entry that names a directory expands to its files, so a new file in scope moves the state.
|
|
199
|
+
function sourceFingerprint(context, declaration, change) {
|
|
200
|
+
const { repoRoot } = context;
|
|
201
|
+
const entries = new Map();
|
|
202
|
+
for (const path of declaration?.scope?.paths ?? []) filesUnder(context, repoRoot, path, entries);
|
|
203
|
+
for (const path of declaration?.scope?.configuration ?? []) filesUnder(context, repoRoot, path, entries);
|
|
204
|
+
for (const path of change) {
|
|
205
|
+
if (entries.has(path)) continue;
|
|
206
|
+
entries.set(path, hashFile(join(repoRoot, path)));
|
|
207
|
+
}
|
|
208
|
+
return hashEntries(entries);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Change-set paths outside every applicable declaration. Never a refusal: the gate does not guess
|
|
212
|
+
// applicability, and the reviewer still sees what the builder did not declare.
|
|
213
|
+
function undeclaredChanges(change, declarations) {
|
|
214
|
+
const scopes = declarations
|
|
215
|
+
.filter(declaration => declaration?.applicability === 'applicable')
|
|
216
|
+
.flatMap(declaration => declaration.scope?.paths ?? []);
|
|
217
|
+
return change.filter(path => !scopes.some(scope => matchesPattern(path, scope)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function coveringDimensions(path, declarationsByDimension) {
|
|
221
|
+
return Object.entries(declarationsByDimension)
|
|
222
|
+
.filter(([, declaration]) => declaration?.applicability === 'applicable'
|
|
223
|
+
&& (declaration.scope?.paths ?? []).some(scope => matchesPattern(path, scope)))
|
|
224
|
+
.map(([dimension]) => dimension);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// packages/ai-engineering-gate/src/registry.mjs
|
|
228
|
+
const SHARED_SCHEMAS = [
|
|
229
|
+
'marker', 'declaration', 'decision-envelope', 'review-envelope',
|
|
230
|
+
'verdict-record', 'dispute', 'arbitration', 'evidence-append', 'gate-output',
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
// The gate resolves its members and its reference bundles from its own distribution root - the
|
|
234
|
+
// plugin root, the npm package or this checkout - and never from the target project, so a
|
|
235
|
+
// reference fingerprint means the same thing wherever the gate runs. Walk to the outermost
|
|
236
|
+
// `contracts/members.json` so a staged copy inside the package never shadows this checkout.
|
|
237
|
+
function findDistributionRoot() {
|
|
238
|
+
const override = process.env.AI_ENGINEERING_GATE_ROOT;
|
|
239
|
+
if (override) return override;
|
|
240
|
+
let directory = dirname(fileURLToPath(import.meta.url));
|
|
241
|
+
let found = null;
|
|
242
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
243
|
+
if (existsSync(join(directory, 'contracts/members.json'))) found = directory;
|
|
244
|
+
const parent = dirname(directory);
|
|
245
|
+
if (parent === directory) break;
|
|
246
|
+
directory = parent;
|
|
247
|
+
}
|
|
248
|
+
if (found) return found;
|
|
249
|
+
throw new Error('cannot resolve the gate distribution root: contracts/members.json not found');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function loadRegistry(distributionRoot) {
|
|
253
|
+
const manifest = JSON.parse(readFileSync(join(distributionRoot, 'contracts/members.json'), 'utf8'));
|
|
254
|
+
const schemas = {};
|
|
255
|
+
for (const name of SHARED_SCHEMAS) {
|
|
256
|
+
schemas[name] = JSON.parse(readFileSync(join(distributionRoot, `contracts/schemas/${name}.schema.json`), 'utf8'));
|
|
257
|
+
}
|
|
258
|
+
return { ...manifest, schemas, root: distributionRoot };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function gateVersion(distributionRoot) {
|
|
262
|
+
const candidates = [
|
|
263
|
+
join(distributionRoot, 'packages/ai-engineering-gate/package.json'),
|
|
264
|
+
join(distributionRoot, 'package.json'),
|
|
265
|
+
];
|
|
266
|
+
for (const candidate of candidates) {
|
|
267
|
+
if (!existsSync(candidate)) continue;
|
|
268
|
+
const { version } = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
269
|
+
if (version) return version;
|
|
270
|
+
}
|
|
271
|
+
return '0.0.0';
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// The dimensions the project registered, resolved against the manifest. An identifier the manifest
|
|
275
|
+
// does not carry is an unresolvable registry member, not a dimension the gate invents.
|
|
276
|
+
function memberAgentType(member) {
|
|
277
|
+
return member.agent.split('/').pop().replace(/\.md$/, '');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function registeredMembers(registry, marker) {
|
|
281
|
+
const byName = new Map(registry.members.map(member => [member.dimension, member]));
|
|
282
|
+
const selected = marker.dimensions === 'all'
|
|
283
|
+
? registry.members
|
|
284
|
+
: marker.dimensions.map(name => {
|
|
285
|
+
const member = byName.get(name);
|
|
286
|
+
if (!member) throw new Error(`the marker registers ${name}, which the member manifest does not`);
|
|
287
|
+
return member;
|
|
288
|
+
});
|
|
289
|
+
for (const member of selected) {
|
|
290
|
+
for (const path of [member.transpose, member.review, member.agent, member.decisionSchema]) {
|
|
291
|
+
if (!existsSync(join(registry.root, path))) {
|
|
292
|
+
throw new Error(`registry member ${member.dimension} does not resolve ${path}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return selected;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function memberDecisionSchema(registry, member) {
|
|
300
|
+
return JSON.parse(readFileSync(join(registry.root, member.decisionSchema), 'utf8'));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function memberEvidenceSchema(registry, member, kind) {
|
|
304
|
+
const path = member.evidenceSchemas?.[kind];
|
|
305
|
+
if (!path) return null;
|
|
306
|
+
return JSON.parse(readFileSync(join(registry.root, path), 'utf8'));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function walkFiles(root, into = []) {
|
|
310
|
+
if (!existsSync(root)) return into;
|
|
311
|
+
if (statSync(root).isFile()) {
|
|
312
|
+
into.push(root);
|
|
313
|
+
return into;
|
|
314
|
+
}
|
|
315
|
+
for (const entry of readdirSync(root, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
316
|
+
const path = join(root, entry.name);
|
|
317
|
+
if (entry.isDirectory()) walkFiles(path, into);
|
|
318
|
+
else if (entry.isFile()) into.push(path);
|
|
319
|
+
}
|
|
320
|
+
return into;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Catalog, decision schema, evidence schemas, guides, the review skill's references and the
|
|
324
|
+
// generated contract copy, plus the contract version and the gate version. A catalog edit
|
|
325
|
+
// therefore reopens every verdict that read it.
|
|
326
|
+
function referenceFingerprint(registry, member, version) {
|
|
327
|
+
const entries = [
|
|
328
|
+
['contract-version', registry.contractVersion],
|
|
329
|
+
['gate-version', version],
|
|
330
|
+
];
|
|
331
|
+
for (const bundle of member.referenceBundle) {
|
|
332
|
+
const absolute = join(registry.root, bundle);
|
|
333
|
+
for (const file of walkFiles(absolute)) {
|
|
334
|
+
entries.push([file.slice(registry.root.length + 1), hashFile(file)]);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return hashEntries(entries);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The bundle the gate distributes is authoritative. When a discoverable installed copy of the same
|
|
341
|
+
// skill differs from it, the state warns rather than guessing which one a reviewer read.
|
|
342
|
+
function installedReferenceMismatch(registry, member) {
|
|
343
|
+
const home = process.env.HOME;
|
|
344
|
+
if (!home) return false;
|
|
345
|
+
const name = member.transpose.split('/').pop();
|
|
346
|
+
for (const base of [join(home, '.claude/skills'), join(home, '.agents/skills')]) {
|
|
347
|
+
const installed = join(base, name, 'references');
|
|
348
|
+
if (!existsSync(installed)) continue;
|
|
349
|
+
const distributed = join(registry.root, member.transpose, 'references');
|
|
350
|
+
for (const file of walkFiles(distributed)) {
|
|
351
|
+
const mirror = join(installed, file.slice(distributed.length + 1));
|
|
352
|
+
if (hashFile(mirror) !== hashFile(file)) return true;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// packages/ai-engineering-gate/src/store.mjs
|
|
359
|
+
// Seven kinds of document live under a task's evidence directory. The first six are written through
|
|
360
|
+
// a gate command; `index` is the gate's own and nobody else writes it.
|
|
361
|
+
function taskPaths(evidenceRoot, task) {
|
|
362
|
+
const root = join(evidenceRoot, task);
|
|
363
|
+
return {
|
|
364
|
+
root,
|
|
365
|
+
declarations: join(root, 'declarations'),
|
|
366
|
+
records: join(root, 'records'),
|
|
367
|
+
evidence: join(root, 'evidence'),
|
|
368
|
+
disputes: join(root, 'disputes'),
|
|
369
|
+
arbitrations: join(root, 'arbitrations'),
|
|
370
|
+
index: join(root, 'index'),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function readJson(path) {
|
|
375
|
+
try {
|
|
376
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
377
|
+
} catch {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Write then rename, one document at a time, so concurrent attestations of two dimensions never
|
|
383
|
+
// interleave and a crashed writer leaves no half document behind.
|
|
384
|
+
function writeJsonAtomic(path, value) {
|
|
385
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
386
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
387
|
+
writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
388
|
+
renameSync(temporary, path);
|
|
389
|
+
return path;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function listDirectories(path) {
|
|
393
|
+
if (!existsSync(path)) return [];
|
|
394
|
+
return readdirSync(path, { withFileTypes: true })
|
|
395
|
+
.filter(entry => entry.isDirectory())
|
|
396
|
+
.map(entry => entry.name)
|
|
397
|
+
.sort();
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function listJsonFiles(path) {
|
|
401
|
+
if (!existsSync(path)) return [];
|
|
402
|
+
return readdirSync(path, { withFileTypes: true })
|
|
403
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.json') && !entry.name.endsWith('.snapshot.json'))
|
|
404
|
+
.map(entry => entry.name)
|
|
405
|
+
.sort();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const revisionNumber = name => Number.parseInt(/^rev-(\d+)\.json$/.exec(name)?.[1] ?? '0', 10);
|
|
409
|
+
|
|
410
|
+
function latestRevision(directory) {
|
|
411
|
+
const files = listJsonFiles(directory).filter(name => revisionNumber(name) > 0);
|
|
412
|
+
if (files.length === 0) return null;
|
|
413
|
+
const name = files.sort((a, b) => revisionNumber(a) - revisionNumber(b)).at(-1);
|
|
414
|
+
const document = readJson(join(directory, name));
|
|
415
|
+
return document && { number: revisionNumber(name), path: join(directory, name), document };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function readDeclaration(paths, dimension) {
|
|
419
|
+
return latestRevision(join(paths.declarations, dimension));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function declarationRevisions(paths, dimension) {
|
|
423
|
+
const directory = join(paths.declarations, dimension);
|
|
424
|
+
return listJsonFiles(directory)
|
|
425
|
+
.filter(name => revisionNumber(name) > 0)
|
|
426
|
+
.sort((a, b) => revisionNumber(a) - revisionNumber(b))
|
|
427
|
+
.map(name => ({ number: revisionNumber(name), document: readJson(join(directory, name)) }));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function writeDeclaration(paths, dimension, number, document) {
|
|
431
|
+
return writeJsonAtomic(join(paths.declarations, dimension, `rev-${number}.json`), document);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// A record identifier is `rec-001` per dimension; a revision is `rec-001@2`. Revision 1 opens a new
|
|
435
|
+
// record, a later one names its predecessor and extends that chain, so no revision is overwritten.
|
|
436
|
+
function recordIds(paths, dimension) {
|
|
437
|
+
return listDirectories(join(paths.records, dimension));
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function readRecord(paths, dimension, recordId) {
|
|
441
|
+
const revision = latestRevision(join(paths.records, dimension, recordId));
|
|
442
|
+
return revision && { ...revision, id: recordId, reference: `${recordId}@${revision.number}` };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function readRecordRevision(paths, dimension, recordId, number) {
|
|
446
|
+
const path = join(paths.records, dimension, recordId, `rev-${number}.json`);
|
|
447
|
+
const document = readJson(path);
|
|
448
|
+
return document && { number, path, document, id: recordId, reference: `${recordId}@${number}` };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function currentRecords(paths, dimension) {
|
|
452
|
+
return recordIds(paths, dimension)
|
|
453
|
+
.map(id => readRecord(paths, dimension, id))
|
|
454
|
+
.filter(Boolean);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function nextRecordId(paths, dimension) {
|
|
458
|
+
const used = recordIds(paths, dimension)
|
|
459
|
+
.map(id => Number.parseInt(/^rec-(\d+)$/.exec(id)?.[1] ?? '0', 10));
|
|
460
|
+
return `rec-${String(Math.max(0, ...used) + 1).padStart(3, '0')}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function writeRecord(paths, dimension, recordId, number, document) {
|
|
464
|
+
return writeJsonAtomic(join(paths.records, dimension, recordId, `rev-${number}.json`), document);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function writeRecordSnapshot(paths, dimension, recordId, number, snapshot) {
|
|
468
|
+
return writeJsonAtomic(join(paths.records, dimension, recordId, `rev-${number}.snapshot.json`), snapshot);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function readRecordSnapshot(paths, dimension, recordId, number) {
|
|
472
|
+
return readJson(join(paths.records, dimension, recordId, `rev-${number}.snapshot.json`));
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function appendEvidence(paths, dimension, document) {
|
|
476
|
+
const directory = join(paths.evidence, dimension);
|
|
477
|
+
const sequence = String(listJsonFiles(directory).length + 1).padStart(4, '0');
|
|
478
|
+
const name = `${sequence}-${document.kind.replaceAll(/[^a-z0-9-]/gi, '-')}.json`;
|
|
479
|
+
return writeJsonAtomic(join(directory, name), document);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function evidenceAppends(paths, dimension) {
|
|
483
|
+
const directory = join(paths.evidence, dimension);
|
|
484
|
+
return listJsonFiles(directory).map(name => readJson(join(directory, name))).filter(Boolean);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function sequencedWrite(directory, prefix, document) {
|
|
488
|
+
const sequence = String(listJsonFiles(directory).length + 1).padStart(3, '0');
|
|
489
|
+
const id = `${prefix}-${sequence}`;
|
|
490
|
+
writeJsonAtomic(join(directory, `${id}.json`), { ...document, id });
|
|
491
|
+
return id;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function sequencedList(directory) {
|
|
495
|
+
return listJsonFiles(directory).map(name => readJson(join(directory, name))).filter(Boolean);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function writeVerdict(paths, kind, dimension, document) {
|
|
499
|
+
const directory = join(paths.index, `${kind}s`, dimension);
|
|
500
|
+
const prefix = kind === 'attestation' ? 'att' : 'rep';
|
|
501
|
+
return sequencedWrite(directory, prefix, document);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function verdicts(paths, kind, dimension) {
|
|
505
|
+
return sequencedList(join(paths.index, `${kind}s`, dimension));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function writeDispute(paths, dimension, document) {
|
|
509
|
+
return sequencedWrite(join(paths.disputes, dimension), 'dis', document);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function disputes(paths, dimension) {
|
|
513
|
+
return sequencedList(join(paths.disputes, dimension));
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function writeArbitration(paths, dimension, document) {
|
|
517
|
+
return sequencedWrite(join(paths.arbitrations, dimension), 'arb', document);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function arbitrations(paths, dimension) {
|
|
521
|
+
return sequencedList(join(paths.arbitrations, dimension));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function windowPath(paths, dimension) {
|
|
525
|
+
return join(paths.index, 'windows', `${dimension}.json`);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function readWindow(paths, dimension) {
|
|
529
|
+
return readJson(windowPath(paths, dimension));
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function writeWindow(paths, dimension, document) {
|
|
533
|
+
return writeJsonAtomic(windowPath(paths, dimension), document);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function closeWindow(paths, dimension, outcome) {
|
|
537
|
+
const open = readWindow(paths, dimension);
|
|
538
|
+
if (!open) return null;
|
|
539
|
+
const directory = join(paths.index, 'closed-windows', dimension);
|
|
540
|
+
sequencedWrite(directory, 'win', { ...open, outcome, closedAt: new Date().toISOString() });
|
|
541
|
+
writeJsonAtomic(windowPath(paths, dimension), { closed: true });
|
|
542
|
+
return open;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function closedWindows(paths, dimension) {
|
|
546
|
+
return sequencedList(join(paths.index, 'closed-windows', dimension));
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function readRounds(paths) {
|
|
550
|
+
return readJson(join(paths.index, 'rounds.json')) ?? { round: 0, conflictRounds: 0, history: [] };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function writeRounds(paths, value) {
|
|
554
|
+
return writeJsonAtomic(join(paths.index, 'rounds.json'), value);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function readStopSnapshot(paths) {
|
|
558
|
+
return readJson(join(paths.index, 'stop-snapshot.json'));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function writeStopSnapshot(paths, value) {
|
|
562
|
+
return writeJsonAtomic(join(paths.index, 'stop-snapshot.json'), value);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function invocationHash(command) {
|
|
566
|
+
const tokens = String(command).trim().split(/\s+/).filter(Boolean);
|
|
567
|
+
const gateAt = tokens.findIndex(token => token.includes('ai-engineering-gate'));
|
|
568
|
+
const rest = gateAt >= 0 ? tokens.slice(gateAt + 1) : tokens;
|
|
569
|
+
return hashText(canonicalJson(rest));
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Keyed on session, tool-use id and the hash of the gate argv so two same-verb calls
|
|
573
|
+
// stay distinct, and a leftover of another verb cannot be consumed as this one.
|
|
574
|
+
function handoffKey(session, toolUseId, commandHash) {
|
|
575
|
+
return hashText(canonicalJson([session ?? '', toolUseId ?? '', commandHash ?? ''])).slice(7, 39);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function writeHandoff(paths, key, document) {
|
|
579
|
+
return writeJsonAtomic(join(paths.index, 'handoffs', `${key}.json`), document);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function readHandoff(paths, key) {
|
|
583
|
+
return readJson(join(paths.index, 'handoffs', `${key}.json`));
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function handoffs(paths) {
|
|
587
|
+
const directory = join(paths.index, 'handoffs');
|
|
588
|
+
return listJsonFiles(directory).map(name => ({ key: name.replace(/\.json$/, ''), document: readJson(join(directory, name)) }));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function dropHandoff(paths, key) {
|
|
592
|
+
const path = join(paths.index, 'handoffs', `${key}.json`);
|
|
593
|
+
if (existsSync(path)) writeJsonAtomic(path, { consumed: true, at: new Date().toISOString() });
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function readBuilders(paths) {
|
|
597
|
+
return readJson(join(paths.index, 'builders.json')) ?? { agents: [], sessions: [] };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// The builder identities seen writing declarations, records and evidence. `attest` refuses a caller
|
|
601
|
+
// that appears here, which checks consistency and never proves independence.
|
|
602
|
+
function noteBuilder(paths, identity) {
|
|
603
|
+
const known = readBuilders(paths);
|
|
604
|
+
const agents = [...new Set([...known.agents, identity.agent].filter(Boolean))];
|
|
605
|
+
const sessions = [...new Set([...known.sessions, identity.session].filter(Boolean))];
|
|
606
|
+
writeJsonAtomic(join(paths.index, 'builders.json'), { agents, sessions });
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// packages/ai-engineering-gate/src/context.mjs
|
|
610
|
+
// Everything a command reads, resolved once. `marked` is false in a repository that carries no
|
|
611
|
+
// marker, and every command then allows and prints nothing that blocks.
|
|
612
|
+
function buildContext(options) {
|
|
613
|
+
const cwd = options.cwd ?? process.cwd();
|
|
614
|
+
const repoRoot = findRepoRoot(cwd);
|
|
615
|
+
const marker = readMarker(repoRoot);
|
|
616
|
+
const distributionRoot = findDistributionRoot();
|
|
617
|
+
const registry = loadRegistry(distributionRoot);
|
|
618
|
+
const version = gateVersion(distributionRoot);
|
|
619
|
+
const context = {
|
|
620
|
+
cwd,
|
|
621
|
+
repoRoot,
|
|
622
|
+
marker,
|
|
623
|
+
marked: marker !== null,
|
|
624
|
+
registry,
|
|
625
|
+
version,
|
|
626
|
+
gateCommand: options.gateCommand ?? gateCommandLine(distributionRoot),
|
|
627
|
+
harness: options.harness ?? null,
|
|
628
|
+
};
|
|
629
|
+
if (!context.marked) return context;
|
|
630
|
+
context.members = registeredMembers(registry, marker);
|
|
631
|
+
context.dimensions = context.members.map(member => member.dimension);
|
|
632
|
+
context.evidenceRoot = resolveEvidenceRoot({
|
|
633
|
+
flag: options.evidenceRoot,
|
|
634
|
+
env: process.env.AI_ENGINEERING_GATE_EVIDENCE_ROOT,
|
|
635
|
+
marker,
|
|
636
|
+
repoRoot,
|
|
637
|
+
});
|
|
638
|
+
context.task = resolveTaskKey({
|
|
639
|
+
flag: options.task,
|
|
640
|
+
// A CI checkout is a detached merge ref, so no branch names the task. One export carries one
|
|
641
|
+
// task, which is a fact the export itself states better than a pipeline variable could.
|
|
642
|
+
env: process.env.AI_ENGINEERING_GATE_TASK ?? (options.fromExport ? soleTask(context.evidenceRoot) : null),
|
|
643
|
+
repoRoot,
|
|
644
|
+
});
|
|
645
|
+
context.paths = taskPaths(context.evidenceRoot, context.task);
|
|
646
|
+
context.conflictRoundCap = marker.conflictRoundCap ?? 3;
|
|
647
|
+
context.roundCap = marker.roundCap ?? 12;
|
|
648
|
+
context.allowReplay = marker.allowReplay !== false;
|
|
649
|
+
context.requireVerifiedIdentity = marker.requireVerifiedIdentity === true;
|
|
650
|
+
return context;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function soleTask(evidenceRoot) {
|
|
654
|
+
const tasks = listDirectories(evidenceRoot).filter(name => name !== 'index');
|
|
655
|
+
return tasks.length === 1 ? tasks[0] : null;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// The command line the gate prints for the next step. It names the entry point it is running from,
|
|
659
|
+
// so a checkout, a plugin install and an npm install each print something the reader can paste.
|
|
660
|
+
function gateCommandLine(distributionRoot) {
|
|
661
|
+
for (const candidate of ['packages/ai-engineering-gate/bin/ai-engineering-gate.mjs', 'bin/ai-engineering-gate.mjs']) {
|
|
662
|
+
if (existsSync(join(distributionRoot, candidate))) return `node ${join(distributionRoot, candidate)}`;
|
|
663
|
+
}
|
|
664
|
+
return 'ai-engineering-gate';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function memberOf(context, dimension) {
|
|
668
|
+
const member = context.members?.find(one => one.dimension === dimension);
|
|
669
|
+
if (!member) throw new Error(`${dimension} is not a dimension this project registers`);
|
|
670
|
+
return member;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function declarations(context) {
|
|
674
|
+
const found = {};
|
|
675
|
+
for (const dimension of context.dimensions) {
|
|
676
|
+
found[dimension] = readDeclaration(context.paths, dimension)?.document ?? null;
|
|
677
|
+
}
|
|
678
|
+
return found;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// The base a dimension compares against: its declaration's, the marker's default, or none.
|
|
682
|
+
function baseOf(context, declaration) {
|
|
683
|
+
return declaration?.base ?? context.marker.base ?? null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// The three fingerprints, always computed here and never accepted from a document.
|
|
687
|
+
function fingerprintsOf(context, dimension, declaration) {
|
|
688
|
+
const member = memberOf(context, dimension);
|
|
689
|
+
const change = changeSet(context, baseOf(context, declaration));
|
|
690
|
+
return {
|
|
691
|
+
source: sourceFingerprint(context, declaration, change),
|
|
692
|
+
reference: referenceFingerprint(context.registry, member, context.version),
|
|
693
|
+
decision: decisionFingerprint(context, dimension),
|
|
694
|
+
change,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// The dimension's declaration and every one of its records, each with its revision number. A
|
|
699
|
+
// re-declaration or a record revision therefore expires the verdicts that read the old ones.
|
|
700
|
+
function decisionFingerprint(context, dimension) {
|
|
701
|
+
const declaration = readDeclaration(context.paths, dimension);
|
|
702
|
+
const entries = [
|
|
703
|
+
['declaration', declaration ? `${declaration.number}:${hashJson(declaration.document)}` : 'absent'],
|
|
704
|
+
];
|
|
705
|
+
for (const record of currentRecords(context.paths, dimension)) {
|
|
706
|
+
entries.push([`record:${record.id}`, `${record.number}:${hashJson(record.document)}`]);
|
|
707
|
+
}
|
|
708
|
+
return hashEntries(entries);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function undeclaredFor(context) {
|
|
712
|
+
const all = declarations(context);
|
|
713
|
+
const applicable = Object.values(all).filter(one => one?.applicability === 'applicable');
|
|
714
|
+
const base = applicable[0]?.base ?? context.marker.base ?? null;
|
|
715
|
+
return undeclaredChanges(changeSet(context, base), Object.values(all));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// packages/ai-engineering-gate/src/identity.mjs
|
|
719
|
+
// Harnesses that expose an agent identifier in hook input inside a subagent call. Elsewhere the
|
|
720
|
+
// identity claim cannot be checked, and the gate says so rather than pretending it verified one.
|
|
721
|
+
const HARNESSES_WITH_AGENT_IDENTITY = new Set(['claude-code']);
|
|
722
|
+
const HANDOFF_LIFETIME_MS = 5 * 60 * 1000;
|
|
723
|
+
|
|
724
|
+
function nonempty(value) {
|
|
725
|
+
return value ? value : null;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function sessionIdentifier() {
|
|
729
|
+
return nonempty(process.env.CLAUDE_SESSION_ID)
|
|
730
|
+
?? nonempty(process.env.CODEX_SESSION_ID)
|
|
731
|
+
?? nonempty(process.env.AI_ENGINEERING_GATE_SESSION)
|
|
732
|
+
?? null;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function toolUseIdentifier() {
|
|
736
|
+
return nonempty(process.env.CLAUDE_TOOL_USE_ID)
|
|
737
|
+
?? nonempty(process.env.AI_ENGINEERING_GATE_TOOL_USE_ID)
|
|
738
|
+
?? null;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// The hook sees a session id the invoked process sometimes does not, so both the tool-use key and
|
|
742
|
+
// the session+command-hash key are written; a null-session twin is not, because a leftover of that
|
|
743
|
+
// twin would make a typed command look like an agent.
|
|
744
|
+
function recordHandoff(context, { session, verb, dimension, agent, agentType, toolUseId, command }) {
|
|
745
|
+
const commandHash = invocationHash(command);
|
|
746
|
+
const document = {
|
|
747
|
+
session, verb, dimension, agent, agentType, toolUseId, commandHash,
|
|
748
|
+
harness: context.harness,
|
|
749
|
+
recordedAt: new Date().toISOString(),
|
|
750
|
+
expiresAt: new Date(Date.now() + HANDOFF_LIFETIME_MS).toISOString(),
|
|
751
|
+
consumed: false,
|
|
752
|
+
};
|
|
753
|
+
const keys = [...new Set([
|
|
754
|
+
handoffKey(session, toolUseId, commandHash),
|
|
755
|
+
handoffKey(session, null, commandHash),
|
|
756
|
+
])];
|
|
757
|
+
for (const key of keys) writeHandoff(context.paths, key, document);
|
|
758
|
+
return keys;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function takeHandoff(context) {
|
|
762
|
+
const session = sessionIdentifier();
|
|
763
|
+
const commandHash = invocationHash(process.argv.slice(1).join(' '));
|
|
764
|
+
const keys = [...new Set([
|
|
765
|
+
handoffKey(session, toolUseIdentifier(), commandHash),
|
|
766
|
+
handoffKey(session, null, commandHash),
|
|
767
|
+
])];
|
|
768
|
+
const found = keys
|
|
769
|
+
.map(key => ({ key, stored: readHandoff(context.paths, key) }))
|
|
770
|
+
.find(one => one.stored && !one.stored.consumed
|
|
771
|
+
&& !(one.stored.expiresAt && one.stored.expiresAt < new Date().toISOString()));
|
|
772
|
+
if (!found) return null;
|
|
773
|
+
for (const key of keys) {
|
|
774
|
+
const stored = readHandoff(context.paths, key);
|
|
775
|
+
if (stored) writeHandoff(context.paths, key, { ...stored, consumed: true });
|
|
776
|
+
}
|
|
777
|
+
return found.stored;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Every gate call carries the session identifier. The agent identifier arrives only through the
|
|
781
|
+
// handoff a PreToolUse hook recorded, which is why its absence is the mechanical sign of a
|
|
782
|
+
// human-typed command.
|
|
783
|
+
function captureIdentity(context, verb, dimension) {
|
|
784
|
+
const handoff = takeHandoff(context, verb, dimension);
|
|
785
|
+
const providesAgents = HARNESSES_WITH_AGENT_IDENTITY.has(context.harness ?? handoff?.harness ?? '');
|
|
786
|
+
return {
|
|
787
|
+
session: sessionIdentifier(),
|
|
788
|
+
agent: handoff?.agent ?? null,
|
|
789
|
+
agentType: handoff?.agentType ?? null,
|
|
790
|
+
harness: context.harness ?? handoff?.harness ?? null,
|
|
791
|
+
handoffPresent: Boolean(handoff),
|
|
792
|
+
providesAgents,
|
|
793
|
+
verified: providesAgents && Boolean(handoff?.agent),
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function stampBuilder(context, identity) {
|
|
798
|
+
noteBuilder(context.paths, identity);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function isBuilderIdentity(context, identity) {
|
|
802
|
+
const known = readBuilders(context.paths);
|
|
803
|
+
return Boolean(identity.agent) && known.agents.includes(identity.agent);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function storedIdentity(identity) {
|
|
807
|
+
return {
|
|
808
|
+
session: identity.session,
|
|
809
|
+
agent: identity.agent,
|
|
810
|
+
agentType: identity.agentType,
|
|
811
|
+
harness: identity.harness,
|
|
812
|
+
verified: identity.verified,
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// packages/ai-engineering-gate/src/state.mjs
|
|
817
|
+
const sameFingerprints = (stored, current) => stored.source === current.source
|
|
818
|
+
&& stored.reference === current.reference
|
|
819
|
+
&& stored.decision === current.decision;
|
|
820
|
+
|
|
821
|
+
function staleCodes(stored, current) {
|
|
822
|
+
const codes = [];
|
|
823
|
+
if (stored.source !== current.source) codes.push('stale-source');
|
|
824
|
+
if (stored.reference !== current.reference) codes.push('stale-reference');
|
|
825
|
+
if (stored.decision !== current.decision) codes.push('stale-decision');
|
|
826
|
+
return codes;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// A planned artifact is produced when it exists in the worktree, under the task's evidence, or as an
|
|
830
|
+
// append that names it. Nothing is dispatched to a reviewer to notice an omission a machine can see.
|
|
831
|
+
function missingPlannedArtifacts(context, dimension, records) {
|
|
832
|
+
const appends = evidenceAppends(context.paths, dimension);
|
|
833
|
+
const produced = new Set(appends.map(append => append.produces).filter(Boolean));
|
|
834
|
+
const missing = [];
|
|
835
|
+
for (const record of records) {
|
|
836
|
+
for (const plan of record.document.plans ?? []) {
|
|
837
|
+
if (produced.has(plan.path)) continue;
|
|
838
|
+
if (existsSync(join(context.repoRoot, plan.path))) continue;
|
|
839
|
+
if (existsSync(join(context.paths.root, plan.path))) continue;
|
|
840
|
+
if (!missing.includes(plan.path)) missing.push(plan.path);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return missing;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const findingsOf = report => report?.envelope?.findings ?? [];
|
|
847
|
+
|
|
848
|
+
function remedySatisfied(context, dimension, finding, report, records) {
|
|
849
|
+
const remedy = finding.remedy;
|
|
850
|
+
if (!remedy) return false;
|
|
851
|
+
const appends = evidenceAppends(context.paths, dimension)
|
|
852
|
+
.filter(append => (append.filedAt ?? '') > (report.filedAt ?? ''));
|
|
853
|
+
if (remedy.kind === 'produce') {
|
|
854
|
+
return appends.some(append => append.produces === remedy.artifact)
|
|
855
|
+
|| existsSync(join(context.repoRoot, remedy.artifact))
|
|
856
|
+
|| existsSync(join(context.paths.root, remedy.artifact));
|
|
857
|
+
}
|
|
858
|
+
if (remedy.kind === 'rerun') {
|
|
859
|
+
return appends.some(append => append.payload?.command === remedy.command);
|
|
860
|
+
}
|
|
861
|
+
if (remedy.kind === 'replay') {
|
|
862
|
+
return appends.some(append => append.replayed === true && append.payload?.scenario === remedy.scenario);
|
|
863
|
+
}
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function judgmentAddressed(context, dimension, finding, report, records, current) {
|
|
868
|
+
const cites = records.some(record => (record.document.revision?.addresses ?? []).includes(finding.id));
|
|
869
|
+
if (!cites) return false;
|
|
870
|
+
return report.fingerprints.source !== current.source || report.fingerprints.decision !== current.decision;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// A finding is pending until it is addressed, disputed or closed by arbitration. A changed
|
|
874
|
+
// fingerprint alone addresses nothing: touching a file reopens no review.
|
|
875
|
+
function findingStatus(context, dimension, report, records, current) {
|
|
876
|
+
if (!report) return { pending: [], disputed: [], closed: [] };
|
|
877
|
+
const openDisputes = disputes(context.paths, dimension).filter(one => one.report === report.id);
|
|
878
|
+
const decided = arbitrations(context.paths, dimension);
|
|
879
|
+
const pending = [];
|
|
880
|
+
const disputed = [];
|
|
881
|
+
const closed = [];
|
|
882
|
+
for (const finding of findingsOf(report)) {
|
|
883
|
+
const dispute = openDisputes.find(one => one.finding === finding.id);
|
|
884
|
+
const arbitration = dispute && decided.find(one => one.dispute === dispute.id);
|
|
885
|
+
if (arbitration?.decision === 'uphold') {
|
|
886
|
+
closed.push(finding.id);
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (dispute && !arbitration) {
|
|
890
|
+
disputed.push(finding.id);
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
const addressed = finding.kind === 'evidence'
|
|
894
|
+
? remedySatisfied(context, dimension, finding, report, records)
|
|
895
|
+
: judgmentAddressed(context, dimension, finding, report, records, current);
|
|
896
|
+
if (addressed) closed.push(finding.id);
|
|
897
|
+
else pending.push(finding.id);
|
|
898
|
+
}
|
|
899
|
+
return { pending, disputed, closed };
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function dimensionState(context, dimension) {
|
|
903
|
+
const declarationRevision = readDeclaration(context.paths, dimension);
|
|
904
|
+
const declaration = declarationRevision?.document ?? null;
|
|
905
|
+
const warnings = [];
|
|
906
|
+
if (installedReferenceMismatch(context.registry, context.members.find(one => one.dimension === dimension))) {
|
|
907
|
+
warnings.push('reference-mismatch');
|
|
908
|
+
}
|
|
909
|
+
if (!declaration) {
|
|
910
|
+
return {
|
|
911
|
+
dimension, state: 'undeclared', codes: ['missing-declaration'], warnings, records: 0,
|
|
912
|
+
missingEvidence: [], openFindings: [], declaration: null,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
if (declaration.applicability === 'non-applicable') {
|
|
916
|
+
return {
|
|
917
|
+
dimension,
|
|
918
|
+
state: 'non-applicable',
|
|
919
|
+
applicability: 'non-applicable',
|
|
920
|
+
codes: declaration.reason ? [] : ['missing-reason'],
|
|
921
|
+
warnings,
|
|
922
|
+
records: 0,
|
|
923
|
+
missingEvidence: [],
|
|
924
|
+
openFindings: [],
|
|
925
|
+
declaration,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const records = currentRecords(context.paths, dimension);
|
|
930
|
+
const current = fingerprintsOf(context, dimension, declaration);
|
|
931
|
+
const openWindow = (() => {
|
|
932
|
+
const found = readWindow(context.paths, dimension);
|
|
933
|
+
return found && !found.closed ? found : null;
|
|
934
|
+
})();
|
|
935
|
+
const attestations = verdicts(context.paths, 'attestation', dimension);
|
|
936
|
+
const reports = verdicts(context.paths, 'report', dimension);
|
|
937
|
+
const filed = [...attestations, ...reports].sort((a, b) => (a.filedAt < b.filedAt ? -1 : 1));
|
|
938
|
+
const latest = filed.at(-1) ?? null;
|
|
939
|
+
const currentAttestation = attestations.find(one => sameFingerprints(one.fingerprints, current)) ?? null;
|
|
940
|
+
const currentReport = reports.filter(one => sameFingerprints(one.fingerprints, current)).at(-1) ?? null;
|
|
941
|
+
const lastReport = reports.at(-1) ?? null;
|
|
942
|
+
const status = findingStatus(context, dimension, lastReport, records, current);
|
|
943
|
+
const unresolvedDispute = status.disputed.length > 0;
|
|
944
|
+
|
|
945
|
+
const codes = [];
|
|
946
|
+
if (records.length === 0) codes.push('missing-record');
|
|
947
|
+
const missingEvidence = missingPlannedArtifacts(context, dimension, records);
|
|
948
|
+
if (missingEvidence.length > 0) codes.push('missing-evidence');
|
|
949
|
+
if (openWindow) codes.push('review-in-flight');
|
|
950
|
+
if (unresolvedDispute) codes.push('unresolved-dispute');
|
|
951
|
+
if (currentReport && status.pending.concat(status.disputed).length > 0) codes.push('non-sound-review');
|
|
952
|
+
if (status.pending.length > 0) codes.push('remedies-pending');
|
|
953
|
+
if (!currentAttestation) {
|
|
954
|
+
const stale = filed.filter(one => !sameFingerprints(one.fingerprints, current)).at(-1) ?? null;
|
|
955
|
+
if (stale) codes.push(...staleCodes(stale.fingerprints, current));
|
|
956
|
+
else if (!currentReport) codes.push('missing-review');
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
const state = (() => {
|
|
960
|
+
if (unresolvedDispute) return 'disputed';
|
|
961
|
+
if (openWindow) return 'in-review';
|
|
962
|
+
if (currentAttestation) return 'attested';
|
|
963
|
+
if (currentReport) return 'reported';
|
|
964
|
+
if (latest) return 'stale';
|
|
965
|
+
if (records.length > 0) return 'recorded';
|
|
966
|
+
return 'declared';
|
|
967
|
+
})();
|
|
968
|
+
|
|
969
|
+
for (const record of records) {
|
|
970
|
+
const wider = (record.document.scope ?? [])
|
|
971
|
+
.some(path => !(declaration.scope?.paths ?? []).some(scope => matchesPattern(path, scope) || path.startsWith(scope)));
|
|
972
|
+
if (wider && !warnings.includes('scope-wider-than-declaration')) warnings.push('scope-wider-than-declaration');
|
|
973
|
+
const claims = (record.document.cites ?? []).map(citation => `${citation.path}\0${citation.claim}`);
|
|
974
|
+
if (new Set(claims).size !== claims.length && !warnings.includes('duplicate-evidence')) {
|
|
975
|
+
warnings.push('duplicate-evidence');
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
if (currentAttestation?.identity?.verified === false) warnings.push('identity-unverified');
|
|
979
|
+
const rounds = readRounds(context.paths);
|
|
980
|
+
if ((rounds.conflicts ?? []).includes(dimension)) warnings.push('cross-dimension-conflict');
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
dimension,
|
|
984
|
+
state,
|
|
985
|
+
applicability: 'applicable',
|
|
986
|
+
codes: [...new Set(codes)],
|
|
987
|
+
warnings,
|
|
988
|
+
records: records.length,
|
|
989
|
+
missingEvidence,
|
|
990
|
+
openFindings: [...status.pending, ...status.disputed],
|
|
991
|
+
fingerprints: { source: current.source, reference: current.reference, decision: current.decision },
|
|
992
|
+
declaration,
|
|
993
|
+
recordRefs: records.map(record => record.reference),
|
|
994
|
+
releasedWindows: closedWindows(context.paths, dimension).filter(one => one.outcome === 'released').length,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function nextAction(context, states, code) {
|
|
999
|
+
if (code === 'missing-review') {
|
|
1000
|
+
const agents = [...new Set(
|
|
1001
|
+
states
|
|
1002
|
+
.filter(state => state.codes.includes(code))
|
|
1003
|
+
.map(state => memberAgentType(memberOf(context, state.dimension))),
|
|
1004
|
+
)];
|
|
1005
|
+
if (agents.length > 0) return `dispatch a review round: launch ${agents.join(', ')}`;
|
|
1006
|
+
}
|
|
1007
|
+
return NEXT_ACTION[code] ?? 'read the full status';
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
const NEXT_ACTION = {
|
|
1011
|
+
'missing-declaration': 'park for the author: declare the dimension applicable or non-applicable with its reason, request and constraints',
|
|
1012
|
+
'missing-reason': 'park for the author: re-declare the dimension with the reason it does not apply',
|
|
1013
|
+
'missing-record': 'park for the author: write a decision record before the first affected write',
|
|
1014
|
+
'missing-evidence': 'park for the author: file the planned artifacts through `evidence append`',
|
|
1015
|
+
'missing-review': 'dispatch a review round: launch the registered reviewer',
|
|
1016
|
+
'non-sound-review': 'park for the author: address or dispute every finding of the report',
|
|
1017
|
+
'remedies-pending': 'dispatch a review round once the builder executes each remedy, or dispute it with counter-evidence',
|
|
1018
|
+
'unresolved-dispute': 'park for the user: run `arbitrate` locally; no agent writes an arbitration',
|
|
1019
|
+
'arbitration-required': 'park for the user: arbitrate the repeating cross-dimension conflict',
|
|
1020
|
+
'round-cap-reached': 'park for the user: the task reached its total round cap',
|
|
1021
|
+
'stale-source': 'dispatch a review round: the code moved since the verdict',
|
|
1022
|
+
'stale-reference': 'dispatch a review round: a catalog, schema or guide moved',
|
|
1023
|
+
'stale-decision': 'dispatch a review round: a declaration or record moved',
|
|
1024
|
+
'review-in-flight': 'park for the author: wait for the open review to file or release',
|
|
1025
|
+
'gate-failure': 'park as infrastructure: the gate itself failed; read the error and fix the setup',
|
|
1026
|
+
};
|
|
1027
|
+
|
|
1028
|
+
function completionOf(context, states) {
|
|
1029
|
+
const rounds = readRounds(context.paths);
|
|
1030
|
+
const codes = states.flatMap(one => one.codes);
|
|
1031
|
+
if ((rounds.conflictRounds ?? 0) >= context.conflictRoundCap) codes.push('arbitration-required');
|
|
1032
|
+
if ((rounds.round ?? 0) >= context.roundCap) codes.push('round-cap-reached');
|
|
1033
|
+
if (context.requireVerifiedIdentity && states.some(one => one.warnings.includes('identity-unverified'))) {
|
|
1034
|
+
codes.push('non-sound-review');
|
|
1035
|
+
}
|
|
1036
|
+
const unique = [...new Set(codes)];
|
|
1037
|
+
return {
|
|
1038
|
+
complete: unique.length === 0,
|
|
1039
|
+
codes: unique,
|
|
1040
|
+
next: unique.map(code => `${code}: ${nextAction(context, states, code)}`),
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function suiteState(context) {
|
|
1045
|
+
const states = context.dimensions.map(dimension => dimensionState(context, dimension));
|
|
1046
|
+
const undeclared = undeclaredFor(context);
|
|
1047
|
+
if (undeclared.length > 0) {
|
|
1048
|
+
for (const state of states) {
|
|
1049
|
+
if (state.applicability === 'applicable' && !state.warnings.includes('undeclared-change')) {
|
|
1050
|
+
state.warnings.push('undeclared-change');
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return { states, undeclaredChanges: undeclared, completion: completionOf(context, states), rounds: readRounds(context.paths) };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// A round is ready when every applicable dimension has its records and its planned evidence, no
|
|
1058
|
+
// finding is pending, no dispute is open and no window is in flight.
|
|
1059
|
+
function reviewReady(states) {
|
|
1060
|
+
const applicable = states.filter(one => one.applicability === 'applicable');
|
|
1061
|
+
if (applicable.length === 0) return false;
|
|
1062
|
+
return applicable.every(one => one.records > 0
|
|
1063
|
+
&& one.missingEvidence.length === 0
|
|
1064
|
+
&& one.openFindings.length === 0
|
|
1065
|
+
&& one.state !== 'in-review');
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function blockingCause(states) {
|
|
1069
|
+
const applicable = states.filter(one => one.applicability === 'applicable');
|
|
1070
|
+
for (const code of ['missing-record', 'missing-evidence', 'remedies-pending', 'unresolved-dispute']) {
|
|
1071
|
+
if (applicable.some(one => one.codes.includes(code))) return code;
|
|
1072
|
+
}
|
|
1073
|
+
return null;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// packages/ai-engineering-gate/src/schema.mjs
|
|
1077
|
+
// A JSON Schema 2020-12 subset validator, bundled so the gate carries no runtime dependency.
|
|
1078
|
+
// It covers the keywords the suite's own schemas use and refuses an unknown keyword rather
|
|
1079
|
+
// than ignoring it, so a schema this validator cannot enforce is never silently accepted.
|
|
1080
|
+
|
|
1081
|
+
const KNOWN = new Set([
|
|
1082
|
+
'$schema', '$id', '$defs', '$ref', '$comment', 'title', 'description', 'default', 'examples',
|
|
1083
|
+
'type', 'const', 'enum', 'required', 'properties', 'patternProperties', 'additionalProperties',
|
|
1084
|
+
'propertyNames', 'dependentRequired', 'items', 'prefixItems', 'minItems', 'maxItems',
|
|
1085
|
+
'uniqueItems', 'contains', 'minContains', 'maxContains', 'minLength', 'maxLength', 'pattern',
|
|
1086
|
+
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
1087
|
+
'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else',
|
|
1088
|
+
]);
|
|
1089
|
+
|
|
1090
|
+
const typeOf = value => {
|
|
1091
|
+
if (value === null) return 'null';
|
|
1092
|
+
if (Array.isArray(value)) return 'array';
|
|
1093
|
+
if (Number.isInteger(value)) return 'integer';
|
|
1094
|
+
return typeof value;
|
|
1095
|
+
};
|
|
1096
|
+
|
|
1097
|
+
const typeMatches = (value, expected) => {
|
|
1098
|
+
const actual = typeOf(value);
|
|
1099
|
+
if (expected === 'number') return actual === 'number' || actual === 'integer';
|
|
1100
|
+
if (expected === 'integer') return actual === 'integer';
|
|
1101
|
+
return actual === expected;
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
const equals = (a, b) => {
|
|
1105
|
+
if (a === b) return true;
|
|
1106
|
+
if (typeOf(a) !== typeOf(b)) return false;
|
|
1107
|
+
if (Array.isArray(a)) return a.length === b.length && a.every((item, index) => equals(item, b[index]));
|
|
1108
|
+
if (a && typeof a === 'object') {
|
|
1109
|
+
const keys = Object.keys(a);
|
|
1110
|
+
return keys.length === Object.keys(b).length && keys.every(key => equals(a[key], b[key]));
|
|
1111
|
+
}
|
|
1112
|
+
return false;
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
const resolvePointer = (root, reference) => {
|
|
1116
|
+
if (!reference.startsWith('#')) throw new Error(`unsupported schema reference: ${reference}`);
|
|
1117
|
+
let node = root;
|
|
1118
|
+
for (const raw of reference.slice(1).split('/').filter(Boolean)) {
|
|
1119
|
+
const token = decodeURIComponent(raw).replaceAll('~1', '/').replaceAll('~0', '~');
|
|
1120
|
+
node = node?.[token];
|
|
1121
|
+
if (node === undefined) throw new Error(`unresolved schema reference: ${reference}`);
|
|
1122
|
+
}
|
|
1123
|
+
return node;
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
const check = (schema, value, path, root, errors) => {
|
|
1127
|
+
if (schema === true) return;
|
|
1128
|
+
if (schema === false) {
|
|
1129
|
+
errors.push({ path, keyword: 'false', message: 'no value is valid here' });
|
|
1130
|
+
return;
|
|
1131
|
+
}
|
|
1132
|
+
for (const keyword of Object.keys(schema)) {
|
|
1133
|
+
if (!KNOWN.has(keyword)) throw new Error(`unsupported schema keyword: ${keyword}`);
|
|
1134
|
+
}
|
|
1135
|
+
if (schema.$ref !== undefined) check(resolvePointer(root, schema.$ref), value, path, root, errors);
|
|
1136
|
+
|
|
1137
|
+
if (schema.type !== undefined) {
|
|
1138
|
+
const expected = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
1139
|
+
if (!expected.some(one => typeMatches(value, one))) {
|
|
1140
|
+
errors.push({ path, keyword: 'type', message: `expected ${expected.join(' or ')}, got ${typeOf(value)}` });
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
if (schema.const !== undefined && !equals(value, schema.const)) {
|
|
1145
|
+
errors.push({ path, keyword: 'const', message: `expected ${JSON.stringify(schema.const)}` });
|
|
1146
|
+
}
|
|
1147
|
+
if (schema.enum !== undefined && !schema.enum.some(one => equals(value, one))) {
|
|
1148
|
+
errors.push({ path, keyword: 'enum', message: `expected one of ${JSON.stringify(schema.enum)}` });
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (typeof value === 'string') {
|
|
1152
|
+
if (schema.minLength !== undefined && [...value].length < schema.minLength) {
|
|
1153
|
+
errors.push({ path, keyword: 'minLength', message: `shorter than ${schema.minLength}` });
|
|
1154
|
+
}
|
|
1155
|
+
if (schema.maxLength !== undefined && [...value].length > schema.maxLength) {
|
|
1156
|
+
errors.push({ path, keyword: 'maxLength', message: `longer than ${schema.maxLength}` });
|
|
1157
|
+
}
|
|
1158
|
+
if (schema.pattern !== undefined && !new RegExp(schema.pattern, 'u').test(value)) {
|
|
1159
|
+
errors.push({ path, keyword: 'pattern', message: `does not match ${schema.pattern}` });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if (typeof value === 'number') {
|
|
1164
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
1165
|
+
errors.push({ path, keyword: 'minimum', message: `below ${schema.minimum}` });
|
|
1166
|
+
}
|
|
1167
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
1168
|
+
errors.push({ path, keyword: 'maximum', message: `above ${schema.maximum}` });
|
|
1169
|
+
}
|
|
1170
|
+
if (schema.exclusiveMinimum !== undefined && value <= schema.exclusiveMinimum) {
|
|
1171
|
+
errors.push({ path, keyword: 'exclusiveMinimum', message: `not above ${schema.exclusiveMinimum}` });
|
|
1172
|
+
}
|
|
1173
|
+
if (schema.exclusiveMaximum !== undefined && value >= schema.exclusiveMaximum) {
|
|
1174
|
+
errors.push({ path, keyword: 'exclusiveMaximum', message: `not below ${schema.exclusiveMaximum}` });
|
|
1175
|
+
}
|
|
1176
|
+
if (schema.multipleOf !== undefined && value % schema.multipleOf !== 0) {
|
|
1177
|
+
errors.push({ path, keyword: 'multipleOf', message: `not a multiple of ${schema.multipleOf}` });
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (Array.isArray(value)) {
|
|
1182
|
+
const prefix = schema.prefixItems ?? [];
|
|
1183
|
+
prefix.forEach((item, index) => {
|
|
1184
|
+
if (index < value.length) check(item, value[index], `${path}/${index}`, root, errors);
|
|
1185
|
+
});
|
|
1186
|
+
if (schema.items !== undefined) {
|
|
1187
|
+
for (let index = prefix.length; index < value.length; index += 1) {
|
|
1188
|
+
check(schema.items, value[index], `${path}/${index}`, root, errors);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
if (schema.minItems !== undefined && value.length < schema.minItems) {
|
|
1192
|
+
errors.push({ path, keyword: 'minItems', message: `fewer than ${schema.minItems} items` });
|
|
1193
|
+
}
|
|
1194
|
+
if (schema.maxItems !== undefined && value.length > schema.maxItems) {
|
|
1195
|
+
errors.push({ path, keyword: 'maxItems', message: `more than ${schema.maxItems} items` });
|
|
1196
|
+
}
|
|
1197
|
+
if (schema.uniqueItems === true) {
|
|
1198
|
+
const duplicate = value.some((item, index) => value.slice(index + 1).some(other => equals(item, other)));
|
|
1199
|
+
if (duplicate) errors.push({ path, keyword: 'uniqueItems', message: 'items are not unique' });
|
|
1200
|
+
}
|
|
1201
|
+
if (schema.contains !== undefined) {
|
|
1202
|
+
const matches = value.filter(item => valid(schema.contains, item, root)).length;
|
|
1203
|
+
const min = schema.minContains ?? 1;
|
|
1204
|
+
if (matches < min) {
|
|
1205
|
+
errors.push({ path, keyword: 'contains', message: `fewer than ${min} matching items` });
|
|
1206
|
+
}
|
|
1207
|
+
if (schema.maxContains !== undefined && matches > schema.maxContains) {
|
|
1208
|
+
errors.push({ path, keyword: 'maxContains', message: `more than ${schema.maxContains} matching items` });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
1214
|
+
for (const key of schema.required ?? []) {
|
|
1215
|
+
if (!Object.hasOwn(value, key)) {
|
|
1216
|
+
errors.push({ path, keyword: 'required', message: `missing property ${key}` });
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
const declared = new Set(Object.keys(schema.properties ?? {}));
|
|
1220
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) {
|
|
1221
|
+
if (Object.hasOwn(value, key)) check(child, value[key], `${path}/${key}`, root, errors);
|
|
1222
|
+
}
|
|
1223
|
+
for (const [pattern, child] of Object.entries(schema.patternProperties ?? {})) {
|
|
1224
|
+
const expression = new RegExp(pattern, 'u');
|
|
1225
|
+
for (const key of Object.keys(value)) {
|
|
1226
|
+
if (!expression.test(key)) continue;
|
|
1227
|
+
declared.add(key);
|
|
1228
|
+
check(child, value[key], `${path}/${key}`, root, errors);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
if (schema.additionalProperties !== undefined) {
|
|
1232
|
+
for (const key of Object.keys(value)) {
|
|
1233
|
+
if (declared.has(key)) continue;
|
|
1234
|
+
if (schema.additionalProperties === false) {
|
|
1235
|
+
errors.push({ path: `${path}/${key}`, keyword: 'additionalProperties', message: 'property is not allowed' });
|
|
1236
|
+
} else {
|
|
1237
|
+
check(schema.additionalProperties, value[key], `${path}/${key}`, root, errors);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (schema.propertyNames !== undefined) {
|
|
1242
|
+
for (const key of Object.keys(value)) check(schema.propertyNames, key, `${path}/${key}`, root, errors);
|
|
1243
|
+
}
|
|
1244
|
+
for (const [key, dependents] of Object.entries(schema.dependentRequired ?? {})) {
|
|
1245
|
+
if (!Object.hasOwn(value, key)) continue;
|
|
1246
|
+
for (const dependent of dependents) {
|
|
1247
|
+
if (!Object.hasOwn(value, dependent)) {
|
|
1248
|
+
errors.push({ path, keyword: 'dependentRequired', message: `${key} requires ${dependent}` });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
for (const child of schema.allOf ?? []) check(child, value, path, root, errors);
|
|
1255
|
+
if (schema.anyOf !== undefined && !schema.anyOf.some(child => valid(child, value, root))) {
|
|
1256
|
+
errors.push({ path, keyword: 'anyOf', message: 'matches no allowed alternative' });
|
|
1257
|
+
}
|
|
1258
|
+
if (schema.oneOf !== undefined) {
|
|
1259
|
+
const matches = schema.oneOf.filter(child => valid(child, value, root)).length;
|
|
1260
|
+
if (matches !== 1) {
|
|
1261
|
+
errors.push({ path, keyword: 'oneOf', message: `matches ${matches} alternatives, expected exactly one` });
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (schema.not !== undefined && valid(schema.not, value, root)) {
|
|
1265
|
+
errors.push({ path, keyword: 'not', message: 'matches a forbidden shape' });
|
|
1266
|
+
}
|
|
1267
|
+
if (schema.if !== undefined) {
|
|
1268
|
+
const branch = valid(schema.if, value, root) ? schema.then : schema.else;
|
|
1269
|
+
if (branch !== undefined) check(branch, value, path, root, errors);
|
|
1270
|
+
}
|
|
1271
|
+
};
|
|
1272
|
+
|
|
1273
|
+
const valid = (schema, value, root) => {
|
|
1274
|
+
const errors = [];
|
|
1275
|
+
check(schema, value, '', root, errors);
|
|
1276
|
+
return errors.length === 0;
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
function schemaErrors(schema, value) {
|
|
1280
|
+
const errors = [];
|
|
1281
|
+
check(schema, value, '', schema, errors);
|
|
1282
|
+
return errors.map(error => `${error.path || '/'}: ${error.message}`);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// packages/ai-engineering-gate/src/validate.mjs
|
|
1286
|
+
// A record is validated twice: against the envelope every dimension shares, and against the
|
|
1287
|
+
// dimension's own schema. The gate reads the envelope; the body stays the dimension's business.
|
|
1288
|
+
function recordErrors(context, dimension, document) {
|
|
1289
|
+
const member = memberOf(context, dimension);
|
|
1290
|
+
return [
|
|
1291
|
+
...schemaErrors(context.registry.schemas['decision-envelope'], document),
|
|
1292
|
+
...schemaErrors(memberDecisionSchema(context.registry, member), document),
|
|
1293
|
+
];
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function sharedErrors(context, name, document) {
|
|
1297
|
+
return schemaErrors(context.registry.schemas[name], document);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function evidenceErrors(context, dimension, document) {
|
|
1301
|
+
const errors = sharedErrors(context, 'evidence-append', document);
|
|
1302
|
+
const schema = memberEvidenceSchema(context.registry, memberOf(context, dimension), document.kind);
|
|
1303
|
+
if (schema) errors.push(...schemaErrors(schema, document.payload));
|
|
1304
|
+
return errors;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// A cited path must exist in the worktree or under the task's evidence when the record is validated.
|
|
1308
|
+
// `plans` are deliberately not checked here, so a record can precede the first affected write.
|
|
1309
|
+
function resolveCitation(context, path) {
|
|
1310
|
+
for (const candidate of [join(context.repoRoot, path), join(context.paths.root, path)]) {
|
|
1311
|
+
if (existsSync(candidate)) return candidate;
|
|
1312
|
+
}
|
|
1313
|
+
return null;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function danglingCitations(context, document) {
|
|
1317
|
+
return (document.cites ?? [])
|
|
1318
|
+
.map(citation => citation.path)
|
|
1319
|
+
.filter(path => resolveCitation(context, path) === null);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function citationSnapshot(context, document) {
|
|
1323
|
+
const cites = {};
|
|
1324
|
+
for (const citation of document.cites ?? []) {
|
|
1325
|
+
const resolved = resolveCitation(context, citation.path);
|
|
1326
|
+
cites[citation.path] = resolved ? hashFile(resolved) : null;
|
|
1327
|
+
}
|
|
1328
|
+
return cites;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const CHECKABLE_FACT = /^unknown\b|^unknown$/i;
|
|
1332
|
+
|
|
1333
|
+
function walkStrings(value, path, into) {
|
|
1334
|
+
if (typeof value === 'string') {
|
|
1335
|
+
into.push([path, value]);
|
|
1336
|
+
return into;
|
|
1337
|
+
}
|
|
1338
|
+
if (Array.isArray(value)) {
|
|
1339
|
+
value.forEach((item, index) => walkStrings(item, `${path}/${index}`, into));
|
|
1340
|
+
return into;
|
|
1341
|
+
}
|
|
1342
|
+
if (value && typeof value === 'object') {
|
|
1343
|
+
for (const [key, child] of Object.entries(value)) walkStrings(child, `${path}/${key}`, into);
|
|
1344
|
+
}
|
|
1345
|
+
return into;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
// A claim of unknown about a checkable fact is accepted only with the sources consulted cited, or
|
|
1349
|
+
// with the paths searched and not found. Nothing else: a fleet declared unknown without opening a
|
|
1350
|
+
// file is what this refuses.
|
|
1351
|
+
function unsourcedUnknowns(document) {
|
|
1352
|
+
const citations = document.cites ?? [];
|
|
1353
|
+
const searched = citations.some(citation => (citation.searched ?? []).length > 0);
|
|
1354
|
+
if (searched) return [];
|
|
1355
|
+
const covered = new Set(citations.flatMap(citation => citation.covers ?? []));
|
|
1356
|
+
return walkStrings(document, '', [])
|
|
1357
|
+
.filter(([path, value]) => CHECKABLE_FACT.test(value.trim()) && !path.startsWith('/cites'))
|
|
1358
|
+
.filter(([path]) => {
|
|
1359
|
+
const segments = path.split('/').filter(Boolean).filter(segment => !/^\d+$/.test(segment));
|
|
1360
|
+
return !segments.some(segment => covered.has(segment)) && !covered.has(path);
|
|
1361
|
+
})
|
|
1362
|
+
.map(([path]) => path);
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// A revision that announces a correction is refused when no cited artifact changed, so a record
|
|
1366
|
+
// cannot describe work a script did not apply.
|
|
1367
|
+
function revisionChangeErrors(context, dimension, recordId, document) {
|
|
1368
|
+
const number = document.revision.number;
|
|
1369
|
+
if (number < 2) return [];
|
|
1370
|
+
const snapshot = readRecordSnapshot(context.paths, dimension, recordId, number - 1);
|
|
1371
|
+
if (!snapshot) return [`no snapshot for revision ${number - 1} of ${recordId}`];
|
|
1372
|
+
const current = citationSnapshot(context, document);
|
|
1373
|
+
const named = document.revision.changed ?? null;
|
|
1374
|
+
if (named) {
|
|
1375
|
+
return named
|
|
1376
|
+
.filter(path => (snapshot.cites?.[path] ?? null) === (current[path] ?? null))
|
|
1377
|
+
.map(path => `${path} is named as changed and its content is identical`);
|
|
1378
|
+
}
|
|
1379
|
+
const moved = Object.keys(current).some(path => (snapshot.cites?.[path] ?? null) !== current[path]);
|
|
1380
|
+
return moved ? [] : ['no cited artifact differs from the previous revision'];
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// A dispute needs a pointer to counter-evidence that exists now.
|
|
1384
|
+
function danglingCounterEvidence(context, document) {
|
|
1385
|
+
return (document.counterEvidence ?? [])
|
|
1386
|
+
.map(pointer => pointer.path)
|
|
1387
|
+
.filter(path => resolveCitation(context, path) === null);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function ignoredFingerprints(document) {
|
|
1391
|
+
const found = walkStrings(document, '', [])
|
|
1392
|
+
.map(([path]) => path)
|
|
1393
|
+
.filter(path => /fingerprint/i.test(path));
|
|
1394
|
+
const keys = Object.keys(document).filter(key => /fingerprint/i.test(key));
|
|
1395
|
+
return found.length > 0 || keys.length > 0;
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// packages/ai-engineering-gate/src/cmd-write.mjs
|
|
1399
|
+
function requireDimension(context, dimension) {
|
|
1400
|
+
memberOf(context, dimension);
|
|
1401
|
+
return dimension;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// A declaration locks once a record, a report, a window or a dispute depends on it, so a
|
|
1405
|
+
// re-declaration cannot erase a stored verdict. Widening the scope or adding constraints is allowed.
|
|
1406
|
+
function declarationLock(context, dimension, next) {
|
|
1407
|
+
const previous = readDeclaration(context.paths, dimension);
|
|
1408
|
+
if (!previous) return null;
|
|
1409
|
+
const dependents = [
|
|
1410
|
+
currentRecords(context.paths, dimension).length > 0 && 'a decision record',
|
|
1411
|
+
verdicts(context.paths, 'report', dimension).length > 0 && 'a report',
|
|
1412
|
+
verdicts(context.paths, 'attestation', dimension).length > 0 && 'an attestation',
|
|
1413
|
+
readWindow(context.paths, dimension)?.closed === false && 'an open review window',
|
|
1414
|
+
disputes(context.paths, dimension).length > 0 && 'a dispute',
|
|
1415
|
+
].filter(Boolean);
|
|
1416
|
+
if (dependents.length === 0) return null;
|
|
1417
|
+
if (onlyWidens(previous.document, next)) return null;
|
|
1418
|
+
return dependents;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function onlyWidens(previous, next) {
|
|
1422
|
+
if (previous.applicability !== next.applicability) return false;
|
|
1423
|
+
if (previous.request !== next.request) return false;
|
|
1424
|
+
if (previous.reason !== next.reason) return false;
|
|
1425
|
+
if (previous.applicability === 'non-applicable') return false;
|
|
1426
|
+
if (previous.base !== next.base) return false;
|
|
1427
|
+
const kept = (previous.scope?.paths ?? []).every(path => (next.scope?.paths ?? []).includes(path));
|
|
1428
|
+
const keptConfiguration = (previous.scope?.configuration ?? [])
|
|
1429
|
+
.every(path => (next.scope?.configuration ?? []).includes(path));
|
|
1430
|
+
const keptConstraints = (previous.constraints ?? []).every(one => (next.constraints ?? []).includes(one));
|
|
1431
|
+
return kept && keptConfiguration && keptConstraints;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function commandDeclare(context, args, document) {
|
|
1435
|
+
const dimension = requireDimension(context, args.dimension);
|
|
1436
|
+
if (document?.dimension !== dimension) {
|
|
1437
|
+
return refuse('invalid-document', `the document declares ${document?.dimension ?? 'no dimension'}, the command declares ${dimension}`);
|
|
1438
|
+
}
|
|
1439
|
+
const errors = sharedErrors(context, 'declaration', document);
|
|
1440
|
+
if (errors.length > 0) return refuse('invalid-document', 'the declaration does not match its schema', errors);
|
|
1441
|
+
|
|
1442
|
+
const revisions = declarationRevisions(context.paths, dimension);
|
|
1443
|
+
const expected = revisions.length + 1;
|
|
1444
|
+
if (document.revision.number !== expected) {
|
|
1445
|
+
return refuse('invalid-document', `the next declaration revision of ${dimension} is ${expected}`);
|
|
1446
|
+
}
|
|
1447
|
+
const locked = declarationLock(context, dimension, document);
|
|
1448
|
+
if (locked) {
|
|
1449
|
+
return refuse('declaration-locked', `${locked.join(' and ')} depends on the declaration of ${dimension}; a revision may only widen its scope or add constraints`);
|
|
1450
|
+
}
|
|
1451
|
+
const identity = captureIdentity(context, 'declare', dimension);
|
|
1452
|
+
stampBuilder(context, identity);
|
|
1453
|
+
const path = writeDeclaration(context.paths, dimension, document.revision.number, document);
|
|
1454
|
+
return accept(
|
|
1455
|
+
`declared ${dimension} ${document.applicability} at revision ${document.revision.number}\nnext: write a decision record before the first affected write`,
|
|
1456
|
+
{ document: { kind: 'declaration', dimension, revision: document.revision.number, path } },
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function commandRecord(context, args, document) {
|
|
1461
|
+
const dimension = requireDimension(context, args.dimension);
|
|
1462
|
+
if (document?.dimension !== dimension) {
|
|
1463
|
+
return refuse('invalid-document', `the document records ${document?.dimension ?? 'no dimension'}, the command records ${dimension}`);
|
|
1464
|
+
}
|
|
1465
|
+
const declaration = readDeclaration(context.paths, dimension)?.document ?? null;
|
|
1466
|
+
if (!declaration) {
|
|
1467
|
+
return refuse('invalid-document', `declare ${dimension} before recording a decision for it`);
|
|
1468
|
+
}
|
|
1469
|
+
if (declaration.applicability !== 'applicable') {
|
|
1470
|
+
return refuse('invalid-document', `${dimension} is declared non-applicable; a non-applicable dimension has a declaration and nothing else`);
|
|
1471
|
+
}
|
|
1472
|
+
const errors = recordErrors(context, dimension, document);
|
|
1473
|
+
if (errors.length > 0) return refuse('invalid-document', 'the record does not match its envelope or its dimension schema', errors);
|
|
1474
|
+
|
|
1475
|
+
const dangling = danglingCitations(context, document);
|
|
1476
|
+
if (dangling.length > 0) {
|
|
1477
|
+
return refuse('dangling-reference', 'a cited path does not exist in the worktree or under the task evidence', dangling);
|
|
1478
|
+
}
|
|
1479
|
+
const unsourced = unsourcedUnknowns(document);
|
|
1480
|
+
if (unsourced.length > 0) {
|
|
1481
|
+
return refuse('unknown-without-source', 'a claim of unknown about a checkable fact cites no source consulted and no path searched', unsourced);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const number = document.revision.number;
|
|
1485
|
+
let recordId;
|
|
1486
|
+
if (number === 1) {
|
|
1487
|
+
recordId = nextRecordId(context.paths, dimension);
|
|
1488
|
+
} else {
|
|
1489
|
+
const reference = /^(rec-\d+)@(\d+)$/.exec(document.revision.previous ?? '');
|
|
1490
|
+
if (!reference) {
|
|
1491
|
+
return refuse('invalid-document', 'revision previous must name the preceding revision as `rec-001@1`');
|
|
1492
|
+
}
|
|
1493
|
+
recordId = reference[1];
|
|
1494
|
+
const existing = readRecord(context.paths, dimension, recordId);
|
|
1495
|
+
if (!existing) return refuse('invalid-document', `${recordId} is not a record of ${dimension} for this task`);
|
|
1496
|
+
if (existing.number !== number - 1 || Number(reference[2]) !== number - 1) {
|
|
1497
|
+
return refuse('invalid-document', `the next revision of ${recordId} is ${existing.number + 1}`);
|
|
1498
|
+
}
|
|
1499
|
+
const changeErrors = revisionChangeErrors(context, dimension, recordId, document);
|
|
1500
|
+
if (changeErrors.length > 0) {
|
|
1501
|
+
return refuse('revision-without-change', 'the revision announces a correction that no cited artifact shows', changeErrors);
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
const warnings = ignoredFingerprints(document) ? ['fingerprint-ignored'] : [];
|
|
1506
|
+
const identity = captureIdentity(context, 'record', dimension);
|
|
1507
|
+
stampBuilder(context, identity);
|
|
1508
|
+
const path = writeRecord(context.paths, dimension, recordId, number, document);
|
|
1509
|
+
writeRecordSnapshot(context.paths, dimension, recordId, number, {
|
|
1510
|
+
revision: number,
|
|
1511
|
+
cites: citationSnapshot(context, document),
|
|
1512
|
+
builder: storedIdentity(identity),
|
|
1513
|
+
writtenAt: new Date().toISOString(),
|
|
1514
|
+
});
|
|
1515
|
+
const planned = (document.plans ?? []).map(plan => plan.path);
|
|
1516
|
+
const lines = [`recorded ${dimension} ${recordId}@${number}`];
|
|
1517
|
+
if (planned.length > 0) lines.push(`plans: ${planned.join(', ')}`);
|
|
1518
|
+
lines.push('next: write the implementation, then file every planned artifact through `evidence append`');
|
|
1519
|
+
return accept(lines.join('\n'), {
|
|
1520
|
+
document: { kind: 'record', dimension, id: recordId, revision: number, path },
|
|
1521
|
+
warnings,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function commandEvidenceAppend(context, args, document) {
|
|
1526
|
+
const dimension = requireDimension(context, args.dimension);
|
|
1527
|
+
if (document?.dimension !== dimension) {
|
|
1528
|
+
return refuse('invalid-document', `the append names ${document?.dimension ?? 'no dimension'}, the command names ${dimension}`);
|
|
1529
|
+
}
|
|
1530
|
+
const errors = evidenceErrors(context, dimension, document);
|
|
1531
|
+
if (errors.length > 0) return refuse('invalid-document', 'the evidence append does not match its schema', errors);
|
|
1532
|
+
const record = readRecord(context.paths, dimension, document.record.split('@')[0]);
|
|
1533
|
+
if (!record) return refuse('invalid-document', `${document.record} is not a record of ${dimension} for this task`);
|
|
1534
|
+
|
|
1535
|
+
const identity = captureIdentity(context, 'evidence', dimension);
|
|
1536
|
+
stampBuilder(context, identity);
|
|
1537
|
+
const stamped = stampAppend(context, dimension, record, document, identity, false);
|
|
1538
|
+
const stampErrors = sharedErrors(context, 'evidence-append', stamped);
|
|
1539
|
+
if (stampErrors.length > 0) {
|
|
1540
|
+
throw new Error(`the gate stamped an append its own schema refuses: ${stampErrors.join('; ')}`);
|
|
1541
|
+
}
|
|
1542
|
+
const path = appendEvidence(context.paths, dimension, stamped);
|
|
1543
|
+
return accept(`appended ${document.kind} to ${dimension} ${record.reference}`, {
|
|
1544
|
+
document: { kind: 'evidence-append', dimension, path },
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Every append carries the content hashes of the record's planned artifacts, by role, so an event
|
|
1549
|
+
// proves the state of test and production files at the moment it was filed.
|
|
1550
|
+
function stampAppend(context, dimension, record, document, identity, replayed, hashRoot = context.repoRoot) {
|
|
1551
|
+
const roles = {};
|
|
1552
|
+
for (const plan of record.document.plans ?? []) {
|
|
1553
|
+
const digest = hashFile(join(hashRoot, plan.path)) ?? hashFile(join(context.paths.root, plan.path));
|
|
1554
|
+
roles[plan.role] = { ...(roles[plan.role] ?? {}), [plan.path]: digest };
|
|
1555
|
+
}
|
|
1556
|
+
return {
|
|
1557
|
+
...document,
|
|
1558
|
+
filedAt: new Date().toISOString(),
|
|
1559
|
+
artifactHashes: roles,
|
|
1560
|
+
builder: storedIdentity(identity),
|
|
1561
|
+
replayed,
|
|
1562
|
+
};
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
function commandDispute(context, args, document) {
|
|
1566
|
+
const dimension = requireDimension(context, args.dimension);
|
|
1567
|
+
const errors = sharedErrors(context, 'dispute', document);
|
|
1568
|
+
if (errors.length > 0) return refuse('invalid-document', 'the dispute does not match its schema', errors);
|
|
1569
|
+
if (document.dimension !== dimension) {
|
|
1570
|
+
return refuse('invalid-document', `the dispute names ${document.dimension}, the command names ${dimension}`);
|
|
1571
|
+
}
|
|
1572
|
+
const report = verdicts(context.paths, 'report', dimension).find(one => one.id === document.report);
|
|
1573
|
+
if (!report) return refuse('invalid-document', `${document.report} is not a report of ${dimension} for this task`);
|
|
1574
|
+
const finding = (report.envelope.findings ?? []).find(one => one.id === document.finding);
|
|
1575
|
+
if (!finding) return refuse('invalid-document', `${document.report} carries no finding ${document.finding}`);
|
|
1576
|
+
const dangling = danglingCounterEvidence(context, document);
|
|
1577
|
+
if (dangling.length > 0) {
|
|
1578
|
+
return refuse('dispute-without-evidence', 'the counter-evidence points at a path that does not exist', dangling);
|
|
1579
|
+
}
|
|
1580
|
+
const already = disputes(context.paths, dimension).find(one => one.finding === document.finding
|
|
1581
|
+
&& !arbitrations(context.paths, dimension).some(decided => decided.dispute === one.id));
|
|
1582
|
+
if (already) return refuse('invalid-document', `${document.finding} is already disputed as ${already.id}`);
|
|
1583
|
+
|
|
1584
|
+
const identity = captureIdentity(context, 'dispute', dimension);
|
|
1585
|
+
stampBuilder(context, identity);
|
|
1586
|
+
const id = writeDispute(context.paths, dimension, {
|
|
1587
|
+
...document, filedAt: new Date().toISOString(), builder: storedIdentity(identity),
|
|
1588
|
+
});
|
|
1589
|
+
return accept(
|
|
1590
|
+
`disputed ${dimension} finding ${document.finding} as ${id}\nnext: the user decides; run \`${context.gateCommand} arbitrate --dimension ${dimension} --stdin\` yourself, no agent writes an arbitration`,
|
|
1591
|
+
{ document: { kind: 'dispute', dimension, id } },
|
|
1592
|
+
);
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// Only a human writes an arbitration. Every agent tool call the hook saw left a handoff; a command
|
|
1596
|
+
// the user types passes through no hook and leaves none, and that absence is the mechanical sign.
|
|
1597
|
+
function commandArbitrate(context, args, document) {
|
|
1598
|
+
const dimension = requireDimension(context, args.dimension);
|
|
1599
|
+
const identity = captureIdentity(context, 'arbitrate', dimension);
|
|
1600
|
+
if (identity.handoffPresent) {
|
|
1601
|
+
return refuse('agent-arbitration-refused', 'an agent tool call invoked arbitrate; only a command the user types can write an arbitration');
|
|
1602
|
+
}
|
|
1603
|
+
const errors = sharedErrors(context, 'arbitration', document);
|
|
1604
|
+
if (errors.length > 0) return refuse('invalid-document', 'the arbitration does not match its schema', errors);
|
|
1605
|
+
if (document.dimension !== dimension) {
|
|
1606
|
+
return refuse('invalid-document', `the arbitration names ${document.dimension}, the command names ${dimension}`);
|
|
1607
|
+
}
|
|
1608
|
+
const dispute = disputes(context.paths, dimension).find(one => one.id === document.dispute);
|
|
1609
|
+
if (!dispute) return refuse('invalid-document', `${document.dispute} is not a dispute of ${dimension} for this task`);
|
|
1610
|
+
if (arbitrations(context.paths, dimension).some(one => one.dispute === document.dispute)) {
|
|
1611
|
+
return refuse('invalid-document', `${document.dispute} is already arbitrated`);
|
|
1612
|
+
}
|
|
1613
|
+
const id = writeArbitration(context.paths, dimension, {
|
|
1614
|
+
...document, finding: dispute.finding, filedAt: new Date().toISOString(),
|
|
1615
|
+
});
|
|
1616
|
+
if (document.decision === 'uphold') {
|
|
1617
|
+
const rounds = readRounds(context.paths);
|
|
1618
|
+
writeRounds(context.paths, {
|
|
1619
|
+
...rounds, conflictRounds: 0, conflicts: [], upheld: [...(rounds.upheld ?? []), { dimension, arbitration: id }],
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1622
|
+
const next = document.decision === 'uphold'
|
|
1623
|
+
? `next: one fresh review of ${dimension} is allowed on this same state`
|
|
1624
|
+
: `next: execute the remedy or correction of ${dispute.finding}`;
|
|
1625
|
+
return accept(`arbitrated ${document.dispute} as ${document.decision} (${id})\n${next}`, {
|
|
1626
|
+
document: { kind: 'arbitration', dimension, id },
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
function plannedEvidenceMissing(context, dimension) {
|
|
1631
|
+
return missingPlannedArtifacts(context, dimension, currentRecords(context.paths, dimension));
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// packages/ai-engineering-gate/src/plan.mjs
|
|
1635
|
+
// The gate never spawns, stops or messages an agent: it prints what to launch. The brief is rendered
|
|
1636
|
+
// from the declaration alone - the requester's wording, the factual constraints, the scope and the
|
|
1637
|
+
// base - so nothing of the builder's rationale reaches a reviewer.
|
|
1638
|
+
function neutralBrief(context, state, view) {
|
|
1639
|
+
const member = memberOf(context, state.dimension);
|
|
1640
|
+
const reviewSkill = member.review.split('/').pop();
|
|
1641
|
+
const declaration = state.declaration;
|
|
1642
|
+
const rounds = readRounds(context.paths);
|
|
1643
|
+
const conflict = (rounds.conflicts ?? []).includes(state.dimension);
|
|
1644
|
+
const lines = [
|
|
1645
|
+
'```text',
|
|
1646
|
+
`You are the fresh ${state.dimension} reviewer. Run /${reviewSkill} yourself; do not delegate.`,
|
|
1647
|
+
'Read-only: do not modify any file, including code, records, catalogs or fixtures.',
|
|
1648
|
+
`Scope: ${(declaration.scope?.paths ?? []).join(', ')}; repository: ${context.repoRoot}.`,
|
|
1649
|
+
`Base: ${declaration.base}; include relevant uncommitted and new files.`,
|
|
1650
|
+
'Use git diff and file reads, never git log or PR descriptions.',
|
|
1651
|
+
`Original request: ${declaration.request}`,
|
|
1652
|
+
`Factual constraints: ${(declaration.constraints ?? []).join(' | ') || 'none stated'}.`,
|
|
1653
|
+
`Records: ${state.recordRefs.map(reference => join(context.paths.records, state.dimension, reference.replace('@', '/rev-') + '.json')).join(', ')}`,
|
|
1654
|
+
`Check evidence: ${join(context.paths.evidence, state.dimension)}`,
|
|
1655
|
+
`Undeclared changes: ${view.undeclaredChanges.join(', ') || 'none'}.`,
|
|
1656
|
+
];
|
|
1657
|
+
if (declaration.scope?.configuration?.length) {
|
|
1658
|
+
lines.push(`Relevant configuration: ${declaration.scope.configuration.join(', ')}.`);
|
|
1659
|
+
}
|
|
1660
|
+
if (state.warnings.includes('duplicate-evidence')) {
|
|
1661
|
+
lines.push('Note: two cited evidence items in one record are identical.');
|
|
1662
|
+
}
|
|
1663
|
+
if (conflict) {
|
|
1664
|
+
lines.push('Note: this dimension turned non-SOUND on a state produced by correcting another; the conflicting findings are in the previous reports.');
|
|
1665
|
+
}
|
|
1666
|
+
for (const arbitration of arbitrationsOf(view, state.dimension)) {
|
|
1667
|
+
lines.push(`Note: the user arbitrated finding ${arbitration.finding} as ${arbitration.decision}: ${arbitration.words}`);
|
|
1668
|
+
}
|
|
1669
|
+
lines.push(`Open your window first: ${context.gateCommand} begin --dimension ${state.dimension}`);
|
|
1670
|
+
lines.push('Report your frozen matrix before opening the records. End with the domain report');
|
|
1671
|
+
lines.push('and one verdict, or explain incomplete execution without attestation.');
|
|
1672
|
+
lines.push('```');
|
|
1673
|
+
return lines.join('\n');
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const arbitrationsOf = (view, dimension) => (view.arbitrations ?? []).filter(one => one.dimension === dimension);
|
|
1677
|
+
|
|
1678
|
+
function dispatchPlan(context, view) {
|
|
1679
|
+
if (!reviewReady(view.states)) return [];
|
|
1680
|
+
return view.states
|
|
1681
|
+
.filter(state => state.applicability === 'applicable' && state.state !== 'attested')
|
|
1682
|
+
.map(state => {
|
|
1683
|
+
const member = memberOf(context, state.dimension);
|
|
1684
|
+
const fingerprints = fingerprintsOf(context, state.dimension, state.declaration);
|
|
1685
|
+
return {
|
|
1686
|
+
dimension: state.dimension,
|
|
1687
|
+
agent: memberAgentType(member),
|
|
1688
|
+
skill: member.review.split('/').pop(),
|
|
1689
|
+
fingerprints: {
|
|
1690
|
+
source: fingerprints.source,
|
|
1691
|
+
reference: fingerprints.reference,
|
|
1692
|
+
decision: fingerprints.decision,
|
|
1693
|
+
},
|
|
1694
|
+
brief: neutralBrief(context, state, view),
|
|
1695
|
+
};
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
// packages/ai-engineering-gate/src/cmd-inspect.mjs
|
|
1700
|
+
function suiteView(context) {
|
|
1701
|
+
const view = suiteState(context);
|
|
1702
|
+
view.arbitrations = context.dimensions.flatMap(dimension => arbitrations(context.paths, dimension)
|
|
1703
|
+
.map(one => ({ ...one, dimension })));
|
|
1704
|
+
view.plan = dispatchPlan(context, view);
|
|
1705
|
+
return view;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function commandStatus(context) {
|
|
1709
|
+
const view = suiteView(context);
|
|
1710
|
+
return { ok: true, view };
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
function commandCanStop(context) {
|
|
1714
|
+
const view = suiteView(context);
|
|
1715
|
+
return { ok: view.completion.complete, view };
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function commandCanStopHook(context, reentrant) {
|
|
1719
|
+
const view = suiteView(context);
|
|
1720
|
+
if (view.completion.complete) return { ok: true, view, silent: true };
|
|
1721
|
+
const fingerprint = hashJson(view.states.map(state => ({
|
|
1722
|
+
dimension: state.dimension,
|
|
1723
|
+
fingerprints: state.fingerprints ?? null,
|
|
1724
|
+
codes: state.codes,
|
|
1725
|
+
})));
|
|
1726
|
+
const previous = readStopSnapshot(context.paths);
|
|
1727
|
+
if (reentrant && previous?.fingerprint === fingerprint) return { ok: true, view, silent: true };
|
|
1728
|
+
writeStopSnapshot(context.paths, { fingerprint });
|
|
1729
|
+
return { ok: false, view };
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
// The text form answers the one question a builder asks - what does the gate see right now - while
|
|
1733
|
+
// `--json` stays the single status shape, whose dimensions already carry the same three digests.
|
|
1734
|
+
function commandFingerprint(context, args) {
|
|
1735
|
+
const all = declarations(context);
|
|
1736
|
+
const wanted = args.dimension ? [args.dimension] : context.dimensions;
|
|
1737
|
+
const lines = wanted.map(dimension => {
|
|
1738
|
+
const fingerprints = fingerprintsOf(context, dimension, all[dimension]);
|
|
1739
|
+
return `${dimension}\n source ${fingerprints.source}\n reference ${fingerprints.reference}\n decision ${fingerprints.decision}`;
|
|
1740
|
+
});
|
|
1741
|
+
return { ok: true, text: lines.join('\n'), view: suiteView(context) };
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
const WRITE_FORM = /(^|[\s;&|])(>|>>|tee\b|sed\s+-i|perl\s+-i|mv\b|cp\b)|git\s+(apply|checkout|restore|stash|clean|rm|mv)\b|\bpatch\b/u;
|
|
1745
|
+
|
|
1746
|
+
// `can-write` is mechanical only. It decides by file path, never by the content of a write, and it
|
|
1747
|
+
// never infers applicability for the builder: a path outside every declared scope passes, except
|
|
1748
|
+
// while a review window is open, when the whole change set is frozen.
|
|
1749
|
+
function commandCanWrite(context, args) {
|
|
1750
|
+
const paths = args.paths ?? [];
|
|
1751
|
+
const command = args.command ?? null;
|
|
1752
|
+
const all = declarations(context);
|
|
1753
|
+
|
|
1754
|
+
const openWindows = context.dimensions
|
|
1755
|
+
.map(dimension => ({ dimension, window: readWindow(context.paths, dimension) }))
|
|
1756
|
+
.filter(one => one.window && !one.window.closed)
|
|
1757
|
+
.map(one => one.dimension);
|
|
1758
|
+
|
|
1759
|
+
const indexHit = paths.some(path => isInside(context.paths.index, absolute(context, path)))
|
|
1760
|
+
|| (command !== null && command.includes(context.paths.index) && WRITE_FORM.test(command));
|
|
1761
|
+
if (indexHit) {
|
|
1762
|
+
return refuse('invalid-document', `the gate index is written by gate commands only: ${context.paths.index}`);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
const relatives = paths.map(path => toRepoRelative(context.repoRoot, path));
|
|
1766
|
+
if (openWindows.length > 0) {
|
|
1767
|
+
const hitsChangeSet = relatives.some(path => path && !excludedFromChangeSet(context, path))
|
|
1768
|
+
|| (command !== null && WRITE_FORM.test(command));
|
|
1769
|
+
if (hitsChangeSet) {
|
|
1770
|
+
return refuse(
|
|
1771
|
+
'review-in-flight',
|
|
1772
|
+
`a review is in flight on ${openWindows.join(', ')}; wait for it to file or release before editing the change set`,
|
|
1773
|
+
openWindows,
|
|
1774
|
+
);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
const scoped = [];
|
|
1779
|
+
for (const relative of relatives) {
|
|
1780
|
+
for (const dimension of coveringDimensions(relative, all)) {
|
|
1781
|
+
if (!scoped.includes(dimension)) scoped.push(dimension);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
if (command !== null && WRITE_FORM.test(command)) {
|
|
1785
|
+
for (const [dimension, declaration] of Object.entries(all)) {
|
|
1786
|
+
if (declaration?.applicability !== 'applicable') continue;
|
|
1787
|
+
const named = (declaration.scope?.paths ?? []).some(scope => command.includes(scope));
|
|
1788
|
+
if (named && !scoped.includes(dimension)) scoped.push(dimension);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
if (scoped.length === 0) {
|
|
1792
|
+
return accept(paths.length > 0
|
|
1793
|
+
? `allowed: no declared scope covers ${paths.join(', ')}`
|
|
1794
|
+
: 'allowed: nothing in a declared scope');
|
|
1795
|
+
}
|
|
1796
|
+
const without = scoped.filter(dimension => currentRecords(context.paths, dimension).length === 0);
|
|
1797
|
+
if (without.length > 0) {
|
|
1798
|
+
return refuse(
|
|
1799
|
+
'invalid-document',
|
|
1800
|
+
`${without.join(' and ')} has no validated decision record and this path is inside its declared scope; record the decision before the first affected write`,
|
|
1801
|
+
without,
|
|
1802
|
+
);
|
|
1803
|
+
}
|
|
1804
|
+
return accept(`allowed: ${scoped.join(', ')} already has a validated decision record`);
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function absolute(context, path) {
|
|
1808
|
+
return isAbsolute(path) ? path : resolve(context.repoRoot, path);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// Paths a harness edit tool names, whatever the tool's own shape.
|
|
1812
|
+
function editedPaths(toolInput) {
|
|
1813
|
+
const found = [];
|
|
1814
|
+
for (const key of ['file_path', 'filePath', 'notebook_path', 'notebookPath', 'path', 'target_file']) {
|
|
1815
|
+
if (typeof toolInput?.[key] === 'string') found.push(toolInput[key]);
|
|
1816
|
+
}
|
|
1817
|
+
for (const edit of toolInput?.edits ?? []) {
|
|
1818
|
+
if (typeof edit?.file_path === 'string') found.push(edit.file_path);
|
|
1819
|
+
if (typeof edit?.path === 'string') found.push(edit.path);
|
|
1820
|
+
}
|
|
1821
|
+
for (const change of toolInput?.changes ?? []) {
|
|
1822
|
+
if (typeof change?.path === 'string') found.push(change.path);
|
|
1823
|
+
}
|
|
1824
|
+
return [...new Set(found)];
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
function shellCommand(toolInput) {
|
|
1828
|
+
for (const key of ['command', 'cmd', 'script']) {
|
|
1829
|
+
if (typeof toolInput?.[key] === 'string') return toolInput[key];
|
|
1830
|
+
}
|
|
1831
|
+
return null;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// packages/ai-engineering-gate/src/cmd-review.mjs
|
|
1835
|
+
function reviewReadiness(context, dimension) {
|
|
1836
|
+
const view = suiteState(context);
|
|
1837
|
+
const own = view.states.find(state => state.dimension === dimension);
|
|
1838
|
+
if (!own) return refuse('invalid-document', `${dimension} is not a dimension this project registers`);
|
|
1839
|
+
if (own.applicability !== 'applicable') {
|
|
1840
|
+
return refuse('not-review-ready', `${dimension} is declared non-applicable; there is nothing to review`, [], 'missing-declaration');
|
|
1841
|
+
}
|
|
1842
|
+
const open = readWindow(context.paths, dimension);
|
|
1843
|
+
if (open && !open.closed) {
|
|
1844
|
+
return refuse('review-in-flight', `${dimension} already has an open review window opened at ${open.openedAt}`);
|
|
1845
|
+
}
|
|
1846
|
+
// Corrections are batched: while any dimension of the task has a pending finding, no review reopens.
|
|
1847
|
+
const cause = blockingCause(view.states);
|
|
1848
|
+
if (cause) {
|
|
1849
|
+
const upheld = (view.rounds.upheld ?? []).some(one => one.dimension === dimension);
|
|
1850
|
+
if (!(cause === 'remedies-pending' && upheld)) {
|
|
1851
|
+
const blocking = view.states.filter(state => state.codes.includes(cause)).map(state => state.dimension);
|
|
1852
|
+
return refuse('not-review-ready', `${cause} on ${blocking.join(', ')}`, blocking, cause);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
if ((view.rounds.conflictRounds ?? 0) >= context.conflictRoundCap) {
|
|
1856
|
+
return refuse('not-review-ready', `${dimension} reached the cross-dimension conflict cap; the user arbitrates before another review`, [], 'arbitration-required');
|
|
1857
|
+
}
|
|
1858
|
+
if ((view.rounds.round ?? 0) >= context.roundCap) {
|
|
1859
|
+
return refuse('not-review-ready', `the task reached its total round cap of ${context.roundCap}`, [], 'round-cap-reached');
|
|
1860
|
+
}
|
|
1861
|
+
return { ok: true, view, own };
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function commandCanReview(context, args) {
|
|
1865
|
+
const dimension = args.dimension;
|
|
1866
|
+
if (!dimension) return accept('allowed: no dimension named, nothing to gate');
|
|
1867
|
+
const readiness = reviewReadiness(context, dimension);
|
|
1868
|
+
if (!readiness.ok) return readiness;
|
|
1869
|
+
return accept(`allowed: ${dimension} is ready for a fresh review`);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function commandBegin(context, args) {
|
|
1873
|
+
const dimension = args.dimension;
|
|
1874
|
+
const readiness = reviewReadiness(context, dimension);
|
|
1875
|
+
if (!readiness.ok) return readiness;
|
|
1876
|
+
const identity = captureIdentity(context, 'begin', dimension);
|
|
1877
|
+
if (identity.providesAgents && !identity.agent) {
|
|
1878
|
+
return refuse('identity-refused', 'this harness reports an agent identifier and none reached the gate; a review cannot begin without one');
|
|
1879
|
+
}
|
|
1880
|
+
if (isBuilderIdentity(context, identity)) {
|
|
1881
|
+
return refuse('identity-refused', 'the caller is the builder of this task; a review is dispatched to a fresh context');
|
|
1882
|
+
}
|
|
1883
|
+
const declaration = readDeclaration(context.paths, dimension).document;
|
|
1884
|
+
const fingerprints = fingerprintsOf(context, dimension, declaration);
|
|
1885
|
+
const window = {
|
|
1886
|
+
dimension,
|
|
1887
|
+
openedAt: new Date().toISOString(),
|
|
1888
|
+
identity: storedIdentity(identity),
|
|
1889
|
+
fingerprints: {
|
|
1890
|
+
source: fingerprints.source,
|
|
1891
|
+
reference: fingerprints.reference,
|
|
1892
|
+
decision: fingerprints.decision,
|
|
1893
|
+
},
|
|
1894
|
+
closed: false,
|
|
1895
|
+
};
|
|
1896
|
+
writeWindow(context.paths, dimension, window);
|
|
1897
|
+
const lines = [
|
|
1898
|
+
`window open for ${dimension}`,
|
|
1899
|
+
`state: source ${fingerprints.source} reference ${fingerprints.reference} decision ${fingerprints.decision}`,
|
|
1900
|
+
`records: ${currentRecords(context.paths, dimension).map(record => record.reference).join(', ')}`,
|
|
1901
|
+
];
|
|
1902
|
+
lines.push(`next: file with \`${context.gateCommand} attest --dimension ${dimension} --stdin\` for SOUND, \`report\` otherwise`);
|
|
1903
|
+
return accept(lines.join('\n'), {
|
|
1904
|
+
document: { kind: 'window', dimension, fingerprints: window.fingerprints },
|
|
1905
|
+
warnings: identity.verified ? [] : ['identity-unverified'],
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
function fileVerdict(context, args, document, kind) {
|
|
1910
|
+
const dimension = args.dimension;
|
|
1911
|
+
const member = memberOf(context, dimension);
|
|
1912
|
+
const errors = sharedErrors(context, 'review-envelope', document);
|
|
1913
|
+
if (errors.length > 0) return refuse('invalid-document', 'the review envelope does not match its schema', errors);
|
|
1914
|
+
if (document.dimension !== dimension) {
|
|
1915
|
+
return refuse('invalid-document', `the envelope reviews ${document.dimension}, the command reviews ${dimension}`);
|
|
1916
|
+
}
|
|
1917
|
+
if (!member.contractVersions.includes(document.contractVersion)) {
|
|
1918
|
+
return refuse('invalid-document', `${dimension} supports contract ${member.contractVersions.join(' or ')}, the envelope declares ${document.contractVersion}`);
|
|
1919
|
+
}
|
|
1920
|
+
if (kind === 'attestation' && document.verdict !== 'SOUND') {
|
|
1921
|
+
return refuse('invalid-document', 'attest accepts SOUND only; file SMELLS and VIOLATIONS through report');
|
|
1922
|
+
}
|
|
1923
|
+
if (kind === 'report' && document.verdict === 'SOUND') {
|
|
1924
|
+
return refuse('invalid-document', 'report accepts SMELLS and VIOLATIONS; file SOUND through attest');
|
|
1925
|
+
}
|
|
1926
|
+
for (const finding of document.findings) {
|
|
1927
|
+
if (finding.kind === 'evidence' && !finding.remedy) {
|
|
1928
|
+
return refuse('finding-without-remedy', `finding ${finding.id} is about evidence and names no remedy the builder can execute`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
const open = readWindow(context.paths, dimension);
|
|
1933
|
+
if (!open || open.closed) {
|
|
1934
|
+
return refuse('no-window', `no open review window for ${dimension}; run \`${context.gateCommand} begin --dimension ${dimension}\` first`);
|
|
1935
|
+
}
|
|
1936
|
+
const identity = captureIdentity(context, kind === 'attestation' ? 'attest' : 'report', dimension);
|
|
1937
|
+
if (identity.providesAgents && !identity.agent) {
|
|
1938
|
+
return refuse('identity-refused', 'this harness reports an agent identifier and none reached the gate');
|
|
1939
|
+
}
|
|
1940
|
+
if (identity.agent && open.identity.agent && identity.agent !== open.identity.agent) {
|
|
1941
|
+
return refuse('no-window', `the window for ${dimension} was opened by another agent`);
|
|
1942
|
+
}
|
|
1943
|
+
if (isBuilderIdentity(context, identity)) {
|
|
1944
|
+
return refuse('identity-refused', 'the caller is the builder of this task; only a fresh reviewer files a verdict');
|
|
1945
|
+
}
|
|
1946
|
+
if (context.requireVerifiedIdentity && !identity.verified) {
|
|
1947
|
+
return refuse('identity-refused', 'the marker requires a verified agent identity and this harness reports none');
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
const state = dimensionState(context, dimension);
|
|
1951
|
+
const missing = state.missingEvidence;
|
|
1952
|
+
if (missing.length > 0) {
|
|
1953
|
+
return refuse('not-review-ready', 'a planned artifact is still not produced', missing, 'missing-evidence');
|
|
1954
|
+
}
|
|
1955
|
+
const current = fingerprintsOf(context, dimension, state.declaration);
|
|
1956
|
+
const moved = ['source', 'reference', 'decision'].filter(one => current[one] !== open.fingerprints[one]);
|
|
1957
|
+
if (moved.length > 0) {
|
|
1958
|
+
closeWindow(context.paths, dimension, 'voided');
|
|
1959
|
+
return refuse('state-moved', `the ${moved.join(' and ')} fingerprint moved since this review began; the review is void and a fresh one is needed`, moved);
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
const warnings = [];
|
|
1963
|
+
if (ignoredFingerprints(document)) warnings.push('fingerprint-ignored');
|
|
1964
|
+
if (!identity.verified) warnings.push('identity-unverified');
|
|
1965
|
+
if (document.reviewer.agentType && document.reviewer.agentType !== memberAgentType(member)) {
|
|
1966
|
+
warnings.push('identity-unverified');
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
const rounds = readRounds(context.paths);
|
|
1970
|
+
const round = (rounds.round ?? 0) + (rounds.filedOn === current.source ? 0 : 1);
|
|
1971
|
+
const previous = verdicts(context.paths, 'attestation', dimension).at(-1) ?? null;
|
|
1972
|
+
const verdict = {
|
|
1973
|
+
document: kind,
|
|
1974
|
+
documentVersion: '1.0.0',
|
|
1975
|
+
dimension,
|
|
1976
|
+
task: context.task,
|
|
1977
|
+
round,
|
|
1978
|
+
filedAt: new Date().toISOString(),
|
|
1979
|
+
gateVersion: context.version,
|
|
1980
|
+
fingerprints: {
|
|
1981
|
+
source: current.source,
|
|
1982
|
+
reference: current.reference,
|
|
1983
|
+
decision: current.decision,
|
|
1984
|
+
},
|
|
1985
|
+
identity: storedIdentity(identity),
|
|
1986
|
+
envelope: document,
|
|
1987
|
+
...(kind === 'report' ? { openFindings: document.findings.map(finding => finding.id) } : {}),
|
|
1988
|
+
warnings,
|
|
1989
|
+
};
|
|
1990
|
+
// The gate holds itself to the same schema it holds every writer to, so a drift in what it stores
|
|
1991
|
+
// fails here rather than reappearing as an unreadable index later.
|
|
1992
|
+
const stored = sharedErrors(context, 'verdict-record', { ...verdict, id: 'pending' });
|
|
1993
|
+
if (stored.length > 0) {
|
|
1994
|
+
throw new Error(`the gate composed a ${kind} its own schema refuses: ${stored.join('; ')}`);
|
|
1995
|
+
}
|
|
1996
|
+
const id = writeVerdict(context.paths, kind, dimension, verdict);
|
|
1997
|
+
closeWindow(context.paths, dimension, 'filed');
|
|
1998
|
+
|
|
1999
|
+
// A dimension that was SOUND and turns non-SOUND on a state produced by correcting another is a
|
|
2000
|
+
// cross-dimension conflict. The next briefs carry it and a repeating one goes to the user.
|
|
2001
|
+
const conflict = kind === 'report' && previous !== null;
|
|
2002
|
+
const conflicts = new Set(rounds.conflicts ?? []);
|
|
2003
|
+
if (conflict) conflicts.add(dimension);
|
|
2004
|
+
const sameState = rounds.filedOn === current.source;
|
|
2005
|
+
let conflictRounds = rounds.conflictRounds ?? 0;
|
|
2006
|
+
if (!sameState) {
|
|
2007
|
+
conflictRounds = conflict ? ((rounds.roundConflicted ? conflictRounds : 0) + 1) : 0;
|
|
2008
|
+
} else if (conflict && !rounds.roundConflicted) {
|
|
2009
|
+
conflictRounds += 1;
|
|
2010
|
+
}
|
|
2011
|
+
writeRounds(context.paths, {
|
|
2012
|
+
...rounds,
|
|
2013
|
+
round,
|
|
2014
|
+
filedOn: current.source,
|
|
2015
|
+
conflicts: [...conflicts],
|
|
2016
|
+
roundConflicted: sameState ? Boolean(rounds.roundConflicted) || conflict : conflict,
|
|
2017
|
+
conflictRounds,
|
|
2018
|
+
history: [...(rounds.history ?? []), { round, dimension, verdict: document.verdict, id }],
|
|
2019
|
+
});
|
|
2020
|
+
|
|
2021
|
+
const lines = [`filed ${kind} ${id} for ${dimension}: ${document.verdict}`];
|
|
2022
|
+
if (kind === 'report') {
|
|
2023
|
+
lines.push(`findings: ${document.findings.map(finding => `${finding.id} (${finding.kind}, ${finding.severity})`).join(', ')}`);
|
|
2024
|
+
lines.push('next: the builder addresses or disputes every finding before any review of this task reopens');
|
|
2025
|
+
}
|
|
2026
|
+
return accept(lines.join('\n'), {
|
|
2027
|
+
document: { kind, dimension, id },
|
|
2028
|
+
warnings: conflict ? [...warnings, 'cross-dimension-conflict'] : warnings,
|
|
2029
|
+
});
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
function commandAttest(context, args, document) {
|
|
2033
|
+
return fileVerdict(context, args, document, 'attestation');
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
function commandReport(context, args, document) {
|
|
2037
|
+
return fileVerdict(context, args, document, 'report');
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
function commandRelease(context, args) {
|
|
2041
|
+
const dimension = args.dimension;
|
|
2042
|
+
if (!dimension) return accept('nothing to release: no dimension named');
|
|
2043
|
+
const released = closeWindow(context.paths, dimension, 'released');
|
|
2044
|
+
if (!released) return accept(`no open window for ${dimension}`);
|
|
2045
|
+
return accept(`released the open review window for ${dimension}\nnext: dispatch a fresh reviewer for ${dimension}`);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// packages/ai-engineering-gate/src/cmd-transfer.mjs
|
|
2049
|
+
// The export directory carries the task's documents into the repository so a CI runner can recompute
|
|
2050
|
+
// the fingerprints from a clean checkout. It is excluded from the change set and never fingerprinted.
|
|
2051
|
+
function commandExport(context) {
|
|
2052
|
+
const directory = context.marker.exportDirectory;
|
|
2053
|
+
if (!directory) {
|
|
2054
|
+
return refuse('invalid-document', 'the marker names no exportDirectory; add one before exporting');
|
|
2055
|
+
}
|
|
2056
|
+
const dirty = git(['status', '--porcelain'], context.repoRoot).stdout
|
|
2057
|
+
.split('\n')
|
|
2058
|
+
.map(line => line.slice(3).trim().replace(/^.* -> /, ''))
|
|
2059
|
+
.filter(Boolean)
|
|
2060
|
+
.filter(path => !path.startsWith(directory));
|
|
2061
|
+
const change = dirty.filter(path => !excludedFromChangeSet(context, path));
|
|
2062
|
+
if (change.length > 0) {
|
|
2063
|
+
return refuse(
|
|
2064
|
+
'state-moved',
|
|
2065
|
+
'HEAD would recompute different fingerprints than the attestations; commit the change set first',
|
|
2066
|
+
change.slice(0, 20),
|
|
2067
|
+
);
|
|
2068
|
+
}
|
|
2069
|
+
const view = suiteView(context);
|
|
2070
|
+
if (!view.completion.complete) {
|
|
2071
|
+
return refuse('not-review-ready', 'the task is not complete, so its verdicts cannot be published', view.completion.codes);
|
|
2072
|
+
}
|
|
2073
|
+
const target = join(context.repoRoot, directory, context.task);
|
|
2074
|
+
rmSync(target, { recursive: true, force: true });
|
|
2075
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
2076
|
+
cpSync(context.paths.root, target, { recursive: true });
|
|
2077
|
+
const attested = view.states.filter(state => state.applicability === 'applicable').length;
|
|
2078
|
+
return accept(
|
|
2079
|
+
`exported ${attested} attested dimensions of ${context.task} to ${directory}/${context.task}\nnext: commit the export directory and let CI verify it from a clean checkout`,
|
|
2080
|
+
{ document: { kind: 'export', path: target } },
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
|
|
2084
|
+
// The CI half of the direct-PR route: the same completion logic over exported evidence, plus the two
|
|
2085
|
+
// checks only a clean checkout can make - the gate version the attestations record, and the marker.
|
|
2086
|
+
function exportVerificationErrors(context, base) {
|
|
2087
|
+
const errors = [];
|
|
2088
|
+
for (const dimension of context.dimensions) {
|
|
2089
|
+
for (const attestation of verdicts(context.paths, 'attestation', dimension)) {
|
|
2090
|
+
if (attestation.gateVersion !== context.version) {
|
|
2091
|
+
errors.push(`${dimension}: ${attestation.id} was filed by gate ${attestation.gateVersion} and this gate is ${context.version}`);
|
|
2092
|
+
}
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
if (base) {
|
|
2096
|
+
const onBase = git(['cat-file', '-e', `${base}:.ai-engineering-suite.json`], context.repoRoot).ok;
|
|
2097
|
+
if (onBase && !context.marked) {
|
|
2098
|
+
errors.push(`the marker exists on ${base} and not on this head`);
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
return errors;
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
function applyExportVerification(context, result, base) {
|
|
2105
|
+
const errors = exportVerificationErrors(context, base);
|
|
2106
|
+
if (errors.length === 0) return result;
|
|
2107
|
+
const versionDrift = errors.some(one => one.includes('was filed by gate'));
|
|
2108
|
+
const codes = new Set(result.view?.completion?.codes ?? []);
|
|
2109
|
+
if (versionDrift) codes.add('stale-reference');
|
|
2110
|
+
const view = result.view && {
|
|
2111
|
+
...result.view,
|
|
2112
|
+
completion: {
|
|
2113
|
+
complete: false,
|
|
2114
|
+
codes: [...codes],
|
|
2115
|
+
next: result.view.completion.next,
|
|
2116
|
+
},
|
|
2117
|
+
};
|
|
2118
|
+
if (versionDrift) {
|
|
2119
|
+
return { ok: false, view, details: errors };
|
|
2120
|
+
}
|
|
2121
|
+
return {
|
|
2122
|
+
ok: false,
|
|
2123
|
+
view,
|
|
2124
|
+
code: 'invalid-document',
|
|
2125
|
+
reason: 'the exported evidence does not verify against this checkout',
|
|
2126
|
+
details: errors,
|
|
2127
|
+
};
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// The gate executes the replay, never the builder. An isolated copy at the record's base carries the
|
|
2131
|
+
// planned test artifacts alone, so the red it captures is a fact the gate stamped.
|
|
2132
|
+
function commandReplay(context, args) {
|
|
2133
|
+
if (!context.allowReplay) {
|
|
2134
|
+
return refuse('replay-forbidden', 'the marker forbids the replay remedy; rerun a full cycle instead');
|
|
2135
|
+
}
|
|
2136
|
+
const dimension = args.dimension;
|
|
2137
|
+
const record = readRecord(context.paths, dimension, (args.record ?? '').split('@')[0]);
|
|
2138
|
+
if (!record) return refuse('invalid-document', `${args.record} is not a record of ${dimension} for this task`);
|
|
2139
|
+
if (!args.command) return refuse('invalid-document', 'replay needs the scenario command to run');
|
|
2140
|
+
const plans = record.document.plans ?? [];
|
|
2141
|
+
const tests = plans.filter(plan => plan.role === 'test');
|
|
2142
|
+
const production = plans.filter(plan => plan.role === 'production');
|
|
2143
|
+
if (tests.length === 0 || production.length === 0) {
|
|
2144
|
+
return refuse('invalid-document', 'a replay needs planned artifacts of role test and of role production');
|
|
2145
|
+
}
|
|
2146
|
+
const base = record.document.base;
|
|
2147
|
+
const point = git(['merge-base', base, 'HEAD'], context.repoRoot);
|
|
2148
|
+
if (!point.ok) return refuse('invalid-document', `cannot resolve the record base ${base}`);
|
|
2149
|
+
|
|
2150
|
+
const isolated = mkdtempSync(join(tmpdir(), 'ai-engineering-replay-'));
|
|
2151
|
+
const worktree = join(isolated, 'worktree');
|
|
2152
|
+
const added = git(['worktree', 'add', '--detach', worktree, point.stdout], context.repoRoot);
|
|
2153
|
+
if (!added.ok) {
|
|
2154
|
+
rmSync(isolated, { recursive: true, force: true });
|
|
2155
|
+
return refuse('invalid-document', `cannot create the isolated copy: ${added.stderr}`);
|
|
2156
|
+
}
|
|
2157
|
+
try {
|
|
2158
|
+
for (const name of ['node_modules', '.venv']) {
|
|
2159
|
+
const source = join(context.repoRoot, name);
|
|
2160
|
+
if (existsSync(source) && !existsSync(join(worktree, name))) symlinkSync(source, join(worktree, name));
|
|
2161
|
+
}
|
|
2162
|
+
place(context, worktree, tests);
|
|
2163
|
+
const red = run(args.command, worktree);
|
|
2164
|
+
const identity = captureIdentity(context, 'replay', dimension);
|
|
2165
|
+
const redEvent = appendEvidence(context.paths, dimension, stampAppend(context, dimension, record, {
|
|
2166
|
+
document: 'evidence-append',
|
|
2167
|
+
documentVersion: '1.0.0',
|
|
2168
|
+
dimension,
|
|
2169
|
+
record: record.reference,
|
|
2170
|
+
kind: 'journal-event',
|
|
2171
|
+
payload: {
|
|
2172
|
+
record: record.reference,
|
|
2173
|
+
scenario: args.scenario ?? 'scenario',
|
|
2174
|
+
phase: 'red',
|
|
2175
|
+
command: args.command,
|
|
2176
|
+
exitCode: red.code,
|
|
2177
|
+
output: writeOutput(context, args.scenario, 'red', red.output),
|
|
2178
|
+
cause: firstFailure(red.output),
|
|
2179
|
+
failureClass: 'expected-behavior-missing',
|
|
2180
|
+
},
|
|
2181
|
+
}, identity, true, worktree));
|
|
2182
|
+
place(context, worktree, production);
|
|
2183
|
+
const green = run(args.command, worktree);
|
|
2184
|
+
const greenEvent = appendEvidence(context.paths, dimension, stampAppend(context, dimension, record, {
|
|
2185
|
+
document: 'evidence-append',
|
|
2186
|
+
documentVersion: '1.0.0',
|
|
2187
|
+
dimension,
|
|
2188
|
+
record: record.reference,
|
|
2189
|
+
kind: 'journal-event',
|
|
2190
|
+
payload: {
|
|
2191
|
+
record: record.reference,
|
|
2192
|
+
scenario: args.scenario ?? 'scenario',
|
|
2193
|
+
phase: 'green',
|
|
2194
|
+
command: args.command,
|
|
2195
|
+
exitCode: green.code,
|
|
2196
|
+
output: writeOutput(context, args.scenario, 'green', green.output),
|
|
2197
|
+
},
|
|
2198
|
+
}, identity, true, worktree));
|
|
2199
|
+
const events = [redEvent, greenEvent];
|
|
2200
|
+
const lines = [
|
|
2201
|
+
`replayed ${dimension} ${record.reference} scenario ${args.scenario ?? 'scenario'}`,
|
|
2202
|
+
`red exit ${red.code}, green exit ${green.code}`,
|
|
2203
|
+
];
|
|
2204
|
+
if (red.code === 0) {
|
|
2205
|
+
lines.push('warn: the test passed without the production change, so it discriminates nothing');
|
|
2206
|
+
}
|
|
2207
|
+
lines.push(`events: ${events.length} appended and stamped by the gate`);
|
|
2208
|
+
return accept(lines.join('\n'), { document: { kind: 'replay', dimension, events } });
|
|
2209
|
+
} finally {
|
|
2210
|
+
git(['worktree', 'remove', '--force', worktree], context.repoRoot);
|
|
2211
|
+
rmSync(isolated, { recursive: true, force: true });
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function writeOutput(context, scenario, phase, output) {
|
|
2216
|
+
const relative = join('replays', `${scenario ?? 'scenario'}-${phase}.txt`);
|
|
2217
|
+
writeFileSync(ensure(join(context.paths.root, relative)), output);
|
|
2218
|
+
return relative;
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
function ensure(path) {
|
|
2222
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2223
|
+
return path;
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
function place(context, worktree, plans) {
|
|
2227
|
+
for (const plan of plans) {
|
|
2228
|
+
const source = join(context.repoRoot, plan.path);
|
|
2229
|
+
if (!existsSync(source)) continue;
|
|
2230
|
+
writeFileSync(ensure(join(worktree, plan.path)), readFileSync(source));
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
// Variables a caller's own runner uses to talk to its children. Inherited, they change the reporter
|
|
2235
|
+
// and the exit code of the scenario, so a replay run from inside a test suite would read green where
|
|
2236
|
+
// a shell reads red.
|
|
2237
|
+
const RUNNER_PRIVATE = ['NODE_TEST_CONTEXT', 'NODE_V8_COVERAGE', 'VITEST', 'VITEST_WORKER_ID', 'JEST_WORKER_ID'];
|
|
2238
|
+
|
|
2239
|
+
function run(command, cwd) {
|
|
2240
|
+
const env = { ...process.env };
|
|
2241
|
+
for (const name of RUNNER_PRIVATE) delete env[name];
|
|
2242
|
+
const result = spawnSync(command, { cwd, env, shell: true, encoding: 'utf8', timeout: 10 * 60 * 1000 });
|
|
2243
|
+
return {
|
|
2244
|
+
code: result.status ?? 1,
|
|
2245
|
+
output: `$ ${command}\n${result.stdout ?? ''}${result.stderr ?? ''}`,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
function firstFailure(output) {
|
|
2250
|
+
const line = output.split('\n').find(one => /fail|error|expected|assert/i.test(one));
|
|
2251
|
+
return (line ?? 'the run failed').trim().slice(0, 300);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// packages/ai-engineering-gate/src/hookio.mjs
|
|
2255
|
+
function readStdin() {
|
|
2256
|
+
try {
|
|
2257
|
+
return readFileSync(0, 'utf8');
|
|
2258
|
+
} catch {
|
|
2259
|
+
return '';
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
function readHookEvent() {
|
|
2264
|
+
const raw = readStdin().trim();
|
|
2265
|
+
if (!raw) return {};
|
|
2266
|
+
try {
|
|
2267
|
+
return JSON.parse(raw);
|
|
2268
|
+
} catch {
|
|
2269
|
+
return {};
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
function hookAgent(event) {
|
|
2274
|
+
// Codex delivers a shell command at the top level of `beforeShellExecution`, with no tool name, so
|
|
2275
|
+
// the two harnesses converge here rather than in every caller.
|
|
2276
|
+
const shellEvent = typeof event.command === 'string' && !event.tool_name && !event.toolName;
|
|
2277
|
+
return {
|
|
2278
|
+
session: event.session_id ?? event.sessionId ?? event.conversation_id ?? null,
|
|
2279
|
+
agent: event.agent_id ?? event.agentId ?? null,
|
|
2280
|
+
agentType: event.agent_type ?? event.agentType ?? event.subagent_type ?? event.subagentType ?? null,
|
|
2281
|
+
toolUseId: event.tool_use_id ?? event.toolUseId ?? event.call_id ?? null,
|
|
2282
|
+
toolName: event.tool_name ?? event.toolName ?? event.tool ?? (shellEvent ? 'shell' : null),
|
|
2283
|
+
toolInput: event.tool_input ?? event.toolInput ?? (shellEvent ? { command: event.command } : {}),
|
|
2284
|
+
reentrant: event.stop_hook_active === true || event.stopHookActive === true,
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
const claudeCode = {
|
|
2289
|
+
sessionContext: text => ({
|
|
2290
|
+
hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text },
|
|
2291
|
+
}),
|
|
2292
|
+
toolDeny: reason => ({
|
|
2293
|
+
hookSpecificOutput: {
|
|
2294
|
+
hookEventName: 'PreToolUse',
|
|
2295
|
+
permissionDecision: 'deny',
|
|
2296
|
+
permissionDecisionReason: reason,
|
|
2297
|
+
},
|
|
2298
|
+
}),
|
|
2299
|
+
stopBlock: reason => ({ decision: 'block', reason }),
|
|
2300
|
+
};
|
|
2301
|
+
|
|
2302
|
+
// Codex shapes, from its documented output cheat sheet: a permission decision for `preToolUse`,
|
|
2303
|
+
// `beforeShellExecution` and `subagentStart`, and a follow-up message for the `stop` and
|
|
2304
|
+
// `subagentStop` loops. Codex documents no output field that injects context at `sessionStart`, so the
|
|
2305
|
+
// gate answers nothing there and the installer says so rather than pretending the status was delivered.
|
|
2306
|
+
const codex = {
|
|
2307
|
+
sessionContext: () => null,
|
|
2308
|
+
toolDeny: reason => ({ permission: 'deny', agent_message: reason, user_message: reason }),
|
|
2309
|
+
stopBlock: reason => ({ followup_message: reason }),
|
|
2310
|
+
};
|
|
2311
|
+
|
|
2312
|
+
const SHAPES = { 'claude-code': claudeCode, codex };
|
|
2313
|
+
|
|
2314
|
+
const shapeOf = harness => SHAPES[harness] ?? claudeCode;
|
|
2315
|
+
|
|
2316
|
+
const sessionContext = (text, harness) => shapeOf(harness).sessionContext(text);
|
|
2317
|
+
const toolDeny = (reason, harness) => shapeOf(harness).toolDeny(reason);
|
|
2318
|
+
const stopBlock = (reason, harness) => shapeOf(harness).stopBlock(reason);
|
|
2319
|
+
|
|
2320
|
+
// packages/ai-engineering-gate/src/render.mjs
|
|
2321
|
+
// AXI shape: a useful no-argument output, a compact aggregate by default, reasons on request and one
|
|
2322
|
+
// `next` line per thing to do. Detail is behind `--full`, automation behind `--json`.
|
|
2323
|
+
function renderTable(rows, headers) {
|
|
2324
|
+
if (rows.length === 0) return '';
|
|
2325
|
+
const widths = headers.map((header, column) => Math.max(
|
|
2326
|
+
header.length,
|
|
2327
|
+
...rows.map(row => String(row[column] ?? '').length),
|
|
2328
|
+
));
|
|
2329
|
+
const line = cells => cells.map((cell, column) => String(cell ?? '').padEnd(widths[column])).join(' ').trimEnd();
|
|
2330
|
+
return [line(headers), ...rows.map(line)].join('\n');
|
|
2331
|
+
}
|
|
2332
|
+
|
|
2333
|
+
function renderStatus(context, view, { full = false } = {}) {
|
|
2334
|
+
const lines = [];
|
|
2335
|
+
lines.push(`task ${context.task} round ${view.rounds.round ?? 0} gate ${context.version}`);
|
|
2336
|
+
lines.push(renderTable(
|
|
2337
|
+
view.states.map(state => [state.dimension, state.state, describe(state)]),
|
|
2338
|
+
['dimension', 'state', 'why'],
|
|
2339
|
+
));
|
|
2340
|
+
const warnings = [...new Set(view.states.flatMap(state => state.warnings))];
|
|
2341
|
+
if (warnings.length > 0) lines.push(`warn: ${warnings.join(', ')}`);
|
|
2342
|
+
for (const next of view.completion.next) lines.push(`next: ${next}`);
|
|
2343
|
+
if (view.completion.complete) lines.push('next: nothing; every registered dimension is complete');
|
|
2344
|
+
|
|
2345
|
+
if (!full) {
|
|
2346
|
+
lines.push(`help: ${context.gateCommand} status --full`);
|
|
2347
|
+
return lines.filter(Boolean).join('\n');
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
for (const state of view.states) {
|
|
2351
|
+
lines.push('');
|
|
2352
|
+
lines.push(`## ${state.dimension} - ${state.state}`);
|
|
2353
|
+
if (state.declaration) {
|
|
2354
|
+
lines.push(`applicability: ${state.declaration.applicability} - ${state.declaration.reason}`);
|
|
2355
|
+
if (state.declaration.scope) lines.push(`scope: ${state.declaration.scope.paths.join(', ')}`);
|
|
2356
|
+
}
|
|
2357
|
+
if (state.records) lines.push(`records: ${state.recordRefs.join(', ')}`);
|
|
2358
|
+
if (state.missingEvidence?.length) lines.push(`missing evidence: ${state.missingEvidence.join(', ')}`);
|
|
2359
|
+
if (state.openFindings?.length) lines.push(`open findings: ${state.openFindings.join(', ')}`);
|
|
2360
|
+
if (state.fingerprints) {
|
|
2361
|
+
lines.push(`fingerprints: source ${short(state.fingerprints.source)} reference ${short(state.fingerprints.reference)} decision ${short(state.fingerprints.decision)}`);
|
|
2362
|
+
}
|
|
2363
|
+
if (state.releasedWindows) lines.push(`released windows: ${state.releasedWindows}`);
|
|
2364
|
+
for (const code of state.codes) lines.push(`code: ${code}`);
|
|
2365
|
+
for (const warning of state.warnings) lines.push(`warn: ${warning}`);
|
|
2366
|
+
}
|
|
2367
|
+
if (view.undeclaredChanges.length > 0) {
|
|
2368
|
+
lines.push('');
|
|
2369
|
+
lines.push('## undeclared changes');
|
|
2370
|
+
for (const path of view.undeclaredChanges) lines.push(`- ${path}`);
|
|
2371
|
+
}
|
|
2372
|
+
if (view.plan?.length) {
|
|
2373
|
+
lines.push('');
|
|
2374
|
+
lines.push('## dispatch plan');
|
|
2375
|
+
lines.push('Launch every reviewer below in one turn, then wait for all filings.');
|
|
2376
|
+
for (const entry of view.plan) {
|
|
2377
|
+
lines.push('');
|
|
2378
|
+
lines.push(`### ${entry.dimension} - agent ${entry.agent}`);
|
|
2379
|
+
lines.push(`state: source ${short(entry.fingerprints.source)} reference ${short(entry.fingerprints.reference)} decision ${short(entry.fingerprints.decision)}`);
|
|
2380
|
+
lines.push(entry.brief);
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
return lines.filter(line => line !== null).join('\n');
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
const short = digest => (digest ?? '').replace('sha256:', '').slice(0, 12);
|
|
2387
|
+
|
|
2388
|
+
function describe(state) {
|
|
2389
|
+
if (state.state === 'non-applicable') return state.declaration?.reason ?? '';
|
|
2390
|
+
if (state.codes.length > 0) return state.codes.join(', ');
|
|
2391
|
+
return 'complete';
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
function statusJson(context, view, extra = {}) {
|
|
2395
|
+
return {
|
|
2396
|
+
outputVersion: '1.0.0',
|
|
2397
|
+
command: extra.command ?? 'status',
|
|
2398
|
+
gateVersion: context.version,
|
|
2399
|
+
marked: true,
|
|
2400
|
+
ok: extra.ok ?? true,
|
|
2401
|
+
task: context.task,
|
|
2402
|
+
repository: context.repoRoot,
|
|
2403
|
+
evidenceRoot: context.evidenceRoot,
|
|
2404
|
+
gateCommand: context.gateCommand,
|
|
2405
|
+
round: view.rounds.round ?? 0,
|
|
2406
|
+
dimensions: view.states.map(state => ({
|
|
2407
|
+
dimension: state.dimension,
|
|
2408
|
+
state: state.state,
|
|
2409
|
+
...(state.applicability ? { applicability: state.applicability } : {}),
|
|
2410
|
+
records: state.records,
|
|
2411
|
+
codes: state.codes,
|
|
2412
|
+
warnings: state.warnings,
|
|
2413
|
+
missingEvidence: state.missingEvidence,
|
|
2414
|
+
openFindings: state.openFindings,
|
|
2415
|
+
...(state.fingerprints ? { fingerprints: state.fingerprints } : {}),
|
|
2416
|
+
})),
|
|
2417
|
+
completion: view.completion,
|
|
2418
|
+
warnings: [...new Set(view.states.flatMap(state => state.warnings))],
|
|
2419
|
+
undeclaredChanges: view.undeclaredChanges,
|
|
2420
|
+
...(view.plan?.length ? { plan: view.plan } : {}),
|
|
2421
|
+
...extra.payload,
|
|
2422
|
+
};
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
function silentJson(context, command) {
|
|
2426
|
+
return {
|
|
2427
|
+
outputVersion: '1.0.0',
|
|
2428
|
+
command,
|
|
2429
|
+
gateVersion: context.version,
|
|
2430
|
+
marked: false,
|
|
2431
|
+
ok: true,
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
// The gate validates its own JSON against the published output schema, so a drift in the machine
|
|
2436
|
+
// surface fails here rather than in a driver.
|
|
2437
|
+
function assertOutputShape(context, payload) {
|
|
2438
|
+
const errors = schemaErrors(context.registry.schemas['gate-output'], payload);
|
|
2439
|
+
if (errors.length > 0) throw new Error(`gate output does not match its schema: ${errors.join('; ')}`);
|
|
2440
|
+
return payload;
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// packages/ai-engineering-gate/src/main.mjs
|
|
2444
|
+
const DOCUMENT_VERBS = new Set([
|
|
2445
|
+
'declare', 'record', 'evidence', 'begin', 'attest', 'report', 'dispute', 'arbitrate', 'replay', 'release',
|
|
2446
|
+
]);
|
|
2447
|
+
const WRITING_COMMANDS = new Set(['declare', 'record', 'evidence append', 'dispute', 'arbitrate']);
|
|
2448
|
+
const EDIT_TOOLS = /^(edit|write|multiedit|multi_edit|notebookedit|notebook_edit|apply_patch|str_replace|create_file)$/i;
|
|
2449
|
+
const SHELL_TOOLS = /^(bash|shell|run_terminal_cmd|terminal|exec)$/i;
|
|
2450
|
+
const DISPATCH_TOOLS = /^(task|agent|dispatch_agent|subagent)$/i;
|
|
2451
|
+
|
|
2452
|
+
const HELP = `ai-engineering-gate - generic enforcement for the transpose/review suite
|
|
2453
|
+
|
|
2454
|
+
usage: ai-engineering-gate [command] [options]
|
|
2455
|
+
|
|
2456
|
+
With no command it prints the compact status of every registered dimension for this task.
|
|
2457
|
+
In a repository that carries no \`.ai-engineering-suite.json\` marker it prints nothing and allows.
|
|
2458
|
+
|
|
2459
|
+
commands
|
|
2460
|
+
status compact state of every registered dimension, plus the next action
|
|
2461
|
+
declare --dimension D --stdin file a declaration before any record
|
|
2462
|
+
record --dimension D --stdin file a decision record; refuses a dangling citation
|
|
2463
|
+
evidence append --dimension D --stdin append a journal event, a check output or a snapshot
|
|
2464
|
+
replay --dimension D --record R --scenario S --command C the gate runs and stamps a red/green pair
|
|
2465
|
+
begin --dimension D open a review window and freeze the state it binds to
|
|
2466
|
+
attest --dimension D --stdin file a SOUND review envelope
|
|
2467
|
+
report --dimension D --stdin file a SMELLS or VIOLATIONS review envelope
|
|
2468
|
+
dispute --dimension D --stdin contest one finding with a pointer to counter-evidence
|
|
2469
|
+
arbitrate --dimension D --stdin the user's decision on a dispute; refused from an agent call
|
|
2470
|
+
release --dimension D abandon an open review window
|
|
2471
|
+
export copy the task's documents into the marker's export directory
|
|
2472
|
+
can-write --path P ... may this path be edited now
|
|
2473
|
+
can-review --dimension D may a fresh review of this dimension start now
|
|
2474
|
+
can-stop is the task complete; the exit code is the publication lock
|
|
2475
|
+
fingerprint [--dimension D] the three fingerprints, as the gate computes them
|
|
2476
|
+
|
|
2477
|
+
options
|
|
2478
|
+
--json stable machine output, versioned and schema-checked
|
|
2479
|
+
--full every reason, the missing evidence and the dispatch plan
|
|
2480
|
+
--hook read the harness event on stdin and answer in its own shape
|
|
2481
|
+
--harness NAME the harness the hook wiring runs under
|
|
2482
|
+
--task KEY the task key, for a pipeline or a detached head
|
|
2483
|
+
--evidence-root PATH where the evidence lives; also the export directory in CI
|
|
2484
|
+
--base REF the comparison base, for CI verification
|
|
2485
|
+
--from-export verify exported evidence: gate version and the marker on the base
|
|
2486
|
+
|
|
2487
|
+
exit codes
|
|
2488
|
+
0 allowed or valid 2 denied by policy 1 gate or infrastructure failure`;
|
|
2489
|
+
|
|
2490
|
+
function parseArguments(argv) {
|
|
2491
|
+
const args = { paths: [], command: null };
|
|
2492
|
+
const positional = [];
|
|
2493
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
2494
|
+
const token = argv[index];
|
|
2495
|
+
const next = () => argv[index += 1];
|
|
2496
|
+
if (!token.startsWith('--')) {
|
|
2497
|
+
positional.push(token);
|
|
2498
|
+
continue;
|
|
2499
|
+
}
|
|
2500
|
+
switch (token) {
|
|
2501
|
+
case '--json': args.json = true; break;
|
|
2502
|
+
case '--full': args.full = true; break;
|
|
2503
|
+
case '--hook': args.hook = true; break;
|
|
2504
|
+
case '--stdin': args.stdin = true; break;
|
|
2505
|
+
case '--from-export': args.fromExport = true; break;
|
|
2506
|
+
case '--help': args.help = true; break;
|
|
2507
|
+
case '--version': args.version = true; break;
|
|
2508
|
+
case '--dimension': args.dimension = next(); break;
|
|
2509
|
+
case '--harness': args.harness = next(); break;
|
|
2510
|
+
case '--task': args.task = next(); break;
|
|
2511
|
+
case '--evidence-root': args.evidenceRoot = next(); break;
|
|
2512
|
+
case '--record': args.record = next(); break;
|
|
2513
|
+
case '--scenario': args.scenario = next(); break;
|
|
2514
|
+
case '--base': args.base = next(); break;
|
|
2515
|
+
case '--path': args.paths.push(next()); break;
|
|
2516
|
+
case '--command': args.command = next(); break;
|
|
2517
|
+
default: throw new Error(`unknown option ${token}; run --help`);
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
args.name = positional.length === 0
|
|
2521
|
+
? 'status'
|
|
2522
|
+
: (positional[0] === 'evidence' ? positional.slice(0, 2).join(' ') : positional[0]);
|
|
2523
|
+
args.positional = positional;
|
|
2524
|
+
return args;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
function documentRefusal(reason) {
|
|
2528
|
+
const error = new Error(reason);
|
|
2529
|
+
error.refusal = refuse('invalid-document', reason);
|
|
2530
|
+
throw error;
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
function readDocument(args) {
|
|
2534
|
+
const raw = readStdin();
|
|
2535
|
+
if (!raw.trim()) {
|
|
2536
|
+
documentRefusal(`${args.name} reads its document on stdin; pipe it and pass --stdin`);
|
|
2537
|
+
}
|
|
2538
|
+
try {
|
|
2539
|
+
return JSON.parse(raw);
|
|
2540
|
+
} catch (error) {
|
|
2541
|
+
documentRefusal(`the document on stdin is not JSON: ${error.message}`);
|
|
2542
|
+
}
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
function requireDimensionFlag(args) {
|
|
2546
|
+
if (!args.dimension) throw new Error(`${args.name} needs --dimension <identifier>`);
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
function runCommand(context, args) {
|
|
2550
|
+
switch (args.name) {
|
|
2551
|
+
case 'status': return commandStatus(context);
|
|
2552
|
+
case 'can-stop': {
|
|
2553
|
+
const result = commandCanStop(context);
|
|
2554
|
+
if (!args.fromExport) return result;
|
|
2555
|
+
return applyExportVerification(context, result, args.base);
|
|
2556
|
+
}
|
|
2557
|
+
case 'can-write': return commandCanWrite(context, args);
|
|
2558
|
+
case 'can-review': return commandCanReview(context, args);
|
|
2559
|
+
case 'fingerprint': return commandFingerprint(context, args);
|
|
2560
|
+
case 'declare': requireDimensionFlag(args); return commandDeclare(context, args, readDocument(args));
|
|
2561
|
+
case 'record': requireDimensionFlag(args); return commandRecord(context, args, readDocument(args));
|
|
2562
|
+
case 'evidence append': requireDimensionFlag(args); return commandEvidenceAppend(context, args, readDocument(args));
|
|
2563
|
+
case 'dispute': requireDimensionFlag(args); return commandDispute(context, args, readDocument(args));
|
|
2564
|
+
case 'arbitrate': requireDimensionFlag(args); return commandArbitrate(context, args, readDocument(args));
|
|
2565
|
+
case 'begin': requireDimensionFlag(args); return commandBegin(context, args);
|
|
2566
|
+
case 'attest': requireDimensionFlag(args); return commandAttest(context, args, readDocument(args));
|
|
2567
|
+
case 'report': requireDimensionFlag(args); return commandReport(context, args, readDocument(args));
|
|
2568
|
+
case 'release': return commandRelease(context, args);
|
|
2569
|
+
case 'replay': requireDimensionFlag(args); return commandReplay(context, args);
|
|
2570
|
+
case 'export': return commandExport(context);
|
|
2571
|
+
default: throw new Error(`unknown command ${args.name}; run --help`);
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function render(context, args, result) {
|
|
2576
|
+
if (args.json) {
|
|
2577
|
+
const view = result.view ?? suiteView(context);
|
|
2578
|
+
return JSON.stringify(assertOutputShape(context, statusJson(context, view, {
|
|
2579
|
+
command: args.name,
|
|
2580
|
+
ok: result.ok,
|
|
2581
|
+
payload: {
|
|
2582
|
+
...(result.code ? { refusal: { code: result.code, reason: result.reason, ...(result.cause ? { cause: result.cause } : {}), ...(result.details?.length ? { details: result.details } : {}) } } : {}),
|
|
2583
|
+
...(result.payload?.document ? { document: result.payload.document } : {}),
|
|
2584
|
+
},
|
|
2585
|
+
})), null, 2);
|
|
2586
|
+
}
|
|
2587
|
+
if (!result.ok) {
|
|
2588
|
+
const refusal = refusalText(result);
|
|
2589
|
+
return result.view ? `${refusal}\n\n${renderStatus(context, result.view, { full: args.full })}` : refusal;
|
|
2590
|
+
}
|
|
2591
|
+
if (result.text) {
|
|
2592
|
+
const warnings = [...new Set(result.payload?.warnings ?? [])];
|
|
2593
|
+
return [result.text, ...(warnings.length > 0 ? [`warn: ${warnings.join(', ')}`] : [])].join('\n');
|
|
2594
|
+
}
|
|
2595
|
+
return renderStatus(context, result.view, { full: args.full });
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
function refusalText(result) {
|
|
2599
|
+
const lines = [`refused: ${result.code}`, `reason: ${result.reason}`];
|
|
2600
|
+
if (result.cause) lines.push(`cause: ${result.cause}`);
|
|
2601
|
+
for (const detail of result.details ?? []) lines.push(`- ${detail}`);
|
|
2602
|
+
return lines.join('\n');
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// A hook answers on stdout in the event's own shape and exits 0. In a marked repository a failure of
|
|
2606
|
+
// the gate itself exits 2 with the failure as its reason, which both harnesses treat as blocking; a
|
|
2607
|
+
// failure is never an approval.
|
|
2608
|
+
function runHook(context, args) {
|
|
2609
|
+
const event = readHookEvent();
|
|
2610
|
+
const agent = hookAgent(event);
|
|
2611
|
+
const harness = context.harness;
|
|
2612
|
+
if (args.name === 'status') {
|
|
2613
|
+
const view = suiteView(context);
|
|
2614
|
+
const text = renderStatus(context, view, { full: false });
|
|
2615
|
+
const answer = sessionContext(`Engineering suite gate: ${context.gateCommand}\n${text}`, harness);
|
|
2616
|
+
return { ...(answer ? { stdout: JSON.stringify(answer) } : {}), exitCode: 0 };
|
|
2617
|
+
}
|
|
2618
|
+
if (args.name === 'can-write') {
|
|
2619
|
+
if (DISPATCH_TOOLS.test(agent.toolName ?? '')) return { exitCode: 0 };
|
|
2620
|
+
const paths = EDIT_TOOLS.test(agent.toolName ?? '') ? editedPaths(agent.toolInput) : [];
|
|
2621
|
+
const command = SHELL_TOOLS.test(agent.toolName ?? '') ? shellCommand(agent.toolInput) : null;
|
|
2622
|
+
if (command) noteHandoff(context, agent, command);
|
|
2623
|
+
if (paths.length === 0 && !command) return { exitCode: 0 };
|
|
2624
|
+
const result = commandCanWrite(context, { ...args, paths, command });
|
|
2625
|
+
if (result.ok) return { exitCode: 0 };
|
|
2626
|
+
return { stdout: JSON.stringify(toolDeny(refusalText(result), harness)), exitCode: 0 };
|
|
2627
|
+
}
|
|
2628
|
+
if (args.name === 'can-review') {
|
|
2629
|
+
const reviewer = reviewerFor(context, agent.toolInput);
|
|
2630
|
+
if (!reviewer) return { exitCode: 0 };
|
|
2631
|
+
const result = commandCanReview(context, { ...args, dimension: reviewer });
|
|
2632
|
+
if (result.ok) return { exitCode: 0 };
|
|
2633
|
+
return { stdout: JSON.stringify(toolDeny(refusalText(result), harness)), exitCode: 0 };
|
|
2634
|
+
}
|
|
2635
|
+
if (args.name === 'can-stop') {
|
|
2636
|
+
const result = commandCanStopHook(context, agent.reentrant);
|
|
2637
|
+
if (result.ok) return { exitCode: 0 };
|
|
2638
|
+
// Keep Stop compact. A full status leaks the reviewer brief into the builder conversation.
|
|
2639
|
+
return { stdout: JSON.stringify(stopBlock(renderStatus(context, result.view, { full: false }), harness)), exitCode: 0 };
|
|
2640
|
+
}
|
|
2641
|
+
if (args.name === 'fingerprint') {
|
|
2642
|
+
return { exitCode: 0 };
|
|
2643
|
+
}
|
|
2644
|
+
if (args.name === 'release') {
|
|
2645
|
+
const reviewer = reviewerFor(context, { subagent_type: agent.agentType }) ?? args.dimension;
|
|
2646
|
+
if (!reviewer) return { exitCode: 0 };
|
|
2647
|
+
commandRelease(context, { ...args, dimension: reviewer });
|
|
2648
|
+
return { exitCode: 0 };
|
|
2649
|
+
}
|
|
2650
|
+
throw new Error(`${args.name} has no hook form`);
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// The agent identifier and agent type exist only in hook input inside a subagent call, so the hook
|
|
2654
|
+
// records them for the gate command that call is about to run.
|
|
2655
|
+
function noteHandoff(context, agent, command) {
|
|
2656
|
+
if (!command.includes('ai-engineering-gate')) return;
|
|
2657
|
+
const verb = /ai-engineering-gate(?:\.mjs)?["']?\s+(\S+)/.exec(command)?.[1];
|
|
2658
|
+
if (!verb || !DOCUMENT_VERBS.has(verb)) return;
|
|
2659
|
+
const dimension = /--dimension\s+([A-Za-z0-9-]+)/.exec(command)?.[1] ?? null;
|
|
2660
|
+
recordHandoff(context, {
|
|
2661
|
+
session: agent.session,
|
|
2662
|
+
verb: verb === 'evidence' ? 'evidence' : verb,
|
|
2663
|
+
dimension,
|
|
2664
|
+
agent: agent.agent,
|
|
2665
|
+
agentType: agent.agentType,
|
|
2666
|
+
toolUseId: agent.toolUseId,
|
|
2667
|
+
command,
|
|
2668
|
+
});
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2671
|
+
function reviewerFor(context, toolInput) {
|
|
2672
|
+
const type = toolInput?.subagent_type ?? toolInput?.agent_type ?? toolInput?.agentType ?? null;
|
|
2673
|
+
if (!type) return null;
|
|
2674
|
+
const member = context.members.find(one => memberAgentType(one) === type);
|
|
2675
|
+
return member?.dimension ?? null;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
function main(argv) {
|
|
2679
|
+
let args;
|
|
2680
|
+
try {
|
|
2681
|
+
args = parseArguments(argv);
|
|
2682
|
+
} catch (error) {
|
|
2683
|
+
process.stderr.write(`${error.message}\n`);
|
|
2684
|
+
return 1;
|
|
2685
|
+
}
|
|
2686
|
+
if (args.help || args.name === 'help') {
|
|
2687
|
+
process.stdout.write(`${HELP}\n`);
|
|
2688
|
+
return 0;
|
|
2689
|
+
}
|
|
2690
|
+
let context;
|
|
2691
|
+
try {
|
|
2692
|
+
context = buildContext({
|
|
2693
|
+
harness: args.harness,
|
|
2694
|
+
task: args.task,
|
|
2695
|
+
evidenceRoot: args.evidenceRoot,
|
|
2696
|
+
fromExport: args.fromExport,
|
|
2697
|
+
});
|
|
2698
|
+
} catch (error) {
|
|
2699
|
+
process.stderr.write(`gate-failure: ${error.message}\n`);
|
|
2700
|
+
return args.hook ? 2 : 1;
|
|
2701
|
+
}
|
|
2702
|
+
if (args.version) {
|
|
2703
|
+
process.stdout.write(`${context.version}\n`);
|
|
2704
|
+
return 0;
|
|
2705
|
+
}
|
|
2706
|
+
// Silence outside a marked repository, even on failure, so installing the hooks once for every
|
|
2707
|
+
// project costs nothing elsewhere.
|
|
2708
|
+
if (!context.marked) {
|
|
2709
|
+
if (args.json) process.stdout.write(`${JSON.stringify(silentJson(context, args.name), null, 2)}\n`);
|
|
2710
|
+
return 0;
|
|
2711
|
+
}
|
|
2712
|
+
if (args.hook) {
|
|
2713
|
+
try {
|
|
2714
|
+
const answer = runHook(context, args);
|
|
2715
|
+
if (answer.stdout) process.stdout.write(`${answer.stdout}\n`);
|
|
2716
|
+
return answer.exitCode;
|
|
2717
|
+
} catch (error) {
|
|
2718
|
+
process.stderr.write(`gate-failure: ${error.message}\n`);
|
|
2719
|
+
return 2;
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
try {
|
|
2723
|
+
const result = runCommand(context, args);
|
|
2724
|
+
const text = render(context, args, result);
|
|
2725
|
+
if (text) process.stdout.write(`${text}\n`);
|
|
2726
|
+
return result.ok ? 0 : 2;
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
if (error.refusal) {
|
|
2729
|
+
const text = render(context, args, error.refusal);
|
|
2730
|
+
if (text) process.stdout.write(`${text}\n`);
|
|
2731
|
+
return 2;
|
|
2732
|
+
}
|
|
2733
|
+
if (args.json) {
|
|
2734
|
+
process.stdout.write(`${JSON.stringify({
|
|
2735
|
+
outputVersion: '1.0.0',
|
|
2736
|
+
command: args.name,
|
|
2737
|
+
gateVersion: context.version,
|
|
2738
|
+
marked: true,
|
|
2739
|
+
ok: false,
|
|
2740
|
+
completion: { complete: false, codes: ['gate-failure'] },
|
|
2741
|
+
error: error.message,
|
|
2742
|
+
}, null, 2)}\n`);
|
|
2743
|
+
} else {
|
|
2744
|
+
process.stderr.write(`gate-failure: ${error.message}\n`);
|
|
2745
|
+
}
|
|
2746
|
+
return 1;
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
|
|
2750
|
+
function invokedAsCli() {
|
|
2751
|
+
if (!process.argv[1]) return false;
|
|
2752
|
+
try {
|
|
2753
|
+
return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(resolve(process.argv[1]));
|
|
2754
|
+
} catch {
|
|
2755
|
+
return false;
|
|
2756
|
+
}
|
|
2757
|
+
}
|
|
2758
|
+
|
|
2759
|
+
if (invokedAsCli()) process.exitCode = main(process.argv.slice(2));
|
|
2760
|
+
|
|
2761
|
+
export { main };
|
|
2762
|
+
|