cadet-agent 0.49.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.49.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
@@ -601,16 +601,26 @@ async function cmdState(opts) {
601
601
  const written = appendEvidenceArchive(opts.targetDir, outgoing);
602
602
  const next = resetGatesForNewWorkItem(state, { epicId: opts.epicId, storyId: opts.story });
603
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;
604
611
  emit(
605
612
  opts,
606
613
  `✅ Began ${toId}.\n Gates reset; ${outgoing.length} evidence record(s) archived (${written.appended} appended, ${written.skipped} already archived).`
607
- + (fromId ? `\n Previous work item: ${fromId}` : '')
614
+ + (completed
615
+ ? `\n Completed: ${completed.workItemId} (${completed.evidenceRecords} evidence record(s) behind it)`
616
+ : fromId ? `\n Previous work item: ${fromId}` : '')
608
617
  + `\n Coverage rows: ${Object.keys(next.evidenceCoverage || {}).length}`,
609
618
  {
610
619
  ok: true,
611
620
  from: fromId,
612
621
  to: toId,
613
622
  gatesReset: true,
623
+ completed: completed ? { workItemId: completed.workItemId, completedAt: completed.completedAt } : null,
614
624
  archived: written.appended,
615
625
  alreadyArchived: written.skipped,
616
626
  coverageRows: Object.keys(next.evidenceCoverage || {}).length,
@@ -1218,6 +1228,12 @@ async function cmdHarness(opts) {
1218
1228
  command: `harness verify-acs --story ${opts.story}`,
1219
1229
  result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
1220
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,
1221
1237
  // Audit pointer only. Not a relevant file: see above.
1222
1238
  artifactPath: reportPath ? reportPath.replace(/\\/g, '/') : null,
1223
1239
  inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
@@ -1387,6 +1403,10 @@ async function cmdHarness(opts) {
1387
1403
  ? `reachability addressed (${validation.code}); project probe exit ${probe.exitCode}`
1388
1404
  : `reachability addressed (${validation.code}); no project probe configured`,
1389
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,
1390
1410
  inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
1391
1411
  criteriaHash: hashCriteria([
1392
1412
  workItemId,
@@ -1501,6 +1521,10 @@ async function cmdHarness(opts) {
1501
1521
  // `--format json` must not have to tolerate a failure exit to get it. A run with
1502
1522
  // no planning artifacts at all is a legitimate state (a framework-source repo, a
1503
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.
1504
1528
  if (sub === 'reconcile') {
1505
1529
  const { exists, state } = readState(opts.targetDir);
1506
1530
  const result = reconcileArtifacts(opts.targetDir, {
@@ -1509,6 +1533,8 @@ async function cmdHarness(opts) {
1509
1533
  story: opts.story || null,
1510
1534
  });
1511
1535
 
1536
+ if (result.scopeError) fail(opts, result.reason, () => 2);
1537
+
1512
1538
  if (opts.format === 'json') {
1513
1539
  emit(opts, '', result);
1514
1540
  return;
@@ -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
  };
@@ -225,6 +225,29 @@ export function validateState(state, context = {}) {
225
225
  }
226
226
  });
227
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
+ }
228
251
  // The coverage index (contract v5). It is what keeps the "a done story owns
229
252
  // evidence" check answerable once the records themselves have moved to
230
253
  // commits and `.cadet/archive/`, so a malformed index is an error: an index
@@ -1121,24 +1144,48 @@ export function recordEvidence(state, evidence) {
1121
1144
  }
1122
1145
 
1123
1146
  /**
1124
- * Active gate exceptions keyed by gate, honoring scope and expiry.
1147
+ * Every active exception entry covering `workItemId`, in record order.
1125
1148
  *
1126
1149
  * Reads both homes for an exception: `changeHistory` (v1-v3, where a `type`
1127
1150
  * discriminator picks it out of the log) and `gateExceptions` (v4, a dedicated
1128
- * field where the discriminator would be redundant). Later entries win, so a
1129
- * 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.
1130
1163
  */
1131
- export function activeExceptions(state, { workItemId, now = new Date() } = {}) {
1164
+ export function activeExceptionEntries(state, { workItemId, now = new Date() } = {}) {
1132
1165
  const candidates = [
1133
1166
  ...(Array.isArray(state?.changeHistory) ? state.changeHistory.filter((e) => e?.type === 'gate-exception') : []),
1134
1167
  ...(Array.isArray(state?.gateExceptions) ? state.gateExceptions : []),
1135
1168
  ];
1136
- const active = {};
1169
+ const active = [];
1137
1170
  for (const entry of candidates) {
1138
1171
  if (!isPlainObject(entry)) continue;
1172
+ if (!entry.gate) continue;
1139
1173
  if (workItemId && entry.scope && !String(entry.scope).includes(workItemId)) continue;
1140
1174
  if (entry.expiresAt && new Date(entry.expiresAt).getTime() <= now.getTime()) continue;
1141
- 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;
1142
1189
  }
1143
1190
  return active;
1144
1191
  }
@@ -1702,6 +1749,34 @@ export function resetGatesForNewWorkItem(state, { epicId = null, storyId = null,
1702
1749
  activeWorkItem: { epicId, storyId },
1703
1750
  };
1704
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
+
1705
1780
  if (isHistoryExternal(state)) {
1706
1781
  // Fold the cleared records into the coverage index before they go.
1707
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