cadet-agent 0.53.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -4
- package/package.json +1 -1
- package/src/cli.mjs +698 -17
- package/src/harness/acceptance-form.mjs +281 -0
- package/src/harness/architecture.mjs +201 -0
- package/src/harness/commands.mjs +60 -0
- package/src/harness/context-protocol.mjs +470 -0
- package/src/harness/design-review.mjs +162 -0
- package/src/harness/gates.mjs +364 -0
- package/src/harness/hosts.mjs +349 -0
- package/src/harness/index.mjs +42 -3
- package/src/harness/policy.mjs +283 -10
- package/src/harness/routing.mjs +14 -2
- package/src/harness/state.mjs +185 -15
- package/src/harness/status.mjs +132 -0
- package/src/harness/verification.mjs +51 -28
- package/src/install.mjs +129 -0
package/src/harness/state.mjs
CHANGED
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
PHASES, GATES, TRANSITIONS, EVIDENCE_STATUSES, DEFAULT_STRICT_CLOSURE,
|
|
14
14
|
EXCEPTION_CATEGORIES, EXCEPTION_EXPIRY_DAYS, EXCEPTION_REQUIRES_REVIEW_NOTE,
|
|
15
15
|
REACHABILITY_GATE, REACHABILITY_TRANSITION_FROM,
|
|
16
|
+
DESIGN_REVIEW_GATE, DESIGN_REVIEW_TRANSITION_FROM,
|
|
17
|
+
HUMAN_ACCEPTANCE_GATE, HUMAN_ACCEPTANCE_TRANSITION_FROM,
|
|
18
|
+
ARCHITECTURE_GATE, ARCHITECTURE_TRANSITION_FROM, ARCHITECTURE_TRANSITION_TO,
|
|
19
|
+
architectureFitnessActive,
|
|
16
20
|
} from './policy.mjs';
|
|
21
|
+
import { gateContractId } from './gates.mjs';
|
|
17
22
|
import { hashTree, hashFile, hashCriteria, timestamp, isUuid } from './util.mjs';
|
|
18
23
|
import { encodeEvidenceTrailers } from './gitmemo.mjs';
|
|
19
24
|
import {
|
|
@@ -194,6 +199,7 @@ export function validateState(state, context = {}) {
|
|
|
194
199
|
});
|
|
195
200
|
}
|
|
196
201
|
}
|
|
202
|
+
const v4 = isHistoryExternal(state);
|
|
197
203
|
// Gate exceptions are categorised under strict closure (contract v3 §4).
|
|
198
204
|
//
|
|
199
205
|
// v4 gives them their own field, because they are *live state* — scoped to a
|
|
@@ -203,6 +209,31 @@ export function validateState(state, context = {}) {
|
|
|
203
209
|
// each is reported under the path it actually lives at.
|
|
204
210
|
if (Array.isArray(state.changeHistory)) {
|
|
205
211
|
state.changeHistory.forEach((entry, i) => {
|
|
212
|
+
if (entry?.type !== 'gate-exception') {
|
|
213
|
+
// A change-log entry is a pointer, not a record.
|
|
214
|
+
//
|
|
215
|
+
// An ERROR rather than the warning the other size rules get, because this is not a
|
|
216
|
+
// preference about tidiness: the entry duplicates an artifact git already holds, and
|
|
217
|
+
// the documentation-only version of this rule is what let it drift back in through
|
|
218
|
+
// every skill except the one it was written for. It is repairable in place —
|
|
219
|
+
// `state compact` moves the entry to the archive and loses nothing — so a document
|
|
220
|
+
// that predates the bound is not stranded by it.
|
|
221
|
+
//
|
|
222
|
+
// v4 only, for the same reason the foreign-record check below is v4 only: a v1-v3
|
|
223
|
+
// document records prose in the log by design, and an error there would cry wolf on
|
|
224
|
+
// documents that were correct when they were written. Their repair is
|
|
225
|
+
// `state migrate`, which compacts the log as it moves the document to v4.
|
|
226
|
+
const length = v4 ? historyEntryLength(entry) : 0;
|
|
227
|
+
if (length > MAX_HISTORY_ENTRY_CHARS) {
|
|
228
|
+
errors.push({
|
|
229
|
+
path: `changeHistory[${i}]`,
|
|
230
|
+
message: `change-log entry is ${length} characters of text across change/reason/rationale (limit ${MAX_HISTORY_ENTRY_CHARS}). `
|
|
231
|
+
+ 'A change-log entry is a pointer: name the artifact path, and the commit when there is one, and let the artifact say the rest. '
|
|
232
|
+
+ 'Prose has a home — the handoff, the review, the ADR, the policy file, or the commit message that carries the change — and the entry names it rather than repeating it. '
|
|
233
|
+
+ 'Run "cadet-agent state compact --keep active" to move over-long entries to .cadet/archive/history.jsonl; nothing is lost.',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
206
237
|
if (entry?.type !== 'gate-exception') return;
|
|
207
238
|
if (!strict) return;
|
|
208
239
|
for (const e of validateGateException(entry, strict)) {
|
|
@@ -469,6 +500,37 @@ function validateEvidenceShape(ev, strict = null) {
|
|
|
469
500
|
if (ev.evidenceId !== undefined && !isUuid(ev.evidenceId)) errors.push({ path: 'evidenceId', message: 'evidenceId must be a UUIDv4' });
|
|
470
501
|
if (ev.phase !== undefined && !PHASES.includes(ev.phase)) errors.push({ path: 'phase', message: `unknown phase "${ev.phase}"` });
|
|
471
502
|
if (ev.gate !== undefined && !GATES.includes(ev.gate)) errors.push({ path: 'gate', message: `unknown gate "${ev.gate}"` });
|
|
503
|
+
// A human acceptance with no witness, or with no statement of its limitations, is
|
|
504
|
+
// a signature on nothing. Checked at validation as well as at creation, because a
|
|
505
|
+
// hand-edited state.json is exactly what this gate must not be satisfiable by.
|
|
506
|
+
if (ev.gate === HUMAN_ACCEPTANCE_GATE) {
|
|
507
|
+
for (const field of ['witness', 'limitations']) {
|
|
508
|
+
const value = ev[field];
|
|
509
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
510
|
+
errors.push({ path: field, message: `a ${HUMAN_ACCEPTANCE_GATE} record must carry a non-empty "${field}"` });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
// The architecture record is a claim about checks, so it must say which checks. One
|
|
515
|
+
// entry per check, each with an id and an outcome, or the record would attest
|
|
516
|
+
// "constraints hold" while naming none of them.
|
|
517
|
+
if (ev.gate === ARCHITECTURE_GATE) {
|
|
518
|
+
if (!Array.isArray(ev.checks) || ev.checks.length === 0) {
|
|
519
|
+
errors.push({ path: 'checks', message: `a ${ARCHITECTURE_GATE} record must carry the checks it ran` });
|
|
520
|
+
} else {
|
|
521
|
+
ev.checks.forEach((check, i) => {
|
|
522
|
+
if (typeof check?.id !== 'string' || check.id === '') {
|
|
523
|
+
errors.push({ path: `checks[${i}].id`, message: 'an architecture check result must name the check id' });
|
|
524
|
+
}
|
|
525
|
+
if (!['passed', 'failed', 'blocked'].includes(check?.status)) {
|
|
526
|
+
errors.push({ path: `checks[${i}].status`, message: 'an architecture check result must be passed, failed or blocked' });
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
if (ev.gateContract !== undefined && ev.gateContract !== null && typeof ev.gateContract !== 'string') {
|
|
532
|
+
errors.push({ path: 'gateContract', message: 'gateContract must be a contract id such as "testsPassed@1", or null' });
|
|
533
|
+
}
|
|
472
534
|
if (ev.status !== undefined && !EVIDENCE_STATUSES.includes(ev.status)) errors.push({ path: 'status', message: `unknown evidence status "${ev.status}"` });
|
|
473
535
|
if (ev.relevantFiles !== undefined && !Array.isArray(ev.relevantFiles)) errors.push({ path: 'relevantFiles', message: 'relevantFiles must be an array' });
|
|
474
536
|
if (ev.inputTreeHash !== undefined && !/^[0-9a-f]{64}$/.test(String(ev.inputTreeHash))) {
|
|
@@ -738,6 +800,38 @@ export function migrateStateV1toV2(v1) {
|
|
|
738
800
|
*/
|
|
739
801
|
export const HISTORY_ENTRIES_KEPT = 25;
|
|
740
802
|
|
|
803
|
+
/**
|
|
804
|
+
* How long one change-log entry's text may be.
|
|
805
|
+
*
|
|
806
|
+
* An entry is a **pointer**: the artifact path, and the commit when there is one. It is not a
|
|
807
|
+
* record of what happened — the artifact is, and the artifact is in git. The rule was already
|
|
808
|
+
* written for `Handoff` ("a reference, not an essay") after 116 handoff entries averaging
|
|
809
|
+
* 1.9 KB each duplicated files already on disk, and it drifted straight back through the other
|
|
810
|
+
* skills: on the audited repository the longest entry was a 1.2 KB retelling of a review report
|
|
811
|
+
* that names its own path in the same sentence.
|
|
812
|
+
*
|
|
813
|
+
* 400 characters holds every legitimate entry measured — a path, a verdict word, a commit —
|
|
814
|
+
* with room to spare, and refuses the retelling. A `reason` field on the entry is where a *why*
|
|
815
|
+
* belongs, and it is bounded by the same discipline: if it needs more than a sentence, it
|
|
816
|
+
* belongs in the artifact.
|
|
817
|
+
*/
|
|
818
|
+
export const MAX_HISTORY_ENTRY_CHARS = 400;
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* The text of a change-log entry, across every field it can sit in.
|
|
822
|
+
*
|
|
823
|
+
* Not `change` alone: measured on the audited consumer, `reason` held 12.3 KB of the 18.9 KB the
|
|
824
|
+
* surviving log occupied — 65% of the bytes, in the field the first version of this bound did not
|
|
825
|
+
* look at. A rule that bounds one field and not the others does not bound prose, it relocates it.
|
|
826
|
+
*/
|
|
827
|
+
export function historyEntryLength(entry) {
|
|
828
|
+
let total = 0;
|
|
829
|
+
for (const field of ['change', 'reason', 'rationale', 'closureReviewNote']) {
|
|
830
|
+
if (typeof entry?.[field] === 'string') total += entry[field].length;
|
|
831
|
+
}
|
|
832
|
+
return total;
|
|
833
|
+
}
|
|
834
|
+
|
|
741
835
|
/**
|
|
742
836
|
* Split a change log into the tail that stays inline and the overflow to archive.
|
|
743
837
|
*
|
|
@@ -745,13 +839,22 @@ export const HISTORY_ENTRIES_KEPT = 25;
|
|
|
745
839
|
* handoff cross-checks against. The overflow is returned rather than discarded so
|
|
746
840
|
* the caller can persist it: an audit trail may move, but it must not evaporate.
|
|
747
841
|
*/
|
|
748
|
-
export function compactHistory(entries, { keepRecent = HISTORY_ENTRIES_KEPT } = {}) {
|
|
842
|
+
export function compactHistory(entries, { keepRecent = HISTORY_ENTRIES_KEPT, maxEntryChars = MAX_HISTORY_ENTRY_CHARS } = {}) {
|
|
749
843
|
const list = Array.isArray(entries) ? entries : [];
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
844
|
+
const boundary = Math.max(0, list.length - keepRecent);
|
|
845
|
+
const kept = [];
|
|
846
|
+
const archived = [];
|
|
847
|
+
list.forEach((entry, i) => {
|
|
848
|
+
// Live state is never archived, whatever it measures: a legacy gate exception still
|
|
849
|
+
// filed in the log is promoted by `toStateV4` before this runs, and this is the second
|
|
850
|
+
// guard against a caller that reaches compaction by another route.
|
|
851
|
+
if (entry?.type === 'gate-exception') { kept.push(entry); return; }
|
|
852
|
+
// Recency is the wrong selector for an over-bound entry: the newest one is exactly the
|
|
853
|
+
// one an agent just wrote, and it is the one that must not sit in the document.
|
|
854
|
+
if (i < boundary || historyEntryLength(entry) > maxEntryChars) archived.push(entry);
|
|
855
|
+
else kept.push(entry);
|
|
856
|
+
});
|
|
857
|
+
return { kept, archived };
|
|
755
858
|
}
|
|
756
859
|
|
|
757
860
|
/**
|
|
@@ -1026,6 +1129,12 @@ export function createEvidence({
|
|
|
1026
1129
|
relevantFiles,
|
|
1027
1130
|
toolVersion,
|
|
1028
1131
|
commit: normalizedCommit,
|
|
1132
|
+
// The builder contract this record was written under. Stamped only on
|
|
1133
|
+
// automated evidence: a manual confirmation is not produced by a builder, so
|
|
1134
|
+
// there is no contract for it to satisfy, and `evidenceFreshness` accepts a
|
|
1135
|
+
// null contract rather than invalidating every record written before this
|
|
1136
|
+
// field existed.
|
|
1137
|
+
gateContract: source === 'automated' ? gateContractId(gate) : null,
|
|
1029
1138
|
createdAt: timestamp(createdAt),
|
|
1030
1139
|
expiresAt: expiresAt ? timestamp(expiresAt) : null,
|
|
1031
1140
|
freshnessPolicy,
|
|
@@ -1062,6 +1171,7 @@ export function evidenceFreshness(evidence, context) {
|
|
|
1062
1171
|
phase = null,
|
|
1063
1172
|
inputTreeHash = null,
|
|
1064
1173
|
criteriaHash = null,
|
|
1174
|
+
gateContract = null,
|
|
1065
1175
|
} = context || {};
|
|
1066
1176
|
|
|
1067
1177
|
if (evidence.status === 'superseded') reasons.push('evidence was superseded');
|
|
@@ -1077,6 +1187,15 @@ export function evidenceFreshness(evidence, context) {
|
|
|
1077
1187
|
if (criteriaHash && evidence.criteriaHash && evidence.criteriaHash !== criteriaHash) {
|
|
1078
1188
|
reasons.push('acceptance criteria changed since the evidence was recorded');
|
|
1079
1189
|
}
|
|
1190
|
+
// A gate's evidence contract is part of the record's identity. When the builder's
|
|
1191
|
+
// rules change, a record written under the old rules no longer answers the
|
|
1192
|
+
// question the gate asks, so it is refused as stale rather than re-read as if
|
|
1193
|
+
// nothing had changed. A record that declares no contract predates this check or
|
|
1194
|
+
// is a manual confirmation; it is accepted, so no existing evidence is
|
|
1195
|
+
// retroactively invalidated by the field arriving.
|
|
1196
|
+
if (gateContract && evidence.gateContract && evidence.gateContract !== gateContract) {
|
|
1197
|
+
reasons.push(`evidence was recorded against gate contract "${evidence.gateContract}", not the current "${gateContract}"`);
|
|
1198
|
+
}
|
|
1080
1199
|
if (evidence.expiresAt && new Date(evidence.expiresAt).getTime() <= now.getTime()) {
|
|
1081
1200
|
reasons.push('evidence expired');
|
|
1082
1201
|
}
|
|
@@ -1129,7 +1248,7 @@ export function appendEvidence(state, evidence) {
|
|
|
1129
1248
|
* Centralised so `harness confirm`, `harness verify`, `harness verify-acs` and the
|
|
1130
1249
|
* tests cannot drift.
|
|
1131
1250
|
*/
|
|
1132
|
-
export function recordEvidence(state, evidence) {
|
|
1251
|
+
export function recordEvidence(state, evidence, { setGate = true } = {}) {
|
|
1133
1252
|
const gate = evidence?.gate;
|
|
1134
1253
|
const superseding = { ...state };
|
|
1135
1254
|
if (gate) {
|
|
@@ -1139,7 +1258,12 @@ export function recordEvidence(state, evidence) {
|
|
|
1139
1258
|
: e));
|
|
1140
1259
|
}
|
|
1141
1260
|
const next = appendEvidence(superseding, evidence);
|
|
1142
|
-
|
|
1261
|
+
// `setGate` exists because a run that did NOT pass must not leave the gate true. The default
|
|
1262
|
+
// keeps every existing caller's behaviour (they record evidence that passed, and the gate they
|
|
1263
|
+
// satisfy). A caller recording a failure passes false, which also clears a gate an earlier pass
|
|
1264
|
+
// had set — the claim is no longer supported by anything, and `state validate` rejects a true
|
|
1265
|
+
// gate whose latest record is not a pass, so leaving it true wrote an invalid document.
|
|
1266
|
+
if (gate) next.gates = { ...(state.gates || {}), [gate]: setGate === true };
|
|
1143
1267
|
return next;
|
|
1144
1268
|
}
|
|
1145
1269
|
|
|
@@ -1193,7 +1317,26 @@ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
|
|
|
1193
1317
|
// ── Transitions ─────────────────────────────────────────────────────────────
|
|
1194
1318
|
|
|
1195
1319
|
/** Required gates for a transition target, or null when the target is not gated. */
|
|
1196
|
-
|
|
1320
|
+
/**
|
|
1321
|
+
* Gates required on an edge that carries no entry in `TRANSITIONS`.
|
|
1322
|
+
*
|
|
1323
|
+
* Why this exists: `requiredGates` resolves a transition by its TARGET phase, so a
|
|
1324
|
+
* gate that belongs to one specific `(from, to)` pair has nowhere to live in that
|
|
1325
|
+
* table — and `story-breakdown` is reached from two places (`architectureComplete`
|
|
1326
|
+
* and `spikes`), so an entry keyed by target would gate the wrong route as well.
|
|
1327
|
+
* The design review belongs to the design route only: a spike that produced a
|
|
1328
|
+
* finding and went straight to breakdown is not a design being approved.
|
|
1329
|
+
*
|
|
1330
|
+
* Returns `[]` when the edge carries nothing, so the caller can treat "no gates"
|
|
1331
|
+
* and "no conditional gates" the same way.
|
|
1332
|
+
*/
|
|
1333
|
+
export function conditionalEdgeGates(fromPhase, toPhase, policy = null) {
|
|
1334
|
+
if (fromPhase !== DESIGN_REVIEW_TRANSITION_FROM) return [];
|
|
1335
|
+
if (toPhase !== 'story-breakdown') return [];
|
|
1336
|
+
return policy?.designReview?.enabled === true ? [DESIGN_REVIEW_GATE] : [];
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
export function requiredGates(toPhase, { reachability = false, humanAcceptance = false, architectureFitness = false } = {}) {
|
|
1197
1340
|
for (const [from, spec] of Object.entries(TRANSITIONS)) {
|
|
1198
1341
|
if (spec.to === toPhase) {
|
|
1199
1342
|
const gates = [...spec.gates];
|
|
@@ -1210,7 +1353,25 @@ export function requiredGates(toPhase, { reachability = false } = {}) {
|
|
|
1210
1353
|
// single-argument call — and every existing caller and test — sees
|
|
1211
1354
|
// unchanged behaviour. See REACHABILITY_GATE.
|
|
1212
1355
|
if (reachability && from === REACHABILITY_TRANSITION_FROM) gates.push(REACHABILITY_GATE);
|
|
1213
|
-
|
|
1356
|
+
// Human acceptance joins only the closure transition, and only when the
|
|
1357
|
+
// repository has asked for it. It is deliberately NOT in `revalidate`: the
|
|
1358
|
+
// closure transition is where it belongs, so it is a primary gate there, and a
|
|
1359
|
+
// revalidated gate is judged without its phase stamp — which for this one would
|
|
1360
|
+
// mean accepting a record written in any phase at all.
|
|
1361
|
+
if (humanAcceptance && from === HUMAN_ACCEPTANCE_TRANSITION_FROM) gates.push(HUMAN_ACCEPTANCE_GATE);
|
|
1362
|
+
// Architecture fitness joins `implementation -> review`, and only when the project
|
|
1363
|
+
// has declared checks. It is appended at evaluation time rather than written into
|
|
1364
|
+
// the transition table, so a project that declares nothing keeps the frozen lists
|
|
1365
|
+
// exactly as the contract states them.
|
|
1366
|
+
if (architectureFitness && from === ARCHITECTURE_TRANSITION_FROM && spec.to === ARCHITECTURE_TRANSITION_TO) {
|
|
1367
|
+
gates.push(ARCHITECTURE_GATE);
|
|
1368
|
+
}
|
|
1369
|
+
// And it is re-examined at closure, because a dependency it governs can be broken
|
|
1370
|
+
// by a LATER story inside the same epic — by the time the epic closes, the check
|
|
1371
|
+
// that passed during implementation may describe a tree that no longer exists.
|
|
1372
|
+
const revalidate = [...(spec.revalidate || [])];
|
|
1373
|
+
if (architectureFitness && spec.to === 'closed') revalidate.push(ARCHITECTURE_GATE);
|
|
1374
|
+
return { from, gates, revalidate };
|
|
1214
1375
|
}
|
|
1215
1376
|
}
|
|
1216
1377
|
return null;
|
|
@@ -1312,6 +1473,7 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
|
|
|
1312
1473
|
phase: phaseScoped ? fromPhase : null,
|
|
1313
1474
|
inputTreeHash: currentTreeHash,
|
|
1314
1475
|
criteriaHash: critHash,
|
|
1476
|
+
gateContract: gateContractId(gate),
|
|
1315
1477
|
});
|
|
1316
1478
|
|
|
1317
1479
|
const allReasons = [...reasons];
|
|
@@ -1413,8 +1575,16 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1413
1575
|
|
|
1414
1576
|
// The reachability gate joins the requirement only when the repository has
|
|
1415
1577
|
// opted in, so a project that has not sees the pre-existing gate list exactly.
|
|
1416
|
-
const spec = requiredGates(toPhase, {
|
|
1417
|
-
|
|
1578
|
+
const spec = requiredGates(toPhase, {
|
|
1579
|
+
reachability: context.policy?.reachability?.enabled === true,
|
|
1580
|
+
humanAcceptance: context.policy?.humanAcceptance?.enabled === true,
|
|
1581
|
+
architectureFitness: architectureFitnessActive(context.policy),
|
|
1582
|
+
});
|
|
1583
|
+
// An edge with no `TRANSITIONS` entry can still carry a gate. It is resolved HERE,
|
|
1584
|
+
// before the ungated-edge shortcut below, because that shortcut is what would
|
|
1585
|
+
// otherwise let the edge through with the gate never consulted at all.
|
|
1586
|
+
const conditional = spec ? [] : conditionalEdgeGates(fromPhase, toPhase, context.policy);
|
|
1587
|
+
if (!spec && conditional.length === 0) {
|
|
1418
1588
|
// Ungated transitions are legal ONLY along the declared forward edges
|
|
1419
1589
|
// (bootstrap + planning progression). A target that is neither gated nor a
|
|
1420
1590
|
// declared forward edge is rejected — most importantly, this makes `closed`
|
|
@@ -1425,7 +1595,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1425
1595
|
errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (not a gated transition, and not a declared forward edge)`);
|
|
1426
1596
|
return { allowed: false, fromPhase, toPhase, missingGates, staleEvidence, errors, revalidated: [] };
|
|
1427
1597
|
}
|
|
1428
|
-
if (spec.from !== fromPhase) {
|
|
1598
|
+
if (spec && spec.from !== fromPhase) {
|
|
1429
1599
|
errors.push(`illegal transition "${fromPhase}" → "${toPhase}" (expected from "${spec.from}")`);
|
|
1430
1600
|
return { allowed: false, fromPhase, toPhase, missingGates: [...spec.gates], staleEvidence, errors, revalidated: [] };
|
|
1431
1601
|
}
|
|
@@ -1442,7 +1612,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1442
1612
|
|
|
1443
1613
|
const shared = { state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash };
|
|
1444
1614
|
|
|
1445
|
-
for (const gate of spec.gates) {
|
|
1615
|
+
for (const gate of (spec ? spec.gates : conditional)) {
|
|
1446
1616
|
const r = checkGate({ ...shared, gate });
|
|
1447
1617
|
missingGates.push(...r.missingGates);
|
|
1448
1618
|
staleEvidence.push(...r.staleEvidence);
|
|
@@ -1463,7 +1633,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1463
1633
|
const recencyFloor = strict.requireFreshRevalidation !== false && state?.lastTransition?.at
|
|
1464
1634
|
? new Date(state.lastTransition.at)
|
|
1465
1635
|
: null;
|
|
1466
|
-
for (const gate of spec
|
|
1636
|
+
for (const gate of (spec?.revalidate || [])) {
|
|
1467
1637
|
if (spec.gates.includes(gate)) continue; // already checked as a primary gate
|
|
1468
1638
|
revalidated.push(gate);
|
|
1469
1639
|
const r = checkGate({ ...shared, gate, recencyFloor, phaseScoped: false });
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The health line — the framework's entire per-reply output.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. The Response Contract shipped in 0.52.0 as a six-field status
|
|
5
|
+
* table (`Item`, `Phase`, `Gates open`, `Blocking`, `You owe`, `Next`). Its own
|
|
6
|
+
* rule was "no line that cannot change a decision the reader is making", and
|
|
7
|
+
* four of those fields broke it on a normal turn: `Item` and `Phase` change about
|
|
8
|
+
* once per story, `Gates open` is a progress bar for the framework rather than
|
|
9
|
+
* for the work, and `Next` restates the prose that follows it. So the contract
|
|
10
|
+
* paid six lines on every reply to fund the rare turn where one of them mattered.
|
|
11
|
+
*
|
|
12
|
+
* The line this command prints replaces all six. It says `ok` when the tracking
|
|
13
|
+
* record is sound, and otherwise it says what is wrong. Nothing else about the
|
|
14
|
+
* framework is printed, ever.
|
|
15
|
+
*
|
|
16
|
+
* WHAT `ok` MEANS, and this is the whole design. The workflow record is readable,
|
|
17
|
+
* valid and FRESH, and no recorded run stopped on a budget or failed to run.
|
|
18
|
+
*
|
|
19
|
+
* WHAT `ok` DOES NOT MEAN. It does not mean every gate for the next phase is
|
|
20
|
+
* satisfied. Gates unmet because the work is not finished yet are NORMAL: the
|
|
21
|
+
* four implementation gates are unmet for most of every story. Reporting them
|
|
22
|
+
* would print a problem on nearly every reply, which is precisely the defect this
|
|
23
|
+
* replaces. Only things that are WRONG are reported:
|
|
24
|
+
*
|
|
25
|
+
* - the state file is missing in a consumer repository, or unreadable;
|
|
26
|
+
* - `validateState` raises an ERROR (schema, a foreign or stale record, an
|
|
27
|
+
* expired one, an unbacked `true` gate). WARNINGS are advisory here on
|
|
28
|
+
* purpose: this repository's own `harness.json` records nineteen permanently
|
|
29
|
+
* expected reconciliation warnings, and treating those as problems would
|
|
30
|
+
* reproduce the "permanently blocking finding" defect one directory up;
|
|
31
|
+
* - the last run's status is `exhausted` or `blocked`. That is the difference
|
|
32
|
+
* between the framework stopping and the framework reporting: a `failed` run
|
|
33
|
+
* is a RED, which is how TDD is supposed to work, and is never a problem.
|
|
34
|
+
*
|
|
35
|
+
* NOTHING HERE IS ASSERTED. Every problem is derived from a check that already
|
|
36
|
+
* exists — `validateState` and the run ledger — so the line cannot claim a health
|
|
37
|
+
* the framework has not verified. That is why the framework prints the line from
|
|
38
|
+
* this command rather than letting the agent compose an `ok` of its own.
|
|
39
|
+
*
|
|
40
|
+
* READ-ONLY. Declared `mutates: false` in the registry, so the test suite sweeps
|
|
41
|
+
* it under every write-shaped flag and asserts it writes nothing.
|
|
42
|
+
*/
|
|
43
|
+
import { readState, validateState, workItemIdOf } from './state.mjs';
|
|
44
|
+
import { REPO_ROLES, detectRepoRole } from './repo-role.mjs';
|
|
45
|
+
import { loadPolicy } from './policy.mjs';
|
|
46
|
+
import { listRuns, loadRun } from './ledger.mjs';
|
|
47
|
+
|
|
48
|
+
/** The prefix every health line carries, so the reader knows which system spoke. */
|
|
49
|
+
export const STATUS_PREFIX = 'cadet-agent:';
|
|
50
|
+
|
|
51
|
+
/** The healthy line. One line, no fields, no version, no work item. */
|
|
52
|
+
export const STATUS_OK_LINE = `${STATUS_PREFIX} ok`;
|
|
53
|
+
|
|
54
|
+
/** Run statuses that mean the framework stopped rather than reported an outcome. */
|
|
55
|
+
const STOPPED_RUN_STATUSES = Object.freeze(['exhausted', 'blocked']);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute the health line for `targetDir`.
|
|
59
|
+
*
|
|
60
|
+
* Returns `{ ok, line, problems, role, workItemId, stateExists, checkedAt }`.
|
|
61
|
+
* `ok` is true when there are no problems; the line is always exactly one line.
|
|
62
|
+
*/
|
|
63
|
+
export function computeStatus(targetDir, { now = new Date() } = {}) {
|
|
64
|
+
const problems = [];
|
|
65
|
+
const { exists, state } = readState(targetDir);
|
|
66
|
+
const role = detectRepoRole(targetDir);
|
|
67
|
+
const workItemId = exists && state ? workItemIdOf(state) || null : null;
|
|
68
|
+
|
|
69
|
+
if (!exists) {
|
|
70
|
+
// A framework-source repository has no workflow state by design, and saying so
|
|
71
|
+
// is the whole line. Anywhere else, a missing record is a real problem: the
|
|
72
|
+
// gates, the transitions and the archive all read from it.
|
|
73
|
+
if (role.role !== REPO_ROLES.FRAMEWORK) {
|
|
74
|
+
problems.push({
|
|
75
|
+
code: 'no-state',
|
|
76
|
+
message: 'no .cadet/state.json, so the workflow has no record',
|
|
77
|
+
blocks: 'every state command and every gate',
|
|
78
|
+
remedy: 'restore state.json, or install into a new project with cadet-agent init',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
const policy = loadPolicy(targetDir);
|
|
83
|
+
const validation = validateState(state, { rootDir: targetDir, strictClosure: policy.strictClosure });
|
|
84
|
+
for (const error of validation.errors) {
|
|
85
|
+
problems.push({
|
|
86
|
+
code: 'state-invalid',
|
|
87
|
+
message: `${error.path} ${error.message}`,
|
|
88
|
+
blocks: 'the gate or transition that depends on this record',
|
|
89
|
+
remedy: 'cadet-agent state validate',
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The last run, not the active run: `activeRunId` is null between commands, and
|
|
95
|
+
// a stop that was never resolved is still the last thing that happened.
|
|
96
|
+
const runs = listRuns(targetDir);
|
|
97
|
+
const lastRun = runs.length > 0 ? loadRun(targetDir, runs[0].runId) : null;
|
|
98
|
+
if (lastRun && STOPPED_RUN_STATUSES.includes(lastRun.status)) {
|
|
99
|
+
const exhausted = lastRun.status === 'exhausted';
|
|
100
|
+
problems.push({
|
|
101
|
+
code: exhausted ? 'budget-exhausted' : 'run-blocked',
|
|
102
|
+
message: `the last harness run (${String(lastRun.runId).slice(0, 8)}) ${exhausted ? 'stopped on a hard budget' : 'could not run'}`,
|
|
103
|
+
blocks: 'the verification it was performing, so no gate was satisfied by it',
|
|
104
|
+
remedy: 'cadet-agent harness report',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: problems.length === 0,
|
|
110
|
+
line: buildLine({ problems, exists, role }),
|
|
111
|
+
problems,
|
|
112
|
+
role: role.role,
|
|
113
|
+
workItemId,
|
|
114
|
+
stateExists: exists,
|
|
115
|
+
checkedAt: now.toISOString(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** One line, always. The healthy form carries nothing that cannot change a decision. */
|
|
120
|
+
function buildLine({ problems, exists, role }) {
|
|
121
|
+
if (problems.length === 0) {
|
|
122
|
+
if (!exists && role.role === REPO_ROLES.FRAMEWORK) {
|
|
123
|
+
return `${STATUS_OK_LINE} — framework-source repository, no workflow state`;
|
|
124
|
+
}
|
|
125
|
+
return STATUS_OK_LINE;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const first = problems[0];
|
|
129
|
+
const more = problems.length > 1 ? ` (+${problems.length - 1} more)` : '';
|
|
130
|
+
const remedy = first.remedy ? ` — ${first.remedy}` : '';
|
|
131
|
+
return `${STATUS_PREFIX} ${first.message}${more}${remedy}`;
|
|
132
|
+
}
|
|
@@ -15,6 +15,7 @@ import { hashCriteria, sha256Bytes, timestamp, newId } from './util.mjs';
|
|
|
15
15
|
import { BudgetTracker, budgetExhaustedResult, evaluateHardStop } from './budget.mjs';
|
|
16
16
|
import { redactString } from './redaction.mjs';
|
|
17
17
|
import { whichAll } from './routing.mjs';
|
|
18
|
+
import { gateBuilder, describeGateRefusal, describeMissingCommand } from './gates.mjs';
|
|
18
19
|
|
|
19
20
|
export const RETRY_CLASSES = Object.freeze(['deterministic', 'transient', 'repair', 'unknown']);
|
|
20
21
|
export const RESULT_STATUSES = Object.freeze(['passed', 'failed', 'flaky', 'blocked', 'timed-out']);
|
|
@@ -342,35 +343,43 @@ export function runCommand(command, {
|
|
|
342
343
|
* Project-specific commands (analyzer/compile/test) come from `.cadet/harness.json`.
|
|
343
344
|
*/
|
|
344
345
|
export function commandForGate(gate, { projectPath = '.', policy = null, unityAvailable = false } = {}) {
|
|
345
|
-
switch
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
346
|
+
// The registry decides, not a switch in this file: a gate whose contract is not
|
|
347
|
+
// a project command has no default command to run, and one whose contract IS a
|
|
348
|
+
// project command may still have nothing configured yet.
|
|
349
|
+
const builder = gateBuilder(gate);
|
|
350
|
+
if (!builder) {
|
|
351
|
+
return { command: null, tool: 'unknown-gate', automated: false, reason: describeGateRefusal(gate) };
|
|
352
|
+
}
|
|
353
|
+
const declared = builder.command;
|
|
354
|
+
if (!declared) {
|
|
355
|
+
const tool = builder.owner === 'agent' ? 'agent-owned' : builder.owner;
|
|
356
|
+
const reason = builder.projectCommand ? describeMissingCommand(gate) : describeGateRefusal(gate);
|
|
357
|
+
return { command: null, tool, automated: false, reason };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const configured = declared.policyKey ? policy?.[declared.policyKey] : null;
|
|
361
|
+
if (configured) {
|
|
362
|
+
return declared.tool === 'unity-analyzer'
|
|
363
|
+
? { command: `unity run ${projectPath} --command ${configured} --format json`, tool: declared.tool, automated: true }
|
|
364
|
+
: { command: configured, tool: declared.tool, automated: true };
|
|
365
|
+
}
|
|
366
|
+
if (declared.fallback) {
|
|
367
|
+
return { command: declared.fallback, tool: declared.tool, automated: true };
|
|
368
|
+
}
|
|
369
|
+
if (declared.unityBuild) {
|
|
370
|
+
if (!unityAvailable) {
|
|
361
371
|
return { command: null, tool: 'manual-confirmation', automated: false, reason: 'Unity CLI unavailable' };
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
};
|
|
369
|
-
}
|
|
370
|
-
return { command: null, tool: 'unity-analyzer', automated: false, reason: 'analyzer command not declared in .cadet/harness.json' };
|
|
371
|
-
default:
|
|
372
|
-
return { command: null, tool: 'agent-owned', automated: false, reason: `${gate} is agent-owned` };
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
command: `unity build ${projectPath} --target StandaloneWindows64 -o "${join(projectPath, 'Temp', 'cadet-build')}" --format json`,
|
|
375
|
+
tool: declared.tool,
|
|
376
|
+
automated: true,
|
|
377
|
+
};
|
|
373
378
|
}
|
|
379
|
+
if (declared.tool === 'unity-analyzer') {
|
|
380
|
+
return { command: null, tool: 'unity-analyzer', automated: false, reason: 'analyzer command not declared in .cadet/harness.json' };
|
|
381
|
+
}
|
|
382
|
+
return { command: null, tool: 'agent-owned', automated: false, reason: describeMissingCommand(gate) };
|
|
374
383
|
}
|
|
375
384
|
|
|
376
385
|
/** Detect zero `UNT*` diagnostics in a Unity analyzer JSON envelope. */
|
|
@@ -678,6 +687,7 @@ export function manualConfirmation({
|
|
|
678
687
|
gate, workItemId, phase, projectPath, editorVersion, scope, acceptanceCriterionId = null,
|
|
679
688
|
relevantFiles = [], criteria = [], rootDir = process.cwd(), approvedBy = 'user', at = new Date(),
|
|
680
689
|
reason = null, expiresAt = null, environment = null, expiresInMs = null, commit = null,
|
|
690
|
+
witness = null, limitations = null,
|
|
681
691
|
} = {}) {
|
|
682
692
|
const inputTreeHash = computeInputTreeHash(rootDir, relevantFiles);
|
|
683
693
|
// v3 quality fields. `scope` is declared both as the free-text `result` line
|
|
@@ -689,6 +699,8 @@ export function manualConfirmation({
|
|
|
689
699
|
// Redaction has no bypass (contract §8).
|
|
690
700
|
const scopeList = (Array.isArray(scope) ? scope : (scope ? [scope] : [])).map(redactString);
|
|
691
701
|
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));
|
|
692
704
|
const env = Object.fromEntries(
|
|
693
705
|
Object.entries(environment || (projectPath || editorVersion
|
|
694
706
|
? { projectPath: projectPath || null, editorVersion: editorVersion || null }
|
|
@@ -728,8 +740,19 @@ export function manualConfirmation({
|
|
|
728
740
|
...(safeReason !== null ? { reason: safeReason } : {}),
|
|
729
741
|
...(hasEnv ? { environment: env } : {}),
|
|
730
742
|
...(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
|
+
};
|
|
752
|
+
return {
|
|
753
|
+
evidence, approvedBy, projectPath, editorVersion, scope: scopeList, reason: safeReason,
|
|
754
|
+
environment: env, witness: safeWitness, limitations: safeLimitations, recordedAt: timestamp(at),
|
|
731
755
|
};
|
|
732
|
-
return { evidence, approvedBy, projectPath, editorVersion, scope: scopeList, reason: safeReason, environment: env, recordedAt: timestamp(at) };
|
|
733
756
|
}
|
|
734
757
|
|
|
735
758
|
/** Convenience: is the verification result an exhaustion that must not read as success? */
|