cadet-agent 0.53.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ /**
2
+ * Gate builder registry — the single source of truth for what may produce each
3
+ * gate's evidence.
4
+ *
5
+ * Why this exists: `GATES` declares the gate *names* and `TRANSITIONS` declares
6
+ * where each name is required, but nothing declared which command is allowed to
7
+ * produce the evidence. So `harness verify` treated any caller-supplied
8
+ * `--command` as an automated pass, and the gate name implied an evidence
9
+ * meaning that nothing checked. Reproduced against 0.55.0 on 2026-09-30:
10
+ *
11
+ * node bin/cli.mjs harness verify --gate codeReviewCompleted \
12
+ * --command "node -e \"process.exit(0)\"" --files src/a.mjs
13
+ *
14
+ * exited 0, wrote `source: "automated"` evidence, and flipped an agent-owned
15
+ * gate to true. An unrelated exit-zero command satisfied a semantic claim, and a
16
+ * made-up gate name was accepted the same way.
17
+ *
18
+ * This registry inverts that: the contract is a property of the gate, resolved
19
+ * before anything runs, and `harness verify` refuses a project command for every
20
+ * gate that does not declare one.
21
+ *
22
+ * Field notes — what is deliberately NOT declared here:
23
+ *
24
+ * - `phase` is not declared. `TRANSITIONS` already states where a gate is
25
+ * required, and a second copy would be free to drift from it. The registry
26
+ * test asserts instead that every registered gate is required somewhere, so
27
+ * a new name cannot arrive with no transition to use it.
28
+ * - `exceptionEligible` is not declared. `EXCEPTION_CATEGORIES` in policy.mjs
29
+ * owns that taxonomy, including per-category expiry.
30
+ *
31
+ * `manual` records whether a human assertion is a meaningful record for the gate
32
+ * — the fact the owner needs in order to tighten §5.5 of the product plan. PR 1
33
+ * does not enforce it: `harness confirm` behaves exactly as it did, because a
34
+ * consumer already holds manual records for `codeReviewCompleted` and
35
+ * `securityReviewPassed`, and refusing them here would break a closure that is
36
+ * otherwise valid. Enforcement belongs with the manual-evidence policy in the
37
+ * next change, and the registry is where that change reads the intent from.
38
+ * Note that `MANUAL_ONLY_GATES` in policy.mjs comments that an agent-owned gate
39
+ * "can never be satisfied by a human assertion" — that is asserted nowhere in
40
+ * the code, `harness confirm` accepts `codeReviewCompleted`, and the registry
41
+ * records the enforced behaviour, not the comment.
42
+ *
43
+ * Contract: docs/core/HarnessContract.md C14.
44
+ */
45
+
46
+ import { GATES, REACHABILITY_GATE, MANUAL_ONLY_GATES } from './policy.mjs';
47
+
48
+ /**
49
+ * Who owns the evidence for a gate.
50
+ *
51
+ * `automated` — a command produces it.
52
+ * `agent` — a reviewer records a judgement (a review, a reconciliation). No
53
+ * command can produce it, and either an agent or a human may make
54
+ * the record: the framework does not force a judgement onto an
55
+ * agent. This is the class `MANUAL_ONLY_GATES` lists in policy.mjs.
56
+ * `human` — a person accepts the work, and no reviewer's record can stand in
57
+ * for them. No gate is human-owned yet; the class exists because
58
+ * `humanAcceptanceConfirmed` is planned as its first member.
59
+ */
60
+ export const GATE_OWNERS = Object.freeze(['automated', 'agent', 'human']);
61
+
62
+ /**
63
+ * Every gate's evidence contract, keyed by gate name.
64
+ *
65
+ * `contract` is a version for the gate's evidence RULES, not for the gate's
66
+ * meaning. Bump it when what the builder accepts or binds changes, so evidence
67
+ * recorded against the earlier rules is refused as stale rather than silently
68
+ * re-read as if it still meant the same thing (see `evidenceFreshness`).
69
+ *
70
+ * `projectCommand` is the load-bearing field: it decides whether a caller may
71
+ * supply `--command` at all. It is true only for the gates whose evidence IS a
72
+ * project command run — the repository owns its test, compile, analyzer and
73
+ * tracking scripts, so Cadet cannot know the right command and the project must
74
+ * be able to name it.
75
+ */
76
+ export const GATE_BUILDERS = Object.freeze({
77
+ testsPassed: {
78
+ owner: 'automated',
79
+ contract: 1,
80
+ automatedPath: 'harness verify --gate testsPassed',
81
+ command: { policyKey: 'testCommand', fallback: 'npm test', tool: 'test' },
82
+ projectCommand: true,
83
+ manual: false,
84
+ binds: 'files',
85
+ attests: 'the project test suite ran on the bound files and passed',
86
+ },
87
+ compileCheckConfirmed: {
88
+ owner: 'automated',
89
+ contract: 1,
90
+ automatedPath: 'harness verify --gate compileCheckConfirmed',
91
+ command: { policyKey: 'compileCommand', fallback: null, tool: 'unity-build', unityBuild: true },
92
+ projectCommand: true,
93
+ manual: true,
94
+ binds: 'files',
95
+ attests: 'the project compiled from the bound files',
96
+ },
97
+ unityAnalyzerClean: {
98
+ owner: 'automated',
99
+ contract: 1,
100
+ automatedPath: 'harness verify --gate unityAnalyzerClean',
101
+ command: { policyKey: 'analyzerCommand', fallback: null, tool: 'unity-analyzer' },
102
+ projectCommand: true,
103
+ manual: true,
104
+ binds: 'files',
105
+ attests: 'the declared analyzer ran over the bound files and reported no finding',
106
+ },
107
+ storyTrackingUpdated: {
108
+ owner: 'automated',
109
+ contract: 1,
110
+ automatedPath: 'harness verify --gate storyTrackingUpdated',
111
+ // No default command: the tracking check reads the repository's own story
112
+ // and epic markdown, so only the repository can name it. `--command` is the
113
+ // documented path, which is why this gate accepts a project command while
114
+ // having no fallback of its own.
115
+ command: { policyKey: null, fallback: null, tool: 'tracking' },
116
+ projectCommand: true,
117
+ manual: true,
118
+ binds: 'files',
119
+ attests: 'the story and epic markdown agree with the work item state',
120
+ },
121
+ acceptanceCriteriaValidated: {
122
+ owner: 'automated',
123
+ contract: 1,
124
+ automatedPath: 'harness verify-acs --story <path>',
125
+ command: null,
126
+ projectCommand: false,
127
+ manual: false,
128
+ binds: 'story',
129
+ attests: 'every declared acceptance criterion names a test that ran in the passing inventory',
130
+ },
131
+ reachabilityAddressed: {
132
+ owner: 'automated',
133
+ contract: 1,
134
+ automatedPath: `harness verify-reachability --story <path>`,
135
+ command: null,
136
+ projectCommand: false,
137
+ manual: false,
138
+ binds: 'story',
139
+ attests: 'the story declares how its deliverable is reached, and the declaration holds',
140
+ },
141
+ architectureFitnessPassed: {
142
+ owner: 'automated',
143
+ contract: 1,
144
+ // The command is the project's own declared check, read out of `architectureFitness`
145
+ // in the policy — never `--command`. That is the difference between this gate and
146
+ // `testsPassed`: a project may substitute its test runner, but it may not substitute
147
+ // the *shape* of an architecture constraint with an unrelated exit-zero command.
148
+ automatedPath: 'cadet-agent harness verify-architecture',
149
+ command: null,
150
+ projectCommand: false,
151
+ manual: false,
152
+ binds: 'files',
153
+ attests: 'every required architecture check that governs the changed files ran, and none of them reported a violation',
154
+ },
155
+ designReviewCompleted: {
156
+ owner: 'automated',
157
+ contract: 1,
158
+ automatedPath: 'harness verify-design-review --artifact <path> --files <inputs>',
159
+ // No default command and no project override: the check reads the review
160
+ // artifact against the design inputs, which an arbitrary exit-zero command
161
+ // cannot attest. Like the AC gate, its evidence is an artifact plus the
162
+ // structure the artifact must have.
163
+ command: null,
164
+ projectCommand: false,
165
+ // A review is a judgement, and a judgement belongs to whoever made it — an
166
+ // agent or a person. The command is the stronger route (it checks the artifact
167
+ // and binds the inputs it names), and it is not the only one: a human who read
168
+ // the design records it with `harness confirm`, which still requires a reason,
169
+ // a scope, an environment and a short expiry under strict closure.
170
+ manual: true,
171
+ binds: 'files',
172
+ attests: 'a reviewer challenged the design, recorded findings with dispositions, and left no contested finding unresolved',
173
+ },
174
+ humanAcceptanceConfirmed: {
175
+ owner: 'human',
176
+ contract: 1,
177
+ // No automated path at all, and no project override: the gate asks whether a
178
+ // PERSON accepted the work. A command cannot answer it, and neither can a
179
+ // reviewer's record — that is the difference between this gate and
180
+ // `codeReviewCompleted`, which is why the registry distinguishes `agent` from
181
+ // `human` rather than lumping both under "manual".
182
+ automatedPath: null,
183
+ command: null,
184
+ projectCommand: false,
185
+ manual: true,
186
+ binds: 'files',
187
+ attests: 'a named person accepted the delivered work against a stated witness, with the limitations they accepted',
188
+ },
189
+ codeReviewCompleted: {
190
+ owner: 'agent',
191
+ contract: 1,
192
+ automatedPath: null,
193
+ command: null,
194
+ projectCommand: false,
195
+ manual: true,
196
+ binds: 'files',
197
+ attests: 'a reviewer read the change and recorded findings and dispositions',
198
+ },
199
+ securityReviewPassed: {
200
+ owner: 'agent',
201
+ contract: 1,
202
+ automatedPath: null,
203
+ command: null,
204
+ projectCommand: false,
205
+ manual: true,
206
+ binds: 'files',
207
+ attests: 'the security review ran and its findings were dispositioned',
208
+ },
209
+ designArtifactSyncConfirmed: {
210
+ owner: 'agent',
211
+ contract: 1,
212
+ automatedPath: null,
213
+ // Read-only, and it records no gate: `harness reconcile` reports the
214
+ // mechanical findings, and the reconciliation skill makes the semantic pass
215
+ // and records the result. Named here so the refusal can point at it.
216
+ supportingCommand: 'harness reconcile --format json',
217
+ command: null,
218
+ projectCommand: false,
219
+ manual: true,
220
+ binds: 'files',
221
+ attests: 'the planning artifacts still agree with each other and with the work item state',
222
+ },
223
+ });
224
+
225
+ /** The contract entry for a gate, or undefined when the gate is not registered. */
226
+ export function gateBuilder(gate) {
227
+ return Object.prototype.hasOwnProperty.call(GATE_BUILDERS, gate) ? GATE_BUILDERS[gate] : undefined;
228
+ }
229
+
230
+ /**
231
+ * The identity of a gate's current evidence contract, e.g. `testsPassed@1`.
232
+ *
233
+ * Stamped into automated evidence and compared at transition time, so a change
234
+ * to the builder's rules refuses the records written under the old rules instead
235
+ * of reading them as if nothing had changed.
236
+ */
237
+ export function gateContractId(gate) {
238
+ const builder = gateBuilder(gate);
239
+ return builder ? `${gate}@${builder.contract}` : null;
240
+ }
241
+
242
+ /** Gates a caller may satisfy with its own `--command`. */
243
+ export function gatesAcceptingProjectCommand() {
244
+ return GATES.filter((gate) => gateBuilder(gate)?.projectCommand === true);
245
+ }
246
+
247
+ /**
248
+ * Gates whose evidence is a record instead of a command — a reviewer's judgement or
249
+ * a person's acceptance. Named for that fact, because the two are different gates
250
+ * with the same route: see `MANUAL_ONLY_GATES` in policy.mjs.
251
+ */
252
+ export function manualOnlyGateNames() {
253
+ return GATES.filter((gate) => gateBuilder(gate)?.owner !== 'automated');
254
+ }
255
+
256
+ /** @deprecated Use `manualOnlyGateNames`; kept only long enough to notice a straggler. */
257
+ export const agentOwnedGateNames = manualOnlyGateNames;
258
+
259
+ /**
260
+ * One sentence explaining why a gate refuses a caller-supplied command, naming
261
+ * the path that does work. Shared by `harness verify` (which refuses) and the
262
+ * registry test (which asserts the sentence matches the contract), so the two
263
+ * cannot disagree about what a gate will accept.
264
+ */
265
+ export function describeGateRefusal(gate) {
266
+ const builder = gateBuilder(gate);
267
+ if (!builder) {
268
+ return `unknown gate "${gate}". Valid gates: ${GATES.join(', ')}`;
269
+ }
270
+ if (!GATE_OWNERS.includes(builder.owner)) {
271
+ return `gate "${gate}" declares an invalid owner "${builder.owner}".`;
272
+ }
273
+ if (builder.owner === 'human') {
274
+ return `gate "${gate}" is human-owned: a person accepts this work, and no command can do it in their place.`;
275
+ }
276
+ const head = builder.owner === 'agent'
277
+ ? `gate "${gate}" is agent-owned: an arbitrary command cannot attest ${builder.attests}`
278
+ : `gate "${gate}" has a fixed evidence contract: an arbitrary command cannot attest ${builder.attests}`;
279
+ const path = builder.automatedPath
280
+ ? ` Run "${builder.automatedPath}" instead.`
281
+ : (builder.supportingCommand ? ` Its mechanical backing is "${builder.supportingCommand}".` : '');
282
+ return `${head}.${path}`;
283
+ }
284
+
285
+ /**
286
+ * Explain why a gate that DOES accept a project command has none to run yet.
287
+ * Separate from `describeGateRefusal`, which forbids a command: this one asks for
288
+ * the missing input. A gate with no default (the tracking check) needs it, and
289
+ * the old message called that gate "agent-owned", which said the opposite of the
290
+ * truth and pointed at no remedy.
291
+ */
292
+ export function describeMissingCommand(gate) {
293
+ const builder = gateBuilder(gate);
294
+ if (!builder) return `unknown gate "${gate}". Valid gates: ${GATES.join(', ')}`;
295
+ const path = builder.automatedPath ? ` Example: "${builder.automatedPath} --command <your command>".` : '';
296
+ return `gate "${gate}" has no default command — its evidence is the repository's own command — so pass --command <command>.${path}`;
297
+ }
298
+
299
+ /**
300
+ * Assert the registry against the frozen gate list and the policy constants.
301
+ * Returns a list of problems (empty when the registry is coherent).
302
+ *
303
+ * Called by the registry test, and by nothing else in production: a check that
304
+ * runs at import time would turn a bad edit into a CLI that cannot start its own
305
+ * diagnostics.
306
+ */
307
+ export function auditGateRegistry() {
308
+ const problems = [];
309
+ for (const gate of GATES) {
310
+ const builder = gateBuilder(gate);
311
+ if (!builder) {
312
+ problems.push(`gate "${gate}" has no builder contract`);
313
+ continue;
314
+ }
315
+ if (!GATE_OWNERS.includes(builder.owner)) {
316
+ problems.push(`gate "${gate}" declares unknown owner "${builder.owner}"`);
317
+ }
318
+ if (!Number.isInteger(builder.contract) || builder.contract < 1) {
319
+ problems.push(`gate "${gate}" declares contract "${builder.contract}"; expected a positive integer`);
320
+ }
321
+ if (typeof builder.projectCommand !== 'boolean') {
322
+ problems.push(`gate "${gate}" must declare projectCommand as a boolean`);
323
+ }
324
+ if (builder.projectCommand && !builder.command) {
325
+ problems.push(`gate "${gate}" accepts a project command but declares no command contract`);
326
+ }
327
+ if (typeof builder.manual !== 'boolean') {
328
+ problems.push(`gate "${gate}" must declare manual as a boolean`);
329
+ }
330
+ if (!['files', 'story'].includes(builder.binds)) {
331
+ problems.push(`gate "${gate}" declares binds "${builder.binds}"; expected "files" or "story"`);
332
+ }
333
+ if (typeof builder.attests !== 'string' || builder.attests.trim() === '') {
334
+ problems.push(`gate "${gate}" declares no attests sentence`);
335
+ }
336
+ if (builder.owner === 'agent' && builder.projectCommand) {
337
+ problems.push(`gate "${gate}" is agent-owned and must not accept a project command`);
338
+ }
339
+ if (builder.owner === 'human' && (builder.projectCommand || builder.automatedPath)) {
340
+ problems.push(`gate "${gate}" is human-owned and must not declare an automated path`);
341
+ }
342
+ }
343
+ for (const name of Object.keys(GATE_BUILDERS)) {
344
+ if (!GATES.includes(name)) problems.push(`registry declares "${name}", which is not a known gate`);
345
+ }
346
+ // Both directions, because the two declarations must be one fact written twice:
347
+ // policy.mjs owns the `disallowManualFor` refusal, the registry owns the owner,
348
+ // and a drift would let one of them forbid a route the other needs.
349
+ for (const gate of GATES) {
350
+ const builder = gateBuilder(gate);
351
+ if (!builder) continue;
352
+ const listed = MANUAL_ONLY_GATES.includes(gate);
353
+ if (listed !== (builder.owner !== 'automated')) {
354
+ problems.push(
355
+ `gate "${gate}" is${listed ? '' : ' not'} listed in MANUAL_ONLY_GATES, but the registry declares owner "${builder.owner}"; `
356
+ + 'the two must agree',
357
+ );
358
+ }
359
+ }
360
+ if (gateBuilder(REACHABILITY_GATE)?.projectCommand !== false) {
361
+ problems.push(`gate "${REACHABILITY_GATE}" must not accept a project command`);
362
+ }
363
+ return problems;
364
+ }