cadet-agent 0.41.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.
- package/README.md +7 -2
- package/package.json +37 -37
- package/src/cli.mjs +323 -40
- package/src/harness/commands.mjs +29 -4
- package/src/harness/gitmemo.mjs +385 -0
- package/src/harness/index.mjs +9 -1
- package/src/harness/state.mjs +520 -51
- package/src/harness/util.mjs +5 -1
|
@@ -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
|
+
}
|
package/src/harness/index.mjs
CHANGED
|
@@ -23,12 +23,20 @@ export {
|
|
|
23
23
|
} from './util.mjs';
|
|
24
24
|
|
|
25
25
|
export {
|
|
26
|
-
STATE_VERSION, READABLE_STATE_VERSIONS,
|
|
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,
|