cadet-agent 0.30.0 → 0.32.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/package.json +37 -37
- package/src/cli.mjs +336 -7
- package/src/harness/index.mjs +10 -3
- package/src/harness/policy.mjs +560 -359
- package/src/harness/state.mjs +919 -661
- package/src/harness/verification.mjs +523 -490
- package/src/harness/verify-acs.mjs +291 -0
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cadet-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"cadet-agent": "bin/cli.mjs"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"test": "node --test test/*.test.mjs",
|
|
11
|
-
"lint": "lychee --offline --include-fragments \"**/*.md\"",
|
|
12
|
-
"verify": "npm test && npm run lint"
|
|
13
|
-
},
|
|
14
|
-
"files": [
|
|
15
|
-
"bin/",
|
|
16
|
-
"src/"
|
|
17
|
-
],
|
|
18
|
-
"keywords": [
|
|
19
|
-
"cadet",
|
|
20
|
-
"cadet-agent",
|
|
21
|
-
"unity",
|
|
22
|
-
"game-development",
|
|
23
|
-
"ai-agent",
|
|
24
|
-
"copilot",
|
|
25
|
-
"cursor",
|
|
26
|
-
"claude-code"
|
|
27
|
-
],
|
|
28
|
-
"license": "CC-BY-4.0",
|
|
29
|
-
"repository": {
|
|
30
|
-
"type": "git",
|
|
31
|
-
"url": "git+https://github.com/naishtech/cadet-agent.git"
|
|
32
|
-
},
|
|
33
|
-
"homepage": "https://github.com/naishtech/cadet-agent#readme",
|
|
34
|
-
"engines": {
|
|
35
|
-
"node": ">=18.0.0"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cadet-agent",
|
|
3
|
+
"version": "0.32.0",
|
|
4
|
+
"description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cadet-agent": "bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test test/*.test.mjs",
|
|
11
|
+
"lint": "lychee --offline --include-fragments \"**/*.md\"",
|
|
12
|
+
"verify": "npm test && npm run lint"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"src/"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cadet",
|
|
20
|
+
"cadet-agent",
|
|
21
|
+
"unity",
|
|
22
|
+
"game-development",
|
|
23
|
+
"ai-agent",
|
|
24
|
+
"copilot",
|
|
25
|
+
"cursor",
|
|
26
|
+
"claude-code"
|
|
27
|
+
],
|
|
28
|
+
"license": "CC-BY-4.0",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/naishtech/cadet-agent.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/naishtech/cadet-agent#readme",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { install, sync } from './install.mjs';
|
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
|
|
7
7
|
workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
|
|
8
8
|
runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
|
|
9
|
-
detectRepoRole, describeRepoRole,
|
|
9
|
+
detectRepoRole, describeRepoRole, GATES, manualConfirmation,
|
|
10
|
+
parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
|
|
11
|
+
createEvidence, newId, computeInputTreeHash, hashCriteria,
|
|
10
12
|
} from './harness/index.mjs';
|
|
11
13
|
|
|
12
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -39,7 +41,9 @@ function showHelp() {
|
|
|
39
41
|
cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
|
|
40
42
|
|
|
41
43
|
cadet-agent harness record Append a sanitized span/evidence/decision event
|
|
44
|
+
cadet-agent harness confirm Record manual-confirmation evidence (writes ledger + state)
|
|
42
45
|
cadet-agent harness verify Run a bounded, classified verification loop
|
|
46
|
+
cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
|
|
43
47
|
cadet-agent harness report Summarize budget consumption and failures
|
|
44
48
|
cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
|
|
45
49
|
cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
|
|
@@ -49,9 +53,13 @@ function showHelp() {
|
|
|
49
53
|
--source Release API URL override (for forked deployments)
|
|
50
54
|
--format human|json (default: human)
|
|
51
55
|
--to Target phase (state transition)
|
|
52
|
-
--gate Gate name (harness verify)
|
|
56
|
+
--gate Gate name (harness verify|confirm)
|
|
53
57
|
--command Command override (harness verify)
|
|
54
|
-
--files Comma-separated relevant files to bind evidence to (harness verify)
|
|
58
|
+
--files Comma-separated relevant files to bind evidence to (harness verify|confirm)
|
|
59
|
+
--reason Why automation was unavailable (harness confirm)
|
|
60
|
+
--expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
|
|
61
|
+
--environment key=value,... describing what was verified (harness confirm)
|
|
62
|
+
--scope Comma-separated scope of the confirmation (harness confirm)
|
|
55
63
|
--agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
|
|
56
64
|
--yes, -y Never prompt; keep existing files (non-interactive installs)
|
|
57
65
|
--help, -h Show this help
|
|
@@ -77,8 +85,14 @@ function parseArgs(argv) {
|
|
|
77
85
|
case '--run': opts.runId = argv[++i]; break;
|
|
78
86
|
case '--type': opts.type = argv[++i]; break;
|
|
79
87
|
case '--reason': opts.reason = argv[++i]; break;
|
|
88
|
+
case '--expires-at': opts.expiresAt = argv[++i]; break;
|
|
89
|
+
case '--environment': opts.environment = argv[++i]; break;
|
|
90
|
+
case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
80
91
|
case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
|
|
81
92
|
case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
93
|
+
case '--story': opts.story = argv[++i]; break;
|
|
94
|
+
case '--report': opts.report = argv[++i]; break;
|
|
95
|
+
case '--write-coverage': opts.writeCoverage = true; break;
|
|
82
96
|
case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
|
|
83
97
|
case '--agents-md': opts.agentsMd = argv[++i]; break;
|
|
84
98
|
case '--yes': case '-y': opts.yes = true; break;
|
|
@@ -96,6 +110,28 @@ function emit(opts, human, json) {
|
|
|
96
110
|
}
|
|
97
111
|
}
|
|
98
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Parse `--environment "projectPath=...,editorVersion=...,tool=...,host=..."`
|
|
115
|
+
* into an object. Unknown keys are preserved: an unusual environment is still
|
|
116
|
+
* evidence, and silently dropping a field would misrepresent what was verified.
|
|
117
|
+
*/
|
|
118
|
+
function parseEnvironment(raw) {
|
|
119
|
+
const env = {};
|
|
120
|
+
if (!raw) return env;
|
|
121
|
+
for (const part of String(raw).split(',')) {
|
|
122
|
+
const eq = part.indexOf('=');
|
|
123
|
+
if (eq === -1) {
|
|
124
|
+
const key = part.trim();
|
|
125
|
+
if (key) env[key] = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const key = part.slice(0, eq).trim();
|
|
129
|
+
const value = part.slice(eq + 1).trim();
|
|
130
|
+
if (key) env[key] = value;
|
|
131
|
+
}
|
|
132
|
+
return env;
|
|
133
|
+
}
|
|
134
|
+
|
|
99
135
|
function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
|
|
100
136
|
const exitCode = code(json);
|
|
101
137
|
if (opts.format === 'json') {
|
|
@@ -127,8 +163,12 @@ async function cmdState(opts) {
|
|
|
127
163
|
);
|
|
128
164
|
return;
|
|
129
165
|
}
|
|
130
|
-
// Pass rootDir so stale/foreign evidence is caught at validation time
|
|
131
|
-
|
|
166
|
+
// Pass rootDir so stale/foreign evidence is caught at validation time, and
|
|
167
|
+
// the resolved policy so strict-closure rules are actually enforced. Without
|
|
168
|
+
// the policy, `strictClosure` was invisible here and every strict rule was
|
|
169
|
+
// silently skipped — the feature would "install cleanly and do nothing".
|
|
170
|
+
const policy = loadPolicy(opts.targetDir);
|
|
171
|
+
const result = validateState(state, { rootDir: opts.targetDir, strictClosure: policy.strictClosure });
|
|
132
172
|
const role = detectRepoRole(opts.targetDir);
|
|
133
173
|
const repoRoleDetail = describeRepoRole(role);
|
|
134
174
|
if (opts.format === 'json') {
|
|
@@ -212,6 +252,137 @@ async function cmdHarness(opts) {
|
|
|
212
252
|
return;
|
|
213
253
|
}
|
|
214
254
|
|
|
255
|
+
if (sub === 'confirm') {
|
|
256
|
+
const gate = opts.gate;
|
|
257
|
+
if (!gate) fail(opts, 'harness confirm requires --gate <gate>');
|
|
258
|
+
if (!GATES.includes(gate)) fail(opts, `unknown gate "${gate}". Valid gates: ${GATES.join(', ')}`);
|
|
259
|
+
|
|
260
|
+
const { exists, state } = readState(opts.targetDir);
|
|
261
|
+
if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
|
|
262
|
+
|
|
263
|
+
const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
|
|
264
|
+
const mc = strict?.manualConfirmation || null;
|
|
265
|
+
// One reference instant for the whole command, captured before any work.
|
|
266
|
+
// Reading Date.now() at the check instead made the validity boundary
|
|
267
|
+
// non-deterministic: process latency absorbed a small overage, so the same
|
|
268
|
+
// input could pass or fail run to run.
|
|
269
|
+
const requestedAt = new Date();
|
|
270
|
+
|
|
271
|
+
// Collect EVERY missing field so the caller fixes the record in one pass,
|
|
272
|
+
// rather than discovering one omission per invocation.
|
|
273
|
+
const missing = [];
|
|
274
|
+
if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
|
|
275
|
+
if (mc?.requireExpiresAt !== false && strict) {
|
|
276
|
+
if (!opts.expiresAt) missing.push('--expires-at');
|
|
277
|
+
else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
|
|
278
|
+
}
|
|
279
|
+
if (mc?.requireEnvironment !== false && strict && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
|
|
280
|
+
if (mc?.requireScope !== false && strict && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
|
|
281
|
+
if (missing.length) {
|
|
282
|
+
fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A gate listed in disallowManualFor may never be satisfied by a human
|
|
286
|
+
// assertion; point at the automated path instead of accepting the record.
|
|
287
|
+
if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
|
|
288
|
+
fail(opts, `manual-confirmation is not permitted for gate "${gate}" under strictClosure.disallowManualFor; run "cadet-agent harness verify --gate ${gate}" instead.`, () => 1, { ok: false, gate, code: 'manual-disallowed' });
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Bound the validity window: an expiry far in the future is how a manual
|
|
292
|
+
// assertion silently becomes permanent. Measured against `requestedAt`, the
|
|
293
|
+
// single instant captured at command start, so the boundary is deterministic
|
|
294
|
+
// and agrees with `validateState` (which anchors to the present too).
|
|
295
|
+
if (mc?.maxValidityMs !== null && mc?.maxValidityMs !== undefined && opts.expiresAt) {
|
|
296
|
+
const window = Date.parse(opts.expiresAt) - requestedAt.getTime();
|
|
297
|
+
if (Number.isFinite(window) && window > mc.maxValidityMs) {
|
|
298
|
+
fail(opts, `requested validity ${window}ms exceeds strictClosure.manualConfirmation.maxValidityMs (${mc.maxValidityMs}ms).`, () => 1, { ok: false, gate, code: 'validity-exceeded', requestedMs: window, maxValidityMs: mc.maxValidityMs });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Freshness binding mirrors `harness verify`: never record a gate against an
|
|
303
|
+
// unknown input tree unless the repository explicitly opted out.
|
|
304
|
+
const allowEmpty = policy?.allowEmptyFreshness === true;
|
|
305
|
+
let relevantFiles;
|
|
306
|
+
if (opts.files && opts.files.length) {
|
|
307
|
+
relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
|
|
308
|
+
} else {
|
|
309
|
+
const changed = gitChangedFiles(opts.targetDir);
|
|
310
|
+
if (!changed.available) {
|
|
311
|
+
if (!allowEmpty) {
|
|
312
|
+
fail(opts, `cannot establish freshness coverage: ${changed.reason}. Pass --files <paths>, or enable allowEmptyFreshness in .cadet/harness.json.`, () => 1, { ok: false, gate, code: 'freshness-unavailable' });
|
|
313
|
+
}
|
|
314
|
+
relevantFiles = [];
|
|
315
|
+
} else {
|
|
316
|
+
relevantFiles = changed.files;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
321
|
+
const phase = state?.session?.currentPhase || 'implementation';
|
|
322
|
+
// Reuse the command-start instant so the recorded createdAt and the validity
|
|
323
|
+
// check describe the same moment.
|
|
324
|
+
const at = requestedAt;
|
|
325
|
+
const environment = parseEnvironment(opts.environment);
|
|
326
|
+
|
|
327
|
+
const { evidence } = manualConfirmation({
|
|
328
|
+
gate,
|
|
329
|
+
workItemId,
|
|
330
|
+
phase,
|
|
331
|
+
projectPath: environment.projectPath || null,
|
|
332
|
+
editorVersion: environment.editorVersion || null,
|
|
333
|
+
scope: opts.scope || [],
|
|
334
|
+
reason: opts.reason || null,
|
|
335
|
+
expiresAt: opts.expiresAt || null,
|
|
336
|
+
environment,
|
|
337
|
+
relevantFiles,
|
|
338
|
+
rootDir: opts.targetDir,
|
|
339
|
+
approvedBy: opts.approvedBy || 'user',
|
|
340
|
+
at,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
// Ledger first, then state. The ledger is append-only and merely references
|
|
344
|
+
// the evidence id; state.json carries the gate claim. Writing state first
|
|
345
|
+
// would let an interruption leave a gate claimed true with no ledger entry.
|
|
346
|
+
// This order fails toward "less proven", never "claimed but unbacked".
|
|
347
|
+
const ledger = new RunLedger({
|
|
348
|
+
targetDir: opts.targetDir,
|
|
349
|
+
policy,
|
|
350
|
+
runId: state?.activeRunId || null,
|
|
351
|
+
workItemId,
|
|
352
|
+
phase,
|
|
353
|
+
});
|
|
354
|
+
ledger.addEvidence(evidence);
|
|
355
|
+
ledger.addDecision({
|
|
356
|
+
kind: 'manual-confirmation',
|
|
357
|
+
reason: opts.reason || 'manual confirmation recorded',
|
|
358
|
+
gate,
|
|
359
|
+
evidenceId: evidence.evidenceId,
|
|
360
|
+
approvedBy: evidence.approvedBy || 'user',
|
|
361
|
+
});
|
|
362
|
+
ledger.finalize({ status: 'ok' });
|
|
363
|
+
const ledgerPath = ledger.persist();
|
|
364
|
+
|
|
365
|
+
const next = { ...state };
|
|
366
|
+
const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
367
|
+
next.gateEvidence = [
|
|
368
|
+
// Immutability: supersede prior passing evidence, never delete it.
|
|
369
|
+
...prior.map((e) => (e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')
|
|
370
|
+
? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
|
|
371
|
+
: e)),
|
|
372
|
+
evidence,
|
|
373
|
+
];
|
|
374
|
+
next.gates = { ...(state.gates || {}), [gate]: true };
|
|
375
|
+
writeState(opts.targetDir, next);
|
|
376
|
+
|
|
377
|
+
const superseded = prior.filter((e) => e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')).length;
|
|
378
|
+
emit(
|
|
379
|
+
opts,
|
|
380
|
+
`✅ Recorded manual confirmation for gate "${gate}". Evidence: ${evidence.evidenceId}\n Ledger: ${ledgerPath}`,
|
|
381
|
+
{ ok: true, gate, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath, stateUpdated: true, superseded },
|
|
382
|
+
);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
|
|
215
386
|
if (sub === 'record') {
|
|
216
387
|
const { state } = readState(opts.targetDir);
|
|
217
388
|
const ledger = new RunLedger({
|
|
@@ -372,6 +543,164 @@ async function cmdHarness(opts) {
|
|
|
372
543
|
return;
|
|
373
544
|
}
|
|
374
545
|
|
|
546
|
+
if (sub === 'verify-acs') {
|
|
547
|
+
// Mechanical AC↔test verification (contract v4). Declared tests must appear
|
|
548
|
+
// in the inventory of a run that actually executed them; a name that was
|
|
549
|
+
// never written cannot be asserted into coverage.
|
|
550
|
+
if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
|
|
551
|
+
const { exists, state } = readState(opts.targetDir);
|
|
552
|
+
const strict = policy.strictClosure?.enabled === true;
|
|
553
|
+
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
554
|
+
const phase = state?.session?.currentPhase || 'implementation';
|
|
555
|
+
|
|
556
|
+
let criteria;
|
|
557
|
+
try {
|
|
558
|
+
({ criteria } = parseStoryCriteria(opts.story));
|
|
559
|
+
} catch (err) {
|
|
560
|
+
fail(opts, `cannot parse story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-parse', story: opts.story });
|
|
561
|
+
}
|
|
562
|
+
if (criteria.length === 0) {
|
|
563
|
+
fail(opts, `story "${opts.story}" declares no acceptance criteria (expected a "## Acceptance Criteria" section).`, () => 1, { ok: false, code: 'no-criteria', story: opts.story });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Resolve the inventory: an explicit --report, else the artifact of the most
|
|
567
|
+
// recent passing testsPassed evidence. Neither resolving is `blocked`, never
|
|
568
|
+
// a pass — an unproven inventory cannot satisfy coverage.
|
|
569
|
+
let reportText = null;
|
|
570
|
+
let reportSource = null;
|
|
571
|
+
let reportPath = null;
|
|
572
|
+
if (opts.report) {
|
|
573
|
+
try {
|
|
574
|
+
reportText = readFileSync(opts.report, 'utf-8');
|
|
575
|
+
reportSource = 'explicit';
|
|
576
|
+
reportPath = opts.report;
|
|
577
|
+
} catch (err) {
|
|
578
|
+
fail(opts, `cannot read --report "${opts.report}": ${err.message}`, () => 1, { ok: false, code: 'report-unreadable', report: opts.report });
|
|
579
|
+
}
|
|
580
|
+
} else if (exists) {
|
|
581
|
+
const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
582
|
+
const passing = prior
|
|
583
|
+
.filter((e) => e.gate === 'testsPassed' && e.status === 'passed' && e.artifactPath)
|
|
584
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
585
|
+
const newest = passing[0];
|
|
586
|
+
if (newest) {
|
|
587
|
+
try {
|
|
588
|
+
reportText = readFileSync(newest.artifactPath, 'utf-8');
|
|
589
|
+
reportSource = 'testsPassed-evidence';
|
|
590
|
+
reportPath = newest.artifactPath;
|
|
591
|
+
} catch { /* fall through to blocked */ }
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (reportText === null) {
|
|
595
|
+
const detail = { ok: false, story: opts.story, blocked: true, code: 'no-test-report', reason: 'no test report available: pass --report <path>, or run `cadet-agent harness verify --gate testsPassed` first so its artifact can be read.' };
|
|
596
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
597
|
+
else console.error(`❌ ${detail.reason}`);
|
|
598
|
+
process.exit(1);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const inventory = parseTestInventory(reportText);
|
|
602
|
+
const coverage = compareCoverage(criteria, inventory);
|
|
603
|
+
const gaps = describeCoverageGaps(coverage);
|
|
604
|
+
|
|
605
|
+
// Under strict closure an unknown/empty inventory can never prove coverage,
|
|
606
|
+
// even if every AC declared no tests in a way that looked consistent.
|
|
607
|
+
const unknownInventory = inventory.format === 'unknown' || inventory.names.length === 0;
|
|
608
|
+
const effectiveOk = coverage.ok && !unknownInventory;
|
|
609
|
+
|
|
610
|
+
if (!strict) {
|
|
611
|
+
// v2/v3 parity: report, write nothing, exit 0.
|
|
612
|
+
if (opts.format === 'json') {
|
|
613
|
+
emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
|
|
614
|
+
} else if (effectiveOk) {
|
|
615
|
+
console.log(`✅ AC coverage verified for ${opts.story} (${coverage.ac.length} criteria, ${coverage.inventorySize} tests in inventory).`);
|
|
616
|
+
console.log(' strictClosure is off — reported only, state.json unchanged.');
|
|
617
|
+
} else {
|
|
618
|
+
console.error(`⚠️ AC coverage gaps in ${opts.story} (strictClosure off — reported only):`);
|
|
619
|
+
if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}).`);
|
|
620
|
+
for (const g of gaps) console.error(g);
|
|
621
|
+
}
|
|
622
|
+
if (!effectiveOk) process.exit(1);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!effectiveOk) {
|
|
627
|
+
const detail = { ok: false, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, code: unknownInventory ? 'inventory-unknown' : 'coverage-gap' };
|
|
628
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
629
|
+
else {
|
|
630
|
+
console.error(`❌ Cannot set acceptanceCriteriaValidated for ${opts.story}:`);
|
|
631
|
+
if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}). An unparseable report proves nothing.`);
|
|
632
|
+
for (const g of gaps) console.error(g);
|
|
633
|
+
}
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const at = new Date();
|
|
638
|
+
const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
|
|
639
|
+
const nowIso = at.toISOString();
|
|
640
|
+
const evidence = createEvidence({
|
|
641
|
+
evidenceId: newId(),
|
|
642
|
+
workItemId,
|
|
643
|
+
acceptanceCriterionId: null,
|
|
644
|
+
phase,
|
|
645
|
+
gate: 'acceptanceCriteriaValidated',
|
|
646
|
+
status: 'passed',
|
|
647
|
+
command: `harness verify-acs --story ${opts.story}`,
|
|
648
|
+
result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
|
|
649
|
+
exitCode: 0,
|
|
650
|
+
inputTreeHash: computeInputTreeHash(opts.targetDir, [opts.story, ...(reportPath ? [reportPath] : [])]),
|
|
651
|
+
criteriaHash: hashCriteria(criteriaStrings),
|
|
652
|
+
relevantFiles: [opts.story, ...(reportPath ? [reportPath] : [])].map((f) => f.replace(/\\/g, '/')),
|
|
653
|
+
createdAt: at,
|
|
654
|
+
expiresAt: null,
|
|
655
|
+
freshnessPolicy: 'current-story',
|
|
656
|
+
source: 'automated',
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
let coveragePath = null;
|
|
660
|
+
if (opts.writeCoverage) {
|
|
661
|
+
const base = opts.story.replace(/\.md$/, '');
|
|
662
|
+
coveragePath = `${base}.coverage.json`;
|
|
663
|
+
const doc = {
|
|
664
|
+
schemaVersion: 1,
|
|
665
|
+
story: opts.story.replace(/\\/g, '/'),
|
|
666
|
+
generatedAt: nowIso,
|
|
667
|
+
ac: coverage.ac,
|
|
668
|
+
inventorySize: coverage.inventorySize,
|
|
669
|
+
format: inventory.format,
|
|
670
|
+
};
|
|
671
|
+
try { writeFileSync(coveragePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8'); } catch { coveragePath = null; }
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Ledger first, then state — the v3 ordering: fail toward "less proven".
|
|
675
|
+
const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
|
|
676
|
+
ledger.addEvidence(evidence);
|
|
677
|
+
ledger.addDecision({ kind: 'stop', reason: `AC coverage verified via ${reportSource}`, scope: `${coverage.ac.length} criteria` });
|
|
678
|
+
ledger.finalize({ status: 'ok' });
|
|
679
|
+
const ledgerPath = ledger.persist();
|
|
680
|
+
|
|
681
|
+
if (exists) {
|
|
682
|
+
const next = { ...state };
|
|
683
|
+
const priorEv = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
684
|
+
next.gateEvidence = [
|
|
685
|
+
...priorEv.map((e) => (e.gate === 'acceptanceCriteriaValidated' && (e.status === 'passed' || e.status === 'manual-confirmation')
|
|
686
|
+
? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
|
|
687
|
+
: e)),
|
|
688
|
+
evidence,
|
|
689
|
+
];
|
|
690
|
+
next.gates = { ...(state.gates || {}), acceptanceCriteriaValidated: true };
|
|
691
|
+
writeState(opts.targetDir, next);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (opts.format === 'json') {
|
|
695
|
+
emit(opts, '', { ok: true, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: exists, coveragePath, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath });
|
|
696
|
+
} else {
|
|
697
|
+
console.log(`✅ acceptanceCriteriaValidated for ${opts.story} (${coverage.ac.length} criteria, inventory ${coverage.inventorySize}, ${inventory.format}).`);
|
|
698
|
+
console.log(` Ledger: ${ledgerPath}`);
|
|
699
|
+
if (coveragePath) console.log(` Coverage: ${coveragePath}`);
|
|
700
|
+
}
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
|
|
375
704
|
if (sub === 'report') {
|
|
376
705
|
const runs = listRuns(opts.targetDir);
|
|
377
706
|
const target = opts.runId || runs[0]?.runId;
|
|
@@ -391,7 +720,7 @@ async function cmdHarness(opts) {
|
|
|
391
720
|
return;
|
|
392
721
|
}
|
|
393
722
|
|
|
394
|
-
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|verify|report|cleanup|capabilities.`);
|
|
723
|
+
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|report|cleanup|capabilities.`);
|
|
395
724
|
}
|
|
396
725
|
|
|
397
726
|
export async function run(argv) {
|
package/src/harness/index.mjs
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
export {
|
|
9
9
|
PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, RETRY_CLASSES, CONTEXT_TIERS,
|
|
10
10
|
DEFAULT_BUDGETS, HARD_CEILINGS, DEFAULT_ARCHIVE_LIMITS, DEFAULT_OUTPUT_POLICY,
|
|
11
|
-
DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY,
|
|
11
|
+
DEFAULT_RETENTION, DEFAULT_ESTIMATION, DEFAULT_HOOK_POLICY, DEFAULT_STRICT_CLOSURE,
|
|
12
|
+
EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE, AGENT_OWNED_GATES,
|
|
12
13
|
validatePolicy, defaultPolicy, loadPolicy, budgetForScope, policyPath, PolicyError,
|
|
13
14
|
} from './policy.mjs';
|
|
14
15
|
|
|
@@ -22,9 +23,9 @@ export {
|
|
|
22
23
|
} from './util.mjs';
|
|
23
24
|
|
|
24
25
|
export {
|
|
25
|
-
STATE_VERSION, validateState, migrateStateV1toV2, migrateStateFile,
|
|
26
|
+
STATE_VERSION, READABLE_STATE_VERSIONS, validateState, migrateStateV1toV2, migrateStateFile,
|
|
26
27
|
createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
|
|
27
|
-
latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition,
|
|
28
|
+
latestEvidenceForGate, activeExceptions, requiredGates, evaluateTransition, resolveStrict,
|
|
28
29
|
applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
|
|
29
30
|
} from './state.mjs';
|
|
30
31
|
|
|
@@ -62,3 +63,9 @@ export {
|
|
|
62
63
|
export {
|
|
63
64
|
REPO_ROLES, REPO_ROLE_MARKER, detectRepoRole, isFrameworkSourceWithoutWorkItem, describeRepoRole,
|
|
64
65
|
} from './repo-role.mjs';
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
INVENTORY_FORMATS, COVERAGE_STATUSES, DEFAULT_MAX_REPORT_BYTES, DEFAULT_MAX_INVENTORY_ENTRIES,
|
|
69
|
+
normalizeTestName, parseTestInventory, parseStoryCriteria, parseStoryCriteriaText,
|
|
70
|
+
compareCoverage, describeCoverageGaps,
|
|
71
|
+
} from './verify-acs.mjs';
|