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/harness/gates.mjs
CHANGED
|
@@ -193,8 +193,9 @@ export const GATE_BUILDERS = Object.freeze({
|
|
|
193
193
|
// humanAcceptanceConfirmed has neither: the gate asks whether a PERSON played the
|
|
194
194
|
// delivered work. A command cannot answer it, and neither can a reviewer's record —
|
|
195
195
|
// the record's substance is the person's own account of what they played and what
|
|
196
|
-
// they saw,
|
|
197
|
-
//
|
|
196
|
+
// they saw, so the route is to ask them for it and record their answer with
|
|
197
|
+
// `harness confirm --gate userPlaythroughConfirmed --reason "<their answer>"`, and
|
|
198
|
+
// `verify-play` refuses to record a `required` declaration on their behalf.
|
|
198
199
|
automatedPath: null,
|
|
199
200
|
command: null,
|
|
200
201
|
projectCommand: false,
|
package/src/harness/index.mjs
CHANGED
|
@@ -47,6 +47,7 @@ export {
|
|
|
47
47
|
HISTORY_ENTRIES_KEPT, MAX_HISTORY_ENTRY_CHARS, historyEntryLength, compactHistory, DEFAULT_MAX_LIVE_EVIDENCE, retainLiveRecords,
|
|
48
48
|
createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
|
|
49
49
|
latestEvidenceForGate, activeExceptions, requiredGates, conditionalEdgeGates, isUngatedForwardEdge,
|
|
50
|
+
buildStateBrief, renderStateBrief, legalEdgesFrom,
|
|
50
51
|
evaluateTransition, resolveStrict,
|
|
51
52
|
applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
|
|
52
53
|
} from './state.mjs';
|
|
@@ -72,7 +73,8 @@ export {
|
|
|
72
73
|
} from './hosts.mjs';
|
|
73
74
|
|
|
74
75
|
export {
|
|
75
|
-
CONTEXT_LEVELS, CONTEXT_DIR, CONTEXT_PLAN_FILE, CONTEXT_RECORD_FILE, PHASE_SKILL,
|
|
76
|
+
CONTEXT_LEVELS, CONTEXT_DIR, CONTEXT_PLAN_FILE, CONTEXT_RECORD_FILE, CONTEXT_BRIEF_FILE, PHASE_SKILL,
|
|
77
|
+
writeStateBrief, resolveWorkItemDocs,
|
|
76
78
|
ContextProtocolError, planEntries, buildContextPlan, writeContextPlan, readContextPlan,
|
|
77
79
|
buildContextRecord, writeContextRecord, readContextRecord, parseTranscript,
|
|
78
80
|
validateContextRecord, describeContextState, declaresContextEnforcement,
|
|
@@ -83,12 +85,6 @@ export {
|
|
|
83
85
|
runArchitectureChecks, summariseChecks, describeCheckResults,
|
|
84
86
|
} from './architecture.mjs';
|
|
85
87
|
|
|
86
|
-
export {
|
|
87
|
-
ACCEPTANCE_FORM_GATE, ACCEPTANCE_FORM_FILENAME, ACCEPTANCE_TEMPLATE_RELATIVE,
|
|
88
|
-
ACCEPTANCE_HUMAN_FIELDS, acceptanceFormPath, acceptanceCandidates, buildAcceptanceForm,
|
|
89
|
-
parseAcceptanceForm, writeAcceptanceForm, isUnfilled,
|
|
90
|
-
} from './acceptance-form.mjs';
|
|
91
|
-
|
|
92
88
|
export {
|
|
93
89
|
ContextManifest, buildBaseManifest, tier0References, TIER_REASONS_REQUIRED,
|
|
94
90
|
DEFAULT_MAX_EXPANSION_PER_STEP, ContextError,
|
|
@@ -142,14 +138,23 @@ export {
|
|
|
142
138
|
validatePlayDeclaration, readSiblingPlayDeclarations, describePlayGaps,
|
|
143
139
|
} from './play.mjs';
|
|
144
140
|
|
|
145
|
-
export {
|
|
146
|
-
PLAY_FORM_GATE, PLAY_FORM_SUFFIX, PLAY_TEMPLATE_RELATIVE, PLAY_HUMAN_FIELDS,
|
|
147
|
-
playFormPath, buildPlayForm, parsePlayForm, writePlayForm, readPlayTemplate,
|
|
148
|
-
} from './play-form.mjs';
|
|
149
|
-
|
|
150
141
|
export {
|
|
151
142
|
COMMANDS, mutatingCommands, readOnlyCommands, resolveCommand, selfBoundFiles,
|
|
152
143
|
describeCommand, describeAllCommands, checkUnattendedRequirements,
|
|
153
144
|
} from './commands.mjs';
|
|
154
145
|
|
|
155
146
|
export { computeStatus, STATUS_OK_LINE, STATUS_PREFIX } from './status.mjs';
|
|
147
|
+
|
|
148
|
+
export {
|
|
149
|
+
DEFAULT_EXCLUDED_DIRS, scanPackages, scanComponents, componentEdges,
|
|
150
|
+
} from './csharp.mjs';
|
|
151
|
+
|
|
152
|
+
export {
|
|
153
|
+
DIAGRAM_OUT_DIR, DIAGRAM_MANIFEST, STATUS_COLORS, STATUS_CLASS,
|
|
154
|
+
collectWorkItemFiles, indexComponents, componentWorkMap, statusesFromWork, componentStatusMap,
|
|
155
|
+
componentSlugs, buildDiagramModel, buildStructureModel,
|
|
156
|
+
renderNoml, renderIndexHtml, renderPackageHtml, renderComponentHtml,
|
|
157
|
+
writeDiagramFiles, syncDiagramTree, generateDiagrams, diagramDrift,
|
|
158
|
+
} from './diagram.mjs';
|
|
159
|
+
|
|
160
|
+
export { renderSvg as renderNomlSvg, nomnomlVersion } from './noml.mjs';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nomnoml renderer wrapper — the only place the framework's UML dependency is
|
|
3
|
+
* touched.
|
|
4
|
+
*
|
|
5
|
+
* nomnoml is CommonJS; this package is ESM. `createRequire` is the interop that
|
|
6
|
+
* works regardless of how Node's named-export detection reads the bundle, and it
|
|
7
|
+
* keeps the load lazy so a run that never renders a diagram never pays for it.
|
|
8
|
+
*
|
|
9
|
+
* The dependency is deliberate and is recorded in the plan and the architecture
|
|
10
|
+
* decision: it breaks the framework's long-standing "dependency-free" property in
|
|
11
|
+
* exchange for a real UML renderer instead of a hand-drawn one.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
let cached = null;
|
|
18
|
+
|
|
19
|
+
function loadRenderer() {
|
|
20
|
+
if (cached) return cached;
|
|
21
|
+
try {
|
|
22
|
+
cached = require('nomnoml');
|
|
23
|
+
} catch (err) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`the UML renderer "nomnoml" is not installed (${err.message}). Run "npm install" in the cadet-agent package.`,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Render nomnoml source to an SVG string. */
|
|
32
|
+
export function renderSvg(source) {
|
|
33
|
+
const nomnoml = loadRenderer();
|
|
34
|
+
if (typeof nomnoml.renderSvg !== 'function') {
|
|
35
|
+
throw new Error('nomnoml did not expose renderSvg; the installed version is not the expected one.');
|
|
36
|
+
}
|
|
37
|
+
return nomnoml.renderSvg(String(source));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The installed nomnoml version, or null when it cannot be read. */
|
|
41
|
+
export function nomnomlVersion() {
|
|
42
|
+
try {
|
|
43
|
+
return loadRenderer().version || null;
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/harness/play.mjs
CHANGED
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
* Play: required — <what the user does, and what they should see>
|
|
16
16
|
* Play: deferred to <work item> — <why it cannot be played yet>
|
|
17
17
|
*
|
|
18
|
-
* A `required` declaration is satisfied only by a person's own record
|
|
19
|
-
*
|
|
18
|
+
* A `required` declaration is satisfied only by a person's own record, asked for and recorded
|
|
19
|
+
* in the conversation; `harness verify-play` refuses to record it. A `deferred` declaration
|
|
20
20
|
* is satisfied by the declaration itself, exactly as a reachability deferral is, and it
|
|
21
21
|
* expires the moment its target is done — a gap with an owner and a term, never a parked
|
|
22
22
|
* excuse. There is deliberately no third form: a story with no playable surface of its
|
|
@@ -56,6 +56,7 @@ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
|
|
|
56
56
|
|
|
57
57
|
import { PHASES } from './policy.mjs';
|
|
58
58
|
import { activeExceptionEntries } from './state.mjs';
|
|
59
|
+
import { diagramDrift, DIAGRAM_OUT_DIR } from './diagram.mjs';
|
|
59
60
|
import {
|
|
60
61
|
collectWorkItems,
|
|
61
62
|
parseReachabilityDeclaration,
|
|
@@ -247,8 +248,13 @@ export function parseEpicHeader(text) {
|
|
|
247
248
|
/**
|
|
248
249
|
* Every directory under `root`, breadth-first and depth-bounded. Skipping the
|
|
249
250
|
* usual game-repo ballast keeps a walk of a real project cheap.
|
|
251
|
+
*
|
|
252
|
+
* EXPORTED, because the context plan needs the same discovery this module worked out for the
|
|
253
|
+
* reconciler. A real project nests its plans (`<project>/epics/<epic-N>/`) and names its own files,
|
|
254
|
+
* so a plan built from fixed paths reports a correct project's own story as missing — the lesson
|
|
255
|
+
* REQUIRED_ARTIFACTS above already records. One walk, two consumers.
|
|
250
256
|
*/
|
|
251
|
-
function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
|
|
257
|
+
export function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
|
|
252
258
|
const found = [];
|
|
253
259
|
let level = [root];
|
|
254
260
|
for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
|
|
@@ -659,7 +665,33 @@ export function reconcileArtifacts(targetDir, {
|
|
|
659
665
|
}
|
|
660
666
|
}
|
|
661
667
|
|
|
662
|
-
// ── 4.
|
|
668
|
+
// ── 4. The generated diagram tree ─────────────────────────────────────────
|
|
669
|
+
//
|
|
670
|
+
// The diagrams are DERIVED — from the code and from `state.json` — so a tree
|
|
671
|
+
// that no longer matches is a projection of the record that has stopped
|
|
672
|
+
// agreeing with the record: exactly the class of inconsistency this module
|
|
673
|
+
// exists to find, one step further down the chain.
|
|
674
|
+
//
|
|
675
|
+
// BLOCKING, not a warning. `designArtifactSyncConfirmed` is supported only when
|
|
676
|
+
// the run reports no unexcused blocking finding, so a warning here would report
|
|
677
|
+
// the drift and gate nothing.
|
|
678
|
+
//
|
|
679
|
+
// Opt-in by PRESENCE: a repository that has never generated the tree has no
|
|
680
|
+
// manifest and is not checked, so adopting this release changes nothing until
|
|
681
|
+
// diagrams are actually produced.
|
|
682
|
+
const drift = diagramDrift({ rootDir: targetDir, state });
|
|
683
|
+
if (drift.available && !drift.ok) {
|
|
684
|
+
const counts = [];
|
|
685
|
+
if (drift.missing.length) counts.push(`${drift.missing.length} missing`);
|
|
686
|
+
if (drift.changed.length) counts.push(`${drift.changed.length} changed`);
|
|
687
|
+
if (drift.extra.length) counts.push(`${drift.extra.length} extra`);
|
|
688
|
+
add('diagram-drift', 'blocking', 'diagrams', `${toPosix(DIAGRAM_OUT_DIR)}/`,
|
|
689
|
+
`the committed diagram tree no longer matches the code and the work record (${counts.join(', ')} file(s)). `
|
|
690
|
+
+ 'Run `cadet-agent harness diagram` and commit the result, so the diagrams travel with the change that moved them.',
|
|
691
|
+
[...drift.missing, ...drift.changed, ...drift.extra].slice(0, 3).join(', ') || null);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ── 5. Sort, number, and summarise ────────────────────────────────────────
|
|
663
695
|
findings.sort((a, b) => {
|
|
664
696
|
const s = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
|
|
665
697
|
if (s !== 0) return s;
|
package/src/harness/state.mjs
CHANGED
|
@@ -540,15 +540,25 @@ function validateEvidenceShape(ev, strict = null) {
|
|
|
540
540
|
if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
|
|
541
541
|
if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
|
|
542
542
|
if (ev.gate !== undefined && !GATES.includes(ev.gate)) errors.push({ path: 'gate', message: `unknown gate "${ev.gate}"` });
|
|
543
|
-
// A human
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
543
|
+
// A human-owned gate's record IS a person's answer, so the record must carry one. Checked at
|
|
544
|
+
// validation as well as at creation, because a hand-edited state.json is exactly what this gate
|
|
545
|
+
// must not be satisfiable by.
|
|
546
|
+
//
|
|
547
|
+
// Both gates carried `witness` and `limitations` here until 2026-10-03, when the form that
|
|
548
|
+
// produced them was removed; the substance moved into `reason`, which now holds what the person
|
|
549
|
+
// said. Two fields on two gates existed only to mirror that form.
|
|
550
|
+
if (ev.gate === HUMAN_ACCEPTANCE_GATE || ev.gate === USER_PLAY_GATE) {
|
|
551
|
+
const hasReason = typeof ev.reason === 'string' && ev.reason.trim() !== '';
|
|
552
|
+
// `witness` + `limitations` are the shape the removed form wrote: two fields that existed only
|
|
553
|
+
// to mirror it. A record already written under that shape keeps validating, because the
|
|
554
|
+
// framework never invalidates evidence it once accepted.
|
|
555
|
+
const hasLegacyPair = ['witness', 'limitations']
|
|
556
|
+
.every((field) => typeof ev[field] === 'string' && ev[field].trim() !== '');
|
|
557
|
+
if (!hasReason && !hasLegacyPair) {
|
|
558
|
+
errors.push({
|
|
559
|
+
path: 'reason',
|
|
560
|
+
message: `a ${ev.gate} record must carry the person's own answer in a non-empty "reason"`,
|
|
561
|
+
});
|
|
552
562
|
}
|
|
553
563
|
}
|
|
554
564
|
// The architecture record is a claim about checks, so it must say which checks. One
|
|
@@ -1133,6 +1143,8 @@ export function createEvidence({
|
|
|
1133
1143
|
command = null,
|
|
1134
1144
|
result = null,
|
|
1135
1145
|
exitCode = null,
|
|
1146
|
+
durationMs = null,
|
|
1147
|
+
outputBytes = null,
|
|
1136
1148
|
artifactPath = null,
|
|
1137
1149
|
artifactHash = null,
|
|
1138
1150
|
inputTreeHash,
|
|
@@ -1162,6 +1174,13 @@ export function createEvidence({
|
|
|
1162
1174
|
command,
|
|
1163
1175
|
result,
|
|
1164
1176
|
exitCode,
|
|
1177
|
+
// WHAT THE RUN COST. Both are measured for the attempt span already, and neither was written where
|
|
1178
|
+
// an operator reads a gate: `state.json -> gateEvidence` carried the command, the exit code and the
|
|
1179
|
+
// verdict, but not how long the command took or whether it said anything. The wall clock is the
|
|
1180
|
+
// cheapest way to tell a real red from a process that never launched, and this repository's own
|
|
1181
|
+
// policy tells a reader to do exactly that. Null when nothing ran.
|
|
1182
|
+
durationMs,
|
|
1183
|
+
outputBytes,
|
|
1165
1184
|
artifactPath,
|
|
1166
1185
|
artifactHash,
|
|
1167
1186
|
inputTreeHash,
|
|
@@ -1356,7 +1375,6 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
|
|
|
1356
1375
|
|
|
1357
1376
|
// ── Transitions ─────────────────────────────────────────────────────────────
|
|
1358
1377
|
|
|
1359
|
-
/** Required gates for a transition target, or null when the target is not gated. */
|
|
1360
1378
|
/**
|
|
1361
1379
|
* Gates required on an edge that carries no entry in `TRANSITIONS`.
|
|
1362
1380
|
*
|
|
@@ -1376,6 +1394,16 @@ export function conditionalEdgeGates(fromPhase, toPhase, policy = null) {
|
|
|
1376
1394
|
return policy?.designReview?.enabled === true ? [DESIGN_REVIEW_GATE] : [];
|
|
1377
1395
|
}
|
|
1378
1396
|
|
|
1397
|
+
/**
|
|
1398
|
+
* The gates a transition to `toPhase` requires: `{ from, gates, revalidate }`, or `null` when the target
|
|
1399
|
+
* is not the target of a gated transition.
|
|
1400
|
+
*
|
|
1401
|
+
* The shape is an OBJECT, not a bare list. The caller reads `.gates`, and `.revalidate` names the gates
|
|
1402
|
+
* this edge re-examines under strict closure. Corrected here because the previous one-line comment said
|
|
1403
|
+
* only "required gates for a transition target" and said nothing about the object, and a caller that read
|
|
1404
|
+
* it as a list then got an object it treated as empty — which reported a gated edge as ungated. A comment
|
|
1405
|
+
* a reader can act on wrongly is worse than no comment.
|
|
1406
|
+
*/
|
|
1379
1407
|
export function requiredGates(toPhase, { reachability = false, humanAcceptance = false, architectureFitness = false, userPlay = false } = {}) {
|
|
1380
1408
|
for (const [from, spec] of Object.entries(TRANSITIONS)) {
|
|
1381
1409
|
if (spec.to === toPhase) {
|
|
@@ -2068,6 +2096,213 @@ export function writeJsonAtomic(path, value) {
|
|
|
2068
2096
|
return path;
|
|
2069
2097
|
}
|
|
2070
2098
|
|
|
2099
|
+
/**
|
|
2100
|
+
* The legal edges out of a phase: what `state transition --to <phase>` would accept.
|
|
2101
|
+
*
|
|
2102
|
+
* WHY THIS EXISTS. Resuming asks "what happens next", and the tool that answers it —
|
|
2103
|
+
* `state transition --to <phase> --dry-run` — takes a TARGET phase the reader has to guess
|
|
2104
|
+
* before it will say anything. Both halves of the answer are already declared in this
|
|
2105
|
+
* framework: `TRANSITIONS` for the gated story loop, and `UNGATED_FORWARD_EDGES` for the
|
|
2106
|
+
* planning progression and the next-story loop. The gate list comes from `requiredGates`,
|
|
2107
|
+
* the same function the transition itself calls, so an opt-in gate (reachability, the
|
|
2108
|
+
* playthrough, human acceptance, architecture fitness) is reported exactly when the resolved
|
|
2109
|
+
* policy would require it — and a repository that enables none of them sees the frozen lists
|
|
2110
|
+
* the contract declares, unchanged.
|
|
2111
|
+
*
|
|
2112
|
+
* Pure: a function of (phase, policy). It runs no transition and reads no file, so a brief
|
|
2113
|
+
* cannot claim an edge the machinery would refuse.
|
|
2114
|
+
*/
|
|
2115
|
+
export function legalEdgesFrom(phase, policy = null) {
|
|
2116
|
+
if (!phase) return [];
|
|
2117
|
+
const edges = [];
|
|
2118
|
+
const spec = TRANSITIONS[phase];
|
|
2119
|
+
if (spec) {
|
|
2120
|
+
// `requiredGates` returns `{ from, gates, revalidate }` — an OBJECT, not a bare list. Read `.gates`;
|
|
2121
|
+
// a caller that treats the return value as an array gets an empty list and reports a gated edge as
|
|
2122
|
+
// ungated. The `revalidate` half is deliberately not carried: on this edge it repeats these gates,
|
|
2123
|
+
// and the transition dry run is what reports revalidation.
|
|
2124
|
+
const required = requiredGates(spec.to, {
|
|
2125
|
+
reachability: policy?.reachability?.enabled === true,
|
|
2126
|
+
humanAcceptance: policy?.humanAcceptance?.enabled === true,
|
|
2127
|
+
architectureFitness: architectureFitnessActive(policy),
|
|
2128
|
+
userPlay: policy?.userPlay?.enabled === true,
|
|
2129
|
+
});
|
|
2130
|
+
edges.push({ to: spec.to, gates: required?.gates || [] });
|
|
2131
|
+
}
|
|
2132
|
+
for (const [from, to] of UNGATED_FORWARD_EDGES) {
|
|
2133
|
+
if (from !== phase) continue;
|
|
2134
|
+
const gates = conditionalEdgeGates(from, to, policy);
|
|
2135
|
+
edges.push({ to, gates, ungated: gates.length === 0 });
|
|
2136
|
+
}
|
|
2137
|
+
return edges;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/**
|
|
2141
|
+
* The state brief: a derived summary of the state document, small enough to load
|
|
2142
|
+
* every turn.
|
|
2143
|
+
*
|
|
2144
|
+
* WHY THIS EXISTS AND WHAT IT REPLACES. The context plan named `.cadet/state.json`
|
|
2145
|
+
* itself as a tier-0 always-load reference, and that document is not the current
|
|
2146
|
+
* story: it is the current story's live evidence plus append-only lists of every
|
|
2147
|
+
* work item ever closed and every change checkpoint ever recorded. Measured in a
|
|
2148
|
+
* real consumer, it reached 94,547 B — about 31,500 tokens — of which the current
|
|
2149
|
+
* story was 31%, and it is re-sent on every turn. A host that loads it pays for the
|
|
2150
|
+
* project's whole history to answer "what phase am I in".
|
|
2151
|
+
*
|
|
2152
|
+
* WHAT IT CARRIES. Only what the phase reads: the phase and the session settings,
|
|
2153
|
+
* the active work item and its story and epic status, what is left in that epic, the legal
|
|
2154
|
+
* edge out of the current phase with the gates it carries, every gate in the policy with the
|
|
2155
|
+
* newest evidence record for each, the last transition, and how much of the document
|
|
2156
|
+
* compaction would archive. Nothing here is decided twice: every field is read
|
|
2157
|
+
* from the document, and `latestEvidenceForGate` is the same reader a gate check
|
|
2158
|
+
* uses, so the brief cannot disagree with the machinery about which record is live.
|
|
2159
|
+
*
|
|
2160
|
+
* WHAT IT DELIBERATELY OMITS. Evidence record bodies, the coverage index, the
|
|
2161
|
+
* archive and the change log. A reader who needs those opens `.cadet/state.json`;
|
|
2162
|
+
* the brief says so in its own first line. It is a summary, not a second record.
|
|
2163
|
+
*
|
|
2164
|
+
* Pure: no I/O, so a test drives it from a document and a caller decides where it
|
|
2165
|
+
* is written. The resolved work-item paths come IN through `docs`, because finding
|
|
2166
|
+
* them needs the filesystem: the caller resolves them, and this stays a function of
|
|
2167
|
+
* its arguments.
|
|
2168
|
+
*/
|
|
2169
|
+
export function buildStateBrief(state, policy = null, docs = null) {
|
|
2170
|
+
const s = isPlainObject(state) ? state : {};
|
|
2171
|
+
const session = isPlainObject(s.session) ? s.session : {};
|
|
2172
|
+
const work = isPlainObject(s.activeWorkItem) ? s.activeWorkItem : null;
|
|
2173
|
+
const claimed = isPlainObject(s.gates) ? s.gates : {};
|
|
2174
|
+
const epics = isPlainObject(s.epics) ? s.epics : {};
|
|
2175
|
+
|
|
2176
|
+
const gates = GATES.map((gate) => {
|
|
2177
|
+
const latest = latestEvidenceForGate(s, gate);
|
|
2178
|
+
return {
|
|
2179
|
+
gate,
|
|
2180
|
+
claimed: claimed[gate] === true,
|
|
2181
|
+
status: latest ? latest.status : null,
|
|
2182
|
+
evidenceId: latest ? latest.evidenceId : null,
|
|
2183
|
+
at: latest ? latest.createdAt : null,
|
|
2184
|
+
// WHAT THE RUN COST, so a reader can tell a real red from a command that never launched without
|
|
2185
|
+
// opening the run ledger. Absent on every record written before the fields existed.
|
|
2186
|
+
durationMs: latest && typeof latest.durationMs === 'number' ? latest.durationMs : null,
|
|
2187
|
+
outputBytes: latest && typeof latest.outputBytes === 'number' ? latest.outputBytes : null,
|
|
2188
|
+
};
|
|
2189
|
+
});
|
|
2190
|
+
|
|
2191
|
+
const epic = work?.epicId && isPlainObject(epics[work.epicId]) ? epics[work.epicId] : null;
|
|
2192
|
+
const stories = isPlainObject(epic?.stories) ? epic.stories : {};
|
|
2193
|
+
const storyStatuses = Object.values(stories);
|
|
2194
|
+
|
|
2195
|
+
// WHAT IS LEFT IN THE ACTIVE EPIC, named rather than counted. `Resume` compares state against the
|
|
2196
|
+
// epic markdown story by story, and a count cannot be compared with anything, so the brief names
|
|
2197
|
+
// each story still not `done` with its status. Derived from the same map the counts come from.
|
|
2198
|
+
const remaining = Object.entries(stories)
|
|
2199
|
+
.filter(([, status]) => status !== 'done')
|
|
2200
|
+
.map(([id, status]) => ({ id, status }));
|
|
2201
|
+
|
|
2202
|
+
// The legal edge out of the current phase, and the gates it carries. See `legalEdgesFrom`.
|
|
2203
|
+
const next = legalEdgesFrom(session.currentPhase, policy);
|
|
2204
|
+
|
|
2205
|
+
// What `state compact --keep active` would move, counted by the same two pure
|
|
2206
|
+
// functions compaction itself uses. Reported rather than implied, because the
|
|
2207
|
+
// number growing without bound is the thing nobody notices.
|
|
2208
|
+
const inline = Array.isArray(s.gateEvidence) ? s.gateEvidence.length : 0;
|
|
2209
|
+
const { archived: archivableEvidence } = splitEvidence(s, { keep: 'active' });
|
|
2210
|
+
const historyInline = Array.isArray(s.changeHistory) ? s.changeHistory.length : 0;
|
|
2211
|
+
const { archived: archivableHistory } = compactHistory(
|
|
2212
|
+
(Array.isArray(s.changeHistory) ? s.changeHistory : []).filter((e) => e?.type !== 'gate-exception'),
|
|
2213
|
+
);
|
|
2214
|
+
|
|
2215
|
+
return {
|
|
2216
|
+
workItemId: work ? `${work.epicId}::${work.storyId}` : null,
|
|
2217
|
+
phase: session.currentPhase || null,
|
|
2218
|
+
workflowPath: session.workflowPath || null,
|
|
2219
|
+
trackingMode: session.trackingMode || null,
|
|
2220
|
+
learnerTier: session.learnerTier || null,
|
|
2221
|
+
operatingMode: session.operatingMode || null,
|
|
2222
|
+
story: work?.storyId
|
|
2223
|
+
? { id: work.storyId, status: stories[work.storyId] || null, at: docs?.story || null }
|
|
2224
|
+
: null,
|
|
2225
|
+
epic: epic
|
|
2226
|
+
? {
|
|
2227
|
+
id: work.epicId,
|
|
2228
|
+
status: epic.status || null,
|
|
2229
|
+
storiesDone: storyStatuses.filter((v) => v === 'done').length,
|
|
2230
|
+
storiesTotal: storyStatuses.length,
|
|
2231
|
+
remaining,
|
|
2232
|
+
at: docs?.epic || null,
|
|
2233
|
+
}
|
|
2234
|
+
: null,
|
|
2235
|
+
next,
|
|
2236
|
+
gates,
|
|
2237
|
+
evidence: { inline, archivable: archivableEvidence.length, superseded: (Array.isArray(s.gateEvidence) ? s.gateEvidence : []).filter((r) => r?.status === 'superseded').length },
|
|
2238
|
+
history: { inline: historyInline, archivable: archivableHistory.length, entryLimit: HISTORY_ENTRIES_KEPT, charLimit: MAX_HISTORY_ENTRY_CHARS },
|
|
2239
|
+
lastTransition: isPlainObject(s.lastTransition)
|
|
2240
|
+
? { from: s.lastTransition.from || null, to: s.lastTransition.to || null, at: s.lastTransition.at || null }
|
|
2241
|
+
: null,
|
|
2242
|
+
activeRunId: s.activeRunId || null,
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
/** Render a state brief as the text a host loads. */
|
|
2247
|
+
export function renderStateBrief(brief) {
|
|
2248
|
+
const b = isPlainObject(brief) ? brief : {};
|
|
2249
|
+
const lines = [];
|
|
2250
|
+
|
|
2251
|
+
lines.push(`state brief — ${b.workItemId || '(no active work item)'}`);
|
|
2252
|
+
lines.push('The tier-0 summary of .cadet/state.json. Open that document for evidence records,');
|
|
2253
|
+
lines.push('the coverage index and the archive; this file carries what the phase reads.');
|
|
2254
|
+
lines.push('');
|
|
2255
|
+
lines.push(`phase: ${b.phase || '(unset)'} · workflow: ${b.workflowPath || '(unset)'} · tracking: ${b.trackingMode || '(unset)'}`);
|
|
2256
|
+
if (b.story) {
|
|
2257
|
+
lines.push(`story: ${b.story.status || '(unset)'}`);
|
|
2258
|
+
// WHERE THE STORY IS. A resume needs the file, and the path is not derivable from the document:
|
|
2259
|
+
// a real project nests its plans, so the host either resolves them (as `harness context plan` does)
|
|
2260
|
+
// or goes looking. The caller resolves and passes it in, which keeps this builder pure.
|
|
2261
|
+
if (b.story.at) lines.push(` at: ${b.story.at}`);
|
|
2262
|
+
}
|
|
2263
|
+
if (b.epic) {
|
|
2264
|
+
lines.push(`epic: ${b.epic.status || '(unset)'} — ${b.epic.storiesDone} of ${b.epic.storiesTotal} stories done`);
|
|
2265
|
+
if (b.epic.at) lines.push(` at: ${b.epic.at}`);
|
|
2266
|
+
const remaining = Array.isArray(b.epic.remaining) ? b.epic.remaining : [];
|
|
2267
|
+
if (remaining.length) {
|
|
2268
|
+
lines.push(` remaining: ${remaining.map((r) => `${r.id} (${r.status || 'unset'})`).join(', ')}`);
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
// The edge out of this phase, so the transition dry run confirms a known edge instead of being the
|
|
2272
|
+
// thing that discovers one. A phase with no legal edge (`closed`) prints nothing, which is the fact.
|
|
2273
|
+
for (const edge of (Array.isArray(b.next) ? b.next : [])) {
|
|
2274
|
+
const needs = Array.isArray(edge.gates) && edge.gates.length ? `needs ${edge.gates.join(', ')}` : 'ungated';
|
|
2275
|
+
lines.push(`next: ${b.phase} -> ${edge.to} (${needs})`);
|
|
2276
|
+
}
|
|
2277
|
+
lines.push('');
|
|
2278
|
+
lines.push('gates (the newest evidence record for each)');
|
|
2279
|
+
for (const row of Array.isArray(b.gates) ? b.gates : []) {
|
|
2280
|
+
const state = row.claimed ? 'claimed' : 'UNMET';
|
|
2281
|
+
// The wall clock beside the verdict. This repository's false reds took 182 ms and its genuine Unity
|
|
2282
|
+
// pass takes tens of seconds, so the number is the difference between a test result and a command
|
|
2283
|
+
// that never ran. Omitted when the record does not carry one, rather than shown as zero.
|
|
2284
|
+
const took = typeof row.durationMs === 'number' ? `${Math.round(row.durationMs)} ms ` : '';
|
|
2285
|
+
const record = row.status
|
|
2286
|
+
? `${row.status} ${took}${row.evidenceId || '(no id)'} ${row.at || ''}`.trimEnd()
|
|
2287
|
+
: '(no evidence)';
|
|
2288
|
+
lines.push(` ${row.gate.padEnd(27)} ${state.padEnd(9)} ${record}`);
|
|
2289
|
+
}
|
|
2290
|
+
lines.push('');
|
|
2291
|
+
const ev = b.evidence || {};
|
|
2292
|
+
lines.push(`evidence inline: ${ev.inline} record(s), ${ev.superseded} superseded, ${ev.archivable} archivable`);
|
|
2293
|
+
const hist = b.history || {};
|
|
2294
|
+
lines.push(`change log: ${hist.inline} entr(ies), ${hist.archivable} archivable (keeps the most recent ${hist.entryLimit}, or ${hist.charLimit} characters per entry)`);
|
|
2295
|
+
if (ev.archivable || hist.archivable) {
|
|
2296
|
+
lines.push(' archive them with: cadet-agent state compact --keep active');
|
|
2297
|
+
}
|
|
2298
|
+
if (b.lastTransition) {
|
|
2299
|
+
lines.push(`last transition: ${b.lastTransition.from} -> ${b.lastTransition.to} at ${b.lastTransition.at}`);
|
|
2300
|
+
}
|
|
2301
|
+
if (b.activeRunId) lines.push(`active run: ${b.activeRunId}`);
|
|
2302
|
+
|
|
2303
|
+
return `${lines.join('\n')}\n`;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2071
2306
|
/** Atomically write state.json. */
|
|
2072
2307
|
export function writeState(targetDir, state) {
|
|
2073
2308
|
return writeJsonAtomic(statePathFor(targetDir), state);
|
|
@@ -531,6 +531,11 @@ export async function runVerificationLoop({
|
|
|
531
531
|
? `budget-blocked: ${hardStop.reason}`
|
|
532
532
|
: (launch.launchFailed ? `launch-failed: ${launch.reason}` : describeResult(result))),
|
|
533
533
|
exitCode: result.exitCode,
|
|
534
|
+
// Carried onto the record, not only onto the attempt below it. An operator reading
|
|
535
|
+
// `gateEvidence` sees how long the command took and how much it wrote, which is how a real red is
|
|
536
|
+
// told from a command that never launched.
|
|
537
|
+
durationMs: result.durationMs ?? null,
|
|
538
|
+
outputBytes: result.outputBytes ?? null,
|
|
534
539
|
artifactPath: result.artifactPath,
|
|
535
540
|
artifactHash: result.artifactHash,
|
|
536
541
|
inputTreeHash,
|
|
@@ -687,7 +692,6 @@ export function manualConfirmation({
|
|
|
687
692
|
gate, workItemId, phase, projectPath, editorVersion, scope, acceptanceCriterionId = null,
|
|
688
693
|
relevantFiles = [], criteria = [], rootDir = process.cwd(), approvedBy = 'user', at = new Date(),
|
|
689
694
|
reason = null, expiresAt = null, environment = null, expiresInMs = null, commit = null,
|
|
690
|
-
witness = null, limitations = null,
|
|
691
695
|
} = {}) {
|
|
692
696
|
const inputTreeHash = computeInputTreeHash(rootDir, relevantFiles);
|
|
693
697
|
// v3 quality fields. `scope` is declared both as the free-text `result` line
|
|
@@ -699,8 +703,6 @@ export function manualConfirmation({
|
|
|
699
703
|
// Redaction has no bypass (contract §8).
|
|
700
704
|
const scopeList = (Array.isArray(scope) ? scope : (scope ? [scope] : [])).map(redactString);
|
|
701
705
|
const safeReason = reason === null || reason === undefined ? null : redactString(String(reason));
|
|
702
|
-
const safeWitness = witness === null || witness === undefined ? null : redactString(String(witness));
|
|
703
|
-
const safeLimitations = limitations === null || limitations === undefined ? null : redactString(String(limitations));
|
|
704
706
|
const env = Object.fromEntries(
|
|
705
707
|
Object.entries(environment || (projectPath || editorVersion
|
|
706
708
|
? { projectPath: projectPath || null, editorVersion: editorVersion || null }
|
|
@@ -740,18 +742,10 @@ export function manualConfirmation({
|
|
|
740
742
|
...(safeReason !== null ? { reason: safeReason } : {}),
|
|
741
743
|
...(hasEnv ? { environment: env } : {}),
|
|
742
744
|
...(scopeList.length ? { scope: scopeList } : {}),
|
|
743
|
-
// A human acceptance states what was witnessed and what was accepted as missing.
|
|
744
|
-
// Both are free prose from a person, so both are redacted like every other field
|
|
745
|
-
// that reaches state.json. `state` validation requires them for
|
|
746
|
-
// `humanAcceptanceConfirmed`, which is why they are recorded rather than folded
|
|
747
|
-
// into `reason`: `reason` says why automation could not answer, these say what the
|
|
748
|
-
// person actually accepted.
|
|
749
|
-
...(safeWitness !== null ? { witness: safeWitness } : {}),
|
|
750
|
-
...(safeLimitations !== null ? { limitations: safeLimitations } : {}),
|
|
751
745
|
};
|
|
752
746
|
return {
|
|
753
747
|
evidence, approvedBy, projectPath, editorVersion, scope: scopeList, reason: safeReason,
|
|
754
|
-
environment: env,
|
|
748
|
+
environment: env, recordedAt: timestamp(at),
|
|
755
749
|
};
|
|
756
750
|
}
|
|
757
751
|
|