cadet-agent 0.48.0 → 0.53.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Cadet-Agent is an **opinionated** cross-IDE agent framework for game-development workflows. It is built on foundational software engineering practices and real-world game-development experience, with the goal of **guiding you through the entire development process** — from requirements and technical design through TDD, implementation, and review.
4
4
 
5
- Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished game from a single prompt. Instead, it walks you through each phase methodically: calibrating the learner model, scoping work into epics and stories, planning architecture, writing tests first, and iterating on feedback. The shared framework core integrates with GitHub Copilot, Cursor, Continue, Claude Code, and Deep Code.
5
+ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished game from a single prompt. Instead, it walks you through each phase methodically: calibrating the learner model, scoping work into epics and stories, planning architecture, writing tests first, and iterating on feedback. The shared framework core integrates with GitHub Copilot, Cursor, Continue, Claude Code, Deep Code, and Hermes.
6
6
 
7
7
  ## Repository Layout
8
8
  - `.cadet/agent/core/` contains the shared Cadet-Agent framework documents.
@@ -20,7 +20,7 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
20
20
  - `.cursor/` contains Cursor-specific authored files.
21
21
  - `.continue/` contains Continue-specific authored files.
22
22
  - `.claude/` contains Claude Code-specific authored files.
23
- - `.agents/skills/` contains Deep Code (and cross-client) skill adapters.
23
+ - `.agents/skills/` contains Deep Code / Hermes (cross-client) skill adapters.
24
24
  - These IDE folders hold thin integration shims; the core framework logic still lives in `.cadet/agent/core/`.
25
25
  - `package-agent.ps1` builds the distributable `cadet-agent.zip` package.
26
26
  - `bump-version.ps1` bumps the version, updates version-bearing files, commits, tags, and pushes. It runs `npm run lint` first and refuses to release if the link check fails.
@@ -28,27 +28,27 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
28
28
 
29
29
  ## Cross-IDE Support
30
30
 
31
- Cadet-Agent provides full workflow parity across five IDEs. The same 11 skills + reviewer are available in each:
32
-
33
- | Feature | GitHub Copilot | Cursor | Continue | Claude Code | Deep Code |
34
- |---|---|---|---|---|---|
35
- | Auto-load rules | Agent definition | `alwaysApply` rule | Project rule | Project skill | Project skill (`.agents/skills/`) |
36
- | Skill dispatch | `/cadet-<skill>` prompts | Natural language | `/cadet-<skill>` commands | `/cadet-<skill>` skills | `/skills` menu (`/`) |
37
- | Planning Review | ✅ | ✅ | ✅ | ✅ | ✅ |
38
- | Requirements | ✅ | ✅ | ✅ | ✅ | ✅ |
39
- | Architecture | ✅ | ✅ | ✅ | ✅ | ✅ |
40
- | Spike | ✅ | ✅ | ✅ | ✅ | ✅ |
41
- | Story Breakdown | ✅ | ✅ | ✅ | ✅ | ✅ |
42
- | TDD | ✅ | ✅ | ✅ | ✅ | ✅ |
43
- | Debugging | ✅ | ✅ | ✅ | ✅ | ✅ |
44
- | Code Review | ✅ | ✅ | ✅ | ✅ | ✅ |
45
- | Resume | ✅ | ✅ | ✅ | ✅ | ✅ |
46
- | MCP Setup | ✅ | ✅ | ✅ | ✅ | ✅ |
47
- | Reconciliation | ✅ | ✅ | ✅ | ✅ | ✅ |
48
- | Reviewer mode | Agent picker | Rule toggle | `/cadet-agent-reviewer` | `/cadet-agent-reviewer` | `cadet-agent-reviewer` skill |
49
- | Git guard | PreToolUse hook | Manual | Manual | Manual | `permissions.ask` (`mutate-git-log`) |
50
-
51
- All adapters delegate to the canonical files under `.cadet/agent/core/` — no duplicated rules or skills. See `ADAPTERS.md` for the full inventory and `docs/guidance/DeepCode.md` for Deep Code setup.
31
+ Cadet-Agent provides full workflow parity across six IDEs. The same 11 skills + reviewer are available in each:
32
+
33
+ | Feature | GitHub Copilot | Cursor | Continue | Claude Code | Deep Code | Hermes |
34
+ |---|---|---|---|---|---|---|
35
+ | Auto-load rules | Agent definition | `alwaysApply` rule | Project rule | Project skill | Project skill (`.agents/skills/`) | Project skill (`.agents/skills/`) |
36
+ | Skill dispatch | `/cadet-<skill>` prompts | Natural language | `/cadet-<skill>` commands | `/cadet-<skill>` skills | `/skills` menu (`/`) | `/cadet-<skill>` commands |
37
+ | Planning Review | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
38
+ | Requirements | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
39
+ | Architecture | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
40
+ | Spike | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
41
+ | Story Breakdown | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
42
+ | TDD | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
43
+ | Debugging | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
44
+ | Code Review | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
45
+ | Resume | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
46
+ | MCP Setup | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
47
+ | Reconciliation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
48
+ | Reviewer mode | Agent picker | Rule toggle | `/cadet-agent-reviewer` | `/cadet-agent-reviewer` | `cadet-agent-reviewer` skill | `/cadet-agent-reviewer` |
49
+ | Git guard | PreToolUse hook | Manual | Manual | Manual | `permissions.ask` (`mutate-git-log`) | Command approval policies |
50
+
51
+ All adapters delegate to the canonical files under `.cadet/agent/core/` — no duplicated rules or skills. See `ADAPTERS.md` for the full inventory, `docs/guidance/DeepCode.md` for Deep Code setup, and `docs/guidance/Hermes.md` for Hermes setup.
52
52
 
53
53
  ## Quick Install
54
54
 
@@ -299,6 +299,17 @@ Because Deep Code has no PreToolUse hook, enforce the commit/push approval gate
299
299
 
300
300
  See `docs/guidance/DeepCode.md` for the full setup, MCP wiring, and configuration reference.
301
301
 
