cadet-agent 0.56.0 → 0.63.1
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 +78 -16
- package/package.json +4 -1
- package/src/cli.mjs +331 -105
- package/src/harness/commands.mjs +85 -7
- package/src/harness/context-protocol.mjs +146 -17
- package/src/harness/csharp.mjs +259 -0
- package/src/harness/diagram.mjs +735 -0
- package/src/harness/gates.mjs +29 -1
- package/src/harness/index.mjs +25 -8
- package/src/harness/noml.mjs +47 -0
- package/src/harness/play.mjs +271 -0
- package/src/harness/policy.mjs +61 -1
- package/src/harness/reconcile.mjs +34 -2
- package/src/harness/state.mjs +298 -12
- package/src/harness/verification.mjs +6 -12
- package/src/harness/acceptance-form.mjs +0 -281
package/src/cli.mjs
CHANGED
|
@@ -13,22 +13,26 @@ import {
|
|
|
13
13
|
parseDesignReviewArtifact, describeDesignReviewGaps, FINDING_DISPOSITIONS,
|
|
14
14
|
DESIGN_REVIEW_GATE, HUMAN_ACCEPTANCE_GATE,
|
|
15
15
|
CONTEXT_LEVELS, buildContextPlan, writeContextPlan, readContextPlan, buildContextRecord,
|
|
16
|
+
resolveWorkItemDocs,
|
|
16
17
|
writeContextRecord, readContextRecord, parseTranscript, validateContextRecord,
|
|
17
18
|
describeContextState, ContextProtocolError,
|
|
18
19
|
enforcementMatrix, describeEnforcement, INTERCEPTION_ACTIONS,
|
|
19
20
|
ARCHITECTURE_GATE, architectureFitnessActive, runArchitectureChecks, summariseChecks,
|
|
20
21
|
describeCheckResults, DEFAULT_CHECK_TIMEOUT_MS,
|
|
21
|
-
ACCEPTANCE_TEMPLATE_RELATIVE, buildAcceptanceForm, parseAcceptanceForm, writeAcceptanceForm,
|
|
22
|
-
acceptanceFormPath, ACCEPTANCE_HUMAN_FIELDS,
|
|
23
22
|
parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
|
|
24
23
|
parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
|
|
25
24
|
findDeferralCycles, readSiblingDeclarations, normalizeWorkItemRef, describeReachabilityGaps,
|
|
26
25
|
REACHABILITY_GATE, runCommand,
|
|
26
|
+
parsePlayDeclaration, validatePlayDeclaration, readSiblingPlayDeclarations, describePlayGaps,
|
|
27
|
+
USER_PLAY_GATE,
|
|
27
28
|
createEvidence, newId, computeInputTreeHash, hashCriteria,
|
|
28
29
|
collectDeclaredTestNames, reconcileTestNames,
|
|
29
30
|
resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
|
|
31
|
+
selfBoundFiles,
|
|
30
32
|
STATE_VERSION, sealedEvidence, recordEvidence, appendEvidence, sealWorkItem, toStateV4,
|
|
31
33
|
isHistoryExternal, HISTORY_ENTRIES_KEPT, resetGatesForNewWorkItem,
|
|
34
|
+
buildStateBrief, renderStateBrief, writeStateBrief, CONTEXT_BRIEF_FILE,
|
|
35
|
+
generateDiagrams, syncDiagramTree, diagramDrift, DIAGRAM_OUT_DIR,
|
|
32
36
|
} from './harness/index.mjs';
|
|
33
37
|
|
|
34
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -57,6 +61,7 @@ function showHelp() {
|
|
|
57
61
|
npx cadet-agent@latest sync --target <dir> Sync a specific directory
|
|
58
62
|
|
|
59
63
|
cadet-agent state init Create the first .cadet/state.json (never overwrites one)
|
|
64
|
+
cadet-agent state brief Print the tier-0 summary: phase, work item, gates, archivable records
|
|
60
65
|
cadet-agent state validate Validate .cadet/state.json against the schema
|
|
61
66
|
cadet-agent state validate --verify-sealed Also read sealed evidence from commit trailers
|
|
62
67
|
cadet-agent state migrate Atomically migrate state to the current version (backup on write)
|
|
@@ -72,8 +77,8 @@ function showHelp() {
|
|
|
72
77
|
cadet-agent harness verify Run a bounded, classified verification loop
|
|
73
78
|
cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
|
|
74
79
|
cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
|
|
80
|
+
cadet-agent harness verify-play Verify a story's Play: declaration and record the deferral (opt-in)
|
|
75
81
|
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
82
|
cadet-agent harness verify-architecture Run the project's declared architecture checks (opt-in)
|
|
78
83
|
cadet-agent harness context plan Write what the phase requires, and why (opt-in protocol)
|
|
79
84
|
cadet-agent harness context record Record what the host loaded, and the level it can claim
|
|
@@ -84,6 +89,8 @@ function showHelp() {
|
|
|
84
89
|
cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
|
|
85
90
|
cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
|
|
86
91
|
cadet-agent harness status Print the one-line framework health line (ok, or the problem)
|
|
92
|
+
cadet-agent harness diagram Render UML component diagrams per package, coloured by progress
|
|
93
|
+
cadet-agent harness verify-diagrams Check the committed diagram tree is current (read-only)
|
|
87
94
|
|
|
88
95
|
Options:
|
|
89
96
|
--target, -t Target directory (default: current working directory)
|
|
@@ -95,14 +102,12 @@ function showHelp() {
|
|
|
95
102
|
--files Comma-separated relevant files to bind evidence to (harness verify|confirm)
|
|
96
103
|
--commit Revision the gate attests, as a hex SHA (harness verify|confirm)
|
|
97
104
|
--expect-phase Refuse to record a gate unless the current phase matches (harness verify|confirm|verify-acs|verify-reachability)
|
|
98
|
-
--reason Why
|
|
105
|
+
--reason Why a human gate is being recorded — the person's own answer (harness confirm)
|
|
99
106
|
--expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
|
|
100
107
|
--environment key=value,... describing what was verified (harness confirm)
|
|
101
108
|
--scope Comma-separated scope of the confirmation (harness confirm)
|
|
102
|
-
--story Story markdown declaring the acceptance criteria or
|
|
103
|
-
--artifact Design-review artifact to check (verify-design-review)
|
|
104
|
-
--epic Epic a generated form belongs to (harness acceptance-form)
|
|
105
|
-
--out Where to write the form (default: the epic's plan directory)
|
|
109
|
+
--story Story markdown declaring the acceptance criteria, reachability or play declaration (harness verify-acs|verify-reachability|verify-play)
|
|
110
|
+
--artifact Design-review artifact to check (harness verify-design-review)
|
|
106
111
|
|
|
107
112
|
--report Test report to derive the inventory from (harness verify-acs|matrix-check)
|
|
108
113
|
--matrix TDD matrix markdown to check (harness matrix-check)
|
|
@@ -111,6 +116,10 @@ function showHelp() {
|
|
|
111
116
|
--relative-to Directory the emitted links are relative to (harness changes; default .cadet/reports)
|
|
112
117
|
--include-cadet Keep .cadet/ bookkeeping among the listed files (harness changes)
|
|
113
118
|
--plans-dir Directory holding the planning artifacts (harness reconcile; default .cadet/agent/project-plans)
|
|
119
|
+
--out Output root for the generated diagram tree (harness diagram|verify-diagrams; default .cadet/diagrams)
|
|
120
|
+
--cs-root Root of the C# sources to scan (harness diagram|verify-diagrams; default the target directory)
|
|
121
|
+
--package Limit the diagram run to one package id (harness diagram|verify-diagrams)
|
|
122
|
+
--include-tests Include Unity test assemblies, which are excluded by default (harness diagram|verify-diagrams)
|
|
114
123
|
--agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
|
|
115
124
|
--older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
|
|
116
125
|
--keep always|active|<work-item ids> for what stays in state.json (state compact; required)
|
|
@@ -198,9 +207,7 @@ function parseArgs(argv) {
|
|
|
198
207
|
case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
199
208
|
case '--story': opts.story = value(a); break;
|
|
200
209
|
case '--artifact': opts.artifact = value(a); break;
|
|
201
|
-
|
|
202
|
-
// The epic the command acts on: `state begin`'s new work item, and the epic an
|
|
203
|
-
// acceptance form is generated for.
|
|
210
|
+
// The epic the command acts on: `state begin`'s new work item.
|
|
204
211
|
case '--epic': opts.epicId = value(a); break;
|
|
205
212
|
// state compact: keep every record inline instead of applying the
|
|
206
213
|
// within-work-item retention rule. Made explicit at the call site, because
|
|
@@ -216,6 +223,12 @@ function parseArgs(argv) {
|
|
|
216
223
|
case '--relative-to': opts.relativeTo = value(a); break;
|
|
217
224
|
case '--include-cadet': opts.includeCadet = true; break;
|
|
218
225
|
case '--plans-dir': opts.plansDir = value(a); break;
|
|
226
|
+
// harness diagram: where the tree lands, which sources to scan, and an
|
|
227
|
+
// optional single-package filter.
|
|
228
|
+
case '--out': opts.out = value(a); break;
|
|
229
|
+
case '--cs-root': opts.csRoot = value(a); break;
|
|
230
|
+
case '--package': opts.packageId = value(a); break;
|
|
231
|
+
case '--include-tests': opts.includeTests = true; break;
|
|
219
232
|
case '--write-coverage': opts.writeCoverage = true; break;
|
|
220
233
|
case '--strict-orphans': opts.strictOrphans = true; break;
|
|
221
234
|
case '--dry-run': opts.dryRun = true; break;
|
|
@@ -578,6 +591,25 @@ async function cmdState(opts) {
|
|
|
578
591
|
return;
|
|
579
592
|
}
|
|
580
593
|
|
|
594
|
+
if (sub === 'brief') {
|
|
595
|
+
// THE TIER-0 SUMMARY OF THE DOCUMENT (0.63.0). Read-only, and derived: every field is read from
|
|
596
|
+
// state.json, and the gate rows use `latestEvidenceForGate` — the same reader a gate check uses —
|
|
597
|
+
// so the brief cannot disagree with the machinery about which record is live. It exists because
|
|
598
|
+
// the context plan used to name the 94 KB document itself as an always-load reference.
|
|
599
|
+
const { exists, state } = readState(opts.targetDir);
|
|
600
|
+
if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
|
|
601
|
+
|
|
602
|
+
// The story and epic paths are resolved HERE and passed in: the builder is pure, and finding a work
|
|
603
|
+
// item's own documents needs the filesystem. A host that can see the path needs no second lookup.
|
|
604
|
+
const brief = buildStateBrief(state, loadPolicy(opts.targetDir), resolveWorkItemDocs(opts.targetDir, state));
|
|
605
|
+
if (opts.format === 'json') {
|
|
606
|
+
emit(opts, '', { ok: true, brief });
|
|
607
|
+
} else {
|
|
608
|
+
emit(opts, renderStateBrief(brief).trimEnd());
|
|
609
|
+
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
581
613
|
if (sub === 'compact') {
|
|
582
614
|
const { exists, state } = readState(opts.targetDir);
|
|
583
615
|
if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
|
|
@@ -853,7 +885,7 @@ async function cmdState(opts) {
|
|
|
853
885
|
return;
|
|
854
886
|
}
|
|
855
887
|
|
|
856
|
-
fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|compact|begin|seal|transition.`);
|
|
888
|
+
fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use brief|validate|migrate|compact|begin|seal|transition.`);
|
|
857
889
|
}
|
|
858
890
|
|
|
859
891
|
/**
|
|
@@ -911,6 +943,94 @@ async function cmdHarness(opts) {
|
|
|
911
943
|
return;
|
|
912
944
|
}
|
|
913
945
|
|
|
946
|
+
if (sub === 'diagram') {
|
|
947
|
+
// UML component diagrams, one per (package × epic), coloured by progress.
|
|
948
|
+
//
|
|
949
|
+
// The work record is READ, never required: on a repository with no state the
|
|
950
|
+
// generator still draws the structure and says why every component is red,
|
|
951
|
+
// which is the same "report, do not fail silently" posture `state validate`
|
|
952
|
+
// takes for a missing document. The UML source and the SVG both come from one
|
|
953
|
+
// model, so they cannot disagree.
|
|
954
|
+
const { exists, state } = readState(opts.targetDir);
|
|
955
|
+
const csRoot = resolve(opts.targetDir, opts.csRoot || '.');
|
|
956
|
+
const outDir = resolve(opts.targetDir, opts.out || DIAGRAM_OUT_DIR);
|
|
957
|
+
let result;
|
|
958
|
+
try {
|
|
959
|
+
result = generateDiagrams({
|
|
960
|
+
rootDir: opts.targetDir,
|
|
961
|
+
outDir,
|
|
962
|
+
csRoot,
|
|
963
|
+
state: exists ? state : null,
|
|
964
|
+
packageFilter: opts.packageId || null,
|
|
965
|
+
includeTests: opts.includeTests === true,
|
|
966
|
+
});
|
|
967
|
+
} catch (err) {
|
|
968
|
+
fail(opts, `could not render the diagrams: ${err.message}`, () => 1, { ok: false, code: 'diagram-failed' });
|
|
969
|
+
}
|
|
970
|
+
const written = syncDiagramTree(outDir, result.files);
|
|
971
|
+
const { summary } = result;
|
|
972
|
+
const shownOut = relative(opts.targetDir, outDir).replace(/\\/g, '/') || '.';
|
|
973
|
+
if (opts.format === 'json') {
|
|
974
|
+
emit(opts, '', { ok: true, ...summary, outDir: shownOut, removed: written.removed });
|
|
975
|
+
} else {
|
|
976
|
+
console.log(`✅ Component diagrams written to ${shownOut} (${summary.packages.length} package(s), ${summary.diagrams} diagram(s)).`);
|
|
977
|
+
for (const pkg of summary.packages) {
|
|
978
|
+
for (const d of pkg.diagrams) {
|
|
979
|
+
const elided = d.elided ? `, ${d.elided} elided` : '';
|
|
980
|
+
console.log(` ${pkg.name} / ${d.epic}: ${d.counts.done} done, ${d.counts['in-progress']} in progress, ${d.counts['not-started']} not started${elided}`);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
if (written.removed) console.log(` Pruned ${written.removed} file(s) left by a previous run.`);
|
|
984
|
+
if (summary.warning) console.log(` ⚠️ ${summary.warning}`);
|
|
985
|
+
console.log(` Open: ${join(shownOut, 'index.html')}`);
|
|
986
|
+
}
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if (sub === 'verify-diagrams') {
|
|
991
|
+
// Read-only: does the committed tree still match the code and the record?
|
|
992
|
+
//
|
|
993
|
+
// It shares one function with `harness reconcile`, so this command and the
|
|
994
|
+
// closure finding cannot disagree about the same tree — and it deliberately
|
|
995
|
+
// does not regenerate, because a check that repaired the thing it checks
|
|
996
|
+
// would always pass.
|
|
997
|
+
const { exists, state } = readState(opts.targetDir);
|
|
998
|
+
const csRoot = resolve(opts.targetDir, opts.csRoot || '.');
|
|
999
|
+
const outDir = resolve(opts.targetDir, opts.out || DIAGRAM_OUT_DIR);
|
|
1000
|
+
const drift = diagramDrift({
|
|
1001
|
+
rootDir: opts.targetDir,
|
|
1002
|
+
outDir,
|
|
1003
|
+
csRoot,
|
|
1004
|
+
state: exists ? state : null,
|
|
1005
|
+
packageFilter: opts.packageId || null,
|
|
1006
|
+
includeTests: opts.includeTests === true,
|
|
1007
|
+
});
|
|
1008
|
+
const shownOut = relative(opts.targetDir, outDir).replace(/\\/g, '/') || '.';
|
|
1009
|
+
if (!drift.available) {
|
|
1010
|
+
emit(opts, `ℹ️ No diagram tree at ${shownOut} — nothing to verify.`, { ok: true, available: false, reason: drift.reason, outDir: shownOut });
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (opts.format === 'json') {
|
|
1014
|
+
emit(opts, '', {
|
|
1015
|
+
ok: drift.ok,
|
|
1016
|
+
available: true,
|
|
1017
|
+
outDir: shownOut,
|
|
1018
|
+
expected: drift.expected,
|
|
1019
|
+
missing: drift.missing,
|
|
1020
|
+
changed: drift.changed,
|
|
1021
|
+
extra: drift.extra,
|
|
1022
|
+
});
|
|
1023
|
+
} else if (drift.ok) {
|
|
1024
|
+
console.log(`✅ Diagrams are current: ${drift.expected} file(s) match the code and the work record.`);
|
|
1025
|
+
} else {
|
|
1026
|
+
console.error(`❌ Diagrams are stale: ${drift.missing.length} missing, ${drift.changed.length} changed, ${drift.extra.length} extra file(s).`);
|
|
1027
|
+
for (const file of [...drift.missing, ...drift.changed, ...drift.extra].slice(0, 10)) console.error(` ${file}`);
|
|
1028
|
+
console.error(' Run "cadet-agent harness diagram" and commit the result.');
|
|
1029
|
+
}
|
|
1030
|
+
if (!drift.ok) process.exit(1);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
914
1034
|
if (sub === 'confirm') {
|
|
915
1035
|
const gate = opts.gate;
|
|
916
1036
|
if (!gate) fail(opts, 'harness confirm requires --gate <gate>');
|
|
@@ -920,23 +1040,52 @@ async function cmdHarness(opts) {
|
|
|
920
1040
|
if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
|
|
921
1041
|
assertExpectedPhase(opts, state);
|
|
922
1042
|
|
|
923
|
-
// A
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
// is
|
|
1043
|
+
// A human gate is recorded from the person's own answer, and from nothing else.
|
|
1044
|
+
//
|
|
1045
|
+
// There is no artifact and no form. These two gates ask what a PERSON did and what they saw,
|
|
1046
|
+
// and the answer is a sentence the person said: `--reason` carries it, and the record binds
|
|
1047
|
+
// the active work item and whatever files the answer covers. A form was the earlier route and
|
|
1048
|
+
// was removed (2026-10-03): it added a document to write, read and keep in sync, and its
|
|
1049
|
+
// blank fields stop a lazy agent rather than a dishonest one, so it bought less than it cost.
|
|
1050
|
+
const HUMAN_GATE = gate === HUMAN_ACCEPTANCE_GATE || gate === USER_PLAY_GATE;
|
|
1051
|
+
|
|
1052
|
+
if (opts.artifact) {
|
|
1053
|
+
fail(
|
|
1054
|
+
opts,
|
|
1055
|
+
'--artifact is not accepted by "harness confirm": "harness verify-design-review" is the command that takes an artifact. '
|
|
1056
|
+
+ 'Record a human gate from the person\'s words with --reason.',
|
|
1057
|
+
() => 1,
|
|
1058
|
+
{ ok: false, gate, code: 'artifact-not-applicable' },
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// A caller that passes the removed flags is refused by name rather than recorded, so nothing
|
|
1063
|
+
// they typed is silently discarded.
|
|
927
1064
|
const removedFlags = ['witness', 'limitations'].filter((k) => opts[k] !== undefined && opts[k] !== null);
|
|
928
1065
|
if (removedFlags.length > 0) {
|
|
929
1066
|
const flags = removedFlags.map((k) => `--${k}`);
|
|
930
1067
|
fail(
|
|
931
1068
|
opts,
|
|
932
|
-
`${flags.join(' and ')} no longer exist, so nothing was recorded. A human
|
|
933
|
-
+ '
|
|
934
|
-
+ 'The witness and the limitations live in that file, where a reviewer can read them.',
|
|
1069
|
+
`${flags.join(' and ')} no longer exist, so nothing was recorded. A human gate is recorded from the `
|
|
1070
|
+
+ 'person\'s own answer: ask them, then pass their words to --reason.',
|
|
935
1071
|
() => 1,
|
|
936
1072
|
{ ok: false, gate, code: 'flag-removed', flags },
|
|
937
1073
|
);
|
|
938
1074
|
}
|
|
939
1075
|
|
|
1076
|
+
// The answer cannot be empty. The form refused three blank fields; the same refusal now stands
|
|
1077
|
+
// on one field, and it holds whether or not strictClosure is enabled — an attestation that
|
|
1078
|
+
// says nothing is not an attestation.
|
|
1079
|
+
if (HUMAN_GATE && (!opts.reason || String(opts.reason).trim() === '')) {
|
|
1080
|
+
fail(
|
|
1081
|
+
opts,
|
|
1082
|
+
`"${gate}" is a person's record, so --reason must carry what they said. `
|
|
1083
|
+
+ 'Ask them what they did and what they saw, then record their answer.',
|
|
1084
|
+
() => 1,
|
|
1085
|
+
{ ok: false, gate, code: 'reason-required' },
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
940
1089
|
const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
|
|
941
1090
|
const mc = strict?.manualConfirmation || null;
|
|
942
1091
|
// One reference instant for the whole command, captured before any work.
|
|
@@ -947,76 +1096,18 @@ async function cmdHarness(opts) {
|
|
|
947
1096
|
|
|
948
1097
|
// Collect EVERY missing field so the caller fixes the record in one pass,
|
|
949
1098
|
// 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 !== '';
|
|
958
1099
|
const missing = [];
|
|
959
1100
|
if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
|
|
960
1101
|
if (mc?.requireExpiresAt !== false && strict) {
|
|
961
1102
|
if (!opts.expiresAt) missing.push('--expires-at');
|
|
962
1103
|
else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
|
|
963
1104
|
}
|
|
964
|
-
if (mc?.requireEnvironment !== false && strict &&
|
|
965
|
-
if (mc?.requireScope !== false && strict &&
|
|
1105
|
+
if (mc?.requireEnvironment !== false && strict && (!opts.environment || String(opts.environment).trim() === '')) missing.push('--environment');
|
|
1106
|
+
if (mc?.requireScope !== false && strict && (!opts.scope || opts.scope.length === 0)) missing.push('--scope');
|
|
966
1107
|
if (missing.length) {
|
|
967
1108
|
fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
|
|
968
1109
|
}
|
|
969
1110
|
|
|
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
|
-
|
|
1020
1111
|
// A gate listed in disallowManualFor may never be satisfied by a human
|
|
1021
1112
|
// assertion; point at the automated path instead of accepting the record.
|
|
1022
1113
|
if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
|
|
@@ -1081,8 +1172,6 @@ async function cmdHarness(opts) {
|
|
|
1081
1172
|
rootDir: opts.targetDir,
|
|
1082
1173
|
approvedBy: opts.approvedBy || 'user',
|
|
1083
1174
|
commit: opts.commit || null,
|
|
1084
|
-
witness: opts.witness || null,
|
|
1085
|
-
limitations: opts.limitations || null,
|
|
1086
1175
|
at,
|
|
1087
1176
|
});
|
|
1088
1177
|
|
|
@@ -1679,36 +1768,132 @@ async function cmdHarness(opts) {
|
|
|
1679
1768
|
return;
|
|
1680
1769
|
}
|
|
1681
1770
|
|
|
1682
|
-
if (sub === '
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1771
|
+
if (sub === 'verify-play') {
|
|
1772
|
+
refuseCommandOverride('harness verify-play', "the story's Play: declaration");
|
|
1773
|
+
// Mechanical play-declaration verification (contract v7 §1). A story states whether a
|
|
1774
|
+
// person can play its deliverable, or which work item will make it playable; this checks
|
|
1775
|
+
// that declaration against the work items that exist.
|
|
1776
|
+
//
|
|
1777
|
+
// WHY THIS COMMAND CANNOT SET THE GATE FOR A PLAYABLE STORY. The gate asks whether a
|
|
1778
|
+
// PERSON played the work. So this records the gate for a `deferred` declaration — the
|
|
1779
|
+
// declaration is the answer, exactly as a reachability deferral is — and REFUSES to
|
|
1780
|
+
// record it for a `required` one, pointing at the form. An agent that could answer
|
|
1781
|
+
// "the user played it" in a sentence would make the gate worthless, which is the whole
|
|
1782
|
+
// reason `userPlaythroughConfirmed` is human-owned in the gate registry.
|
|
1783
|
+
if (!opts.story) fail(opts, 'harness verify-play requires --story <path>');
|
|
1784
|
+
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1785
|
+
const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
|
|
1686
1786
|
const { exists, state } = readState(opts.targetDir);
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
const templatePath = join(opts.targetDir, ...ACCEPTANCE_TEMPLATE_RELATIVE.split('/'));
|
|
1694
|
-
let template;
|
|
1787
|
+
assertExpectedPhase(opts, state);
|
|
1788
|
+
const playEnabled = policy.userPlay?.enabled === true;
|
|
1789
|
+
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
1790
|
+
const phase = state?.session?.currentPhase || 'implementation';
|
|
1791
|
+
|
|
1792
|
+
let playDeclaration;
|
|
1695
1793
|
try {
|
|
1696
|
-
|
|
1794
|
+
playDeclaration = parsePlayDeclaration(storyPath);
|
|
1697
1795
|
} catch (err) {
|
|
1698
|
-
fail(opts, `
|
|
1796
|
+
fail(opts, `cannot read story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-unreadable', story: opts.story });
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
const playWorkItems = exists ? collectWorkItems(state) : null;
|
|
1800
|
+
const playValidation = validatePlayDeclaration(playDeclaration, { workItems: playWorkItems, self: basename(storyPath) });
|
|
1801
|
+
const playSiblings = readSiblingPlayDeclarations(storyPath, { workItems: playWorkItems });
|
|
1802
|
+
const playGraph = playSiblings.length > 0
|
|
1803
|
+
? playSiblings
|
|
1804
|
+
: [{ id: basename(storyPath), aliases: [], declaration: playDeclaration }];
|
|
1805
|
+
const playCycles = exists ? findDeferralCycles(playGraph) : [];
|
|
1806
|
+
const playGaps = describePlayGaps({ validation: playValidation, cycles: playCycles, story: opts.story });
|
|
1807
|
+
|
|
1808
|
+
// A playable story. The gate is NOT set here; the refusal names the route that can set it —
|
|
1809
|
+
// a person plays the work, and the agent records that person's answer with `--reason`.
|
|
1810
|
+
if (playValidation.ok && playValidation.code === 'required') {
|
|
1811
|
+
fail(
|
|
1812
|
+
opts,
|
|
1813
|
+
`${USER_PLAY_GATE} is a person's record, and this story declares its deliverable playable: ${playDeclaration.instruction} `
|
|
1814
|
+
+ `Ask the person to play it, then record their answer: cadet-agent harness confirm --gate ${USER_PLAY_GATE} `
|
|
1815
|
+
+ `--reason "<what they did and what they saw>" --files <story>.`,
|
|
1816
|
+
() => 1,
|
|
1817
|
+
{ ok: false, gate: USER_PLAY_GATE, code: 'play-required', story: opts.story, declaration: playDeclaration },
|
|
1818
|
+
);
|
|
1699
1819
|
}
|
|
1700
|
-
|
|
1701
|
-
const
|
|
1702
|
-
|
|
1703
|
-
|
|
1820
|
+
|
|
1821
|
+
const playOk = playValidation.ok && playCycles.length === 0;
|
|
1822
|
+
|
|
1823
|
+
if (!playEnabled) {
|
|
1824
|
+
if (opts.format === 'json') {
|
|
1825
|
+
emit(opts, '', { ok: playOk, story: opts.story, declaration: playDeclaration, play: playValidation, cycles: playCycles, gateSet: false, enabled: false });
|
|
1826
|
+
} else if (playOk) {
|
|
1827
|
+
console.log(`✅ Play declaration for ${opts.story}: ${playValidation.message}`);
|
|
1828
|
+
console.log(' userPlay.enabled is false — reported only, state.json unchanged.');
|
|
1829
|
+
} else {
|
|
1830
|
+
console.error(`⚠️ Play gaps in ${opts.story} (userPlay.enabled is false — reported only):`);
|
|
1831
|
+
for (const line of playGaps) console.error(line);
|
|
1832
|
+
}
|
|
1833
|
+
if (!playOk) process.exit(1);
|
|
1834
|
+
return;
|
|
1704
1835
|
}
|
|
1705
|
-
|
|
1836
|
+
|
|
1837
|
+
if (!playOk) {
|
|
1838
|
+
const detail = {
|
|
1839
|
+
ok: false,
|
|
1840
|
+
story: opts.story,
|
|
1841
|
+
declaration: playDeclaration,
|
|
1842
|
+
play: playValidation,
|
|
1843
|
+
cycles: playCycles,
|
|
1844
|
+
gateSet: false,
|
|
1845
|
+
code: playValidation.ok !== true ? playValidation.code : 'deferral-cycle',
|
|
1846
|
+
};
|
|
1847
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
1848
|
+
else {
|
|
1849
|
+
console.error(`❌ Cannot set ${USER_PLAY_GATE} for ${opts.story}:`);
|
|
1850
|
+
for (const line of playGaps) console.error(line);
|
|
1851
|
+
}
|
|
1852
|
+
process.exit(1);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
const playAt = new Date();
|
|
1856
|
+
const playEvidence = createEvidence({
|
|
1857
|
+
evidenceId: newId(),
|
|
1858
|
+
workItemId,
|
|
1859
|
+
acceptanceCriterionId: null,
|
|
1860
|
+
phase,
|
|
1861
|
+
gate: USER_PLAY_GATE,
|
|
1862
|
+
status: 'passed',
|
|
1863
|
+
command: `harness verify-play --story ${opts.story}`,
|
|
1864
|
+
result: `play deferred (${playValidation.code})`,
|
|
1865
|
+
exitCode: 0,
|
|
1866
|
+
commit: opts.commit || null,
|
|
1867
|
+
inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
|
|
1868
|
+
criteriaHash: hashCriteria([
|
|
1869
|
+
workItemId,
|
|
1870
|
+
playValidation.code,
|
|
1871
|
+
playDeclaration.deferTo || playDeclaration.instruction || '',
|
|
1872
|
+
]),
|
|
1873
|
+
relevantFiles: [storyRel],
|
|
1874
|
+
createdAt: playAt,
|
|
1875
|
+
expiresAt: null,
|
|
1876
|
+
freshnessPolicy: { scope: 'story' },
|
|
1877
|
+
source: 'automated',
|
|
1878
|
+
});
|
|
1879
|
+
|
|
1880
|
+
const playLedger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
|
|
1881
|
+
playLedger.addEvidence(playEvidence);
|
|
1882
|
+
playLedger.addDecision({ kind: 'stop', reason: `play deferred (${playValidation.code})`, scope: 'declaration only' });
|
|
1883
|
+
playLedger.finalize({ status: 'ok' });
|
|
1884
|
+
const playLedgerPath = playLedger.persist();
|
|
1885
|
+
|
|
1886
|
+
if (exists) {
|
|
1887
|
+
const next = recordEvidence(state, playEvidence);
|
|
1888
|
+
writeState(opts.targetDir, next);
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1706
1891
|
if (opts.format === 'json') {
|
|
1707
|
-
emit(opts, '', { ok: true,
|
|
1892
|
+
emit(opts, '', { ok: true, story: opts.story, play: playValidation, cycles: playCycles, evidenceId: playEvidence.evidenceId, gateSet: exists, runId: playLedger.runId, path: playLedgerPath });
|
|
1708
1893
|
} else {
|
|
1709
|
-
console.log(
|
|
1710
|
-
console.log(
|
|
1711
|
-
console.log(`
|
|
1894
|
+
console.log(`✅ ${USER_PLAY_GATE} for ${opts.story}: ${playValidation.message}`);
|
|
1895
|
+
console.log(' The story defers the playthrough, so the declaration is the record — no form is needed.');
|
|
1896
|
+
console.log(` Ledger: ${playLedgerPath}`);
|
|
1712
1897
|
}
|
|
1713
1898
|
return;
|
|
1714
1899
|
}
|
|
@@ -1728,7 +1913,13 @@ async function cmdHarness(opts) {
|
|
|
1728
1913
|
const { exists, state } = readState(opts.targetDir);
|
|
1729
1914
|
|
|
1730
1915
|
if (action === 'plan') {
|
|
1731
|
-
|
|
1916
|
+
// THE BRIEF IS WRITTEN BEFORE THE PLAN IS BUILT, so the plan can name it and hash it. A plan
|
|
1917
|
+
// that named a file nobody had written would report its own tier-0 reference as MISSING, and
|
|
1918
|
+
// the caller would have to run two commands to get one answer.
|
|
1919
|
+
// Resolved once and shared, so the brief that is written and the plan that hashes it agree.
|
|
1920
|
+
const docs = resolveWorkItemDocs(opts.targetDir, state);
|
|
1921
|
+
writeStateBrief(opts.targetDir, state, policy, docs);
|
|
1922
|
+
const plan = buildContextPlan({ targetDir: opts.targetDir, policy, state, docs });
|
|
1732
1923
|
const planPath = writeContextPlan(opts.targetDir, plan);
|
|
1733
1924
|
const shown = relative(opts.targetDir, planPath).replace(/\\/g, '/');
|
|
1734
1925
|
const fit = plan.budget.fits === null ? 'no context budget declared'
|
|
@@ -2363,6 +2554,41 @@ export async function run(argv) {
|
|
|
2363
2554
|
const opts = parseArgs(argv);
|
|
2364
2555
|
const commandKey = resolveCommand(argv);
|
|
2365
2556
|
|
|
2557
|
+
// A command may not bind its own outputs as the evidence it records.
|
|
2558
|
+
//
|
|
2559
|
+
// A record's `inputTreeHash` covers the files `--files` names, and `harness verify`,
|
|
2560
|
+
// `harness confirm`, `harness verify-acs`, `harness verify-reachability`,
|
|
2561
|
+
// `harness verify-design-review` and `harness verify-architecture` then write the ledger
|
|
2562
|
+
// and `.cadet/state.json`. Binding one of those makes the record stale at the instant it
|
|
2563
|
+
// is created — the write it describes changes a file the hash covers — so the next
|
|
2564
|
+
// `state transition --dry-run` refuses the boundary for a record the harness itself just
|
|
2565
|
+
// wrote. Refused here, in the dispatcher, rather than at each of the four sites that
|
|
2566
|
+
// resolve `--files`: the registry already declares what each command writes, so the check
|
|
2567
|
+
// covers a new command and a new flag the moment they are registered, which is the same
|
|
2568
|
+
// reason `--dry-run` is driven from here (contract C13).
|
|
2569
|
+
if (commandKey && opts.files && opts.files.length) {
|
|
2570
|
+
const selfBound = selfBoundFiles(commandKey, opts.files);
|
|
2571
|
+
if (selfBound.length > 0) {
|
|
2572
|
+
const named = selfBound.join(', ');
|
|
2573
|
+
fail(
|
|
2574
|
+
opts,
|
|
2575
|
+
`--files names ${selfBound.length === 1 ? 'a path' : 'paths'} that "${commandKey}" writes itself: ${named}. `
|
|
2576
|
+
+ 'Bind the files this gate JUDGES — sources, tests, and the story or epic markdown — never the ledger '
|
|
2577
|
+
+ `or state document the evidence is recorded in: "${commandKey}" writes ${named} as it records the `
|
|
2578
|
+
+ 'record, so a binding to it reads as stale the moment it is written ("input tree hash changed since '
|
|
2579
|
+
+ 'the evidence was recorded"), and the gate can never be fresh.',
|
|
2580
|
+
() => 1,
|
|
2581
|
+
{
|
|
2582
|
+
ok: false,
|
|
2583
|
+
command: commandKey,
|
|
2584
|
+
code: 'self-bound-files',
|
|
2585
|
+
files: selfBound,
|
|
2586
|
+
writes: COMMANDS[commandKey].writes || [],
|
|
2587
|
+
},
|
|
2588
|
+
);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2366
2592
|
// Global `--dry-run`, driven by the registry rather than by each handler.
|
|
2367
2593
|
//
|
|
2368
2594
|
// This is the structural fix for the class of bug where a mutating command
|