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
package/README.md
CHANGED
|
@@ -184,13 +184,18 @@ Gates are backed by **evidence**, not assertion. Each claimed gate must have a f
|
|
|
184
184
|
- When Git is unavailable and no `--files` are given, verification blocks (`freshness-unavailable`) rather than recording unscoped evidence.
|
|
185
185
|
- `state validate` rejects a `true` gate whose evidence is missing, stale, expired, superseded, or bound to another work item; evidence records are schema-validated in full (`command`, `result`, `criteriaHash`, and a freshness bound).
|
|
186
186
|
- Evidence must include a UUID, work item, phase, gate, status, command/result, input-tree hash, criteria hash, relevant files, timestamp, and either `expiresAt` or `freshnessPolicy`.
|
|
187
|
+
- **Evidence history does not live in `state.json`.** A v4 document keeps only the active work item's records inline; a closed work item's evidence is written into the commit that closes it, as `Cadet-*` trailers, and archived to `.cadet/archive/`. `evidenceCoverage` indexes what left, so the "a done story owns evidence" check still works offline. Cadet still never commits: `state seal` prepares a message file and you commit with `git commit -F`.
|
|
187
188
|
- Command output counts against the output budget; a configured cost budget cannot be satisfied by unmeasurable cost (the run is blocked, `budget-blocked`).
|
|
188
189
|
- State and run ledgers are written atomically, so an interrupted write cannot truncate a record; persisted artifacts are redacted before hashing or writing.
|
|
189
190
|
- Empty freshness coverage is an explicit policy decision: set `allowEmptyFreshness: true` in `.cadet/harness.json` only when unscoped evidence is acceptable.
|
|
190
191
|
|
|
191
192
|
```bash
|
|
192
|
-
cadet-agent state validate # validate state against the schema
|
|
193
|
-
cadet-agent state
|
|
193
|
+
cadet-agent state validate # validate state against the schema (read-only)
|
|
194
|
+
cadet-agent state validate --verify-sealed # also read evidence out of commit trailers
|
|
195
|
+
cadet-agent state migrate # atomically upgrade v1 → the current version
|
|
196
|
+
cadet-agent state migrate --to 4 # archive closed work items' evidence; build the index
|
|
197
|
+
cadet-agent state compact --keep active # routine housekeeping on a v4 state
|
|
198
|
+
cadet-agent state seal # write the active work item's evidence as commit trailers
|
|
194
199
|
cadet-agent state transition --to review # enforce the matrix + evidence
|
|
195
200
|
cadet-agent harness verify --gate testsPassed --files src/a.cs # bounded, classified loop
|
|
196
201
|
cadet-agent harness report # budget consumption and failures (no secrets)
|
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cadet-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"cadet-agent": "bin/cli.mjs"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"test": "node --test test/*.test.mjs",
|
|
11
|
-
"lint": "lychee --offline --include-fragments \"**/*.md\"",
|
|
12
|
-
"verify": "npm test && npm run lint"
|
|
13
|
-
},
|
|
14
|
-
"files": [
|
|
15
|
-
"bin/",
|
|
16
|
-
"src/"
|
|
17
|
-
],
|
|
18
|
-
"keywords": [
|
|
19
|
-
"cadet",
|
|
20
|
-
"cadet-agent",
|
|
21
|
-
"unity",
|
|
22
|
-
"game-development",
|
|
23
|
-
"ai-agent",
|
|
24
|
-
"copilot",
|
|
25
|
-
"cursor",
|
|
26
|
-
"claude-code"
|
|
27
|
-
],
|
|
28
|
-
"license": "CC-BY-4.0",
|
|
29
|
-
"repository": {
|
|
30
|
-
"type": "git",
|
|
31
|
-
"url": "git+https://github.com/naishtech/cadet-agent.git"
|
|
32
|
-
},
|
|
33
|
-
"homepage": "https://github.com/naishtech/cadet-agent#readme",
|
|
34
|
-
"engines": {
|
|
35
|
-
"node": ">=18.0.0"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cadet-agent",
|
|
3
|
+
"version": "0.43.0",
|
|
4
|
+
"description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cadet-agent": "bin/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test test/*.test.mjs",
|
|
11
|
+
"lint": "lychee --offline --include-fragments \"**/*.md\"",
|
|
12
|
+
"verify": "npm test && npm run lint"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"bin/",
|
|
16
|
+
"src/"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"cadet",
|
|
20
|
+
"cadet-agent",
|
|
21
|
+
"unity",
|
|
22
|
+
"game-development",
|
|
23
|
+
"ai-agent",
|
|
24
|
+
"copilot",
|
|
25
|
+
"cursor",
|
|
26
|
+
"claude-code"
|
|
27
|
+
],
|
|
28
|
+
"license": "CC-BY-4.0",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/naishtech/cadet-agent.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/naishtech/cadet-agent#readme",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, copyFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { install, sync } from './install.mjs';
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
createEvidence, newId, computeInputTreeHash, hashCriteria,
|
|
12
12
|
collectDeclaredTestNames, reconcileTestNames,
|
|
13
13
|
resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
|
|
14
|
+
STATE_VERSION, sealedEvidence, recordEvidence, appendEvidence, sealWorkItem, toStateV4,
|
|
15
|
+
isHistoryExternal, HISTORY_ENTRIES_KEPT,
|
|
14
16
|
} from './harness/index.mjs';
|
|
15
17
|
|
|
16
18
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -38,8 +40,12 @@ function showHelp() {
|
|
|
38
40
|
npx cadet-agent@latest sync Update framework, preserving local policies/plans
|
|
39
41
|
npx cadet-agent@latest sync --target <dir> Sync a specific directory
|
|
40
42
|
|
|
41
|
-
cadet-agent state validate Validate .cadet/state.json against the
|
|
42
|
-
cadet-agent state
|
|
43
|
+
cadet-agent state validate Validate .cadet/state.json against the schema
|
|
44
|
+
cadet-agent state validate --verify-sealed Also read sealed evidence from commit trailers
|
|
45
|
+
cadet-agent state migrate Atomically migrate state to the current version (backup on write)
|
|
46
|
+
cadet-agent state migrate --to 4 Compact: archive closed work items' evidence, build the index
|
|
47
|
+
cadet-agent state compact --keep <bound> Move closed work items' evidence into .cadet/archive/
|
|
48
|
+
cadet-agent state seal Write the active work item's evidence as commit trailers
|
|
43
49
|
cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
|
|
44
50
|
cadet-agent state transition --to <phase> --dry-run Check only; writes nothing
|
|
45
51
|
|
|
@@ -70,6 +76,9 @@ function showHelp() {
|
|
|
70
76
|
--inventory Newline-separated test names, when no report is available (harness matrix-check)
|
|
71
77
|
--agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
|
|
72
78
|
--older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
|
|
79
|
+
--keep always|active|<work-item ids> for what stays in state.json (state compact; required)
|
|
80
|
+
--commit-msg Path to write the prepared commit message to (state seal)
|
|
81
|
+
--verify-sealed Also verify evidence sealed in commit trailers (state validate)
|
|
73
82
|
--dry-run Report what a mutating command would do and write nothing (all mutating commands)
|
|
74
83
|
--yes, -y Never prompt; keep existing files (non-interactive installs)
|
|
75
84
|
--help, -h Show this help (valid at any depth; never writes)
|
|
@@ -155,6 +164,14 @@ function parseArgs(argv) {
|
|
|
155
164
|
case '--strict-orphans': opts.strictOrphans = true; break;
|
|
156
165
|
case '--dry-run': opts.dryRun = true; break;
|
|
157
166
|
case '--older-than-ms': opts.olderThanMs = Number(value(a)); break;
|
|
167
|
+
// state compact: the bound on what may leave state.json. Content-bearing
|
|
168
|
+
// rather than a confirmation flag, so an unattended agent must state which
|
|
169
|
+
// work items it is keeping inline.
|
|
170
|
+
case '--keep': opts.keep = value(a); break;
|
|
171
|
+
// state seal: where the prepared commit message goes.
|
|
172
|
+
case '--commit-msg': opts.commitMsgPath = value(a); break;
|
|
173
|
+
// state validate: read commit trailers too, not just the live document.
|
|
174
|
+
case '--verify-sealed': opts.verifySealed = true; break;
|
|
158
175
|
case '--agents-md': opts.agentsMd = value(a); break;
|
|
159
176
|
case '--yes': case '-y': opts.yes = true; break;
|
|
160
177
|
default: opts.rest.push(a);
|
|
@@ -203,6 +220,101 @@ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
|
|
|
203
220
|
process.exit(exitCode);
|
|
204
221
|
}
|
|
205
222
|
|
|
223
|
+
// ── evidence archive (contract v5) ──────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `.cadet/archive/evidence/` — the append-only home for evidence that has left
|
|
227
|
+
* `state.json`.
|
|
228
|
+
*
|
|
229
|
+
* A directory of per-work-item JSONL files rather than one file, because a single
|
|
230
|
+
* log for a whole repository would be rewritten on every append by anything that
|
|
231
|
+
* wanted to dedupe it, and an audit trail should not be rewritten. One file per
|
|
232
|
+
* work item also means the common read — "what evidence did this story have?" —
|
|
233
|
+
* touches one small file instead of parsing everything.
|
|
234
|
+
*/
|
|
235
|
+
function evidenceArchiveDir(targetDir) {
|
|
236
|
+
return join(targetDir, '.cadet', 'archive', 'evidence');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A filesystem-safe file name for a work item id, which contains `::`. */
|
|
240
|
+
function archiveFileName(workItemId) {
|
|
241
|
+
const safe = String(workItemId).replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 120);
|
|
242
|
+
return `${safe || 'unscoped'}.jsonl`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Every evidenceId already archived, so a repeated compaction is idempotent. */
|
|
246
|
+
function readArchivedIds(targetDir) {
|
|
247
|
+
const dir = evidenceArchiveDir(targetDir);
|
|
248
|
+
const ids = new Set();
|
|
249
|
+
if (!existsSync(dir)) return ids;
|
|
250
|
+
for (const file of readdirSync(dir)) {
|
|
251
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
252
|
+
let text;
|
|
253
|
+
try { text = readFileSync(join(dir, file), 'utf-8'); } catch { continue; }
|
|
254
|
+
for (const line of text.split(/\r?\n/)) {
|
|
255
|
+
if (!line.trim()) continue;
|
|
256
|
+
try {
|
|
257
|
+
const record = JSON.parse(line);
|
|
258
|
+
if (record?.evidenceId) ids.add(record.evidenceId);
|
|
259
|
+
} catch {
|
|
260
|
+
// A malformed line is skipped rather than fatal: this is an append-only
|
|
261
|
+
// log, and refusing to read the rest of it because of one bad line would
|
|
262
|
+
// make the archive less durable than the file it replaced.
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return ids;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Append records to the archive, one JSON object per line, grouped by work item.
|
|
271
|
+
*
|
|
272
|
+
* Records whose `evidenceId` is already archived are skipped, so re-running a
|
|
273
|
+
* compaction after a partial failure cannot duplicate history — which matters
|
|
274
|
+
* because this log is the only remaining copy of the records it holds.
|
|
275
|
+
*/
|
|
276
|
+
function appendEvidenceArchive(targetDir, records) {
|
|
277
|
+
const known = readArchivedIds(targetDir);
|
|
278
|
+
const byFile = new Map();
|
|
279
|
+
let skipped = 0;
|
|
280
|
+
for (const record of records) {
|
|
281
|
+
if (!record || typeof record !== 'object') continue;
|
|
282
|
+
if (record.evidenceId && known.has(record.evidenceId)) { skipped += 1; continue; }
|
|
283
|
+
const name = archiveFileName(record.workItemId || 'unscoped');
|
|
284
|
+
if (!byFile.has(name)) byFile.set(name, []);
|
|
285
|
+
byFile.get(name).push(JSON.stringify(record));
|
|
286
|
+
}
|
|
287
|
+
if (byFile.size === 0) return { files: [], appended: 0, skipped };
|
|
288
|
+
const dir = evidenceArchiveDir(targetDir);
|
|
289
|
+
mkdirSync(dir, { recursive: true });
|
|
290
|
+
const files = [];
|
|
291
|
+
let appended = 0;
|
|
292
|
+
for (const [name, lines] of byFile) {
|
|
293
|
+
const path = join(dir, name);
|
|
294
|
+
writeFileSync(path, `${lines.join('\n')}\n`, { flag: 'a', encoding: 'utf-8' });
|
|
295
|
+
files.push(path);
|
|
296
|
+
appended += lines.length;
|
|
297
|
+
}
|
|
298
|
+
return { files, appended, skipped };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Append change-log entries that no longer fit inline to `.cadet/archive/history.jsonl`.
|
|
303
|
+
*
|
|
304
|
+
* `changeHistory` is not retired — eight skills write artifact paths into it and
|
|
305
|
+
* `Resume` reads its tail — so compaction bounds it rather than dropping it. The
|
|
306
|
+
* overflow goes here in full, one entry per line, so bounding the document never
|
|
307
|
+
* destroys the audit trail it used to hold.
|
|
308
|
+
*/
|
|
309
|
+
function appendHistoryArchive(targetDir, entries) {
|
|
310
|
+
if (!Array.isArray(entries) || entries.length === 0) return { path: null, appended: 0 };
|
|
311
|
+
const dir = join(targetDir, '.cadet', 'archive');
|
|
312
|
+
mkdirSync(dir, { recursive: true });
|
|
313
|
+
const path = join(dir, 'history.jsonl');
|
|
314
|
+
writeFileSync(path, `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`, { flag: 'a', encoding: 'utf-8' });
|
|
315
|
+
return { path, appended: entries.length };
|
|
316
|
+
}
|
|
317
|
+
|
|
206
318
|
// ── state commands ──────────────────────────────────────────────────────────
|
|
207
319
|
|
|
208
320
|
async function cmdState(opts) {
|
|
@@ -232,8 +344,64 @@ async function cmdState(opts) {
|
|
|
232
344
|
const result = validateState(state, { rootDir: opts.targetDir, strictClosure: policy.strictClosure });
|
|
233
345
|
const role = detectRepoRole(opts.targetDir);
|
|
234
346
|
const repoRoleDetail = describeRepoRole(role);
|
|
347
|
+
|
|
348
|
+
// `--verify-sealed` consults git for evidence sealed into commit trailers,
|
|
349
|
+
// which is where a closed work item's records live from v4 on.
|
|
350
|
+
//
|
|
351
|
+
// It is additive by construction: it can only clear an error that a real
|
|
352
|
+
// sealed record backs, never raise a new one. That direction is deliberate —
|
|
353
|
+
// a check that could fail because git was unavailable would make the
|
|
354
|
+
// read-only validation command depend on the environment it is auditing.
|
|
355
|
+
let sealed = null;
|
|
356
|
+
if (opts.verifySealed) {
|
|
357
|
+
const probe = sealedEvidence(opts.targetDir, { workItemId: workItemIdOf(state) });
|
|
358
|
+
if (!probe.available) {
|
|
359
|
+
// Never a silent pass: "not verified" and "verified clean" must not look
|
|
360
|
+
// the same, matching how a missing rootDir is reported for freshness.
|
|
361
|
+
result.warnings.push({
|
|
362
|
+
path: 'sealedEvidence',
|
|
363
|
+
message: `sealed evidence was not verified: ${probe.reason}. Live evidence only; a gate satisfied by a sealed record will be reported as unbacked.`,
|
|
364
|
+
});
|
|
365
|
+
} else {
|
|
366
|
+
const latestFor = (gate) => probe.records
|
|
367
|
+
.filter((r) => r.gate === gate && (r.status === 'passed' || r.status === 'manual-confirmation') && !r.partial)
|
|
368
|
+
.reduce((a, b) => {
|
|
369
|
+
if (!a) return b;
|
|
370
|
+
return (Date.parse(a.createdAt ?? '') || 0) >= (Date.parse(b.createdAt ?? '') || 0) ? a : b;
|
|
371
|
+
}, null);
|
|
372
|
+
const resolvedBySeal = [];
|
|
373
|
+
const unresolved = [];
|
|
374
|
+
for (const err of result.errors) {
|
|
375
|
+
const match = /^gates\.([A-Za-z]+)$/.exec(String(err.path));
|
|
376
|
+
const record = match ? latestFor(match[1]) : null;
|
|
377
|
+
if (record) {
|
|
378
|
+
resolvedBySeal.push({ gate: match[1], sealedCommit: record.sealedCommit });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
unresolved.push(err);
|
|
382
|
+
}
|
|
383
|
+
result.errors = unresolved;
|
|
384
|
+
result.valid = unresolved.length === 0;
|
|
385
|
+
sealed = {
|
|
386
|
+
available: true,
|
|
387
|
+
records: probe.records.length,
|
|
388
|
+
gates: [...new Set(probe.records.map((r) => r.gate))].filter(Boolean).sort(),
|
|
389
|
+
resolvedBySeal,
|
|
390
|
+
diagnostics: probe.diagnostics,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
235
395
|
if (opts.format === 'json') {
|
|
236
|
-
emit(opts, '', {
|
|
396
|
+
emit(opts, '', {
|
|
397
|
+
ok: result.valid,
|
|
398
|
+
valid: result.valid,
|
|
399
|
+
errors: result.errors,
|
|
400
|
+
warnings: result.warnings,
|
|
401
|
+
repoRole: role.role,
|
|
402
|
+
repoRoleDetail,
|
|
403
|
+
...(sealed ? { sealed } : {}),
|
|
404
|
+
});
|
|
237
405
|
} else {
|
|
238
406
|
if (result.valid) console.log(`✅ state.json is valid (v${state.version}).`);
|
|
239
407
|
else {
|
|
@@ -241,6 +409,12 @@ async function cmdState(opts) {
|
|
|
241
409
|
for (const e of result.errors) console.error(` ${e.path}: ${e.message}`);
|
|
242
410
|
}
|
|
243
411
|
for (const w of result.warnings) console.log(` ⚠️ ${w.path}: ${w.message}`);
|
|
412
|
+
if (sealed) {
|
|
413
|
+
if (sealed.available) {
|
|
414
|
+
console.log(` Sealed evidence: ${sealed.records} record(s) in commit trailers; gates: ${sealed.gates.join(', ') || '(none)'}`);
|
|
415
|
+
for (const r of sealed.resolvedBySeal) console.log(` ✅ ${r.gate} backed by sealed record in ${String(r.sealedCommit).slice(0, 8)}`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
244
418
|
console.log(` Repo role: ${role.role} — ${repoRoleDetail}`);
|
|
245
419
|
}
|
|
246
420
|
if (!result.valid) process.exit(1);
|
|
@@ -248,14 +422,122 @@ async function cmdState(opts) {
|
|
|
248
422
|
}
|
|
249
423
|
|
|
250
424
|
if (sub === 'migrate') {
|
|
251
|
-
|
|
425
|
+
// The archive is written through `beforeWrite`, which runs after the migrated
|
|
426
|
+
// document validates but before the backup and the rename — so a migration
|
|
427
|
+
// that would fail writes nothing at all, while a crash after the archive
|
|
428
|
+
// leaves records in both places rather than neither.
|
|
429
|
+
const archivePaths = [];
|
|
430
|
+
let historyArchived = 0;
|
|
431
|
+
const result = migrateStateFile(statePath, {
|
|
432
|
+
backup: true,
|
|
433
|
+
to: opts.to ?? null,
|
|
434
|
+
keep: opts.keep ?? 'active',
|
|
435
|
+
beforeWrite: ({ archived, archivedHistory }) => {
|
|
436
|
+
const written = appendEvidenceArchive(opts.targetDir, archived);
|
|
437
|
+
archivePaths.push(...written.files);
|
|
438
|
+
const history = appendHistoryArchive(opts.targetDir, archivedHistory);
|
|
439
|
+
if (history.path) archivePaths.push(history.path);
|
|
440
|
+
historyArchived = history.appended;
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
const detail = {
|
|
444
|
+
ok: true,
|
|
445
|
+
migrated: result.migrated,
|
|
446
|
+
statePath: result.statePath,
|
|
447
|
+
version: STATE_VERSION,
|
|
448
|
+
archived: result.archived.length,
|
|
449
|
+
archivedHistory: historyArchived,
|
|
450
|
+
promotedExceptions: result.promoted,
|
|
451
|
+
droppedHistoryEntries: result.droppedHistory,
|
|
452
|
+
archivePaths,
|
|
453
|
+
backupPath: result.backupPath ?? null,
|
|
454
|
+
};
|
|
252
455
|
if (opts.format === 'json') {
|
|
253
|
-
emit(opts, '',
|
|
456
|
+
emit(opts, '', detail);
|
|
254
457
|
} else if (result.migrated) {
|
|
255
|
-
console.log(`✅ Migrated ${statePath} to
|
|
458
|
+
console.log(`✅ Migrated ${statePath} to v${STATE_VERSION} (backup: ${result.backupPath}).`);
|
|
459
|
+
if (result.archived.length) {
|
|
460
|
+
console.log(` Archived ${result.archived.length} evidence record(s) to ${evidenceArchiveDir(opts.targetDir)}`);
|
|
461
|
+
}
|
|
462
|
+
if (historyArchived) console.log(` Archived ${historyArchived} change-log entr(ies) to .cadet/archive/history.jsonl (the last ${HISTORY_ENTRIES_KEPT} stay inline).`);
|
|
463
|
+
if (result.promoted) console.log(` Promoted ${result.promoted} gate exception(s) out of changeHistory.`);
|
|
256
464
|
} else {
|
|
257
|
-
console.log(
|
|
465
|
+
console.log(`✅ state.json is already v${STATE_VERSION} — nothing to migrate.`);
|
|
466
|
+
}
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (sub === 'compact') {
|
|
471
|
+
const { exists, state } = readState(opts.targetDir);
|
|
472
|
+
if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
|
|
473
|
+
// Compaction is the v4 shape, so a v2/v3 document needs the explicit version
|
|
474
|
+
// migration first. Doing it implicitly here would hide a version change
|
|
475
|
+
// inside what a caller thinks is housekeeping.
|
|
476
|
+
if (!isHistoryExternal(state)) {
|
|
477
|
+
fail(opts, `state.json is v${state.version ?? state.stateVersion}; compaction requires v${STATE_VERSION}. Run "cadet-agent state migrate --to ${STATE_VERSION}" first.`, () => 1, { ok: false, code: 'compact-requires-v4' });
|
|
258
478
|
}
|
|
479
|
+
const keep = parseKeepBound(opts.keep);
|
|
480
|
+
const { state: next, archived, archivedHistory } = toStateV4(state, { keep });
|
|
481
|
+
const written = appendEvidenceArchive(opts.targetDir, archived);
|
|
482
|
+
const history = appendHistoryArchive(opts.targetDir, archivedHistory);
|
|
483
|
+
const changed = archived.length > 0 || archivedHistory.length > 0;
|
|
484
|
+
if (changed) writeState(opts.targetDir, next);
|
|
485
|
+
emit(
|
|
486
|
+
opts,
|
|
487
|
+
changed
|
|
488
|
+
? `✅ Compacted state.json: archived ${archived.length} evidence record(s), ${history.appended} change-log entr(ies); kept ${next.gateEvidence.length} record(s) and ${(next.changeHistory || []).length} entr(ies) inline.\n Archive: ${join(opts.targetDir, '.cadet', 'archive')}`
|
|
489
|
+
: '✅ Nothing to compact: every evidence record and change-log entry is already kept inline.',
|
|
490
|
+
{
|
|
491
|
+
ok: true,
|
|
492
|
+
archived: archived.length,
|
|
493
|
+
archivedHistory: history.appended,
|
|
494
|
+
kept: next.gateEvidence.length,
|
|
495
|
+
appended: written.appended,
|
|
496
|
+
skipped: written.skipped,
|
|
497
|
+
archivePaths: [...written.files, ...(history.path ? [history.path] : [])],
|
|
498
|
+
coverageRows: Object.keys(next.evidenceCoverage || {}).length,
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (sub === 'seal') {
|
|
505
|
+
const { exists, state } = readState(opts.targetDir);
|
|
506
|
+
if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
|
|
507
|
+
const policy = loadPolicy(opts.targetDir);
|
|
508
|
+
const { workItemId, records, lines, partial } = sealWorkItem(state, {
|
|
509
|
+
workItemId: opts.workItemId || null,
|
|
510
|
+
maxBytes: policy.output?.maxInlineBytes,
|
|
511
|
+
});
|
|
512
|
+
if (records.length === 0) {
|
|
513
|
+
fail(opts, `no inline evidence to seal for ${workItemId || '(no active work item)'}. Evidence for a closed work item lives in its commit and .cadet/archive/.`, () => 1, { ok: false, code: 'nothing-to-seal', workItemId });
|
|
514
|
+
}
|
|
515
|
+
// The message file is what makes this compatible with C5: Cadet prepares the
|
|
516
|
+
// message, and the commit is still the user's action.
|
|
517
|
+
const messagePath = opts.commitMsgPath || join(opts.targetDir, '.cadet', 'seal.commit-msg');
|
|
518
|
+
const header = [
|
|
519
|
+
`chore(gates): seal evidence for ${workItemId}`,
|
|
520
|
+
'',
|
|
521
|
+
'Evidence for this work item, written here so it travels with the code.',
|
|
522
|
+
'Edit the subject line to describe the change; keep the trailer block intact —',
|
|
523
|
+
'the records are read back out of it, and altering one changes the commit id.',
|
|
524
|
+
'',
|
|
525
|
+
].join('\n');
|
|
526
|
+
writeFileSync(messagePath, `${header}${lines.join('\n')}`, 'utf-8');
|
|
527
|
+
const archived = appendEvidenceArchive(opts.targetDir, records);
|
|
528
|
+
const { state: next } = toStateV4(state, { keep: opts.keep ?? 'active' });
|
|
529
|
+
writeState(opts.targetDir, next);
|
|
530
|
+
emit(
|
|
531
|
+
opts,
|
|
532
|
+
[
|
|
533
|
+
`✅ Prepared ${records.length} evidence record(s) for commit.`,
|
|
534
|
+
` Message: ${messagePath}`,
|
|
535
|
+
` Commit: git commit -F "${messagePath}"`,
|
|
536
|
+
archived.appended ? ` Archived: ${archived.appended} record(s) to ${evidenceArchiveDir(opts.targetDir)}` : ' Archive: already up to date',
|
|
537
|
+
partial.length ? ` ⚠️ ${partial.length} record(s) exceeded the trailer bound and were marked partial (they cannot satisfy a gate).` : null,
|
|
538
|
+
].filter(Boolean).join('\n'),
|
|
539
|
+
{ ok: true, workItemId, records: records.length, messagePath, archived: archived.appended, partial },
|
|
540
|
+
);
|
|
259
541
|
return;
|
|
260
542
|
}
|
|
261
543
|
|
|
@@ -303,7 +585,18 @@ async function cmdState(opts) {
|
|
|
303
585
|
return;
|
|
304
586
|
}
|
|
305
587
|
|
|
306
|
-
fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|transition.`);
|
|
588
|
+
fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|compact|seal|transition.`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Parse the `--keep` bound for `state compact`: `always`, `active`, or a
|
|
593
|
+
* comma-separated list of work-item ids. Returns what `toStateV4` expects.
|
|
594
|
+
*/
|
|
595
|
+
function parseKeepBound(raw) {
|
|
596
|
+
const text = String(raw ?? '').trim();
|
|
597
|
+
if (text === 'always') return 'always';
|
|
598
|
+
if (text === 'active' || text === '') return 'active';
|
|
599
|
+
return text.split(',').map((s) => s.trim()).filter(Boolean);
|
|
307
600
|
}
|
|
308
601
|
|
|
309
602
|
// ── harness commands ────────────────────────────────────────────────────────
|
|
@@ -453,19 +746,15 @@ async function cmdHarness(opts) {
|
|
|
453
746
|
ledger.finalize({ status: 'ok' });
|
|
454
747
|
const ledgerPath = ledger.persist();
|
|
455
748
|
|
|
456
|
-
|
|
749
|
+
// Supersede-and-append plus the coverage index live in `recordEvidence`, so
|
|
750
|
+
// this path and `harness verify` cannot disagree about either. Before that
|
|
751
|
+
// helper the two commands built the array separately, which is how an index
|
|
752
|
+
// would have ended up maintained by one of them and not the other.
|
|
457
753
|
const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
...prior.map((e) => (e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')
|
|
461
|
-
? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
|
|
462
|
-
: e)),
|
|
463
|
-
evidence,
|
|
464
|
-
];
|
|
465
|
-
next.gates = { ...(state.gates || {}), [gate]: true };
|
|
754
|
+
const superseded = prior.filter((e) => e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')).length;
|
|
755
|
+
const next = recordEvidence(state, evidence);
|
|
466
756
|
writeState(opts.targetDir, next);
|
|
467
757
|
|
|
468
|
-
const superseded = prior.filter((e) => e.gate === gate && (e.status === 'passed' || e.status === 'manual-confirmation')).length;
|
|
469
758
|
emit(
|
|
470
759
|
opts,
|
|
471
760
|
`✅ Recorded manual confirmation for gate "${gate}". Evidence: ${evidence.evidenceId}\n Ledger: ${ledgerPath}`,
|
|
@@ -610,18 +899,20 @@ async function cmdHarness(opts) {
|
|
|
610
899
|
// only when it is evidence-backed; a failing one records the attempt.
|
|
611
900
|
let stateUpdated = false;
|
|
612
901
|
if (state) {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
next
|
|
902
|
+
// The failed attempts from this run are appended first, then the passing
|
|
903
|
+
// record supersedes prior passing evidence for the gate. Red-before-green is
|
|
904
|
+
// why the attempts are not filtered out on success: the red record is the
|
|
905
|
+
// thing that made the green one permissible, and dropping it would leave a
|
|
906
|
+
// green gate whose justification no longer exists.
|
|
907
|
+
const attempts = result.attempts.map((a) => a.evidence);
|
|
908
|
+
let next = state;
|
|
909
|
+
if (result.ok && result.finalEvidence) {
|
|
910
|
+
for (const ev of attempts) {
|
|
911
|
+
if (ev?.evidenceId !== result.finalEvidence.evidenceId) next = appendEvidence(next, ev);
|
|
912
|
+
}
|
|
913
|
+
next = recordEvidence(next, result.finalEvidence);
|
|
914
|
+
} else {
|
|
915
|
+
for (const ev of attempts) next = appendEvidence(next, ev);
|
|
625
916
|
}
|
|
626
917
|
writeState(opts.targetDir, next);
|
|
627
918
|
stateUpdated = true;
|
|
@@ -799,15 +1090,7 @@ async function cmdHarness(opts) {
|
|
|
799
1090
|
const ledgerPath = ledger.persist();
|
|
800
1091
|
|
|
801
1092
|
if (exists) {
|
|
802
|
-
const next =
|
|
803
|
-
const priorEv = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
804
|
-
next.gateEvidence = [
|
|
805
|
-
...priorEv.map((e) => (e.gate === 'acceptanceCriteriaValidated' && (e.status === 'passed' || e.status === 'manual-confirmation')
|
|
806
|
-
? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
|
|
807
|
-
: e)),
|
|
808
|
-
evidence,
|
|
809
|
-
];
|
|
810
|
-
next.gates = { ...(state.gates || {}), acceptanceCriteriaValidated: true };
|
|
1093
|
+
const next = recordEvidence(state, evidence);
|
|
811
1094
|
writeState(opts.targetDir, next);
|
|
812
1095
|
}
|
|
813
1096
|
|
package/src/harness/commands.mjs
CHANGED
|
@@ -37,12 +37,14 @@ export const COMMANDS = {
|
|
|
37
37
|
|
|
38
38
|
'state validate': {
|
|
39
39
|
mutates: false,
|
|
40
|
-
summary: 'Validate .cadet/state.json against the schema.',
|
|
40
|
+
summary: 'Validate .cadet/state.json against the current schema.',
|
|
41
|
+
// `--verify-sealed` reads commit trailers. It is a read: verifying a seal
|
|
42
|
+
// must never repair one, so the flag cannot write.
|
|
41
43
|
},
|
|
42
44
|
'state migrate': {
|
|
43
45
|
mutates: true,
|
|
44
|
-
summary: 'Atomically migrate
|
|
45
|
-
writes: ['.cadet/state.json', '.cadet/state.json.
|
|
46
|
+
summary: 'Atomically migrate state to the current version (backup on write).',
|
|
47
|
+
writes: ['.cadet/state.json', '.cadet/state.json.v*.bak', '.cadet/archive/**'],
|
|
46
48
|
unattended: false,
|
|
47
49
|
// A failed migration must leave the tree exactly as it found it: no backup,
|
|
48
50
|
// no partial write. The backup is an artifact of a *successful* migration,
|
|
@@ -52,6 +54,24 @@ export const COMMANDS = {
|
|
|
52
54
|
// before validation — verified by reintroducing the original ordering.
|
|
53
55
|
atomicFailure: true,
|
|
54
56
|
},
|
|
57
|
+
'state seal': {
|
|
58
|
+
mutates: true,
|
|
59
|
+
summary: 'Write a work item\'s evidence as commit trailers, for `git commit -F`.',
|
|
60
|
+
// Cadet does not commit (contract C5). Sealing prepares a message file and
|
|
61
|
+
// archives the records; the commit itself stays a user action.
|
|
62
|
+
writes: ['.cadet/archive/**', '*.commit-msg'],
|
|
63
|
+
unattended: true,
|
|
64
|
+
},
|
|
65
|
+
'state compact': {
|
|
66
|
+
mutates: true,
|
|
67
|
+
summary: 'Move closed work items\' evidence out of state.json into .cadet/archive/.',
|
|
68
|
+
writes: ['.cadet/state.json', '.cadet/archive/**'],
|
|
69
|
+
// Irreversible in the sense that matters: records leave the document that
|
|
70
|
+
// every gate check reads. An unattended agent must say what to keep, so the
|
|
71
|
+
// bound is content-bearing rather than a bare confirmation.
|
|
72
|
+
unattended: false,
|
|
73
|
+
requiresForUnattended: ['--keep'],
|
|
74
|
+
},
|
|
55
75
|
'state transition': {
|
|
56
76
|
mutates: true,
|
|
57
77
|
summary: 'Enforce the transition matrix and evidence; applies the transition.',
|
|
@@ -184,12 +204,17 @@ export function checkUnattendedRequirements(key, opts) {
|
|
|
184
204
|
if (flag === '--older-than-ms') {
|
|
185
205
|
return !Number.isFinite(opts.olderThanMs);
|
|
186
206
|
}
|
|
207
|
+
if (flag === '--keep') {
|
|
208
|
+
// `always`, `active`, or a comma-separated work-item list. An empty value
|
|
209
|
+
// is not a bound, so it must fail the same way a missing flag does.
|
|
210
|
+
return !(typeof opts.keep === 'string' && opts.keep.trim() !== '');
|
|
211
|
+
}
|
|
187
212
|
return true;
|
|
188
213
|
});
|
|
189
214
|
if (missing.length === 0) return { ok: true };
|
|
190
215
|
return {
|
|
191
216
|
ok: false,
|
|
192
217
|
missing,
|
|
193
|
-
reason: `"${key}"
|
|
218
|
+
reason: `"${key}" removes records that gate checks read, so it requires ${missing.join(', ')} when run unattended.`,
|
|
194
219
|
};
|
|
195
220
|
}
|