302
+ ### Hermes request
303
+ With [Hermes Agent](https://hermes-agent.nousresearch.com/docs/) installed (`iex (irm https://hermes-agent.nousresearch.com/install.ps1)` on native Windows), run `hermes` in the repository and confirm the `cadet-*` skills are discovered from `.agents/skills/` — the first run requires `hermes skills trust` to enable project skills. Then invoke a skill directly:
304
+
305
+ ```text
306
+ /cadet-tdd for the ghost-replay story
307
+ ```
308
+
309
+ Because Hermes has no PreToolUse hook, enable command approval policies so commit/push require confirmation (see the [Hermes security docs](https://hermes-agent.nousresearch.com/docs/user-guide/security)).
310
+
311
+ See `docs/guidance/Hermes.md` for the full setup, MCP wiring, and configuration reference.
312
+
302
313
  ### Repository policy example
303
314
  If a specific game repository needs local conventions, add a policy file under `.cadet/agent/policies` using `.cadet/agent/core/Templates/PolicyTemplate.md`. For example, a repository policy could define:
304
315
  - where project plans should live
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cadet-agent",
3
- "version": "0.48.0",
3
+ "version": "0.53.0",
4
4
  "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  collectDeclaredTestNames, reconcileTestNames,
18
18
  resolveCommand, describeCommand, describeAllCommands, checkUnattendedRequirements, COMMANDS,
19
19
  STATE_VERSION, sealedEvidence, recordEvidence, appendEvidence, sealWorkItem, toStateV4,
20
- isHistoryExternal, HISTORY_ENTRIES_KEPT,
20
+ isHistoryExternal, HISTORY_ENTRIES_KEPT, resetGatesForNewWorkItem,
21
21
  } from './harness/index.mjs';
22
22
 
23
23
  const __filename = fileURLToPath(import.meta.url);
@@ -50,6 +50,7 @@ function showHelp() {
50
50
  cadet-agent state migrate Atomically migrate state to the current version (backup on write)
51
51
  cadet-agent state migrate --to 4 Compact: archive closed work items' evidence, build the index
52
52
  cadet-agent state compact --keep <bound> Move closed work items' evidence into .cadet/archive/
53
+ cadet-agent state begin --epic <id> --story <file> Start a work item: reset gates, archive the previous item's evidence
53
54
  cadet-agent state seal Write the active work item's evidence as commit trailers
54
55
  cadet-agent state transition --to <phase> Enforce the transition matrix + evidence
55
56
  cadet-agent state transition --to <phase> --dry-run Check only; writes nothing
@@ -90,6 +91,8 @@ function showHelp() {
90
91
  --agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
91
92
  --older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
92
93
  --keep always|active|<work-item ids> for what stays in state.json (state compact; required)
94
+ --retain-all state compact: keep every record of the kept work items; only cross-work-item records leave
95
+ --epic Epic id of the work item being started (state begin)
93
96
  --commit-msg Path to write the prepared commit message to (state seal)
94
97
  --verify-sealed Also verify evidence sealed in commit trailers (state validate)
95
98
  --dry-run Report what a mutating command would do and write nothing (all mutating commands)
@@ -170,6 +173,12 @@ function parseArgs(argv) {
170
173
  // working-tree scan (which could bind evidence to Cadet's own files).
171
174
  case '--files': opts.filesGiven = true; opts.files = value(a).split(',').map((s) => s.trim()).filter(Boolean); break;
172
175
  case '--story': opts.story = value(a); break;
176
+ // state begin: the epic the new work item belongs to.
177
+ case '--epic': opts.epicId = value(a); break;
178
+ // state compact: keep every record inline instead of applying the
179
+ // within-work-item retention rule. Made explicit at the call site, because
180
+ // otherwise a reader cannot tell a retained document from an unbounded one.
181
+ case '--retain-all': opts.retainAll = true; break;
173
182
  case '--report': opts.report = value(a); break;
174
183
  // AR-1: the revision a gate record attests, so a gate-related fix claim
175
184
  // can be traced to the commit that contains it.
@@ -531,7 +540,7 @@ async function cmdState(opts) {
531
540
  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' });
532
541
  }
533
542
  const keep = parseKeepBound(opts.keep);
534
- const { state: next, archived, archivedHistory } = toStateV4(state, { keep });
543
+ const { state: next, archived, archivedHistory } = toStateV4(state, { keep, retainAll: opts.retainAll === true });
535
544
  const written = appendEvidenceArchive(opts.targetDir, archived);
536
545
  const history = appendHistoryArchive(opts.targetDir, archivedHistory);
537
546
  const changed = archived.length > 0 || archivedHistory.length > 0;
@@ -539,7 +548,7 @@ async function cmdState(opts) {
539
548
  emit(
540
549
  opts,
541
550
  changed
542
- ? `✅ 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')}`
551
+ ? `✅ 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.${opts.retainAll ? '\n --retain-all: every kept work item\'s records stay inline, so only cross-work-item records left.' : ''}\n Archive: ${join(opts.targetDir, '.cadet', 'archive')}`
543
552
  : '✅ Nothing to compact: every evidence record and change-log entry is already kept inline.',
544
553
  {
545
554
  ok: true,
@@ -555,6 +564,72 @@ async function cmdState(opts) {
555
564
  return;
556
565
  }
557
566
 
567
+ if (sub === 'begin') {
568
+ // The story boundary, as a command.
569
+ //
570
+ // It existed only as a sentence in `skills/Resume.md` — "set `activeWorkItem`,
571
+ // reset gates" — which an agent carried out by editing state.json by hand, and
572
+ // the sentence never mentions evidence. So the previous work item's records
573
+ // stayed inline for ever. On the audited repository that was 63 records and
574
+ // ~3,000 lines, none of which any gate could read: `evidenceFreshness` rejects
575
+ // a record whose `workItemId` is not the active one. `resetGatesForNewWorkItem`
576
+ // already did the job correctly — cleared the evidence, folded it into the
577
+ // coverage index first, dropped expired exceptions, wrote one boundary line —
578
+ // and had no caller. This is the door.
579
+ if (!opts.epicId) fail(opts, 'state begin requires --epic <epicId>');
580
+ if (!opts.story) fail(opts, 'state begin requires --story <storyFile>');
581
+ const { exists, state } = readState(opts.targetDir);
582
+ if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before starting a work item.', () => 2);
583
+
584
+ const fromId = state.activeWorkItem ? workItemIdOf(state) : null;
585
+ const toId = `${opts.epicId}::${opts.story}`;
586
+ if (fromId === toId) {
587
+ fail(opts, `the active work item is already "${toId}"; nothing to begin.`, () => 1, { ok: false, code: 'already-active', workItemId: toId });
588
+ }
589
+ // `closed` is terminal: Resume says new work starts from a fresh session
590
+ // (context-resolution) rather than by beginning a work item inside a plan that
591
+ // is already finished.
592
+ if (state.session?.currentPhase === 'closed') {
593
+ fail(opts, 'the session is closed — start new work from a fresh session (context-resolution) rather than beginning a work item inside a closed plan.', () => 1, { ok: false, code: 'session-closed' });
594
+ }
595
+
596
+ // Nothing leaves state.json without being written down first (contract v5 §1).
597
+ // The reset folds the outgoing records into the coverage index, which is a
598
+ // *summary* — so the records themselves are archived here, exactly as `compact`
599
+ // archives a closed work item's, and before the document is written.
600
+ const outgoing = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
601
+ const written = appendEvidenceArchive(opts.targetDir, outgoing);
602
+ const next = resetGatesForNewWorkItem(state, { epicId: opts.epicId, storyId: opts.story });
603
+ writeState(opts.targetDir, next);
604
+ // The boundary records the outgoing work item as complete (see
605
+ // `resetGatesForNewWorkItem`), so report it: that record is the answer to "is
606
+ // this story finished, with its epic still open?" — the question the framework
607
+ // had no vocabulary for.
608
+ const completed = fromId && Array.isArray(next.storyCompletions)
609
+ ? next.storyCompletions.find((row) => row?.workItemId === fromId) ?? null
610
+ : null;
611
+ emit(
612
+ opts,
613
+ `✅ Began ${toId}.\n Gates reset; ${outgoing.length} evidence record(s) archived (${written.appended} appended, ${written.skipped} already archived).`
614
+ + (completed
615
+ ? `\n Completed: ${completed.workItemId} (${completed.evidenceRecords} evidence record(s) behind it)`
616
+ : fromId ? `\n Previous work item: ${fromId}` : '')
617
+ + `\n Coverage rows: ${Object.keys(next.evidenceCoverage || {}).length}`,
618
+ {
619
+ ok: true,
620
+ from: fromId,
621
+ to: toId,
622
+ gatesReset: true,
623
+ completed: completed ? { workItemId: completed.workItemId, completedAt: completed.completedAt } : null,
624
+ archived: written.appended,
625
+ alreadyArchived: written.skipped,
626
+ coverageRows: Object.keys(next.evidenceCoverage || {}).length,
627
+ archivePaths: written.files,
628
+ },
629
+ );
630
+ return;
631
+ }
632
+
558
633
  if (sub === 'seal') {
559
634
  const { exists, state } = readState(opts.targetDir);
560
635
  if (!exists) fail(opts, 'No .cadet/state.json found.', () => 2);
@@ -656,7 +731,7 @@ async function cmdState(opts) {
656
731
  return;
657
732
  }
658
733
 
659
- fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|compact|seal|transition.`);
734
+ fail(opts, `Unknown state subcommand: ${sub || '(none)'}. Use validate|migrate|compact|begin|seal|transition.`);
660
735
  }
661
736
 
662
737
  /**
@@ -1153,6 +1228,12 @@ async function cmdHarness(opts) {
1153
1228
  command: `harness verify-acs --story ${opts.story}`,
1154
1229
  result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
1155
1230
  exitCode: 0,
1231
+ // The revision this coverage claim attests, when the caller names one. AR-1/the
1232
+ // citation policy make it required once the gated work is committed: without it the
1233
+ // record can name its work item and its files but never the commit, and a reviewer
1234
+ // has no structured way to check the claim (Harness.md §1). Validated and normalized
1235
+ // by createEvidence, so a branch or tag name is refused rather than stored.
1236
+ commit: opts.commit || null,
1156
1237
  // Audit pointer only. Not a relevant file: see above.
1157
1238
  artifactPath: reportPath ? reportPath.replace(/\\/g, '/') : null,
1158
1239
  inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
@@ -1322,6 +1403,10 @@ async function cmdHarness(opts) {
1322
1403
  ? `reachability addressed (${validation.code}); project probe exit ${probe.exitCode}`
1323
1404
  : `reachability addressed (${validation.code}); no project probe configured`,
1324
1405
  exitCode: 0,
1406
+ // Same citation requirement as verify-acs: the declaration is what is being attested,
1407
+ // and it is attested of a revision. Validated and normalized by createEvidence, so a
1408
+ // branch or tag name is refused rather than stored.
1409
+ commit: opts.commit || null,
1325
1410
  inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
1326
1411
  criteriaHash: hashCriteria([
1327
1412
  workItemId,
@@ -1436,6 +1521,10 @@ async function cmdHarness(opts) {
1436
1521
  // `--format json` must not have to tolerate a failure exit to get it. A run with
1437
1522
  // no planning artifacts at all is a legitimate state (a framework-source repo, a
1438
1523
  // small change), not an error.
1524
+ //
1525
+ // A scope that names no epic is the one exception, and it is not a verdict: the
1526
+ // request itself was unsatisfiable, so it exits 2 like every other bad argument
1527
+ // rather than quietly reconciling a set the caller never asked about.
1439
1528
  if (sub === 'reconcile') {
1440
1529
  const { exists, state } = readState(opts.targetDir);
1441
1530
  const result = reconcileArtifacts(opts.targetDir, {
@@ -1444,6 +1533,8 @@ async function cmdHarness(opts) {
1444
1533
  story: opts.story || null,
1445
1534
  });
1446
1535
 
1536
+ if (result.scopeError) fail(opts, result.reason, () => 2);
1537
+
1447
1538
  if (opts.format === 'json') {
1448
1539
  emit(opts, '', result);
1449
1540
  return;
@@ -72,6 +72,15 @@ export const COMMANDS = {
72
72
  unattended: false,
73
73
  requiresForUnattended: ['--keep'],
74
74
  },
75
+ 'state begin': {
76
+ mutates: true,
77
+ summary: 'Start a work item: reset gates, archive the previous item\'s evidence, fold it into the coverage index.',
78
+ writes: ['.cadet/state.json', '.cadet/archive/**'],
79
+ // The bound is already content-bearing: `--epic` and `--story` name the work
80
+ // item being started, so an unattended caller cannot begin one without saying
81
+ // which. That is why this needs no separate confirmation flag.
82
+ unattended: true,
83
+ },
75
84
  'state transition': {
76
85
  mutates: true,
77
86
  summary: 'Enforce the transition matrix and evidence; applies the transition.',
@@ -34,7 +34,7 @@ export {
34
34
  STATE_VERSION, READABLE_STATE_VERSIONS, HISTORY_EXTERNAL_SINCE, isHistoryExternal,
35
35
  validateState, migrateStateV1toV2, migrateStateDocument, migrateStateFile, parseTargetVersion,
36
36
  toStateV4, splitEvidence, buildEvidenceCoverage, mergeEvidenceCoverage, sealWorkItem, recordEvidence, appendEvidence,
37
- HISTORY_ENTRIES_KEPT, compactHistory,
37
+ HISTORY_ENTRIES_KEPT, compactHistory, DEFAULT_MAX_LIVE_EVIDENCE, retainLiveRecords,
38
38
  createEvidence, computeInputTreeHash, workItemIdOf, evidenceFreshness,
39
39
  latestEvidenceForGate, activeExceptions, requiredGates, isUngatedForwardEdge, evaluateTransition, resolveStrict,
40
40
  applyTransition, resetGatesForNewWorkItem, statePathFor, readState, writeState, writeJsonAtomic, StateError,
@@ -31,7 +31,11 @@
31
31
  * story in flight (it is written during implementation), a witness checkpoint by
32
32
  * an epic that is not closed, and a `done` story's evidence ALWAYS — a completion
33
33
  * claim has to be traceable whenever it was made, and the framework's answer to
34
- * an accepted historical gap is a recorded gate-exception, not silence.
34
+ * an accepted historical gap is a recorded gate-exception, not silence. That
35
+ * exception is therefore HONOURED here rather than merely permitted: a gap an
36
+ * exception covers is reported as `info` naming the exception, because a blocking
37
+ * section filled with rows a human already ruled on teaches its reader to ignore
38
+ * the whole report.
35
39
  *
36
40
  * Discovery is by content, not by path: an epic is a directory containing
37
41
  * `epic.md` wherever it sits, and a required document is matched by filename
@@ -48,9 +52,10 @@
48
52
  */
49
53
 
50
54
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
51
- import { basename, isAbsolute, join, relative } from 'node:path';
55
+ import { basename, dirname, isAbsolute, join, relative } from 'node:path';
52
56
 
53
57
  import { PHASES } from './policy.mjs';
58
+ import { activeExceptionEntries } from './state.mjs';
54
59
  import {
55
60
  collectWorkItems,
56
61
  parseReachabilityDeclaration,
@@ -306,14 +311,16 @@ export function collectArtifacts(targetDir, {
306
311
  return { name, path, relPath: path ? repoRelative(targetDir, path) : null, present: path !== null };
307
312
  });
308
313
 
309
- // Scope to one epic when a story path is given. State keys epics by directory
310
- // NAME (`epic-1-player-movement`), not by path, so the scope is that name — and
311
- // every epic lookup in this module uses the same key.
312
- let scopedEpic = null;
313
- if (story) {
314
- const abs = isAbsolute(story) ? story : join(targetDir, story);
315
- scopedEpic = basename(join(abs, '..'));
316
- }
314
+ // Scope to one epic when a story or epic path is given. State keys epics by
315
+ // directory NAME (`epic-1-player-movement`), not by path, so the scope is that
316
+ // name — and every epic lookup in this module uses the same key.
317
+ //
318
+ // RESOLVED here, APPLIED by the caller. Collecting the whole tree first is what
319
+ // lets `reconcileArtifacts` refuse a scope that names nothing: filtering during
320
+ // collection made an unrecognised argument indistinguishable from no argument
321
+ // at all, which is how an epic *directory* came to be understood as the literal
322
+ // scope `"epics"` and reconciled every epic regardless.
323
+ const scopedEpic = resolveScope(targetDir, story);
317
324
 
318
325
  const epics = [];
319
326
  for (const dir of dirs) {
@@ -323,7 +330,6 @@ export function collectArtifacts(targetDir, {
323
330
  // name the folder anything, and `adr/`, `spikes/` and `evidence/` live in the
324
331
  // same tree — at whatever depth the project chose.
325
332
  if (!existsSync(epicFile)) continue;
326
- if (scopedEpic && name !== scopedEpic) continue;
327
333
 
328
334
  const epicRead = readBounded(epicFile, maxBytes);
329
335
  let storyFiles = [];
@@ -355,6 +361,45 @@ export function collectArtifacts(targetDir, {
355
361
  return { available: true, reason: null, root, docs, epics, scopedEpic };
356
362
  }
357
363
 
364
+ /**
365
+ * The epic key that a `--story` argument names.
366
+ *
367
+ * Accepts what a caller actually has to hand: the path to a story file, the path
368
+ * to `epic.md`, the epic's own directory, or the epic's key on its own. A FILE
369
+ * lives inside its epic; anything else IS the epic. Getting this wrong is not
370
+ * harmless: the previous version took the parent directory of whatever it was
371
+ * given, so an epic *directory* resolved to `"epics"` — a key that matches no
372
+ * epic — and the request was silently reinterpreted as "reconcile everything".
373
+ */
374
+ function resolveScope(targetDir, story) {
375
+ if (!story) return null;
376
+ const abs = isAbsolute(story) ? story : join(targetDir, story);
377
+ return /\.md$/i.test(abs) ? basename(dirname(abs)) : basename(abs);
378
+ }
379
+
380
+ /**
381
+ * Refuse a scope that names nothing.
382
+ *
383
+ * The caller asked about ONE epic. Answering about a different set — or about all
384
+ * of them — is a wrong answer delivered confidently, which is worse than an error
385
+ * the user can correct; `Principles.md` says to state uncertainty explicitly
386
+ * rather than guess, and a scope that resolves to nothing is the guess.
387
+ *
388
+ * An epic tracked in `state.json` but absent from disk is NOT a failure: that is
389
+ * the `missing-epic-dir` finding, which is the whole point of asking.
390
+ */
391
+ function scopeFailure(scope, collected, stateEpics) {
392
+ if (!scope) return null;
393
+ if (collected.epics.some((e) => e.key === scope)) return null;
394
+ if (Object.hasOwn(stateEpics, scope)) return null;
395
+ const known = [...new Set([...collected.epics.map((e) => e.key), ...Object.keys(stateEpics)])].sort();
396
+ return `--story scope \`${scope}\` matches no epic on disk or in state.json, so nothing was reconciled. `
397
+ + (known.length > 0
398
+ ? `Known epics: ${known.map((k) => `\`${k}\``).join(', ')}.`
399
+ : 'No epic directories were found under the plans directory.')
400
+ + ' Pass an epic directory, an epic key, or the path to one of its story files.';
401
+ }
402
+
358
403
  /**
359
404
  * Reconcile the planning tree against `state.json`.
360
405
  *
@@ -369,6 +414,25 @@ export function reconcileArtifacts(targetDir, {
369
414
  } = {}) {
370
415
  const collected = collectArtifacts(targetDir, { plansDir, story, maxBytes });
371
416
  const plansDirRel = toPosix(plansDir);
417
+ const stateEpics = state?.epics && typeof state.epics === 'object' ? state.epics : {};
418
+
419
+ // ── 0. An explicit scope must name an epic ────────────────────────────────
420
+ // Checked before anything else, so a scope that names nothing is refused rather
421
+ // than answered with the whole tree.
422
+ const scopeError = scopeFailure(collected.scopedEpic, collected, stateEpics);
423
+ if (scopeError) {
424
+ return {
425
+ ok: false,
426
+ available: true,
427
+ plansDir: plansDirRel,
428
+ scopedEpic: collected.scopedEpic,
429
+ scopeError,
430
+ verdict: 'unknown',
431
+ findings: [],
432
+ summary: { total: 0, blocking: 0, warning: 0, info: 0 },
433
+ reason: scopeError,
434
+ };
435
+ }
372
436
 
373
437
  if (!collected.available) {
374
438
  return {
@@ -383,7 +447,12 @@ export function reconcileArtifacts(targetDir, {
383
447
  findings.push({ code, severity, subject, artifact, detail, evidence });
384
448
  };
385
449
 
386
- const stateEpics = state?.epics && typeof state.epics === 'object' ? state.epics : {};
450
+ // Apply the scope (validated above): every per-epic and per-story check below
451
+ // must see only the epic that was asked about.
452
+ const epics = collected.scopedEpic
453
+ ? collected.epics.filter((e) => e.key === collected.scopedEpic)
454
+ : collected.epics;
455
+
387
456
  const phase = state?.session?.currentPhase ?? null;
388
457
  const workflowPath = state?.session?.workflowPath ?? null;
389
458
  const phaseIndex = PHASES.indexOf(phase);
@@ -405,15 +474,21 @@ export function reconcileArtifacts(targetDir, {
405
474
  }
406
475
 
407
476
  // ── 2. Epics: state vs disk, both directions ──────────────────────────────
408
- const onDisk = new Set(collected.epics.map((e) => e.key));
477
+ const onDisk = new Set(epics.map((e) => e.key));
409
478
  for (const epicDir of Object.keys(stateEpics).sort()) {
479
+ // Compare only what is in scope. Iterating every epic in `state.json` while
480
+ // `onDisk` held just the scoped epic reported each UNSCOPED epic as
481
+ // `missing-epic-dir` (blocking) — twelve blocking findings about epics the run
482
+ // was never asked about, which also made a scoped run unable to reach
483
+ // `consistent` by construction.
484
+ if (collected.scopedEpic && epicDir !== collected.scopedEpic) continue;
410
485
  if (!onDisk.has(epicDir)) {
411
486
  add('missing-epic-dir', 'blocking', epicDir, `${plansDirRel}/${epicDir}`,
412
487
  'state.json tracks this epic, but no `epic.md` exists for it on disk. Every story under it is unreachable as an artifact.');
413
488
  }
414
489
  }
415
490
 
416
- for (const epic of collected.epics) {
491
+ for (const epic of epics) {
417
492
  const stateEpic = stateEpics[epic.key];
418
493
 
419
494
  if (!stateEpic) {
@@ -554,8 +629,31 @@ export function reconcileArtifacts(targetDir, {
554
629
  ?? Object.values(state?.evidenceCoverage ?? {}).find((r) => r?.workItemId === subject);
555
630
  const count = Number(row?.recordCount ?? 0);
556
631
  if (!row || !Number.isFinite(count) || count <= 0) {
557
- add('done-without-evidence', 'blocking', subject, storyFile.relPath,
558
- 'state.json marks this story done, but no evidence record is indexed against it. The completion cannot be traced to anything that ran.');
632
+ // … unless the framework has already ruled the gap accepted. A recorded
633
+ // gate-exception is a reviewed human decision naming this work item, its
634
+ // category and its reason; re-raising it as `blocking` on every run fills
635
+ // the blocking section with rows nobody can act on, and teaches the
636
+ // reader to skim the one section that must never be skimmed. The honest
637
+ // fix — re-running eight merged, green stories' suites to manufacture
638
+ // records that never existed — is explicitly out of scope, which is what
639
+ // the exception records.
640
+ //
641
+ // The gap is still REPORTED, as `info` naming the exception, because this
642
+ // module's contract is a recorded gap rather than silence. The match is
643
+ // the one a transition uses (`activeExceptionEntries`, by work-item
644
+ // scope), so the two can never disagree about what is excused.
645
+ const excused = activeExceptionEntries(state, { workItemId: subject });
646
+ if (excused.length > 0) {
647
+ const exception = excused[excused.length - 1];
648
+ const date = exception.date ? String(exception.date).slice(0, 10) : null;
649
+ add('excused-gap', 'info', subject, storyFile.relPath,
650
+ `state.json marks this story done without an indexed evidence record, and \`${exception.gate}\` is recorded as an accepted gap `
651
+ + `(${exception.category ?? 'uncategorised'}${date ? `, ${date}` : ''}): ${exception.rationale ?? 'no rationale recorded'}`,
652
+ date ? `excepted ${date}` : 'excepted');
653
+ } else {
654
+ add('done-without-evidence', 'blocking', subject, storyFile.relPath,
655
+ 'state.json marks this story done, but no evidence record is indexed against it. The completion cannot be traced to anything that ran.');
656
+ }
559
657
  }
560
658
  }
561
659
  }
@@ -593,13 +691,14 @@ export function reconcileArtifacts(targetDir, {
593
691
  available: true,
594
692
  plansDir: plansDirRel,
595
693
  scopedEpic: collected.scopedEpic,
694
+ scopeError: null,
596
695
  verdict,
597
696
  findings,
598
697
  summary,
599
698
  artifacts: {
600
699
  docs: collected.docs.map((d) => ({ name: d.name, present: d.present, path: d.relPath })),
601
- epicCount: collected.epics.length,
602
- storyCount: collected.epics.reduce((n, e) => n + e.stories.length, 0),
700
+ epicCount: epics.length,
701
+ storyCount: epics.reduce((n, e) => n + e.stories.length, 0),
603
702
  },
604
703
  reason: null,
605
704
  };
@@ -150,6 +150,50 @@ export function validateState(state, context = {}) {
150
150
  for (const e of validateEvidenceShape(ev, strict)) errors.push({ path: `gateEvidence[${i}].${e.path}`, message: e.message });
151
151
  });
152
152
  }
153
+ // Contract v5 C14 says a v4 document's `gateEvidence` holds the ACTIVE work
154
+ // item's records — and in practice it did not. The story boundary is a
155
+ // hand-edit (`skills/Resume.md`: "set `activeWorkItem`, reset gates"), that
156
+ // sentence says nothing about evidence, and `resetGatesForNewWorkItem` — the
157
+ // function that clears it correctly — has no caller. Nothing surfaced the
158
+ // result: the gate checks below only ever ask whether a claimed-true gate's
159
+ // OWN record is bound to the active item, never whether foreign records are
160
+ // sitting in the array. On the audited repository that was 63 records and
161
+ // ~3,000 lines of dead weight inside an 8,000-line document.
162
+ //
163
+ // Warnings, not errors. A foreign record is unreadable by every gate check
164
+ // (`evidenceFreshness` rejects the work-item mismatch), so this is hygiene
165
+ // rather than a safety violation; and an error would invalidate every document
166
+ // that predates the check, on upgrade, for a condition its reader cannot
167
+ // repair in place. Only v4 documents are scoped this way — a v1-v3 document
168
+ // keeps every record inline by design.
169
+ if (version === STATE_VERSION && Array.isArray(state.gateEvidence)) {
170
+ const active = isPlainObject(state.activeWorkItem) ? state.activeWorkItem : null;
171
+ const activeId = active ? `${active.epicId || 'none'}::${active.storyId || 'none'}` : null;
172
+ const foreign = new Map();
173
+ for (const record of state.gateEvidence) {
174
+ const id = record?.workItemId;
175
+ if (!id || id === activeId) continue;
176
+ foreign.set(id, (foreign.get(id) || 0) + 1);
177
+ }
178
+ if (foreign.size > 0) {
179
+ const total = [...foreign.values()].reduce((a, b) => a + b, 0);
180
+ const named = [...foreign.entries()].map(([id, n]) => `${id} (${n})`).join(', ');
181
+ warnings.push({
182
+ path: 'gateEvidence',
183
+ message: `gateEvidence holds ${total} record(s) for work item(s) that are not the active one: ${named}. `
184
+ + 'They cannot satisfy any gate and only grow the document. Run "cadet-agent state compact --keep active" to move them to .cadet/archive/.',
185
+ });
186
+ }
187
+ const maxLive = context.maxLiveEvidence ?? DEFAULT_MAX_LIVE_EVIDENCE;
188
+ if (Number.isFinite(maxLive) && state.gateEvidence.length > maxLive) {
189
+ warnings.push({
190
+ path: 'gateEvidence',
191
+ message: `gateEvidence holds ${state.gateEvidence.length} records inline (limit ${maxLive}). `
192
+ + 'Most of a record is its `relevantFiles` list, so re-running a gate inflates the document rather than the story. '
193
+ + 'Run "cadet-agent state compact --keep active", which keeps the newest record per gate plus the red-before-green records and archives the rest.',
194
+ });
195
+ }
196
+ }
153
197
  // Gate exceptions are categorised under strict closure (contract v3 §4).
154
198
  //
155
199
  // v4 gives them their own field, because they are *live state* — scoped to a
@@ -181,6 +225,29 @@ export function validateState(state, context = {}) {
181
225
  }
182
226
  });
