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/harness/state.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
REACHABILITY_GATE, REACHABILITY_TRANSITION_FROM,
|
|
16
16
|
DESIGN_REVIEW_GATE, DESIGN_REVIEW_TRANSITION_FROM,
|
|
17
17
|
HUMAN_ACCEPTANCE_GATE, HUMAN_ACCEPTANCE_TRANSITION_FROM,
|
|
18
|
+
USER_PLAY_GATE, USER_PLAY_TRANSITION_FROM,
|
|
18
19
|
ARCHITECTURE_GATE, ARCHITECTURE_TRANSITION_FROM, ARCHITECTURE_TRANSITION_TO,
|
|
19
20
|
architectureFitnessActive,
|
|
20
21
|
} from './policy.mjs';
|
|
@@ -435,11 +436,50 @@ export function validateState(state, context = {}) {
|
|
|
435
436
|
const scope = Array.isArray(entry.scope) ? entry.scope : (entry.scope ? [entry.scope] : []);
|
|
436
437
|
for (const s of scope) excepted.add(String(s));
|
|
437
438
|
}
|
|
439
|
+
// The same ambiguity, seen from the other side: a story the session moved on
|
|
440
|
+
// FROM, with evidence behind it, that still reads `planned` — which makes a
|
|
441
|
+
// finished story indistinguishable from one that was never started.
|
|
442
|
+
//
|
|
443
|
+
// The data already tells the two apart. `storyCompletions` records the
|
|
444
|
+
// boundary and how much the item left behind, and its comment says why the
|
|
445
|
+
// count is there: so "a completion with nothing behind it is visible rather
|
|
446
|
+
// than implied". A row with nothing behind it is a hollow completion — an
|
|
447
|
+
// item begun and then abandoned — and is left alone here. A row with records
|
|
448
|
+
// in it is a story that was carried to a verdict, and `planned` is then a
|
|
449
|
+
// false statement about it.
|
|
450
|
+
//
|
|
451
|
+
// It is an ERROR, not a warning, for the reason the rule below gives, and its
|
|
452
|
+
// remedy has the same shape: say what happened. A story that was superseded
|
|
453
|
+
// rather than delivered is not `planned` either.
|
|
454
|
+
//
|
|
455
|
+
// `in-progress` is deliberately NOT checked: `state begin` on an item that
|
|
456
|
+
// still carries its old completion row leaves it active, so active-plus-row
|
|
457
|
+
// is a legitimate combination and refusing it would be a false accusation.
|
|
458
|
+
const completedWith = new Map();
|
|
459
|
+
if (Array.isArray(state.storyCompletions)) {
|
|
460
|
+
for (const row of state.storyCompletions) {
|
|
461
|
+
if (!isPlainObject(row)) continue;
|
|
462
|
+
if (typeof row.workItemId !== 'string' || row.workItemId.length === 0) continue;
|
|
463
|
+
const records = Number(row.evidenceRecords);
|
|
464
|
+
if (!(records > 0)) continue;
|
|
465
|
+
completedWith.set(row.workItemId, records);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
438
468
|
for (const [epicId, epic] of Object.entries(state.epics)) {
|
|
439
469
|
if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
|
|
440
470
|
for (const [storyId, status] of Object.entries(epic.stories)) {
|
|
441
|
-
if (status !== 'done') continue;
|
|
442
471
|
const workItemId = `${epicId}::${storyId}`;
|
|
472
|
+
if (status === 'planned' && completedWith.has(workItemId)) {
|
|
473
|
+
errors.push({
|
|
474
|
+
path: `epics.${epicId}.stories.${storyId}`,
|
|
475
|
+
message: `story "${storyId}" reads "planned" but the completion row for its work item `
|
|
476
|
+
+ `"${workItemId}" records ${completedWith.get(workItemId)} evidence record(s). A story the `
|
|
477
|
+
+ 'session moved on from, with evidence behind it, is not planned: it is done, or it is '
|
|
478
|
+
+ 'superseded. Leaving it planned makes a finished story indistinguishable from one that '
|
|
479
|
+
+ 'was never started.',
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
if (status !== 'done') continue;
|
|
443
483
|
if (evidenced.has(workItemId)) continue;
|
|
444
484
|
if (excepted.has(workItemId)) continue;
|
|
445
485
|
errors.push({
|
|
@@ -500,15 +540,25 @@ function validateEvidenceShape(ev, strict = null) {
|
|
|
500
540
|
if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
|
|
501
541
|
if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
|
|
502
542
|
if (ev.gate !== undefined && !GATES.includes(ev.gate)) errors.push({ path: 'gate', message: `unknown gate "${ev.gate}"` });
|
|
503
|
-
// A human
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
+
});
|
|
512
562
|
}
|
|
513
563
|
}
|
|
514
564
|
// The architecture record is a claim about checks, so it must say which checks. One
|
|
@@ -1093,6 +1143,8 @@ export function createEvidence({
|
|
|
1093
1143
|
command = null,
|
|
1094
1144
|
result = null,
|
|
1095
1145
|
exitCode = null,
|
|
1146
|
+
durationMs = null,
|
|
1147
|
+
outputBytes = null,
|
|
1096
1148
|
artifactPath = null,
|
|
1097
1149
|
artifactHash = null,
|
|
1098
1150
|
inputTreeHash,
|
|
@@ -1122,6 +1174,13 @@ export function createEvidence({
|
|
|
1122
1174
|
command,
|
|
1123
1175
|
result,
|
|
1124
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,
|
|
1125
1184
|
artifactPath,
|
|
1126
1185
|
artifactHash,
|
|
1127
1186
|
inputTreeHash,
|
|
@@ -1316,7 +1375,6 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
|
|
|
1316
1375
|
|
|
1317
1376
|
// ── Transitions ─────────────────────────────────────────────────────────────
|
|
1318
1377
|
|
|
1319
|
-
/** Required gates for a transition target, or null when the target is not gated. */
|
|
1320
1378
|
/**
|
|
1321
1379
|
* Gates required on an edge that carries no entry in `TRANSITIONS`.
|
|
1322
1380
|
*
|
|
@@ -1336,7 +1394,17 @@ export function conditionalEdgeGates(fromPhase, toPhase, policy = null) {
|
|
|
1336
1394
|
return policy?.designReview?.enabled === true ? [DESIGN_REVIEW_GATE] : [];
|
|
1337
1395
|
}
|
|
1338
1396
|
|
|
1339
|
-
|
|
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
|
+
*/
|
|
1407
|
+
export function requiredGates(toPhase, { reachability = false, humanAcceptance = false, architectureFitness = false, userPlay = false } = {}) {
|
|
1340
1408
|
for (const [from, spec] of Object.entries(TRANSITIONS)) {
|
|
1341
1409
|
if (spec.to === toPhase) {
|
|
1342
1410
|
const gates = [...spec.gates];
|
|
@@ -1359,6 +1427,16 @@ export function requiredGates(toPhase, { reachability = false, humanAcceptance =
|
|
|
1359
1427
|
// revalidated gate is judged without its phase stamp — which for this one would
|
|
1360
1428
|
// mean accepting a record written in any phase at all.
|
|
1361
1429
|
if (humanAcceptance && from === HUMAN_ACCEPTANCE_TRANSITION_FROM) gates.push(HUMAN_ACCEPTANCE_GATE);
|
|
1430
|
+
// User play joins the STORY boundary (`review -> validation`) and nothing else, and
|
|
1431
|
+
// only when the repository has asked for it. Placement: a playthrough is a claim
|
|
1432
|
+
// about a delivered, reviewed story, and this is the edge a story crosses before the
|
|
1433
|
+
// next one starts — `validation -> implementation` is the next-story loop and stays
|
|
1434
|
+
// unblocked, which is why the requirement cannot live there. It is deliberately NOT
|
|
1435
|
+
// in `revalidate`: at `validation -> closed` the epic's own
|
|
1436
|
+
// `humanAcceptanceConfirmed` covers the whole epic, and demanding every story's play
|
|
1437
|
+
// again at closure would multiply the person's work at the exact moment they are
|
|
1438
|
+
// being asked to accept the finished thing.
|
|
1439
|
+
if (userPlay && from === USER_PLAY_TRANSITION_FROM) gates.push(USER_PLAY_GATE);
|
|
1362
1440
|
// Architecture fitness joins `implementation -> review`, and only when the project
|
|
1363
1441
|
// has declared checks. It is appended at evaluation time rather than written into
|
|
1364
1442
|
// the transition table, so a project that declares nothing keeps the frozen lists
|
|
@@ -1579,6 +1657,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1579
1657
|
reachability: context.policy?.reachability?.enabled === true,
|
|
1580
1658
|
humanAcceptance: context.policy?.humanAcceptance?.enabled === true,
|
|
1581
1659
|
architectureFitness: architectureFitnessActive(context.policy),
|
|
1660
|
+
userPlay: context.policy?.userPlay?.enabled === true,
|
|
1582
1661
|
});
|
|
1583
1662
|
// An edge with no `TRANSITIONS` entry can still carry a gate. It is resolved HERE,
|
|
1584
1663
|
// before the ungated-edge shortcut below, because that shortcut is what would
|
|
@@ -2017,6 +2096,213 @@ export function writeJsonAtomic(path, value) {
|
|
|
2017
2096
|
return path;
|
|
2018
2097
|
}
|
|
2019
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
|
+
|
|
2020
2306
|
/** Atomically write state.json. */
|
|
2021
2307
|
export function writeState(targetDir, state) {
|
|
2022
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
|
|