cadet-agent 0.60.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 +40 -12
- package/package.json +4 -1
- package/src/cli.mjs +180 -201
- package/src/harness/commands.mjs +23 -20
- package/src/harness/context-protocol.mjs +143 -14
- package/src/harness/csharp.mjs +259 -0
- package/src/harness/diagram.mjs +735 -0
- package/src/harness/gates.mjs +3 -2
- package/src/harness/index.mjs +17 -12
- package/src/harness/noml.mjs +47 -0
- package/src/harness/play.mjs +2 -2
- package/src/harness/reconcile.mjs +34 -2
- package/src/harness/state.mjs +245 -10
- package/src/harness/verification.mjs +6 -12
- package/src/harness/acceptance-form.mjs +0 -281
- package/src/harness/play-form.mjs +0 -193
package/src/cli.mjs
CHANGED
|
@@ -13,26 +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,
|
|
27
26
|
parsePlayDeclaration, validatePlayDeclaration, readSiblingPlayDeclarations, describePlayGaps,
|
|
28
|
-
USER_PLAY_GATE,
|
|
29
|
-
PLAY_FORM_GATE, PLAY_FORM_SUFFIX,
|
|
27
|
+
USER_PLAY_GATE,
|
|
30
28
|
createEvidence, newId, computeInputTreeHash, hashCriteria,
|
|
31
29
|
collectDeclaredTestNames, reconcileTestNames,
|
|
32
30
|
resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
|
|
33
31
|
selfBoundFiles,
|
|
34
32
|
STATE_VERSION, sealedEvidence, recordEvidence, appendEvidence, sealWorkItem, toStateV4,
|
|
35
33
|
isHistoryExternal, HISTORY_ENTRIES_KEPT, resetGatesForNewWorkItem,
|
|
34
|
+
buildStateBrief, renderStateBrief, writeStateBrief, CONTEXT_BRIEF_FILE,
|
|
35
|
+
generateDiagrams, syncDiagramTree, diagramDrift, DIAGRAM_OUT_DIR,
|
|
36
36
|
} from './harness/index.mjs';
|
|
37
37
|
|
|
38
38
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -61,6 +61,7 @@ function showHelp() {
|
|
|
61
61
|
npx cadet-agent@latest sync --target <dir> Sync a specific directory
|
|
62
62
|
|
|
63
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
|
|
64
65
|
cadet-agent state validate Validate .cadet/state.json against the schema
|
|
65
66
|
cadet-agent state validate --verify-sealed Also read sealed evidence from commit trailers
|
|
66
67
|
cadet-agent state migrate Atomically migrate state to the current version (backup on write)
|
|
@@ -77,9 +78,7 @@ function showHelp() {
|
|
|
77
78
|
cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
|
|
78
79
|
cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
|
|
79
80
|
cadet-agent harness verify-play Verify a story's Play: declaration and record the deferral (opt-in)
|
|
80
|
-
cadet-agent harness play-form Write a user-playthrough form for a story, filled in from state (opt-in)
|
|
81
81
|
cadet-agent harness verify-design-review Check the design-review artifact and record the gate (opt-in)
|
|
82
|
-
cadet-agent harness acceptance-form Write a human-acceptance form for an epic, filled in from state
|
|
83
82
|
cadet-agent harness verify-architecture Run the project's declared architecture checks (opt-in)
|
|
84
83
|
cadet-agent harness context plan Write what the phase requires, and why (opt-in protocol)
|
|
85
84
|
cadet-agent harness context record Record what the host loaded, and the level it can claim
|
|
@@ -90,6 +89,8 @@ function showHelp() {
|
|
|
90
89
|
cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
|
|
91
90
|
cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
|
|
92
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)
|
|
93
94
|
|
|
94
95
|
Options:
|
|
95
96
|
--target, -t Target directory (default: current working directory)
|
|
@@ -101,14 +102,12 @@ function showHelp() {
|
|
|
101
102
|
--files Comma-separated relevant files to bind evidence to (harness verify|confirm)
|
|
102
103
|
--commit Revision the gate attests, as a hex SHA (harness verify|confirm)
|
|
103
104
|
--expect-phase Refuse to record a gate unless the current phase matches (harness verify|confirm|verify-acs|verify-reachability)
|
|
104
|
-
--reason Why
|
|
105
|
+
--reason Why a human gate is being recorded — the person's own answer (harness confirm)
|
|
105
106
|
--expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
|
|
106
107
|
--environment key=value,... describing what was verified (harness confirm)
|
|
107
108
|
--scope Comma-separated scope of the confirmation (harness confirm)
|
|
108
|
-
--story Story markdown declaring the acceptance criteria, reachability or play declaration (harness verify-acs|verify-reachability|verify-play
|
|
109
|
-
--artifact Design-review artifact to check (verify-design-review)
|
|
110
|
-
--epic Epic a generated form belongs to (harness acceptance-form)
|
|
111
|
-
--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)
|
|
112
111
|
|
|
113
112
|
--report Test report to derive the inventory from (harness verify-acs|matrix-check)
|
|
114
113
|
--matrix TDD matrix markdown to check (harness matrix-check)
|
|
@@ -117,6 +116,10 @@ function showHelp() {
|
|
|
117
116
|
--relative-to Directory the emitted links are relative to (harness changes; default .cadet/reports)
|
|
118
117
|
--include-cadet Keep .cadet/ bookkeeping among the listed files (harness changes)
|
|
119
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)
|
|
120
123
|
--agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
|
|
121
124
|
--older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
|
|
122
125
|
--keep always|active|<work-item ids> for what stays in state.json (state compact; required)
|
|
@@ -204,9 +207,7 @@ function parseArgs(argv) {
|
|
|
204
207
|
case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
205
208
|
case '--story': opts.story = value(a); break;
|
|
206
209
|
case '--artifact': opts.artifact = value(a); break;
|
|
207
|
-
|
|
208
|
-
// The epic the command acts on: `state begin`'s new work item, and the epic an
|
|
209
|
-
// acceptance form is generated for.
|
|
210
|
+
// The epic the command acts on: `state begin`'s new work item.
|
|
210
211
|
case '--epic': opts.epicId = value(a); break;
|
|
211
212
|
// state compact: keep every record inline instead of applying the
|
|
212
213
|
// within-work-item retention rule. Made explicit at the call site, because
|
|
@@ -222,6 +223,12 @@ function parseArgs(argv) {
|
|
|
222
223
|
case '--relative-to': opts.relativeTo = value(a); break;
|
|
223
224
|
case '--include-cadet': opts.includeCadet = true; break;
|
|
224
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;
|
|
225
232
|
case '--write-coverage': opts.writeCoverage = true; break;
|
|
226
233
|
case '--strict-orphans': opts.strictOrphans = true; break;
|
|
227
234
|
case '--dry-run': opts.dryRun = true; break;
|
|
@@ -584,6 +591,25 @@ async function cmdState(opts) {
|
|
|
584
591
|
return;
|
|
585
592
|
}
|
|
586
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
|
+
|
|
587
613
|
if (sub === 'compact') {
|
|
588
614
|
const { exists, state } = readState(opts.targetDir);
|
|
589
615
|
if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
|
|
@@ -859,7 +885,7 @@ async function cmdState(opts) {
|
|
|
859
885
|
return;
|
|
860
886
|
}
|
|
861
887
|
|
|
862
|
-
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.`);
|
|
863
889
|
}
|
|
864
890
|
|
|
865
891
|
/**
|
|
@@ -917,6 +943,94 @@ async function cmdHarness(opts) {
|
|
|
917
943
|
return;
|
|
918
944
|
}
|
|
919
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
|
+
|
|
920
1034
|
if (sub === 'confirm') {
|
|
921
1035
|
const gate = opts.gate;
|
|
922
1036
|
if (!gate) fail(opts, 'harness confirm requires --gate <gate>');
|
|
@@ -926,23 +1040,52 @@ async function cmdHarness(opts) {
|
|
|
926
1040
|
if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
|
|
927
1041
|
assertExpectedPhase(opts, state);
|
|
928
1042
|
|
|
929
|
-
// A
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
// 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.
|
|
933
1064
|
const removedFlags = ['witness', 'limitations'].filter((k) => opts[k] !== undefined && opts[k] !== null);
|
|
934
1065
|
if (removedFlags.length > 0) {
|
|
935
1066
|
const flags = removedFlags.map((k) => `--${k}`);
|
|
936
1067
|
fail(
|
|
937
1068
|
opts,
|
|
938
|
-
`${flags.join(' and ')} no longer exist, so nothing was recorded. A human
|
|
939
|
-
+ '
|
|
940
|
-
+ '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.',
|
|
941
1071
|
() => 1,
|
|
942
1072
|
{ ok: false, gate, code: 'flag-removed', flags },
|
|
943
1073
|
);
|
|
944
1074
|
}
|
|
945
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
|
+
|
|
946
1089
|
const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
|
|
947
1090
|
const mc = strict?.manualConfirmation || null;
|
|
948
1091
|
// One reference instant for the whole command, captured before any work.
|
|
@@ -953,109 +1096,18 @@ async function cmdHarness(opts) {
|
|
|
953
1096
|
|
|
954
1097
|
// Collect EVERY missing field so the caller fixes the record in one pass,
|
|
955
1098
|
// rather than discovering one omission per invocation.
|
|
956
|
-
//
|
|
957
|
-
// The two fields the acceptance ARTIFACT supplies are exempt when it is present. The check
|
|
958
|
-
// below refuses `--scope` and `--environment` beside `--artifact` (they would be a second,
|
|
959
|
-
// competing source for the same claim), so demanding them here made the command unsatisfiable
|
|
960
|
-
// in both directions under the shipped policy: with them it was `artifact-conflicts-with-flags`,
|
|
961
|
-
// without them `strict-metadata-missing`. A gate no caller can satisfy is not a strict gate.
|
|
962
|
-
// The form carries both, and its completeness is checked when it is parsed.
|
|
963
|
-
const artifactSuppliesScope = typeof opts.artifact === 'string' && opts.artifact !== '';
|
|
964
1099
|
const missing = [];
|
|
965
1100
|
if (mc?.requireReason !== false && strict && (!opts.reason || String(opts.reason).trim() === '')) missing.push('--reason');
|
|
966
1101
|
if (mc?.requireExpiresAt !== false && strict) {
|
|
967
1102
|
if (!opts.expiresAt) missing.push('--expires-at');
|
|
968
1103
|
else if (Number.isNaN(Date.parse(opts.expiresAt))) missing.push('--expires-at (not an ISO-8601 date-time)');
|
|
969
1104
|
}
|
|
970
|
-
if (mc?.requireEnvironment !== false && strict &&
|
|
971
|
-
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');
|
|
972
1107
|
if (missing.length) {
|
|
973
1108
|
fail(opts, `strictClosure requires manual-confirmation metadata. Missing: ${missing.join(', ')}.`, () => 1, { ok: false, gate, code: 'strict-metadata-missing', missing });
|
|
974
1109
|
}
|
|
975
1110
|
|
|
976
|
-
// A human acceptance is recorded from a form, and from nothing else.
|
|
977
|
-
//
|
|
978
|
-
// There is no flag route. Two routes to one gate means the weaker route defines the
|
|
979
|
-
// gate, and the flag route's only advantage was skipping the file — at the cost of a
|
|
980
|
-
// record no person can read later. The form costs two commands, leaves the acceptance
|
|
981
|
-
// somewhere a reviewer can open, and removes the second copy that could disagree with
|
|
982
|
-
// the record. `--witness` and `--limitations` no longer exist; a caller that passes
|
|
983
|
-
// them is refused here rather than silently recorded, which is why the check is on
|
|
984
|
-
// the missing artifact rather than on the flags.
|
|
985
|
-
const FORM_RECORDED_GATES = new Set([HUMAN_ACCEPTANCE_GATE, USER_PLAY_GATE]);
|
|
986
|
-
if (gate === HUMAN_ACCEPTANCE_GATE && !opts.artifact) {
|
|
987
|
-
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' });
|
|
988
|
-
}
|
|
989
|
-
// A playthrough is recorded from a form too, and from nothing else. The gate asks whether a
|
|
990
|
-
// PERSON played the delivered work, so the only route is that person's own account of it —
|
|
991
|
-
// which is also why `harness verify-play` refuses a `required` declaration.
|
|
992
|
-
if (gate === USER_PLAY_GATE && !opts.artifact) {
|
|
993
|
-
fail(opts, `a playthrough is recorded from a form: run "cadet-agent harness play-form --story <path>" to write one pre-filled from state, play the work, fill in its blank fields, then pass it back with --artifact <the form>.`, () => 1, { ok: false, gate, code: 'play-form-required' });
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
// `harness acceptance-form` and `harness play-form` write the form pre-filled from state;
|
|
997
|
-
// this reads it back.
|
|
998
|
-
if (opts.artifact) {
|
|
999
|
-
const isPlaythrough = gate === USER_PLAY_GATE;
|
|
1000
|
-
if (!FORM_RECORDED_GATES.has(gate)) {
|
|
1001
|
-
fail(opts, `--artifact is only for ${HUMAN_ACCEPTANCE_GATE} and ${USER_PLAY_GATE}: every other gate's evidence comes from its own command or from explicit fields.`, () => 1, { ok: false, gate, code: 'artifact-not-applicable' });
|
|
1002
|
-
}
|
|
1003
|
-
const conflicting = ['scope', 'environment'].filter((k) => opts[k]);
|
|
1004
|
-
if (conflicting.length > 0) {
|
|
1005
|
-
fail(opts, `--artifact already carries the record, 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 });
|
|
1006
|
-
}
|
|
1007
|
-
let formText;
|
|
1008
|
-
try {
|
|
1009
|
-
formText = readFileSync(opts.artifact, 'utf-8');
|
|
1010
|
-
} catch (err) {
|
|
1011
|
-
fail(opts, `the ${isPlaythrough ? 'playthrough' : 'acceptance'} form could not be read (${err.message}).`, () => 1, { ok: false, gate, code: 'artifact-unreadable' });
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
if (isPlaythrough) {
|
|
1015
|
-
const form = parsePlayForm(formText);
|
|
1016
|
-
if (form.incomplete.length > 0) {
|
|
1017
|
-
fail(opts, `the form still has unfilled fields: ${form.incomplete.join(', ')}. Fill them in ${opts.artifact} and run the command again — a playthrough nobody described is not a playthrough.`, () => 1, { ok: false, gate, code: 'play-form-incomplete', missing: form.incomplete });
|
|
1018
|
-
}
|
|
1019
|
-
// The record binds to the ACTIVE work item, so a form that plays a different story is
|
|
1020
|
-
// refused rather than recorded against the one in flight — the same failure the
|
|
1021
|
-
// story-bound evidence rules exist to prevent, wearing a filled-in form.
|
|
1022
|
-
const activeStory = state?.activeWorkItem?.storyId || null;
|
|
1023
|
-
const formStory = String(form.story || '');
|
|
1024
|
-
if (activeStory && formStory && !formStory.includes(activeStory)) {
|
|
1025
|
-
fail(opts, `the form plays "${form.story}", but the active work item is "${activeStory}". Record a playthrough of the work in flight, or begin that story first.`, () => 1, { ok: false, gate, code: 'play-form-story-mismatch', story: form.story, active: activeStory });
|
|
1026
|
-
}
|
|
1027
|
-
opts.witness = form.witness;
|
|
1028
|
-
opts.limitations = form.limitations;
|
|
1029
|
-
opts.environment = form.environment || null;
|
|
1030
|
-
opts.scope = [form.story || activeStory || 'playthrough'];
|
|
1031
|
-
if (!opts.files || opts.files.length === 0) opts.files = form.fileList;
|
|
1032
|
-
opts.files = opts.files && opts.files.length ? opts.files : null;
|
|
1033
|
-
opts.filesGiven = opts.files !== null;
|
|
1034
|
-
opts.playedBy = form.player;
|
|
1035
|
-
} else {
|
|
1036
|
-
const form = parseAcceptanceForm(formText);
|
|
1037
|
-
if (form.incomplete.length > 0) {
|
|
1038
|
-
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 });
|
|
1039
|
-
}
|
|
1040
|
-
if (state && form.epic && state.epics && !state.epics[form.epic]) {
|
|
1041
|
-
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 });
|
|
1042
|
-
}
|
|
1043
|
-
opts.witness = form.witness;
|
|
1044
|
-
opts.limitations = form.limitations;
|
|
1045
|
-
opts.environment = form.environment || null;
|
|
1046
|
-
opts.scope = [form.epic];
|
|
1047
|
-
if (!opts.files || opts.files.length === 0) opts.files = form.fileList;
|
|
1048
|
-
opts.files = opts.files && opts.files.length ? opts.files : null;
|
|
1049
|
-
opts.filesGiven = opts.files !== null;
|
|
1050
|
-
opts.acceptedBy = form.acceptor;
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
// No check for empty witness/limitations is needed here: the form is the only route
|
|
1055
|
-
// to this gate, and `parseAcceptanceForm` refuses a form whose fields are blank or
|
|
1056
|
-
// still placeholders, so an empty value cannot reach this point. A second check would
|
|
1057
|
-
// be a guard that cannot fire, and the one that cannot fire is the one that rots.
|
|
1058
|
-
|
|
1059
1111
|
// A gate listed in disallowManualFor may never be satisfied by a human
|
|
1060
1112
|
// assertion; point at the automated path instead of accepting the record.
|
|
1061
1113
|
if (strict && Array.isArray(strict.disallowManualFor) && strict.disallowManualFor.includes(gate)) {
|
|
@@ -1120,8 +1172,6 @@ async function cmdHarness(opts) {
|
|
|
1120
1172
|
rootDir: opts.targetDir,
|
|
1121
1173
|
approvedBy: opts.approvedBy || 'user',
|
|
1122
1174
|
commit: opts.commit || null,
|
|
1123
|
-
witness: opts.witness || null,
|
|
1124
|
-
limitations: opts.limitations || null,
|
|
1125
1175
|
at,
|
|
1126
1176
|
});
|
|
1127
1177
|
|
|
@@ -1755,14 +1805,16 @@ async function cmdHarness(opts) {
|
|
|
1755
1805
|
const playCycles = exists ? findDeferralCycles(playGraph) : [];
|
|
1756
1806
|
const playGaps = describePlayGaps({ validation: playValidation, cycles: playCycles, story: opts.story });
|
|
1757
1807
|
|
|
1758
|
-
// A playable story. The gate is NOT set here
|
|
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`.
|
|
1759
1810
|
if (playValidation.ok && playValidation.code === 'required') {
|
|
1760
|
-
const formHint = `run "cadet-agent harness play-form --story ${opts.story}" to write one pre-filled from state, play the game, fill in the three blank fields, then pass it back with --artifact <the form>.`;
|
|
1761
1811
|
fail(
|
|
1762
1812
|
opts,
|
|
1763
|
-
`${USER_PLAY_GATE} is a person's record, and this story declares its deliverable playable: ${playDeclaration.instruction}
|
|
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>.`,
|
|
1764
1816
|
() => 1,
|
|
1765
|
-
{ ok: false, gate: USER_PLAY_GATE, code: 'play-
|
|
1817
|
+
{ ok: false, gate: USER_PLAY_GATE, code: 'play-required', story: opts.story, declaration: playDeclaration },
|
|
1766
1818
|
);
|
|
1767
1819
|
}
|
|
1768
1820
|
|
|
@@ -1846,85 +1898,6 @@ async function cmdHarness(opts) {
|
|
|
1846
1898
|
return;
|
|
1847
1899
|
}
|
|
1848
1900
|
|
|
1849
|
-
if (sub === 'play-form') {
|
|
1850
|
-
if (!opts.story) {
|
|
1851
|
-
fail(opts, 'harness play-form needs --story <path>: the form belongs to the story being played.', () => 1, { ok: false, code: 'story-required' });
|
|
1852
|
-
}
|
|
1853
|
-
const { exists, state } = readState(opts.targetDir);
|
|
1854
|
-
if (!exists) {
|
|
1855
|
-
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' });
|
|
1856
|
-
}
|
|
1857
|
-
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1858
|
-
const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
|
|
1859
|
-
let playDeclaration = null;
|
|
1860
|
-
try {
|
|
1861
|
-
playDeclaration = parsePlayDeclaration(storyPath);
|
|
1862
|
-
} catch (err) {
|
|
1863
|
-
fail(opts, `cannot read story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-unreadable', story: opts.story });
|
|
1864
|
-
}
|
|
1865
|
-
let playTemplate;
|
|
1866
|
-
try {
|
|
1867
|
-
playTemplate = readPlayTemplate(opts.targetDir);
|
|
1868
|
-
} catch (err) {
|
|
1869
|
-
fail(opts, `${err.message}. 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' });
|
|
1870
|
-
}
|
|
1871
|
-
const playText = buildPlayForm({
|
|
1872
|
-
template: playTemplate,
|
|
1873
|
-
state,
|
|
1874
|
-
storyPath,
|
|
1875
|
-
storyRel,
|
|
1876
|
-
targetDir: opts.targetDir,
|
|
1877
|
-
declaration: playDeclaration,
|
|
1878
|
-
});
|
|
1879
|
-
const playResult = writePlayForm(opts.targetDir, storyRel, playText, { out: opts.out });
|
|
1880
|
-
if (!playResult.written) {
|
|
1881
|
-
fail(opts, `harness play-form refuses to overwrite an existing form: ${playResult.reason} (${playResult.path}).`, () => 1, { ok: false, code: 'form-exists', path: playResult.path });
|
|
1882
|
-
}
|
|
1883
|
-
const playShown = playResult.path.slice(opts.targetDir.length + 1).replace(/\\/g, '/');
|
|
1884
|
-
if (opts.format === 'json') {
|
|
1885
|
-
emit(opts, '', { ok: true, path: playShown, story: opts.story, next: `cadet-agent harness confirm --gate ${PLAY_FORM_GATE} --artifact ${playShown}` });
|
|
1886
|
-
} else {
|
|
1887
|
-
console.log(`Playthrough form written: ${playShown}`);
|
|
1888
|
-
console.log(' Play the work, fill in the three unfilled fields, then run:');
|
|
1889
|
-
console.log(` cadet-agent harness confirm --gate ${PLAY_FORM_GATE} --artifact ${playShown}`);
|
|
1890
|
-
}
|
|
1891
|
-
return;
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
if (sub === 'acceptance-form') {
|
|
1895
|
-
if (!opts.epicId) {
|
|
1896
|
-
fail(opts, 'harness acceptance-form needs --epic <epicId>: the form belongs to the epic being accepted.', () => 1, { ok: false, code: 'epic-required' });
|
|
1897
|
-
}
|
|
1898
|
-
const { exists, state } = readState(opts.targetDir);
|
|
1899
|
-
if (!exists) {
|
|
1900
|
-
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' });
|
|
1901
|
-
}
|
|
1902
|
-
if (state.epics && !state.epics[opts.epicId]) {
|
|
1903
|
-
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 });
|
|
1904
|
-
}
|
|
1905
|
-
const templatePath = join(opts.targetDir, ...ACCEPTANCE_TEMPLATE_RELATIVE.split('/'));
|
|
1906
|
-
let template;
|
|
1907
|
-
try {
|
|
1908
|
-
template = readFileSync(templatePath, 'utf-8');
|
|
1909
|
-
} catch (err) {
|
|
1910
|
-
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' });
|
|
1911
|
-
}
|
|
1912
|
-
const { text } = buildAcceptanceForm({ template, state, epicId: opts.epicId, targetDir: opts.targetDir });
|
|
1913
|
-
const result = writeAcceptanceForm(opts.targetDir, opts.epicId, text, { out: opts.out });
|
|
1914
|
-
if (!result.written) {
|
|
1915
|
-
fail(opts, `harness acceptance-form refuses to overwrite an existing form: ${result.reason} (${result.path}).`, () => 1, { ok: false, code: 'form-exists', path: result.path });
|
|
1916
|
-
}
|
|
1917
|
-
const shown = result.path.slice(opts.targetDir.length + 1).replace(/\\/g, '/');
|
|
1918
|
-
if (opts.format === 'json') {
|
|
1919
|
-
emit(opts, '', { ok: true, path: shown, epic: opts.epicId, next: `cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}` });
|
|
1920
|
-
} else {
|
|
1921
|
-
console.log(`Acceptance form written: ${shown}`);
|
|
1922
|
-
console.log(` Fill in the three unfilled fields, then run:`);
|
|
1923
|
-
console.log(` cadet-agent harness confirm --gate ${HUMAN_ACCEPTANCE_GATE} --artifact ${shown}`);
|
|
1924
|
-
}
|
|
1925
|
-
return;
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
1901
|
if (sub === 'context') {
|
|
1929
1902
|
// The runtime context boundary: plan, record, validate.
|
|
1930
1903
|
//
|
|
@@ -1940,7 +1913,13 @@ async function cmdHarness(opts) {
|
|
|
1940
1913
|
const { exists, state } = readState(opts.targetDir);
|
|
1941
1914
|
|
|
1942
1915
|
if (action === 'plan') {
|
|
1943
|
-
|
|
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 });
|
|
1944
1923
|
const planPath = writeContextPlan(opts.targetDir, plan);
|
|
1945
1924
|
const shown = relative(opts.targetDir, planPath).replace(/\\/g, '/');
|
|
1946
1925
|
const fit = plan.budget.fits === null ? 'no context budget declared'
|
package/src/harness/commands.mjs
CHANGED
|
@@ -35,6 +35,10 @@ export const COMMANDS = {
|
|
|
35
35
|
unattended: true,
|
|
36
36
|
},
|
|
37
37
|
|
|
38
|
+
'state brief': {
|
|
39
|
+
mutates: false,
|
|
40
|
+
summary: 'Print the tier-0 summary of state.json: phase, active work item, every gate, and what compaction would archive.',
|
|
41
|
+
},
|
|
38
42
|
'state validate': {
|
|
39
43
|
mutates: false,
|
|
40
44
|
summary: 'Validate .cadet/state.json against the current schema.',
|
|
@@ -141,7 +145,7 @@ export const COMMANDS = {
|
|
|
141
145
|
'harness context plan': {
|
|
142
146
|
mutates: true,
|
|
143
147
|
summary: 'Write the context plan for the current phase: what must be loaded, and why.',
|
|
144
|
-
writes: ['.cadet/context/plan.json'],
|
|
148
|
+
writes: ['.cadet/context/plan.json', '.cadet/context/state-brief.md'],
|
|
145
149
|
unattended: true,
|
|
146
150
|
},
|
|
147
151
|
'harness context record': {
|
|
@@ -170,28 +174,10 @@ export const COMMANDS = {
|
|
|
170
174
|
summary: 'Verify a story\'s Play: declaration; record the gate for a deferral, refuse it for a playable story.',
|
|
171
175
|
// Same posture as verify-reachability: it records evidence for its gate, so it writes
|
|
172
176
|
// the ledger and state. It writes no artifact of its own — the declaration lives in the
|
|
173
|
-
// story, and a playable story's record comes from the person's
|
|
177
|
+
// story, and a playable story's record comes from the person's own answer instead.
|
|
174
178
|
writes: ['.cadet/runs/**', '.cadet/state.json'],
|
|
175
179
|
unattended: true,
|
|
176
180
|
},
|
|
177
|
-
'harness play-form': {
|
|
178
|
-
mutates: true,
|
|
179
|
-
summary: 'Write a user-playthrough form for a story, pre-filled from state.',
|
|
180
|
-
// One file, and only when it does not exist: a form is a person's worksheet once they
|
|
181
|
-
// have touched it, and regenerating it would discard what they wrote. It never writes
|
|
182
|
-
// state — recording the gate is `harness confirm --artifact`.
|
|
183
|
-
writes: ['.cadet/agent/project-plans/**'],
|
|
184
|
-
unattended: true,
|
|
185
|
-
},
|
|
186
|
-
'harness acceptance-form': {
|
|
187
|
-
mutates: true,
|
|
188
|
-
summary: 'Write a human-acceptance form for an epic, pre-filled from state.',
|
|
189
|
-
// It writes exactly one file, and only when that file does not exist: a form is a
|
|
190
|
-
// person's worksheet once they have touched it, and a regeneration would discard
|
|
191
|
-
// what they wrote. It never writes state — recording the gate is `harness confirm`.
|
|
192
|
-
writes: ['.cadet/agent/project-plans/**'],
|
|
193
|
-
unattended: true,
|
|
194
|
-
},
|
|
195
181
|
'harness report': {
|
|
196
182
|
mutates: false,
|
|
197
183
|
summary: 'Summarize budget consumption and failures.',
|
|
@@ -224,6 +210,23 @@ export const COMMANDS = {
|
|
|
224
210
|
mutates: false,
|
|
225
211
|
summary: 'Report available CLI/Unity/MCP/hook/token/cost telemetry.',
|
|
226
212
|
},
|
|
213
|
+
'harness diagram': {
|
|
214
|
+
mutates: true,
|
|
215
|
+
summary: 'Render UML component diagrams per package, coloured by workflow progress.',
|
|
216
|
+
// The tree is fully generated: one .noml + .svg per (package × epic), plus an
|
|
217
|
+
// index.html per package and at the root. `--out` may redirect it, but the
|
|
218
|
+
// default is the declared path, which is what the guard keys on.
|
|
219
|
+
writes: ['.cadet/diagrams/**'],
|
|
220
|
+
unattended: true,
|
|
221
|
+
},
|
|
222
|
+
'harness verify-diagrams': {
|
|
223
|
+
mutates: false,
|
|
224
|
+
summary: 'Check the committed diagram tree against a freshly computed model.',
|
|
225
|
+
// Read-only by construction: it regenerates the model in memory and compares
|
|
226
|
+
// it with the files on disk. It does not write the tree — a check that
|
|
227
|
+
// repaired the thing it checks would prove nothing — and `harness reconcile`
|
|
228
|
+
// calls the same function, so the two cannot disagree.
|
|
229
|
+
},
|
|
227
230
|
'harness cleanup': {
|
|
228
231
|
mutates: true,
|
|
229
232
|
summary: 'Apply the retention policy to .cadet/runs/, deleting run records.',
|