183
227
  }
228
+ // The story-completion marker. Non-terminal and additive: it records which
229
+ // work items a session has moved on from, and it is what makes "this story is
230
+ // finished, its epic is not" a state a reader can check rather than infer. A
231
+ // malformed entry is an error for the same reason a malformed coverage index
232
+ // is: a marker that reads as absent makes a finished story's outcome
233
+ // unattributable again, which is the ambiguity it exists to remove.
234
+ if (state.storyCompletions !== undefined && !Array.isArray(state.storyCompletions)) {
235
+ errors.push({ path: 'storyCompletions', message: 'storyCompletions must be an array' });
236
+ }
237
+ if (Array.isArray(state.storyCompletions)) {
238
+ state.storyCompletions.forEach((row, i) => {
239
+ if (!isPlainObject(row)) {
240
+ errors.push({ path: `storyCompletions[${i}]`, message: 'story completion must be an object' });
241
+ return;
242
+ }
243
+ if (typeof row.workItemId !== 'string' || row.workItemId.length === 0) {
244
+ errors.push({ path: `storyCompletions[${i}].workItemId`, message: 'workItemId must be a non-empty string' });
245
+ }
246
+ if (typeof row.completedAt !== 'string' || row.completedAt.length === 0) {
247
+ errors.push({ path: `storyCompletions[${i}].completedAt`, message: 'completedAt must be a timestamp string' });
248
+ }
249
+ });
250
+ }
184
251
  // The coverage index (contract v5). It is what keeps the "a done story owns
