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.
- package/README.md +18 -4
- package/package.json +1 -1
- package/src/cli.mjs +698 -17
- package/src/harness/acceptance-form.mjs +281 -0
- package/src/harness/architecture.mjs +201 -0
- package/src/harness/commands.mjs +60 -0
- package/src/harness/context-protocol.mjs +470 -0
- package/src/harness/design-review.mjs +162 -0
- package/src/harness/gates.mjs +364 -0
- package/src/harness/hosts.mjs +349 -0
- package/src/harness/index.mjs +42 -3
- package/src/harness/policy.mjs +283 -10
- package/src/harness/routing.mjs +14 -2
- package/src/harness/state.mjs +185 -15
- package/src/harness/status.mjs +132 -0
- package/src/harness/verification.mjs +51 -28
- package/src/install.mjs +129 -0
package/src/cli.mjs
CHANGED
|
@@ -6,9 +6,20 @@ 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, GATES, PHASES, manualConfirmation,
|
|
9
|
+
detectRepoRole, describeRepoRole, GATES, PHASES, manualConfirmation, computeStatus,
|
|
10
|
+
gateBuilder, describeGateRefusal,
|
|
10
11
|
gitChangeSet, DEFAULT_REPORT_DIR,
|
|
11
12
|
reconcileArtifacts, PLANS_DEFAULT_DIR,
|
|
13
|
+
parseDesignReviewArtifact, describeDesignReviewGaps, FINDING_DISPOSITIONS,
|
|
14
|
+
DESIGN_REVIEW_GATE, HUMAN_ACCEPTANCE_GATE,
|
|
15
|
+
CONTEXT_LEVELS, buildContextPlan, writeContextPlan, readContextPlan, buildContextRecord,
|
|
16
|
+
writeContextRecord, readContextRecord, parseTranscript, validateContextRecord,
|
|
17
|
+
describeContextState, ContextProtocolError,
|
|
18
|
+
enforcementMatrix, describeEnforcement, INTERCEPTION_ACTIONS,
|
|
19
|
+
ARCHITECTURE_GATE, architectureFitnessActive, runArchitectureChecks, summariseChecks,
|
|
20
|
+
describeCheckResults, DEFAULT_CHECK_TIMEOUT_MS,
|
|
21
|
+
ACCEPTANCE_TEMPLATE_RELATIVE, buildAcceptanceForm, parseAcceptanceForm, writeAcceptanceForm,
|
|
22
|
+
acceptanceFormPath, ACCEPTANCE_HUMAN_FIELDS,
|
|
12
23
|
parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
|
|
13
24
|
parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
|
|
14
25
|
findDeferralCycles, readSiblingDeclarations, normalizeWorkItemRef, describeReachabilityGaps,
|
|
@@ -45,6 +56,7 @@ function showHelp() {
|
|
|
45
56
|
npx cadet-agent@latest sync Update framework, preserving local policies/plans
|
|
46
57
|
npx cadet-agent@latest sync --target <dir> Sync a specific directory
|
|
47
58
|
|
|
59
|
+
cadet-agent state init Create the first .cadet/state.json (never overwrites one)
|
|
48
60
|
cadet-agent state validate Validate .cadet/state.json against the schema
|
|
49
61
|
cadet-agent state validate --verify-sealed Also read sealed evidence from commit trailers
|
|
50
62
|
cadet-agent state migrate Atomically migrate state to the current version (backup on write)
|
|
@@ -60,11 +72,18 @@ function showHelp() {
|
|
|
60
72
|
cadet-agent harness verify Run a bounded, classified verification loop
|
|
61
73
|
cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
|
|
62
74
|
cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
|
|
75
|
+
cadet-agent harness verify-design-review Check the design-review artifact and record the gate (opt-in)
|
|
76
|
+
cadet-agent harness acceptance-form Write a human-acceptance form for an epic, filled in from state
|
|
77
|
+
cadet-agent harness verify-architecture Run the project's declared architecture checks (opt-in)
|
|
78
|
+
cadet-agent harness context plan Write what the phase requires, and why (opt-in protocol)
|
|
79
|
+
cadet-agent harness context record Record what the host loaded, and the level it can claim
|
|
80
|
+
cadet-agent harness context validate Compare plan, record and current files (read-only)
|
|
63
81
|
cadet-agent harness report Summarize budget consumption and failures
|
|
64
82
|
cadet-agent harness changes List the files a story changed, with status, counts, and links
|
|
65
83
|
cadet-agent harness reconcile Reconcile the planning chain against state.json (read-only)
|
|
66
84
|
cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
|
|
67
85
|
cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
|
|
86
|
+
cadet-agent harness status Print the one-line framework health line (ok, or the problem)
|
|
68
87
|
|
|
69
88
|
Options:
|
|
70
89
|
--target, -t Target directory (default: current working directory)
|
|
@@ -81,6 +100,10 @@ function showHelp() {
|
|
|
81
100
|
--environment key=value,... describing what was verified (harness confirm)
|
|
82
101
|
--scope Comma-separated scope of the confirmation (harness confirm)
|
|
83
102
|
--story Story markdown declaring the acceptance criteria or reachability (harness verify-acs|verify-reachability)
|
|
103
|
+
--artifact Design-review artifact to check (verify-design-review), or the acceptance form to record from (confirm --gate humanAcceptanceConfirmed)
|
|
104
|
+
--epic Epic a generated form belongs to (harness acceptance-form)
|
|
105
|
+
--out Where to write the form (default: the epic's plan directory)
|
|
106
|
+
|
|
84
107
|
--report Test report to derive the inventory from (harness verify-acs|matrix-check)
|
|
85
108
|
--matrix TDD matrix markdown to check (harness matrix-check)
|
|
86
109
|
--inventory Newline-separated test names, when no report is available (harness matrix-check)
|
|
@@ -95,6 +118,7 @@ function showHelp() {
|
|
|
95
118
|
--epic Epic id of the work item being started (state begin)
|
|
96
119
|
--commit-msg Path to write the prepared commit message to (state seal)
|
|
97
120
|
--verify-sealed Also verify evidence sealed in commit trailers (state validate)
|
|
121
|
+
--verify-host Probe the configured host controls and report the measured level (harness capabilities)
|
|
98
122
|
--dry-run Report what a mutating command would do and write nothing (all mutating commands)
|
|
99
123
|
--yes, -y Never prompt; keep existing files (non-interactive installs)
|
|
100
124
|
--help, -h Show this help (valid at any depth; never writes)
|
|
@@ -173,7 +197,10 @@ function parseArgs(argv) {
|
|
|
173
197
|
// working-tree scan (which could bind evidence to Cadet's own files).
|
|
174
198
|
case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
175
199
|
case '--story': opts.story = value(a); break;
|
|
176
|
-
|
|
200
|
+
case '--artifact': opts.artifact = value(a); break;
|
|
201
|
+
case '--out': opts.out = value(a); break;
|
|
202
|
+
// The epic the command acts on: `state begin`'s new work item, and the epic an
|
|
203
|
+
// acceptance form is generated for.
|
|
177
204
|
case '--epic': opts.epicId = value(a); break;
|
|
178
205
|
// state compact: keep every record inline instead of applying the
|
|
179
206
|
// within-work-item retention rule. Made explicit at the call site, because
|
|
@@ -201,6 +228,27 @@ function parseArgs(argv) {
|
|
|
201
228
|
case '--commit-msg': opts.commitMsgPath = value(a); break;
|
|
202
229
|
// state validate: read commit trailers too, not just the live document.
|
|
203
230
|
case '--verify-sealed': opts.verifySealed = true; break;
|
|
231
|
+
// The two flags the acceptance form replaced. They are PARSED so that they can be refused by
|
|
232
|
+
// name: without a case here they fell through into `opts.rest`, the command exited 0, and the
|
|
233
|
+
// values went nowhere — which contradicts this parser's own rule, written a few lines above,
|
|
234
|
+
// that a silently swallowed option is worse than a rejected one because the command still
|
|
235
|
+
// reports success. `harness confirm` refuses them (see the acceptance gate).
|
|
236
|
+
case '--witness': opts.witness = value(a); break;
|
|
237
|
+
case '--limitations': opts.limitations = value(a); break;
|
|
238
|
+
// state init: the first document's declared values. Each is validated before anything is
|
|
239
|
+
// written, so a typo cannot become a state file the rest of the framework then refuses.
|
|
240
|
+
case '--workflow-path': opts.workflowPath = value(a); break;
|
|
241
|
+
case '--tracking-mode': opts.trackingMode = value(a); break;
|
|
242
|
+
case '--learner-tier': opts.learnerTier = value(a); break;
|
|
243
|
+
case '--operating-mode': opts.operatingMode = value(a); break;
|
|
244
|
+
// `--phase` is declared once, above: state init and harness record both read opts.phase.
|
|
245
|
+
case '--verify-host': opts.verifyHost = true; break;
|
|
246
|
+
// The context protocol: what the host loaded, and what it can claim about it.
|
|
247
|
+
case '--level': opts.contextLevel = value(a); break;
|
|
248
|
+
case '--loaded': opts.contextLoaded = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
249
|
+
case '--enforced-by': opts.enforcedBy = value(a); break;
|
|
250
|
+
case '--transcript': opts.transcript = value(a); break;
|
|
251
|
+
case '--host': opts.host = value(a); break;
|
|
204
252
|
case '--agents-md': opts.agentsMd = value(a); break;
|
|
205
253
|
case '--yes': case '-y': opts.yes = true; break;
|
|
206
254
|
default: opts.rest.push(a);
|
|
@@ -564,6 +612,80 @@ async function cmdState(opts) {
|
|
|
564
612
|
return;
|
|
565
613
|
}
|
|
566
614
|
|
|
615
|
+
if (sub === 'init') {
|
|
616
|
+
// The first state document, as a command.
|
|
617
|
+
//
|
|
618
|
+
// It had no command. `cadet-agent init` installs the framework, and then EVERY entry point
|
|
619
|
+
// refuses: `state begin` says "Initialise state before starting a work item", `state
|
|
620
|
+
// transition` says the same, `harness confirm` says the same — and there was no way to
|
|
621
|
+
// initialise it. The only route was a hand-written document, which is the pattern this
|
|
622
|
+
// framework condemns everywhere else, and the instruction that described it
|
|
623
|
+
// (`skills/Resume.md`) wrote `version: 1` with `session.workflowPath: null` — a document
|
|
624
|
+
// `state validate` REJECTS ("workflowPath is required", "unknown workflowPath \"null\"").
|
|
625
|
+
// So a new consumer's first document was unaudited and, followed literally, invalid; and
|
|
626
|
+
// every later gate reads that document.
|
|
627
|
+
const existing = readState(opts.targetDir);
|
|
628
|
+
if (existing.exists) {
|
|
629
|
+
fail(
|
|
630
|
+
opts,
|
|
631
|
+
'A state document already exists. state init never overwrites one: '
|
|
632
|
+
+ 'it is the document every gate reads, and rewriting it silently would discard the '
|
|
633
|
+
+ 'evidence those gates were satisfied with. Delete or archive it first if that is '
|
|
634
|
+
+ 'really what you want.',
|
|
635
|
+
() => 1,
|
|
636
|
+
{ ok: false, code: 'state-exists', path: '.cadet/state.json' },
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const workflowPath = opts.workflowPath || 'large';
|
|
641
|
+
const trackingMode = opts.trackingMode || 'markdown';
|
|
642
|
+
const currentPhase = opts.phase || PHASES[0];
|
|
643
|
+
const values = [
|
|
644
|
+
['--workflow-path', workflowPath, ['large', 'small', 'no_test_required']],
|
|
645
|
+
['--tracking-mode', trackingMode, ['markdown', 'github']],
|
|
646
|
+
['--phase', currentPhase, PHASES],
|
|
647
|
+
];
|
|
648
|
+
if (opts.learnerTier !== undefined) values.push(['--learner-tier', opts.learnerTier, ['beginner', 'intermediate', 'advanced', 'guided']]);
|
|
649
|
+
if (opts.operatingMode !== undefined) values.push(['--operating-mode', opts.operatingMode, ['instruction-first', 'implementation-first', 'hybrid']]);
|
|
650
|
+
for (const [flag, value, allowed] of values) {
|
|
651
|
+
if (!allowed.includes(value)) {
|
|
652
|
+
fail(opts, `unknown ${flag} value "${value}" (expected one of: ${allowed.join(', ')})`, () => 1, { ok: false, code: 'unknown-value', flag, value, allowed });
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const initial = {
|
|
657
|
+
version: STATE_VERSION,
|
|
658
|
+
stateVersion: STATE_VERSION,
|
|
659
|
+
session: {
|
|
660
|
+
workflowPath,
|
|
661
|
+
currentPhase,
|
|
662
|
+
trackingMode,
|
|
663
|
+
...(opts.learnerTier !== undefined ? { learnerTier: opts.learnerTier } : {}),
|
|
664
|
+
...(opts.operatingMode !== undefined ? { operatingMode: opts.operatingMode } : {}),
|
|
665
|
+
},
|
|
666
|
+
activeWorkItem: null,
|
|
667
|
+
gates: Object.fromEntries(GATES.map((g) => [g, false])),
|
|
668
|
+
gateEvidence: [],
|
|
669
|
+
epics: {},
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
// Validate before writing, like every other writer here. A document that the framework
|
|
673
|
+
// would refuse must never reach disk: it is the file every gate reads.
|
|
674
|
+
const { errors } = validateState(initial, { rootDir: opts.targetDir });
|
|
675
|
+
if (errors.length > 0) {
|
|
676
|
+
fail(opts, `refusing to write an invalid state document: ${errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`, () => 1, { ok: false, code: 'invalid-state', errors });
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const written = writeState(opts.targetDir, initial);
|
|
680
|
+
emit(
|
|
681
|
+
opts,
|
|
682
|
+
`✅ Created .cadet/state.json (v${STATE_VERSION}): workflowPath "${workflowPath}", phase "${currentPhase}", trackingMode "${trackingMode}".\n`
|
|
683
|
+
+ ' The framework is installed and the workflow starts here; nothing is tracked until the first work item ("cadet-agent state begin").',
|
|
684
|
+
{ ok: true, version: STATE_VERSION, workflowPath, currentPhase, trackingMode, gates: Object.keys(initial.gates).length, appended: written?.appended ?? 0 },
|
|
685
|
+
);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
567
689
|
if (sub === 'begin') {
|
|
568
690
|
// The story boundary, as a command.
|
|
569
691
|
//
|
|
@@ -759,16 +881,27 @@ async function cmdHarness(opts) {
|
|
|
759
881
|
// depend on the agent reading it, because the dispatcher enforces the
|
|
760
882
|
// registry regardless.
|
|
761
883
|
const commands = describeAllCommands();
|
|
762
|
-
|
|
884
|
+
// What the host can actually stop, per action. `--verify-host` runs the probes; without it the
|
|
885
|
+
// declared position is printed and marked "declared", so a reader can always tell a measurement
|
|
886
|
+
// from a declaration — and never from the presence of a file.
|
|
887
|
+
const matrix = enforcementMatrix(opts.targetDir, { verify: opts.verifyHost === true });
|
|
888
|
+
if (opts.verifyHost === true) caps.hook.verified = matrix.hostHook.verified;
|
|
889
|
+
if (opts.format === 'json') emit(opts, '', { ok: true, capabilities: caps, enforcement: matrix, commands });
|
|
763
890
|
else {
|
|
764
891
|
console.log('Cadet-Agent capability report');
|
|
765
892
|
console.log(` CLI: ${caps.cli ? 'available' : 'unavailable'}`);
|
|
766
893
|
console.log(` Unity CLI: ${caps.unityCli.available ? `available (${caps.unityCli.version || 'version unknown'})` : 'unavailable — compile/analyzer gates fall back to manual confirmation'}`);
|
|
767
894
|
console.log(` MCP: ${caps.mcp.available ? 'configured' : 'unavailable — live inspection not available'}`);
|
|
768
|
-
console.log(` Copilot hook: ${caps.hook.copilot ? 'installed' : 'not installed'}`);
|
|
769
895
|
console.log(` Token telemetry:${caps.tokenTelemetry.provider ? ' provider' : ' estimate/unknown'}`);
|
|
770
896
|
console.log(` Cost telemetry: ${caps.costTelemetry.available ? 'available' : `unavailable (${caps.costTelemetry.reason})`}`);
|
|
771
|
-
console.log(`
|
|
897
|
+
console.log(` Host interception (${matrix.verified ? 'measured' : 'declared — pass --verify-host to measure'}):`);
|
|
898
|
+
for (const line of describeEnforcement(matrix)) console.log(line);
|
|
899
|
+
if (matrix.verified) {
|
|
900
|
+
console.log(` Host hook: ${matrix.hostHook.verified ? `verified — ${matrix.hostHook.reason}` : `not verified — ${matrix.hostHook.reason}`}`);
|
|
901
|
+
console.log(` Repo git hook: ${matrix.repoHook.verified ? `verified — ${matrix.repoHook.reason}` : `not verified — ${matrix.repoHook.reason}`}`);
|
|
902
|
+
} else {
|
|
903
|
+
console.log(` Note: ${caps.hook.note}`);
|
|
904
|
+
}
|
|
772
905
|
console.log(' Commands (mutating commands honour --dry-run; nothing writes without it being declared):');
|
|
773
906
|
for (const c of commands) {
|
|
774
907
|
const bound = c.requiresForUnattended.length ? ` [unattended requires ${c.requiresForUnattended.join(', ')}]` : '';
|
|
@@ -787,6 +920,23 @@ async function cmdHarness(opts) {
|
|
|
787
920
|
if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
|
|
788
921
|
assertExpectedPhase(opts, state);
|
|
789
922
|
|
|
923
|
+
// A caller that passes the removed flags is refused by name. The acceptance is recorded from
|
|
924
|
+
// the form and from nothing else (see below); a witness or a limitation typed here would be
|
|
925
|
+
// discarded, and a discarded field that looked accepted is exactly the failure this framework
|
|
926
|
+
// is built to prevent.
|
|
927
|
+
const removedFlags = ['witness', 'limitations'].filter((k) => opts[k] !== undefined && opts[k] !== null);
|
|
928
|
+
if (removedFlags.length > 0) {
|
|
929
|
+
const flags = removedFlags.map((k) => `--${k}`);
|
|
930
|
+
fail(
|
|
931
|
+
opts,
|
|
932
|
+
`${flags.join(' and ')} no longer exist, so nothing was recorded. A human acceptance is recorded from its form: `
|
|
933
|
+
+ 'run "cadet-agent harness acceptance-form --epic <epicId>", fill it in, and pass it back with --artifact <the form>. '
|
|
934
|
+
+ 'The witness and the limitations live in that file, where a reviewer can read them.',
|
|
935
|
+
() => 1,
|
|
936
|
+
{ ok: false, gate, code: 'flag-removed', flags },
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
790
940
|
const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
|
|
791
941
|
const mc = strict?.manualConfirmation || null;
|
|
792
942
|
// One reference instant for the whole command, captured before any work.
|
|
@@ -797,18 +947,76 @@ async function cmdHarness(opts) {
|
|
|
797
947
|
|
|
798
948
|
// Collect EVERY missing field so the caller fixes the record in one pass,
|
|
799
949
|
// rather than discovering one omission per invocation.
|
|
950
|
+
//
|
|
951
|
+
// The two fields the acceptance ARTIFACT supplies are exempt when it is present. The check
|
|
952
|
+
// below refuses `--scope` and `--environment` beside `--artifact` (they would be a second,
|
|
953
|
+
// competing source for the same claim), so demanding them here made the command unsatisfiable
|
|
954
|
+
// in both directions under the shipped policy: with them it was `artifact-conflicts-with-flags`,
|
|
955
|
+
// without them `strict-metadata-missing`. A gate no caller can satisfy is not a strict gate.
|
|
956
|
+
// The form carries both, and its completeness is checked when it is parsed.
|
|
957
|
+
const artifactSuppliesScope = typeof opts.artifact === 'string' && opts.artifact !== '';
|
|
800
958
|
const missing = [];
|
|
801
959
|
if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
|
|
802
960
|
if (mc?.requireExpiresAt !== false && strict) {
|
|
803
961
|
if (!opts.expiresAt) missing.push('--expires-at');
|
|
804
962
|
else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
|
|
805
963
|
}
|
|
806
|
-
if (mc?.requireEnvironment !== false && strict && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
|
|
807
|
-
if (mc?.requireScope !== false && strict && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
|
|
964
|
+
if (mc?.requireEnvironment !== false && strict && !artifactSuppliesScope && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
|
|
965
|
+
if (mc?.requireScope !== false && strict && !artifactSuppliesScope && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
|
|
808
966
|
if (missing.length) {
|
|
809
967
|
fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
|
|
810
968
|
}
|
|
811
969
|
|
|
970
|
+
// A human acceptance is recorded from a form, and from nothing else.
|
|
971
|
+
//
|
|
972
|
+
// There is no flag route. Two routes to one gate means the weaker route defines the
|
|
973
|
+
// gate, and the flag route's only advantage was skipping the file — at the cost of a
|
|
974
|
+
// record no person can read later. The form costs two commands, leaves the acceptance
|
|
975
|
+
// somewhere a reviewer can open, and removes the second copy that could disagree with
|
|
976
|
+
// the record. `--witness` and `--limitations` no longer exist; a caller that passes
|
|
977
|
+
// them is refused here rather than silently recorded, which is why the check is on
|
|
978
|
+
// the missing artifact rather than on the flags.
|
|
979
|
+
if (gate === HUMAN_ACCEPTANCE_GATE && !opts.artifact) {
|
|
980
|
+
fail(opts, `a human acceptance is recorded from a form: run "cadet-agent harness acceptance-form --epic <epicId>" to write one pre-filled from state, fill in its blank fields, then pass it back with --artifact <the form>.`, () => 1, { ok: false, gate, code: 'acceptance-form-required' });
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// `harness acceptance-form` writes the form pre-filled from state; this reads it back.
|
|
984
|
+
if (opts.artifact) {
|
|
985
|
+
if (gate !== HUMAN_ACCEPTANCE_GATE) {
|
|
986
|
+
fail(opts, `--artifact is only for ${HUMAN_ACCEPTANCE_GATE}: every other gate's evidence comes from its own command or from explicit fields.`, () => 1, { ok: false, gate, code: 'artifact-not-applicable' });
|
|
987
|
+
}
|
|
988
|
+
const conflicting = ['scope', 'environment'].filter((k) => opts[k]);
|
|
989
|
+
if (conflicting.length > 0) {
|
|
990
|
+
fail(opts, `--artifact already carries the acceptance, so ${conflicting.map((k) => `--${k}`).join(' and ')} would be a second, competing source. Pass the artifact alone.`, () => 1, { ok: false, gate, code: 'artifact-conflicts-with-flags', conflicting });
|
|
991
|
+
}
|
|
992
|
+
let formText;
|
|
993
|
+
try {
|
|
994
|
+
formText = readFileSync(opts.artifact, 'utf-8');
|
|
995
|
+
} catch (err) {
|
|
996
|
+
fail(opts, `the acceptance form could not be read (${err.message}).`, () => 1, { ok: false, gate, code: 'artifact-unreadable' });
|
|
997
|
+
}
|
|
998
|
+
const form = parseAcceptanceForm(formText);
|
|
999
|
+
if (form.incomplete.length > 0) {
|
|
1000
|
+
fail(opts, `the form still has unfilled fields: ${form.incomplete.join(', ')}. Fill them in ${opts.artifact} and run the command again — an acceptance nobody wrote down is not an acceptance.`, () => 1, { ok: false, gate, code: 'acceptance-form-incomplete', missing: form.incomplete });
|
|
1001
|
+
}
|
|
1002
|
+
if (state && form.epic && state.epics && !state.epics[form.epic]) {
|
|
1003
|
+
fail(opts, `the form accepts epic "${form.epic}", which does not exist in state.json. Fix the form, or accept the epic the repository actually has.`, () => 1, { ok: false, gate, code: 'acceptance-form-epic-unknown', epic: form.epic });
|
|
1004
|
+
}
|
|
1005
|
+
opts.witness = form.witness;
|
|
1006
|
+
opts.limitations = form.limitations;
|
|
1007
|
+
opts.environment = form.environment || null;
|
|
1008
|
+
opts.scope = [form.epic];
|
|
1009
|
+
if (!opts.files || opts.files.length === 0) opts.files = form.fileList;
|
|
1010
|
+
opts.files = opts.files && opts.files.length ? opts.files : null;
|
|
1011
|
+
opts.filesGiven = opts.files !== null;
|
|
1012
|
+
opts.acceptedBy = form.acceptor;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// No check for empty witness/limitations is needed here: the form is the only route
|
|
1016
|
+
// to this gate, and `parseAcceptanceForm` refuses a form whose fields are blank or
|
|
1017
|
+
// still placeholders, so an empty value cannot reach this point. A second check would
|
|
1018
|
+
// be a guard that cannot fire, and the one that cannot fire is the one that rots.
|
|
1019
|
+
|
|
812
1020
|
// A gate listed in disallowManualFor may never be satisfied by a human
|
|
813
1021
|
// assertion; point at the automated path instead of accepting the record.
|
|
814
1022
|
if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
|
|
@@ -873,6 +1081,8 @@ async function cmdHarness(opts) {
|
|
|
873
1081
|
rootDir: opts.targetDir,
|
|
874
1082
|
approvedBy: opts.approvedBy || 'user',
|
|
875
1083
|
commit: opts.commit || null,
|
|
1084
|
+
witness: opts.witness || null,
|
|
1085
|
+
limitations: opts.limitations || null,
|
|
876
1086
|
at,
|
|
877
1087
|
});
|
|
878
1088
|
|
|
@@ -942,6 +1152,20 @@ async function cmdHarness(opts) {
|
|
|
942
1152
|
if (sub === 'verify') {
|
|
943
1153
|
const gate = opts.gate;
|
|
944
1154
|
if (!gate) fail(opts, 'harness verify requires --gate <gate>');
|
|
1155
|
+
// The gate NAME selects the evidence contract, so an unknown name is refused
|
|
1156
|
+
// before anything runs. A made-up gate used to be accepted with --command and
|
|
1157
|
+
// recorded as an automated pass, which put evidence into state.json for a name
|
|
1158
|
+
// no transition can read.
|
|
1159
|
+
if (!GATES.includes(gate)) {
|
|
1160
|
+
fail(opts, describeGateRefusal(gate), () => 1, { ok: false, gate, code: 'unknown-gate' });
|
|
1161
|
+
}
|
|
1162
|
+
// A project command may fill only the slots whose evidence IS a project
|
|
1163
|
+
// command. Anywhere else it would let an unrelated exit-zero command attest a
|
|
1164
|
+
// claim it does not prove — which is how `codeReviewCompleted` could be
|
|
1165
|
+
// satisfied by `node -e "process.exit(0)"`.
|
|
1166
|
+
if (opts.command && gateBuilder(gate)?.projectCommand !== true) {
|
|
1167
|
+
fail(opts, describeGateRefusal(gate), () => 1, { ok: false, gate, code: 'gate-not-overridable' });
|
|
1168
|
+
}
|
|
945
1169
|
const { state } = readState(opts.targetDir);
|
|
946
1170
|
assertExpectedPhase(opts, state);
|
|
947
1171
|
const caps = detectCapabilities({ targetDir: opts.targetDir });
|
|
@@ -1083,10 +1307,12 @@ async function cmdHarness(opts) {
|
|
|
1083
1307
|
}
|
|
1084
1308
|
|
|
1085
1309
|
if (sub === 'verify-acs') {
|
|
1310
|
+
refuseCommandOverride('harness verify-acs', 'the acceptance criteria in the story and the test report');
|
|
1086
1311
|
// Mechanical AC↔test verification (contract v4). Declared tests must appear
|
|
1087
1312
|
// in the inventory of a run that actually executed them; a name that was
|
|
1088
1313
|
// never written cannot be asserted into coverage.
|
|
1089
1314
|
if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
|
|
1315
|
+
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1090
1316
|
const { exists, state } = readState(opts.targetDir);
|
|
1091
1317
|
assertExpectedPhase(opts, state);
|
|
1092
1318
|
const strict = policy.strictClosure?.enabled === true;
|
|
@@ -1095,7 +1321,14 @@ async function cmdHarness(opts) {
|
|
|
1095
1321
|
|
|
1096
1322
|
let criteria;
|
|
1097
1323
|
try {
|
|
1098
|
-
|
|
1324
|
+
// The story is READ from the target repository, exactly as verify-reachability reads it, and
|
|
1325
|
+
// not from the process working directory. Reading it from the CWD while the record below
|
|
1326
|
+
// binds `<target>/<story>` was two defects in one line: the command was unusable from
|
|
1327
|
+
// outside the project (`--target` with a CWD elsewhere), and — worse — when a file of that
|
|
1328
|
+
// name happened to exist under the CWD it parsed THAT file's criteria and wrote a record
|
|
1329
|
+
// attesting them against the target's path, which is the silently-inert binding the comment
|
|
1330
|
+
// below warns about.
|
|
1331
|
+
({ criteria } = parseStoryCriteria(storyPath));
|
|
1099
1332
|
} catch (err) {
|
|
1100
1333
|
fail(opts, `cannot parse story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-parse', story: opts.story });
|
|
1101
1334
|
}
|
|
@@ -1111,7 +1344,10 @@ async function cmdHarness(opts) {
|
|
|
1111
1344
|
let reportPath = null;
|
|
1112
1345
|
if (opts.report) {
|
|
1113
1346
|
try {
|
|
1114
|
-
|
|
1347
|
+
// Resolved against the target, like `--story` above and like every other path flag in this
|
|
1348
|
+
// CLI (matrix-check, harness changes). CWD-relative reads made the command unusable from
|
|
1349
|
+
// outside the project and could have read a report from a different tree.
|
|
1350
|
+
reportText = readFileSync(resolve(opts.targetDir, opts.report), 'utf-8');
|
|
1115
1351
|
reportSource = 'explicit';
|
|
1116
1352
|
reportPath = opts.report;
|
|
1117
1353
|
} catch (err) {
|
|
@@ -1212,11 +1448,10 @@ async function cmdHarness(opts) {
|
|
|
1212
1448
|
// the same treatment and is kept as `artifactPath` for audit, where nothing
|
|
1213
1449
|
// re-hashes it.
|
|
1214
1450
|
//
|
|
1215
|
-
// The story path is made repo-relative
|
|
1216
|
-
//
|
|
1217
|
-
// re-derived at transition time, so both
|
|
1218
|
-
// missing file and match — a binding that is silently inert.
|
|
1219
|
-
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1451
|
+
// The story path is made repo-relative (see the read above: it resolves against the target,
|
|
1452
|
+
// never the working directory) for the same reason verify-reachability does it: an absolute
|
|
1453
|
+
// path never resolves under the root when freshness is re-derived at transition time, so both
|
|
1454
|
+
// hashes would be computed over a missing file and match — a binding that is silently inert.
|
|
1220
1455
|
const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
|
|
1221
1456
|
const evidence = createEvidence({
|
|
1222
1457
|
evidenceId: newId(),
|
|
@@ -1287,6 +1522,7 @@ async function cmdHarness(opts) {
|
|
|
1287
1522
|
}
|
|
1288
1523
|
|
|
1289
1524
|
if (sub === 'verify-reachability') {
|
|
1525
|
+
refuseCommandOverride('harness verify-reachability', "the story's reachability declaration and the repository's own probe");
|
|
1290
1526
|
// Mechanical reachability verification (contract v6 §2). A story declares how
|
|
1291
1527
|
// its deliverable becomes witnessable, or which work item will make it so;
|
|
1292
1528
|
// this checks that declaration against the work items that exist, and runs
|
|
@@ -1443,14 +1679,459 @@ async function cmdHarness(opts) {
|
|
|
1443
1679
|
return;
|
|
1444
1680
|
}
|
|
1445
1681
|
|
|
1682
|
+
if (sub === 'acceptance-form') {
|
|
1683
|
+
if (!opts.epicId) {
|
|
1684
|
+
fail(opts, 'harness acceptance-form needs --epic <epicId>: the form belongs to the epic being accepted.', () => 1, { ok: false, code: 'epic-required' });
|
|
1685
|
+
}
|
|
1686
|
+
const { exists, state } = readState(opts.targetDir);
|
|
1687
|
+
if (!exists) {
|
|
1688
|
+
fail(opts, 'No .cadet/state.json found. The form is generated from state, so there is nothing to fill it from yet.', () => 1, { ok: false, code: 'no-state' });
|
|
1689
|
+
}
|
|
1690
|
+
if (state.epics && !state.epics[opts.epicId]) {
|
|
1691
|
+
fail(opts, `epic "${opts.epicId}" does not exist in state.json. Known epics: ${Object.keys(state.epics).join(', ') || '(none)'}.`, () => 1, { ok: false, code: 'epic-unknown', epic: opts.epicId });
|
|
1692
|
+
}
|
|
1693
|
+
const templatePath = join(opts.targetDir, ...ACCEPTANCE_TEMPLATE_RELATIVE.split('/'));
|
|
1694
|
+
let template;
|
|
1695
|
+
try {
|
|
1696
|
+
template = readFileSync(templatePath, 'utf-8');
|
|
1697
|
+
} catch (err) {
|
|
1698
|
+
fail(opts, `the acceptance template is missing (${ACCEPTANCE_TEMPLATE_RELATIVE}). Run "cadet-agent sync" to restore it — the form is generated from that file so the template and the form cannot drift apart.`, () => 1, { ok: false, code: 'template-missing' });
|
|
1699
|
+
}
|
|
1700
|
+
const { text } = buildAcceptanceForm({ template, state, epicId: opts.epicId, targetDir: opts.targetDir });
|
|
1701
|
+
const result = writeAcceptanceForm(opts.targetDir, opts.epicId, text, { out: opts.out });
|
|
1702
|
+
if (!result.written) {
|
|
1703
|
+
fail(opts, `harness acceptance-form refuses to overwrite an existing form: ${result.reason} (${result.path}).`, () => 1, { ok: false, code: 'form-exists', path: result.path });
|
|
1704
|
+
}
|
|
1705
|
+
const shown = result.path.slice(opts.targetDir.length + 1).replace(/\\/g, '/');
|
|
1706
|
+
if (opts.format === 'json') {
|
|
1707
|
+
emit(opts, '', { ok: true, path: shown, epic: opts.epicId, next: `cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}` });
|
|
1708
|
+
} else {
|
|
1709
|
+
console.log(`Acceptance form written: ${shown}`);
|
|
1710
|
+
console.log(` Fill in the three unfilled fields, then run:`);
|
|
1711
|
+
console.log(` cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}`);
|
|
1712
|
+
}
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (sub === 'context') {
|
|
1717
|
+
// The runtime context boundary: plan, record, validate.
|
|
1718
|
+
//
|
|
1719
|
+
// WHY THIS EXISTS. Cadet does not inject context into a model — hosts own model context — so
|
|
1720
|
+
// the honest thing to build is a protocol rather than a claim: say what a phase requires,
|
|
1721
|
+
// let the host report what it loaded, and compare the two. Without the record, "the agent
|
|
1722
|
+
// read the skill file" is an assertion nothing can check, and a framework whose whole point
|
|
1723
|
+
// is that claims carry evidence should not make an exception for its own central act.
|
|
1724
|
+
const action = opts.rest[1];
|
|
1725
|
+
if (!['plan', 'record', 'validate'].includes(action)) {
|
|
1726
|
+
fail(opts, `harness context needs an action: plan, record or validate (got "${action || '(none)'}").`, () => 1, { ok: false, code: 'context-action-required' });
|
|
1727
|
+
}
|
|
1728
|
+
const { exists, state } = readState(opts.targetDir);
|
|
1729
|
+
|
|
1730
|
+
if (action === 'plan') {
|
|
1731
|
+
const plan = buildContextPlan({ targetDir: opts.targetDir, policy, state });
|
|
1732
|
+
const planPath = writeContextPlan(opts.targetDir, plan);
|
|
1733
|
+
const shown = relative(opts.targetDir, planPath).replace(/\\/g, '/');
|
|
1734
|
+
const fit = plan.budget.fits === null ? 'no context budget declared'
|
|
1735
|
+
: plan.budget.fits ? `fits the ${plan.budget.hardContextTokens}-token budget`
|
|
1736
|
+
: `DOES NOT fit the ${plan.budget.hardContextTokens}-token budget (required ${plan.budget.requiredTokens})`;
|
|
1737
|
+
if (opts.format === 'json') {
|
|
1738
|
+
emit(opts, '', { ok: true, path: shown, phase: plan.phase, workItemId: plan.workItemId, required: plan.required, advisory: plan.advisory, absent: plan.absent, budget: plan.budget });
|
|
1739
|
+
} else {
|
|
1740
|
+
console.log(`Context plan for ${plan.phase}${plan.workItemId ? ` (${plan.workItemId})` : ''}: ${plan.required.length} required, ${plan.advisory.length} advisory — ${fit}`);
|
|
1741
|
+
for (const item of plan.required) {
|
|
1742
|
+
console.log(` ${item.present ? '📌' : '⚠️ '} ${item.reference} [${item.tier}] ${item.reason}${item.present ? ` — ${item.bytes} B, ~${item.estimatedTokens} tokens` : ' — MISSING'}`);
|
|
1743
|
+
}
|
|
1744
|
+
for (const item of plan.advisory) {
|
|
1745
|
+
console.log(` · ${item.reference} [${item.tier}] ${item.reason}${item.present ? '' : ' (absent)'}`);
|
|
1746
|
+
}
|
|
1747
|
+
console.log(` Written: ${shown}`);
|
|
1748
|
+
}
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
if (action === 'record') {
|
|
1753
|
+
const plan = readContextPlan(opts.targetDir);
|
|
1754
|
+
let loaded = opts.contextLoaded || [];
|
|
1755
|
+
const notes = [];
|
|
1756
|
+
if (opts.transcript) {
|
|
1757
|
+
let text;
|
|
1758
|
+
try {
|
|
1759
|
+
text = readFileSync(opts.transcript, 'utf-8');
|
|
1760
|
+
} catch (err) {
|
|
1761
|
+
fail(opts, `the transcript could not be read (${err.message}).`, () => 1, { ok: false, code: 'transcript-unreadable' });
|
|
1762
|
+
}
|
|
1763
|
+
const parsed = parseTranscript(text);
|
|
1764
|
+
if (parsed.problems.length > 0) {
|
|
1765
|
+
fail(opts, `the transcript has ${parsed.problems.length} unusable line(s): ${parsed.problems.slice(0, 3).join('; ')}. Each line is one JSON object naming a reference.`, () => 1, { ok: false, code: 'transcript-malformed', problems: parsed.problems });
|
|
1766
|
+
}
|
|
1767
|
+
loaded = parsed.loaded;
|
|
1768
|
+
notes.push(`loads taken from the transcript at ${opts.transcript}`);
|
|
1769
|
+
}
|
|
1770
|
+
let record;
|
|
1771
|
+
try {
|
|
1772
|
+
record = buildContextRecord({
|
|
1773
|
+
targetDir: opts.targetDir, policy, state, plan,
|
|
1774
|
+
level: opts.contextLevel || 'recorded',
|
|
1775
|
+
loaded, enforcedBy: opts.enforcedBy || null, host: opts.host || null, notes,
|
|
1776
|
+
});
|
|
1777
|
+
} catch (err) {
|
|
1778
|
+
if (err instanceof ContextProtocolError) fail(opts, err.message, () => 1, { ok: false, code: err.code });
|
|
1779
|
+
throw err;
|
|
1780
|
+
}
|
|
1781
|
+
const recordPath = writeContextRecord(opts.targetDir, record);
|
|
1782
|
+
const shown = relative(opts.targetDir, recordPath).replace(/\\/g, '/');
|
|
1783
|
+
if (opts.format === 'json') {
|
|
1784
|
+
emit(opts, '', { ok: true, path: shown, level: record.level, enforcedBy: record.enforcedBy, workItemId: record.workItemId, loaded: record.loaded, notes: record.notes });
|
|
1785
|
+
} else {
|
|
1786
|
+
console.log(`Context record: level "${record.level}"${record.enforcedBy ? ` (enforced by ${record.enforcedBy})` : ''}, ${record.loaded.length} reference(s) reported`);
|
|
1787
|
+
console.log(` ${describeContextState({ plan, record }).line}`);
|
|
1788
|
+
console.log(` Written: ${shown}`);
|
|
1789
|
+
if (record.level === 'estimated' || record.level === 'unavailable') {
|
|
1790
|
+
console.log(' This level cannot certify a context-complete checkpoint: nobody observed what was loaded.');
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// validate — read-only, so it must write nothing.
|
|
1797
|
+
const plan = readContextPlan(opts.targetDir);
|
|
1798
|
+
const record = readContextRecord(opts.targetDir);
|
|
1799
|
+
if (!plan) {
|
|
1800
|
+
fail(opts, 'no context plan exists: run "cadet-agent harness context plan" first — a record with nothing to compare against cannot be validated.', () => 1, { ok: false, code: 'no-plan' });
|
|
1801
|
+
}
|
|
1802
|
+
// Rebuild the plan against the CURRENT phase and work item: a plan written for a phase the
|
|
1803
|
+
// session has since left describes context this turn does not need, and validating against it
|
|
1804
|
+
// would pass a checkpoint for the wrong turn.
|
|
1805
|
+
const current = buildContextPlan({ targetDir: opts.targetDir, policy, state });
|
|
1806
|
+
const verdict = validateContextRecord({ targetDir: opts.targetDir, plan: current, record });
|
|
1807
|
+
const state_ = describeContextState({ plan: current, record, verdict });
|
|
1808
|
+
|
|
1809
|
+
if (opts.format === 'json') {
|
|
1810
|
+
emit(opts, '', {
|
|
1811
|
+
ok: verdict.ok, code: verdict.code, level: verdict.level,
|
|
1812
|
+
workItemId: current.workItemId, phase: current.phase,
|
|
1813
|
+
required: current.required.length, loaded: (record?.loaded || []).length,
|
|
1814
|
+
missingRequired: verdict.missingRequired, staleRequired: verdict.staleRequired,
|
|
1815
|
+
advisoryMissing: verdict.advisoryMissing, absentRequired: verdict.absentRequired || [],
|
|
1816
|
+
reasons: verdict.reasons, plannedFor: record?.phase || null,
|
|
1817
|
+
});
|
|
1818
|
+
} else {
|
|
1819
|
+
console.log(`${verdict.ok ? '✅' : '❌'} ${state_.line}`);
|
|
1820
|
+
for (const reason of verdict.reasons) console.log(` ${reason}`);
|
|
1821
|
+
if (verdict.ok) console.log(' Required context is loaded and unchanged: a context-complete checkpoint can be claimed.');
|
|
1822
|
+
}
|
|
1823
|
+
process.exit(verdict.ok ? 0 : 1);
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
/**
|
|
1827
|
+
* Refuse `--command` on a command whose evidence comes from somewhere else.
|
|
1828
|
+
*
|
|
1829
|
+
* The same rule the gate registry states for a gate: a command supplied at the call site proves
|
|
1830
|
+
* nothing about the repository, because the caller chooses both the question and the answer. The
|
|
1831
|
+
* flag was PARSED globally and then ignored by these commands, so it exited 0 having done
|
|
1832
|
+
* nothing with it — the silently swallowed option this CLI's own parser comments warn about.
|
|
1833
|
+
* `harness verify --gate <g>` refuses it with `gate-not-overridable` for the gates that take no
|
|
1834
|
+
* command; these refuse it with `command-not-accepted`.
|
|
1835
|
+
*/
|
|
1836
|
+
// Declared as a function, not a const arrow: the two `verify-acs`/`verify-reachability` branches
|
|
1837
|
+
// run before this point in the dispatch, and a const would leave them in the temporal dead zone.
|
|
1838
|
+
function refuseCommandOverride(command, instead) {
|
|
1839
|
+
if (opts.command === undefined) return;
|
|
1840
|
+
fail(
|
|
1841
|
+
opts,
|
|
1842
|
+
`${command} takes no --command: it reads its evidence from ${instead}, and a command supplied here would `
|
|
1843
|
+
+ 'let the caller choose both the question and the answer. Pass the artifact or the declaration instead.',
|
|
1844
|
+
() => 1,
|
|
1845
|
+
{ ok: false, code: 'command-not-accepted', command },
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
if (sub === 'verify-architecture') {
|
|
1850
|
+
refuseCommandOverride('harness verify-architecture', 'the checks declared under architectureFitness in .cadet/harness.json');
|
|
1851
|
+
// The project's declared executable constraints, run and recorded.
|
|
1852
|
+
//
|
|
1853
|
+
// WHY THE POLICY IS THE ONLY SOURCE OF THE COMMANDS. A gate whose command can be
|
|
1854
|
+
// supplied at the call site proves nothing about the repository: the caller chooses
|
|
1855
|
+
// both the question and the answer. Here the questions are declared in
|
|
1856
|
+
// `.cadet/harness.json` — with ids, scopes and severities — and this command only runs
|
|
1857
|
+
// them and writes down what happened. There is deliberately no `--command`.
|
|
1858
|
+
//
|
|
1859
|
+
// NOT OPTED IN: report and write nothing, the compatibility rule every opt-in gate in
|
|
1860
|
+
// this repository follows.
|
|
1861
|
+
if (!architectureFitnessActive(policy)) {
|
|
1862
|
+
const declared = policy.architectureFitness?.checks?.length ?? 0;
|
|
1863
|
+
const detail = {
|
|
1864
|
+
ok: true, gateSet: false, enabled: policy.architectureFitness?.enabled === true,
|
|
1865
|
+
declared, checks: [], note: declared === 0
|
|
1866
|
+
? 'architectureFitness declares no checks: nothing to verify, nothing recorded.'
|
|
1867
|
+
: 'architectureFitness.enabled is false: the checks are declared but nothing runs.',
|
|
1868
|
+
};
|
|
1869
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
1870
|
+
else {
|
|
1871
|
+
console.log(`ℹ️ ${detail.note}`);
|
|
1872
|
+
console.log(' Declare checks under architectureFitness in .cadet/harness.json and set "enabled": true to require them.');
|
|
1873
|
+
}
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const { exists, state } = readState(opts.targetDir);
|
|
1878
|
+
assertExpectedPhase(opts, state);
|
|
1879
|
+
|
|
1880
|
+
// Which files do the checks judge? The same answer `harness verify` and `harness
|
|
1881
|
+
// confirm` give, for the same reason: a record that does not know what it judged
|
|
1882
|
+
// cannot go stale.
|
|
1883
|
+
const allowEmpty = policy?.allowEmptyFreshness === true;
|
|
1884
|
+
let relevantFiles;
|
|
1885
|
+
if (opts.files && opts.files.length) {
|
|
1886
|
+
relevantFiles = opts.files.map((f) => String(f).replace(/\\/g, '/'));
|
|
1887
|
+
} else {
|
|
1888
|
+
const changed = gitChangedFiles(opts.targetDir);
|
|
1889
|
+
if (!changed.available) {
|
|
1890
|
+
if (!allowEmpty) {
|
|
1891
|
+
const scoped = (policy.architectureFitness.checks || []).some((c) => c.files?.length);
|
|
1892
|
+
fail(opts, `cannot establish which files the checks should judge: ${changed.reason}.`
|
|
1893
|
+
+ (scoped
|
|
1894
|
+
? ' Pass --files <paths> — a project that scopes its checks needs to know which files changed, or a scoped check would silently skip.'
|
|
1895
|
+
: ' Pass --files <paths>, or enable allowEmptyFreshness in .cadet/harness.json.'), () => 1, { ok: false, code: 'freshness-unavailable' });
|
|
1896
|
+
}
|
|
1897
|
+
relevantFiles = [];
|
|
1898
|
+
} else {
|
|
1899
|
+
relevantFiles = changed.files;
|
|
1900
|
+
}
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
const { results, skipped } = await runArchitectureChecks({
|
|
1904
|
+
checks: policy.architectureFitness.checks,
|
|
1905
|
+
relevantFiles,
|
|
1906
|
+
rootDir: opts.targetDir,
|
|
1907
|
+
defaultTimeoutMs: DEFAULT_CHECK_TIMEOUT_MS,
|
|
1908
|
+
evidenceDir: join(opts.targetDir, '.cadet', 'runs', 'architecture'),
|
|
1909
|
+
});
|
|
1910
|
+
const summary = summariseChecks(results, { declared: policy.architectureFitness.checks.length, skipped });
|
|
1911
|
+
|
|
1912
|
+
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
1913
|
+
const phase = state?.session?.currentPhase || 'implementation';
|
|
1914
|
+
const status = summary.gatePassed ? 'passed' : (summary.blocked.length > 0 && summary.failed.length === 0 ? 'blocked' : 'failed');
|
|
1915
|
+
const resultText = results.length === 0
|
|
1916
|
+
? summary.note
|
|
1917
|
+
: `${results.filter((r) => r.status === 'passed').length}/${results.length} check(s) passed`
|
|
1918
|
+
+ (summary.failed.length ? `; failed: ${summary.failed.join(', ')}` : '')
|
|
1919
|
+
+ (summary.blocked.length ? `; blocked: ${summary.blocked.join(', ')}` : '')
|
|
1920
|
+
+ (summary.advisoryFailed.length ? `; advisory (not blocking): ${summary.advisoryFailed.join(', ')}` : '');
|
|
1921
|
+
|
|
1922
|
+
// The artifacts a check declared are part of what this record attests, and they are bound
|
|
1923
|
+
// per check (`checks[].artifactPath` + `artifactHash`) — NOT folded into `relevantFiles` and
|
|
1924
|
+
// NOT hashed into `inputTreeHash`: a check that rewrites its own report would otherwise stale
|
|
1925
|
+
// a record that describes an unchanged tree.
|
|
1926
|
+
//
|
|
1927
|
+
// Folding them into `relevantFiles` was a defect, and a self-inconsistent one: `inputTreeHash`
|
|
1928
|
+
// covers `relevantFiles` as it was BEFORE the artifact paths were added, while the record then
|
|
1929
|
+
// stored the union. Freshness re-derives the hash from the record's own `relevantFiles`, so
|
|
1930
|
+
// every record from a check that declared an artifact read as stale the moment it was written
|
|
1931
|
+
// ("input tree hash changed since the evidence was recorded"), and the gate it satisfied could
|
|
1932
|
+
// never be used again — which made `implementation -> review` unreachable for any project whose
|
|
1933
|
+
// checks write a report. The record now hashes exactly the set it stores, and the artifacts stay
|
|
1934
|
+
// bound where they were already recorded: in the check entries.
|
|
1935
|
+
const artifactPaths = results.map((r) => r.artifactPath).filter(Boolean);
|
|
1936
|
+
const boundFiles = relevantFiles;
|
|
1937
|
+
|
|
1938
|
+
const evidence = createEvidence({
|
|
1939
|
+
evidenceId: newId(),
|
|
1940
|
+
workItemId,
|
|
1941
|
+
acceptanceCriterionId: null,
|
|
1942
|
+
phase,
|
|
1943
|
+
gate: ARCHITECTURE_GATE,
|
|
1944
|
+
status,
|
|
1945
|
+
command: `harness verify-architecture (${results.map((r) => r.id).join(',') || 'none applicable'})`,
|
|
1946
|
+
result: resultText,
|
|
1947
|
+
exitCode: 0,
|
|
1948
|
+
inputTreeHash: computeInputTreeHash(opts.targetDir, relevantFiles),
|
|
1949
|
+
criteriaHash: hashCriteria([]),
|
|
1950
|
+
relevantFiles,
|
|
1951
|
+
createdAt: new Date(),
|
|
1952
|
+
});
|
|
1953
|
+
evidence.checks = results.map((r) => ({
|
|
1954
|
+
id: r.id, severity: r.severity, status: r.status, exitCode: r.exitCode,
|
|
1955
|
+
artifactPath: r.artifactPath, artifactHash: r.artifactHash, refs: r.refs,
|
|
1956
|
+
}));
|
|
1957
|
+
|
|
1958
|
+
const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
|
|
1959
|
+
ledger.addEvidence(evidence);
|
|
1960
|
+
for (const r of results) {
|
|
1961
|
+
ledger.addDecision({ kind: 'check', reason: `architecture check ${r.id}: ${r.status}${r.reason ? ` (${r.reason})` : ''}`, scope: r.cwd });
|
|
1962
|
+
}
|
|
1963
|
+
ledger.finalize({ status: summary.gatePassed ? 'ok' : 'failed' });
|
|
1964
|
+
const ledgerPath = ledger.persist();
|
|
1965
|
+
|
|
1966
|
+
// The gate follows the outcome. `recordEvidence` used to be called unconditionally, so a
|
|
1967
|
+
// failed or blocked run wrote `gates.architectureFitnessPassed = true` beside a `failed` record
|
|
1968
|
+
// — a document `state validate` then rejects ("gate is true but has no supporting evidence
|
|
1969
|
+
// record"), which the shipped pre-commit hook turns into a refused commit. A run that did not
|
|
1970
|
+
// pass now clears the gate, so a failure invalidates an earlier pass instead of leaving it
|
|
1971
|
+
// standing.
|
|
1972
|
+
if (exists) writeState(opts.targetDir, recordEvidence(state, evidence, { setGate: summary.gatePassed }));
|
|
1973
|
+
|
|
1974
|
+
const detail = {
|
|
1975
|
+
ok: summary.gatePassed, gateSet: exists && summary.gatePassed, gate: ARCHITECTURE_GATE,
|
|
1976
|
+
inputTreeHash: evidence.inputTreeHash,
|
|
1977
|
+
status, passed: summary.passed, failed: summary.failed, blocked: summary.blocked,
|
|
1978
|
+
advisoryFailed: summary.advisoryFailed, skipped: summary.skipped, note: summary.note,
|
|
1979
|
+
checks: results.map((r) => ({ id: r.id, severity: r.severity, status: r.status, exitCode: r.exitCode, artifactPath: r.artifactPath })),
|
|
1980
|
+
relevantFiles: boundFiles, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath,
|
|
1981
|
+
};
|
|
1982
|
+
if (opts.format === 'json') {
|
|
1983
|
+
emit(opts, '', detail);
|
|
1984
|
+
} else {
|
|
1985
|
+
const head = summary.gatePassed ? `✅ ${ARCHITECTURE_GATE}` : `❌ ${ARCHITECTURE_GATE}`;
|
|
1986
|
+
console.log(`${head}: ${resultText}`);
|
|
1987
|
+
for (const line of describeCheckResults(results, summary)) console.log(line);
|
|
1988
|
+
if (summary.note) console.log(` ${summary.note}`);
|
|
1989
|
+
console.log(` Bound to ${boundFiles.length} judged file(s), so a change to one stales this record.`
|
|
1990
|
+
+ (artifactPaths.length ? ` Plus ${artifactPaths.length} artifact(s), bound per check and deliberately unhashed.` : ''));
|
|
1991
|
+
console.log(` Ledger: ${ledgerPath}`);
|
|
1992
|
+
if (!summary.gatePassed) {
|
|
1993
|
+
console.log(' Review cannot start until every required check passes. A check that cannot run is a tooling-gap exception, not a manual record.');
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
process.exit(summary.gatePassed ? 0 : 1);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
if (sub === 'verify-design-review') {
|
|
2000
|
+
refuseCommandOverride('harness verify-design-review', 'the design-review artifact it is handed');
|
|
2001
|
+
// The formal design review: checked, then recorded.
|
|
2002
|
+
//
|
|
2003
|
+
// WHY THE ARTIFACT IS WHAT PROVES IT. Cadet cannot read a design and decide
|
|
2004
|
+
// whether it is good, so it does not pretend to. The reviewer's judgement lives
|
|
2005
|
+
// in the artifact; this command checks the properties an artifact must have to be
|
|
2006
|
+
// readable as a review at all, and blocks the one case the gate exists for — a
|
|
2007
|
+
// contested decision with nobody's name against it. The bound inputs give the
|
|
2008
|
+
// record its freshness, which is the only honest way to say "this review was of
|
|
2009
|
+
// THAT design".
|
|
2010
|
+
if (!opts.artifact) fail(opts, 'harness verify-design-review requires --artifact <path>');
|
|
2011
|
+
const { exists, state } = readState(opts.targetDir);
|
|
2012
|
+
assertExpectedPhase(opts, state);
|
|
2013
|
+
|
|
2014
|
+
const artifactPath = resolve(opts.targetDir, opts.artifact);
|
|
2015
|
+
const artifactRel = relative(opts.targetDir, artifactPath).replace(/\\/g, '/') || basename(artifactPath);
|
|
2016
|
+
let artifactText;
|
|
2017
|
+
try {
|
|
2018
|
+
artifactText = readFileSync(artifactPath, 'utf-8');
|
|
2019
|
+
} catch (err) {
|
|
2020
|
+
fail(opts, `cannot read the design-review artifact "${opts.artifact}": ${err.message}`, () => 1, { ok: false, code: 'artifact-unreadable', artifact: opts.artifact });
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
// The review must bind what it reviewed. Without this the record would attest
|
|
2024
|
+
// "a review happened" while naming nothing it was a review OF.
|
|
2025
|
+
if (!opts.files || opts.files.length === 0) {
|
|
2026
|
+
fail(opts, 'the review must bind its inputs: pass --files <technical-design,requirements,ADRs,...> alongside --artifact.', () => 1, { ok: false, code: 'no-inputs-bound' });
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
const parsed = parseDesignReviewArtifact(artifactText);
|
|
2030
|
+
const enabled = policy.designReview?.enabled === true;
|
|
2031
|
+
|
|
2032
|
+
if (parsed.errors.length > 0) {
|
|
2033
|
+
const detail = {
|
|
2034
|
+
ok: false,
|
|
2035
|
+
artifact: opts.artifact,
|
|
2036
|
+
code: parsed.errors[0].code,
|
|
2037
|
+
errors: parsed.errors,
|
|
2038
|
+
findings: parsed.findings.length,
|
|
2039
|
+
contested: parsed.contested,
|
|
2040
|
+
gateSet: false,
|
|
2041
|
+
enabled,
|
|
2042
|
+
};
|
|
2043
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
2044
|
+
else {
|
|
2045
|
+
console.error(`❌ Cannot set ${DESIGN_REVIEW_GATE} from ${opts.artifact}:`);
|
|
2046
|
+
for (const line of describeDesignReviewGaps(parsed)) console.error(line);
|
|
2047
|
+
}
|
|
2048
|
+
process.exit(1);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// NOT OPTED IN: report and write nothing, the same compatibility rule the other
|
|
2052
|
+
// opt-in gates follow. A caller who ran the command asked the question, so a
|
|
2053
|
+
// failure still exits nonzero.
|
|
2054
|
+
if (!enabled) {
|
|
2055
|
+
const summary = `design review readable: ${parsed.findings.length} finding(s), reviewer ${parsed.reviewer}`;
|
|
2056
|
+
if (opts.format === 'json') emit(opts, '', { ok: true, artifact: opts.artifact, review: { reviewer: parsed.reviewer, inputs: parsed.inputs, findings: parsed.findings.length, contested: parsed.contested }, gateSet: false, enabled: false });
|
|
2057
|
+
else {
|
|
2058
|
+
console.log(`✅ ${summary}`);
|
|
2059
|
+
console.log(' designReview.enabled is false — reported only, state.json unchanged.');
|
|
2060
|
+
}
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
2065
|
+
const phase = state?.session?.currentPhase || 'implementation';
|
|
2066
|
+
const inputs = opts.files.map((f) => String(f).replace(/\\/g, '/'));
|
|
2067
|
+
const relevantFiles = [artifactRel, ...inputs.filter((f) => f !== artifactRel)];
|
|
2068
|
+
|
|
2069
|
+
const evidence = createEvidence({
|
|
2070
|
+
evidenceId: newId(),
|
|
2071
|
+
workItemId,
|
|
2072
|
+
acceptanceCriterionId: null,
|
|
2073
|
+
phase,
|
|
2074
|
+
gate: DESIGN_REVIEW_GATE,
|
|
2075
|
+
status: 'passed',
|
|
2076
|
+
command: `harness verify-design-review --artifact ${artifactRel}`,
|
|
2077
|
+
result: `design review complete: ${parsed.findings.length} finding(s), ${parsed.contested.length} contested with a named resolution; reviewer ${parsed.reviewer}`,
|
|
2078
|
+
exitCode: 0,
|
|
2079
|
+
inputTreeHash: computeInputTreeHash(opts.targetDir, relevantFiles),
|
|
2080
|
+
criteriaHash: hashCriteria([]),
|
|
2081
|
+
relevantFiles,
|
|
2082
|
+
createdAt: new Date(),
|
|
2083
|
+
});
|
|
2084
|
+
|
|
2085
|
+
const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
|
|
2086
|
+
ledger.addEvidence(evidence);
|
|
2087
|
+
ledger.addDecision({ kind: 'stop', reason: `design review checked (${parsed.findings.length} finding(s))`, scope: artifactRel });
|
|
2088
|
+
ledger.finalize({ status: 'ok' });
|
|
2089
|
+
const ledgerPath = ledger.persist();
|
|
2090
|
+
|
|
2091
|
+
if (exists) writeState(opts.targetDir, recordEvidence(state, evidence));
|
|
2092
|
+
|
|
2093
|
+
if (opts.format === 'json') {
|
|
2094
|
+
emit(opts, '', { ok: true, artifact: opts.artifact, review: { reviewer: parsed.reviewer, inputs: parsed.inputs, findings: parsed.findings.length, contested: parsed.contested }, relevantFiles, evidenceId: evidence.evidenceId, gateSet: exists, runId: ledger.runId, path: ledgerPath });
|
|
2095
|
+
} else {
|
|
2096
|
+
console.log(`✅ ${DESIGN_REVIEW_GATE} for ${artifactRel}: ${parsed.findings.length} finding(s), reviewer ${parsed.reviewer}`);
|
|
2097
|
+
console.log(` Bound to ${relevantFiles.length} file(s), so a change to the design or the review stales this record.`);
|
|
2098
|
+
console.log(` Ledger: ${ledgerPath}`);
|
|
2099
|
+
}
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
// The health line. This command exists so the framework's one line of output is
|
|
2104
|
+
// DERIVED rather than asserted: an agent composing its own `ok` is a claim, and
|
|
2105
|
+
// this repository's whole complaint about itself is claims that nothing checks.
|
|
2106
|
+
//
|
|
2107
|
+
// The exit code carries the same verdict as the line, because the CLI's contract
|
|
2108
|
+
// is that a command returns nonzero for invalid state. `process.exitCode` is set
|
|
2109
|
+
// rather than calling `process.exit()`, so buffered stdout cannot be truncated
|
|
2110
|
+
// when the caller is reading the line from a pipe.
|
|
2111
|
+
if (sub === 'status') {
|
|
2112
|
+
const status = computeStatus(opts.targetDir);
|
|
2113
|
+
emit(opts, status.line, { ok: status.ok, status });
|
|
2114
|
+
process.exitCode = status.ok ? 0 : 1;
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
|
|
1446
2118
|
if (sub === 'report') {
|
|
1447
2119
|
const runs = listRuns(opts.targetDir);
|
|
1448
2120
|
const target = opts.runId || runs[0]?.runId;
|
|
1449
2121
|
if (!target) fail(opts, 'No run records found in .cadet/runs/.', () => 2);
|
|
1450
2122
|
const run = loadRun(opts.targetDir, target);
|
|
1451
2123
|
if (!run) fail(opts, `Run ${target} not found.`, () => 2);
|
|
1452
|
-
|
|
1453
|
-
|
|
2124
|
+
// The run report carries the context level, because it is the one place a reader looks to
|
|
2125
|
+
// ask what happened in a run. The level is reported as recorded — never upgraded: a report
|
|
2126
|
+
// that calls advisory loading "enforced" is worse than no report, because the reader stops
|
|
2127
|
+
// looking. Reading the plan and record writes nothing, which is what this command promises.
|
|
2128
|
+
const contextLine = describeContextState({
|
|
2129
|
+
plan: buildContextPlan({ targetDir: opts.targetDir, policy, state: readState(opts.targetDir).state }),
|
|
2130
|
+
record: readContextRecord(opts.targetDir),
|
|
2131
|
+
});
|
|
2132
|
+
if (opts.format === 'json') emit(opts, '', { ok: true, report: { ...buildReport(run), context: contextLine } });
|
|
2133
|
+
else console.log(`${formatReport(run)}
|
|
2134
|
+
${contextLine.line}`);
|
|
1454
2135
|
return;
|
|
1455
2136
|
}
|
|
1456
2137
|
|
|
@@ -1660,7 +2341,7 @@ async function cmdHarness(opts) {
|
|
|
1660
2341
|
return;
|
|
1661
2342
|
}
|
|
1662
2343
|
|
|
1663
|
-
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|changes|reconcile|cleanup|capabilities.`);
|
|
2344
|
+
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|verify-design-review|matrix-check|report|status|changes|reconcile|cleanup|capabilities.`);
|
|
1664
2345
|
}
|
|
1665
2346
|
|
|
1666
2347
|
export async function run(argv) {
|