cadet-agent 0.40.0 → 0.43.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.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Cadet-Agent sealed gate evidence — commit-message trailers.
3
+ *
4
+ * Gate evidence has two lifecycles fused in v2/v3: a handful of records are
5
+ * live, and every record the harness has ever written is retained forever inside
6
+ * `.cadet/state.json`. On a real consumer project that grew the state document to
7
+ * 2 MB, 72% of it evidence, with 823 of 832 records superseded or failed — and
8
+ * because `state.json` is rewritten by the very command that records a gate, the
9
+ * cost of each write scaled with all accumulated history (see the CADET_MACHINERY
10
+ * note in util.mjs, which exists to work around exactly this).
11
+ *
12
+ * The fix is to stop storing history in the cursor. A work item's records are
13
+ * written into the commit that closes it, as git trailers. The commit object then
14
+ * carries the evidence, and because the trailers are part of the object, editing
15
+ * one changes the SHA: a sealed record is self-verifying in a way an array entry
16
+ * inside a rewritten JSON file never could be.
17
+ *
18
+ * Why trailers rather than `git notes` or tags:
19
+ * - a note is not fetched or pushed by default and can be rewritten silently,
20
+ * so it cannot support an immutability claim;
21
+ * - per-gate tags would add hundreds of refs to the namespace;
22
+ * - a trailer is in the commit object, so the seal is the hash itself, and the
23
+ * existing optional `commit` field on an evidence record finally means
24
+ * something in both directions.
25
+ *
26
+ * Cadet still does not commit (contract C5). `state seal` produces a message file
27
+ * that a human or agent then commits with `git commit -F <file>`.
28
+ *
29
+ * Contract: docs/core/HarnessContract-v5.md.
30
+ */
31
+
32
+ import { spawnSync } from 'node:child_process';
33
+
34
+ /** Prefix every trailer shares, so a message can be scanned cheaply. */
35
+ export const TRAILER_PREFIX = 'Cadet-';
36
+
37
+ /** The trailer that opens an evidence block. */
38
+ export const BLOCK_TRAILER = 'Cadet-Gate';
39
+
40
+ /** `git log --grep` argument that bounds a scan to commits carrying evidence. */
41
+ export const TRAILER_GREP = BLOCK_TRAILER;
42
+
43
+ /**
44
+ * Field table. `kind` decides how a value is written and read back:
45
+ * - `string` plain when unambiguous, JSON-quoted otherwise
46
+ * - `number` / `boolean` literal, `null` for absent
47
+ * - `array` / `object` always JSON, so a comma in a filename cannot
48
+ * be mistaken for a separator
49
+ *
50
+ * Two flags control how a missing value is handled, and they are not the same
51
+ * question:
52
+ *
53
+ * - `always` (write side): emit the line even when the value is null. Required
54
+ * for the fields whose *key presence* the contract demands — `command` and
55
+ * `result` may be null but must be present — so a round trip cannot turn a
56
+ * declared-null into a missing key and change the record's validity.
57
+ * - `fill` (read side): materialise the key as null when it is absent, so a
58
+ * record recovered from a commit is shaped like the record that was sealed.
59
+ *
60
+ * Fields with neither flag are v3-optional and *not* nullable in the schema
61
+ * (`reason`, `environment`, `scope`, `source`). They are simply omitted when
62
+ * absent: filling them with null would produce a record that fails validation for
63
+ * having a null where the schema requires a string.
64
+ */
65
+ const FIELDS = Object.freeze([
66
+ { key: 'gate', trailer: 'Cadet-Gate', kind: 'string', fill: true },
67
+ { key: 'workItemId', trailer: 'Cadet-Work-Item', kind: 'string', fill: true },
68
+ { key: 'status', trailer: 'Cadet-Status', kind: 'string', fill: true },
69
+ { key: 'evidenceId', trailer: 'Cadet-Evidence-Id', kind: 'string', fill: true },
70
+ { key: 'phase', trailer: 'Cadet-Phase', kind: 'string', fill: true },
71
+ { key: 'acceptanceCriterionId', trailer: 'Cadet-Acceptance-Criterion', kind: 'string', fill: true },
72
+ { key: 'inputTreeHash', trailer: 'Cadet-Input-Tree-Hash', kind: 'string', fill: true },
73
+ { key: 'criteriaHash', trailer: 'Cadet-Criteria-Hash', kind: 'string', fill: true },
74
+ { key: 'relevantFiles', trailer: 'Cadet-Relevant-Files', kind: 'array', fill: true },
75
+ { key: 'command', trailer: 'Cadet-Command', kind: 'string', always: true, fill: true },
76
+ { key: 'result', trailer: 'Cadet-Result', kind: 'string', always: true, fill: true },
77
+ { key: 'exitCode', trailer: 'Cadet-Exit-Code', kind: 'number', fill: true },
78
+ { key: 'artifactPath', trailer: 'Cadet-Artifact-Path', kind: 'string', fill: true },
79
+ { key: 'artifactHash', trailer: 'Cadet-Artifact-Hash', kind: 'string', fill: true },
80
+ { key: 'toolVersion', trailer: 'Cadet-Tool-Version', kind: 'string', fill: true },
81
+ { key: 'createdAt', trailer: 'Cadet-Created-At', kind: 'string', fill: true },
82
+ { key: 'expiresAt', trailer: 'Cadet-Expires-At', kind: 'string', always: true, fill: true },
83
+ { key: 'freshnessPolicy', trailer: 'Cadet-Freshness-Policy', kind: 'object', fill: true },
84
+ { key: 'reason', trailer: 'Cadet-Reason', kind: 'string' },
85
+ { key: 'environment', trailer: 'Cadet-Environment', kind: 'object' },
86
+ { key: 'scope', trailer: 'Cadet-Scope', kind: 'array' },
87
+ { key: 'source', trailer: 'Cadet-Source', kind: 'string' },
88
+ { key: 'supersededBy', trailer: 'Cadet-Superseded-By', kind: 'string', fill: true },
89
+ ]);
90
+
91
+ const BY_TRAILER = new Map(FIELDS.map((f) => [f.trailer, f]));
92
+
93
+ /** Default ceiling for one commit's evidence block (bytes). */
94
+ export const DEFAULT_MAX_TRAILER_BYTES = 64 * 1024;
95
+
96
+ /**
97
+ * Is a string safe to write unquoted?
98
+ *
99
+ * It must survive a line-oriented, `interpret-trailers`-compatible format: no
100
+ * newline (it would end the trailer), no leading or trailing whitespace (git
101
+ * strips it), and it must not look like an encoded value on the way back in —
102
+ * a literal `null`, or something beginning with a JSON delimiter, would decode
103
+ * as something else.
104
+ */
105
+ function isBareString(value) {
106
+ if (value === '') return true;
107
+ if (value === 'null') return false;
108
+ if (/[\r\n]/.test(value)) return false;
109
+ if (value !== value.trim()) return false;
110
+ return !/^["[{]/.test(value);
111
+ }
112
+
113
+ function encodeValue(value, kind) {
114
+ if (value === null || value === undefined) return 'null';
115
+ if (kind === 'array' || kind === 'object') return JSON.stringify(value);
116
+ if (kind === 'number') return String(value);
117
+ if (kind === 'boolean') return value ? 'true' : 'false';
118
+ const text = String(value);
119
+ return isBareString(text) ? text : JSON.stringify(text);
120
+ }
121
+
122
+ function decodeValue(raw, kind) {
123
+ const text = raw.trim();
124
+ if (text === 'null') return null;
125
+ if (kind === 'array' || kind === 'object') {
126
+ try {
127
+ return JSON.parse(text);
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+ if (kind === 'number') {
133
+ const n = Number(text);
134
+ return Number.isFinite(n) ? n : null;
135
+ }
136
+ if (kind === 'boolean') return text === 'true';
137
+ if (text.startsWith('"')) {
138
+ try {
139
+ return JSON.parse(text);
140
+ } catch {
141
+ return text;
142
+ }
143
+ }
144
+ return text;
145
+ }
146
+
147
+ /**
148
+ * Encode one record as trailer lines, `Cadet-Gate` first.
149
+ *
150
+ * `relevantFiles` is the only field that plausibly runs long, so when the block
151
+ * would exceed `maxBytes` it is truncated first and the record is marked
152
+ * `partial`. A partial record still parses, but it must never satisfy a gate: it
153
+ * no longer binds the evidence to the files it claims.
154
+ *
155
+ * Returns `{ lines, partial, bytes }`.
156
+ */
157
+ export function encodeEvidenceTrailers(record, { maxBytes = DEFAULT_MAX_TRAILER_BYTES } = {}) {
158
+ if (!record || typeof record !== 'object') {
159
+ throw new TypeError('encodeEvidenceTrailers requires an evidence record');
160
+ }
161
+ const PARTIAL_LINE = `${TRAILER_PREFIX}Partial: true`;
162
+ const markerBytes = Buffer.byteLength(`${PARTIAL_LINE}\n`, 'utf-8');
163
+
164
+ const render = (rec) => FIELDS
165
+ .filter((f) => f.always || (rec[f.key] !== null && rec[f.key] !== undefined))
166
+ .map((f) => `${f.trailer}: ${encodeValue(rec[f.key], f.kind)}`);
167
+
168
+ const sizeOf = (lines) => Buffer.byteLength(lines.join('\n'), 'utf-8');
169
+
170
+ let working = { ...record };
171
+ let rendered = render(working);
172
+ if (sizeOf(rendered) <= maxBytes) {
173
+ return { lines: rendered, partial: false, bytes: sizeOf(rendered) };
174
+ }
175
+
176
+ // The block does not fit. Reserve room for the marker first: a record that was
177
+ // truncated without saying so is worse than no record, because it still looks
178
+ // complete and would be read as binding the evidence to a partial file list.
179
+ const budget = Math.max(0, maxBytes - markerBytes);
180
+ const files = Array.isArray(working.relevantFiles) ? working.relevantFiles : [];
181
+ let kept = files.length;
182
+ while (kept > 0 && sizeOf(rendered) > budget) {
183
+ kept -= 1;
184
+ working = { ...working, relevantFiles: files.slice(0, kept) };
185
+ rendered = render(working);
186
+ }
187
+ if (sizeOf(rendered) > budget) {
188
+ // Even without the file list it does not fit: the free-text fields are the
189
+ // problem. Seal the structural fields and drop the prose.
190
+ working = { ...working, relevantFiles: [], result: null, reason: null };
191
+ rendered = render(working);
192
+ if (sizeOf(rendered) > budget) {
193
+ throw new Error(`evidence record ${record.evidenceId || '(no id)'} exceeds the ${maxBytes}-byte trailer bound even after truncation`);
194
+ }
195
+ }
196
+ const lines = [...rendered, PARTIAL_LINE];
197
+ return { lines, partial: true, bytes: sizeOf(lines) };
198
+ }
199
+
200
+ /**
201
+ * Parse every evidence block out of a commit message.
202
+ *
203
+ * Deliberately tolerant: an unrecognized or malformed line is recorded as a
204
+ * diagnostic and skipped, because a commit message is human-authored and a single
205
+ * typo must not make the whole message unreadable. What it will *not* do is invent
206
+ * a field — a record missing a required key is returned as-is and rejected by
207
+ * `validateEvidenceShape` downstream, where the error path already exists.
208
+ */
209
+ export function parseEvidenceTrailers(message) {
210
+ const records = [];
211
+ const diagnostics = [];
212
+ let current = null;
213
+
214
+ const flush = () => {
215
+ if (!current) return;
216
+ const record = {};
217
+ for (const f of FIELDS) {
218
+ if (f.key in current.values) {
219
+ record[f.key] = current.values[f.key];
220
+ } else if (f.fill) {
221
+ // Required by the evidence contract, or nullable: either way the key must
222
+ // come back so a recovered record has the shape it was sealed with.
223
+ record[f.key] = f.kind === 'array' ? [] : null;
224
+ }
225
+ }
226
+ if (current.partial) record.partial = true;
227
+ records.push(record);
228
+ current = null;
229
+ };
230
+
231
+ for (const rawLine of String(message ?? '').split(/\r?\n/)) {
232
+ const line = rawLine.trim();
233
+ if (!line.startsWith(TRAILER_PREFIX)) continue;
234
+ const sep = line.indexOf(':');
235
+ if (sep === -1) {
236
+ diagnostics.push(`trailer without a value: ${line}`);
237
+ continue;
238
+ }
239
+ const name = line.slice(0, sep).trim();
240
+ const rawValue = line.slice(sep + 1).trim();
241
+
242
+ if (name === `${TRAILER_PREFIX}Partial`) {
243
+ if (current) current.partial = true;
244
+ continue;
245
+ }
246
+ const field = BY_TRAILER.get(name);
247
+ if (!field) {
248
+ // An unknown `Cadet-*` trailer is likely a typo in a field name. Report it
249
+ // rather than dropping it silently — a silently ignored field would let a
250
+ // record look complete while carrying nothing.
251
+ diagnostics.push(`unknown trailer "${name}"`);
252
+ continue;
253
+ }
254
+ if (name === BLOCK_TRAILER) flush();
255
+ if (!current) {
256
+ if (name !== BLOCK_TRAILER) {
257
+ diagnostics.push(`trailer "${name}" appears before any ${BLOCK_TRAILER} block`);
258
+ continue;
259
+ }
260
+ current = { values: {}, partial: false };
261
+ }
262
+ current.values[field.key] = decodeValue(rawValue, field.kind);
263
+ }
264
+ flush();
265
+
266
+ return { records, diagnostics };
267
+ }
268
+
269
+ /**
270
+ * Run `git` and report whether it was actually usable.
271
+ *
272
+ * Mirrors `gitChangedFiles` in util.mjs: `available: false` means the question
273
+ * could not be asked, which callers must not confuse with "no sealed evidence".
274
+ * `runner` is injectable so the parser is testable without a repository.
275
+ */
276
+ function defaultRunner(cmd, args) {
277
+ try {
278
+ return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Read sealed evidence from git history.
286
+ *
287
+ * The scan is bounded twice: `--grep` restricts it to commits that mention the
288
+ * trailer prefix at all, and `--max-count` caps how far back it walks. Without
289
+ * both, a large repository would pay a full-history parse on every call — the
290
+ * same class of unbounded growth this design exists to remove.
291
+ *
292
+ * `range` narrows the walk (e.g. `HEAD~20..HEAD`) and exists mainly so tests can
293
+ * pin an exact commit set.
294
+ *
295
+ * Returns `{ available, records, reason, diagnostics }`.
296
+ */
297
+ export function sealedEvidence(cwd, { workItemId = null, gate = null, maxCount = 500, range = null, runner = defaultRunner, log = null } = {}) {
298
+ const args = ['-C', cwd, 'log', `--max-count=${maxCount}`, `--grep=${TRAILER_GREP}`, '--format=%H%x00%B%x00'];
299
+ if (range) args.splice(3, 0, range);
300
+
301
+ let res;
302
+ try {
303
+ res = runner('git', args);
304
+ } catch (err) {
305
+ return { available: false, records: [], reason: `git invocation failed: ${err.message}`, diagnostics: [] };
306
+ }
307
+ if (!res) return { available: false, records: [], reason: 'git is not available', diagnostics: [] };
308
+ if (res.error || res.status === null) {
309
+ return { available: false, records: [], reason: 'git is not installed or could not be executed', diagnostics: [] };
310
+ }
311
+ if (res.status !== 0) {
312
+ return { available: false, records: [], reason: String(res.stderr || '').trim() || `git exited ${res.status}`, diagnostics: [] };
313
+ }
314
+
315
+ // A commit message cannot contain a NUL byte, so NUL is a safe field
316
+ // separator where a printable marker would risk colliding with prose.
317
+ const parts = String(res.stdout || '').split('\0');
318
+ const records = [];
319
+ const diagnostics = [];
320
+ for (let i = 0; i + 1 < parts.length; i += 2) {
321
+ const commit = parts[i].trim();
322
+ const body = parts[i + 1];
323
+ if (!commit || !/^[0-9a-f]{4,40}$/i.test(commit)) continue;
324
+ const parsed = parseEvidenceTrailers(body);
325
+ for (const d of parsed.diagnostics) diagnostics.push(`${commit.slice(0, 8)}: ${d}`);
326
+ for (const record of parsed.records) {
327
+ if (workItemId && record.workItemId !== workItemId) continue;
328
+ if (gate && record.gate !== gate) continue;
329
+ records.push({ ...record, sealedCommit: commit });
330
+ }
331
+ }
332
+ if (log && diagnostics.length) log(diagnostics.join('; '));
333
+ return { available: true, records, reason: null, diagnostics };
334
+ }
335
+
336
+ /**
337
+ * The newest sealed record for a gate and work item, or null.
338
+ *
339
+ * Ordering is by `createdAt` with the commit as tie-break, matching
340
+ * `latestEvidenceForGate`'s "most recent wins" rule so a sealed record and a live
341
+ * one are compared on the same basis.
342
+ */
343
+ export function latestSealedForGate(cwd, { workItemId, gate, ...rest } = {}) {
344
+ const result = sealedEvidence(cwd, { workItemId, gate, ...rest });
345
+ if (!result.available || result.records.length === 0) return { ...result, record: null };
346
+ const record = result.records.reduce((a, b) => {
347
+ const ta = Date.parse(a.createdAt ?? '') || 0;
348
+ const tb = Date.parse(b.createdAt ?? '') || 0;
349
+ if (ta !== tb) return ta >= tb ? a : b;
350
+ return String(a.sealedCommit) >= String(b.sealedCommit) ? a : b;
351
+ });
352
+ return { ...result, record };
353
+ }
354
+
355
+ /**
356
+ * Build the coverage index from sealed history alone.
357
+ *
358
+ * This is the read side of the Tier C index: `state.json` keeps a per-work-item
359
+ * summary so the "every `done` story owns evidence" check stays answerable
360
+ * without git, and this function is what can regenerate that summary from the
361
+ * commits when the index is missing or suspect.
362
+ */
363
+ export function coverageFromSealed(cwd, options = {}) {
364
+ const result = sealedEvidence(cwd, options);
365
+ if (!result.available) return { ...result, coverage: {} };
366
+ const coverage = {};
367
+ for (const record of result.records) {
368
+ const id = record.workItemId;
369
+ if (!id) continue;
370
+ const row = coverage[id] || { workItemId: id, recordCount: 0, gates: [], firstAt: null, lastAt: null, sealedCommit: null };
371
+ row.recordCount += 1;
372
+ if (record.gate && !row.gates.includes(record.gate)) row.gates.push(record.gate);
373
+ const at = record.createdAt ? Date.parse(record.createdAt) : NaN;
374
+ if (Number.isFinite(at)) {
375
+ if (!row.firstAt || at < Date.parse(row.firstAt)) row.firstAt = record.createdAt;
376
+ if (!row.lastAt || at >= Date.parse(row.lastAt)) {
377
+ row.lastAt = record.createdAt;
378
+ row.sealedCommit = record.sealedCommit;
379
+ }
380
+ }
381
+ coverage[id] = row;
382
+ }
383
+ for (const row of Object.values(coverage)) row.gates.sort();
384
+ return { ...result, coverage };
385
+ }
@@ -23,12 +23,20 @@ export {
23
23
  } from './util.mjs';
24
24
 
25
25
  export {
26
- STATE_VERSION, READABLE_STATE_VERSIONS, validateState, migrateStateV1toV2, migrateStateFile,
26
+ STATE_VERSION, READABLE_STATE_VERSIONS, HISTORY_EXTERNAL_SINCE, isHistoryExternal,
27
+ validateState, migrateStateV1toV2, migrateStateDocument, migrateStateFile, parseTargetVersion,
28
+ toStateV4, splitEvidence, buildEvidenceCoverage, mergeEvidenceCoverage, sealWorkItem, recordEvidence, appendEvidence,
29
+ HISTORY_ENTRIES_KEPT, compactHistory,
27
30
  createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
28
31
  latestEvidenceForGate, activeExceptions, requiredGates, isUngatedForwardEdge, evaluateTransition, resolveStrict,
29
32
  applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
30
33
  } from './state.mjs';
31
34
 
35
+ export {
36
+ TRAILER_PREFIX, BLOCK_TRAILER, TRAILER_GREP, DEFAULT_MAX_TRAILER_BYTES,
37
+ encodeEvidenceTrailers, parseEvidenceTrailers, sealedEvidence, latestSealedForGate, coverageFromSealed,
38
+ } from './gitmemo.mjs';
39
+
32
40
  export {
33
41
  RETRY_CLASSES as VERIFICATION_RETRY_CLASSES, RESULT_STATUSES, TRANSIENT_BACKOFF_MS,
34
42
  DEFAULT_FLAKY_SIGNATURES, classifyResult, classifyRepair, runCommand,