185
252
  // evidence" check answerable once the records themselves have moved to
186
253
  // commits and `.cadet/archive/`, so a malformed index is an error: an index
@@ -688,16 +755,21 @@ export function compactHistory(entries, { keepRecent = HISTORY_ENTRIES_KEPT } =
688
755
  }
689
756
 
690
757
  /**
691
- * Reshape a document into v4 (contract v5): keep only the active work item's
758
+ * Reshape a document into v4 (contract v5): keep the active work item's live
692
759
  * evidence inline, move the rest to `archived` for the caller to persist, promote
693
760
  * gate exceptions into their own field, bound the change log, and install the
694
761
  * coverage index.
695
762
  *
763
+ * Two independent bounds decide what stays inline — `keep` selects the work items
764
+ * and `retainAll` controls the within-item retention described on
765
+ * `retainLiveRecords`. Passing `retainAll: true` keeps an item's whole record set,
766
+ * which is the pre-retention behaviour.
767
+ *
696
768
  * Pure — it returns the records to archive rather than writing them, so the
697
769
  * caller owns the archive location and this stays testable without a filesystem.
698
770
  */
699
- export function toStateV4(state, { keep = 'active', keepHistory = HISTORY_ENTRIES_KEPT } = {}) {
700
- const { live, archived, coverage } = splitEvidence(state, { keep });
771
+ export function toStateV4(state, { keep = 'active', keepHistory = HISTORY_ENTRIES_KEPT, retainAll = false } = {}) {
772
+ const { live, archived, coverage } = splitEvidence(state, { keep, retainAll });
701
773
 
702
774
  const promoted = [];
703
775
  const remainingHistory = [];
@@ -1072,24 +1144,48 @@ export function recordEvidence(state, evidence) {
1072
1144
  }
1073
1145
 
1074
1146
  /**
1075
- * Active gate exceptions keyed by gate, honoring scope and expiry.
1147
+ * Every active exception entry covering `workItemId`, in record order.
1076
1148
  *
1077
1149
  * Reads both homes for an exception: `changeHistory` (v1-v3, where a `type`
1078
1150
  * discriminator picks it out of the log) and `gateExceptions` (v4, a dedicated
1079
- * field where the discriminator would be redundant). Later entries win, so a
1080
- * v4 document that still carries legacy entries behaves as it did before.
1151
+ * field where the discriminator would be redundant).
1152
+ *
1153
+ * `activeExceptions` collapses these to one entry per gate, which is what a
1154
+ * transition needs. A caller that must NAME the exception which excused
1155
+ * something — the reconciler does, for a `done` story with no evidence — needs
1156
+ * the entry itself, and a gate-keyed map has already discarded the ones it did
1157
+ * not keep. Both read this function, so the two can never disagree about what is
1158
+ * excused.
1159
+ *
1160
+ * An entry naming no gate is not active: an exception records that a GATE was
1161
+ * not required, or was satisfied another way, so one that names no gate excuses
1162
+ * nothing.
1081
1163
  */
1082
- export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
1164
+ export function activeExceptionEntries(state, { workItemId, now = new Date() } = {}) {
1083
1165
  const candidates = [
1084
1166
  ...(Array.isArray(state?.changeHistory) ? state.changeHistory.filter((e) => e?.type === 'gate-exception') : []),
1085
1167
  ...(Array.isArray(state?.gateExceptions) ? state.gateExceptions : []),
1086
1168
  ];
1087
- const active = {};
1169
+ const active = [];
1088
1170
  for (const entry of candidates) {
1089
1171
  if (!isPlainObject(entry)) continue;
1172
+ if (!entry.gate) continue;
1090
1173
  if (workItemId && entry.scope && !String(entry.scope).includes(workItemId)) continue;
1091
1174
  if (entry.expiresAt && new Date(entry.expiresAt).getTime() <= now.getTime()) continue;
1092
- if (entry.gate) active[entry.gate] = entry;
1175
+ active.push(entry);
1176
+ }
1177
+ return active;
1178
+ }
1179
+
1180
+ /**
1181
+ * Active gate exceptions keyed by gate, honoring scope and expiry — a projection
1182
+ * of {@link activeExceptionEntries}. Later entries win, so a v4 document that
1183
+ * still carries legacy entries behaves as it did before.
1184
+ */
1185
+ export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
1186
+ const active = {};
1187
+ for (const entry of activeExceptionEntries(state, { workItemId, now })) {
1188
+ active[entry.gate] = entry;
1093
1189
  }
1094
1190
  return active;
1095
1191
  }
@@ -1527,10 +1623,11 @@ export function mergeEvidenceCoverage(existing, records) {
1527
1623
  * merely conservative — it is provably safe for any document that was valid before
1528
1624
  * compaction: `validateState` already rejects a claimed-true gate whose supporting
1529
1625
  * record belongs to a *different* work item, so every gate a valid document
1530
- * depends on is already backed by exactly the records this keeps. A stricter
1531
- * selector (say, "newest passing record per gate") would be smaller and would
1532
- * silently break the red-before-green rule, which needs the prior failing record
1533
- * for the same work item and gate to still exist.
1626
+ * depends on is already backed by exactly the records this keeps.
1627
+ *
1628
+ * This selector answers *which work item*. It deliberately does not answer *which
1629
+ * of that item's records* — see `retainLiveRecords` for that second, independent
1630
+ * bound, and for why "newest passing record per gate" would be wrong on its own.
1534
1631
  */
1535
1632
  function keepSelector(keep, state) {
1536
1633
  if (keep === 'always') return () => true;
@@ -1549,23 +1646,88 @@ function keepSelector(keep, state) {
1549
1646
  throw new StateError(`unknown keep selector ${JSON.stringify(keep)}; expected "always", "active", or a list of work item ids`);
1550
1647
  }
1551
1648
 
1649
+ /**
1650
+ * How many evidence records may stay inline for one work item before
1651
+ * `state validate` warns.
1652
+ *
1653
+ * A record is ~24 fields plus one line per path in `relevantFiles`, and a long
1654
+ * story re-records the same gate many times, so the array grows with *re-runs*
1655
+ * rather than with the size of the story. Measured on the audited repository: 26
1656
+ * `testsPassed` records for a single story, of which one was live.
1657
+ */
1658
+ export const DEFAULT_MAX_LIVE_EVIDENCE = 60;
1659
+
1660
+ /**
1661
+ * Which of one work item's records stay inline.
1662
+ *
1663
+ * `keepSelector` answers *which work item*; this answers *which of its records*,
1664
+ * and the two bounds are independent. Without this second one, a single long
1665
+ * story's re-run history stays for ever — the story boundary never fires inside a
1666
+ * story, so nothing bounds it.
1667
+ *
1668
+ * The retained set is exactly what the machinery can still read:
1669
+ *
1670
+ * - the **newest record per gate**, because `latestEvidenceForGate` reads
1671
+ * precisely that and a newer record shadows every older one for its gate;
1672
+ * - every **`passed` / `manual-confirmation`** record, because a claimed-true
1673
+ * gate must be backed by one, and "newest per gate" alone would drop a live
1674
+ * record that a later-appended but *older-stamped* record shadows (a
1675
+ * `manual-confirmation` may carry its own `at`);
1676
+ * - every **`failed`** record, because red-before-green reads the prior red for
1677
+ * the same work item and gate (`runVerificationLoop`'s `priorEvidence`).
1678
+ *
1679
+ * Everything else — `superseded`, and `blocked` that is not the newest for its
1680
+ * gate — is history. It leaves the document exactly as a closed work item's
1681
+ * records do: same archive, same ordering guarantee, same coverage rebuild.
1682
+ */
1683
+ export function retainLiveRecords(records, { retainAll = false } = {}) {
1684
+ const list = Array.isArray(records) ? records : [];
1685
+ if (retainAll) return new Set(list);
1686
+
1687
+ const at = (record) => {
1688
+ const parsed = Date.parse(record?.createdAt);
1689
+ return Number.isFinite(parsed) ? parsed : -Infinity;
1690
+ };
1691
+
1692
+ const newestByGate = new Map();
1693
+ for (const record of list) {
1694
+ if (!record || typeof record !== 'object' || !record.gate) continue;
1695
+ const current = newestByGate.get(record.gate);
1696
+ if (!current || at(record) >= at(current)) newestByGate.set(record.gate, record);
1697
+ }
1698
+
1699
+ const retained = new Set(newestByGate.values());
1700
+ for (const record of list) {
1701
+ if (!record || typeof record !== 'object') continue;
1702
+ if (record.status === 'passed' || record.status === 'manual-confirmation' || record.status === 'failed') {
1703
+ retained.add(record);
1704
+ }
1705
+ }
1706
+ return retained;
1707
+ }
1708
+
1552
1709
  /**
1553
1710
  * Split a document's evidence into the part that stays live and the part that
1554
1711
  * becomes history (contract v5).
1555
1712
  *
1556
- * "Live" is defined by a keep selector, defaulting to the active work item — see
1557
- * `keepSelector` for why that boundary is the safe one.
1713
+ * Two independent bounds decide "live": the keep selector (which work items),
1714
+ * defaulting to the active one — see `keepSelector` for why that boundary is safe
1715
+ * — and `retainLiveRecords` (which of that item's records), which is what bounds
1716
+ * a single long story.
1558
1717
  *
1559
1718
  * Returns `{ live, archived, coverage }`. Pure: no I/O, so the caller decides
1560
1719
  * where the archive is written.
1561
1720
  */
1562
- export function splitEvidence(state, { keep = 'active' } = {}) {
1721
+ export function splitEvidence(state, { keep = 'active', retainAll = false } = {}) {
1563
1722
  const records = Array.isArray(state?.gateEvidence) ? state.gateEvidence : [];
1564
1723
  const keepRecord = keepSelector(keep, state);
1724
+ const inScope = records.filter((record) => keepRecord(record));
1725
+ const retained = retainLiveRecords(inScope, { retainAll });
1565
1726
  const live = [];
1566
1727
  const archived = [];
1567
1728
  for (const record of records) {
1568
- if (keepRecord(record)) live.push(record);
1729
+ // Original order is preserved on both sides so the archive stays readable.
1730
+ if (inScope.includes(record) && retained.has(record)) live.push(record);
1569
1731
  else archived.push(record);
1570
1732
  }
1571
1733
  const prior = isPlainObject(state?.evidenceCoverage) ? state.evidenceCoverage : {};
@@ -1587,6 +1749,34 @@ export function resetGatesForNewWorkItem(state, { epicId = null, storyId = null,
1587
1749
  activeWorkItem: { epicId, storyId },
1588
1750
  };
1589
1751
 
1752
+ // The story boundary, recorded rather than implied.
1753
+ //
1754
+ // There is no story-level terminal transition — `closed` is the epic's, and it
1755
+ // stays terminal — so a story that finishes while its epic is still open had no
1756
+ // vocabulary at all, and every boundary ended in a judgement call about which
1757
+ // edge was legal. None of them means "this story is finished", so that call had
1758
+ // no correct answer: a session spent a full decision cycle on it and closed
1759
+ // nothing. Moving on from a work item now names the outcome.
1760
+ //
1761
+ // It records what HAPPENED — the session moved on from this item — not a
1762
+ // verdict: `completedAt` is the boundary's timestamp and `evidenceRecords` is
1763
+ // how much the item left behind, so a completion with nothing behind it is
1764
+ // visible rather than implied. One row per work item, replaced rather than
1765
+ // appended if the same item is begun again, and no prose: bounded, like
1766
+ // everything else this version keeps in the document.
1767
+ const outgoingId = state?.activeWorkItem ? workItemIdOf(state) : null;
1768
+ if (outgoingId && outgoingId !== `${epicId}::${storyId}`) {
1769
+ base.storyCompletions = [
1770
+ ...(Array.isArray(state.storyCompletions) ? state.storyCompletions : [])
1771
+ .filter((row) => row?.workItemId !== outgoingId),
1772
+ {
1773
+ workItemId: outgoingId,
1774
+ completedAt: timestamp(at),
1775
+ evidenceRecords: Array.isArray(state.gateEvidence) ? state.gateEvidence.length : 0,
1776
+ },
1777
+ ];
1778
+ }
1779
+
1590
1780
  if (isHistoryExternal(state)) {
1591
1781
  // Fold the cleared records into the coverage index before they go.
1592
1782
  //
package/src/install.mjs CHANGED
@@ -352,6 +352,13 @@ export async function install(targetDir, opts = {}) {
352
352
  console.log(' Reviewer: the cadet-agent-reviewer skill');
353
353
  console.log(' Git guard: no hook — approve via .deepcode\\settings.json permissions.ask (mutate-git-log)');
354
354
  console.log(' Docs: https://deepcode.vegamo.cn/');
355
+ console.log(' Hermes:');
356
+ console.log(' Already active — .agents\\skills\\cadet-agent\\SKILL.md is discovered as a project skill');
357
+ console.log(' Run `hermes skills trust` once inside the repo to enable project skills');
358
+ console.log(' Slash commands: /cadet-requirements, /cadet-tdd, /cadet-resume, ...');
359
+ console.log(' Reviewer: /cadet-agent-reviewer');
360
+ console.log(' Git guard: no hook — configure command approval policies for commit/push');
361
+ console.log(' Docs: https://hermes-agent.nousresearch.com/docs/');
355
362
  console.log('');
356
363
  }
357
364