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 +34 -23
- package/package.json +1 -1
- package/src/cli.mjs +95 -4
- package/src/harness/commands.mjs +9 -0
- package/src/harness/index.mjs +1 -1
- package/src/harness/reconcile.mjs +117 -18
- package/src/harness/state.mjs +207 -17
- package/src/install.mjs +7 -0
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,
|
|
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 (
|
|
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
|
|
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
|
|
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
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
|
|
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;
|
package/src/harness/commands.mjs
CHANGED
|
@@ -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.',
|
package/src/harness/index.mjs
CHANGED
|
@@ -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
|
|
310
|
-
// NAME (`epic-1-player-movement`), not by path, so the scope is that
|
|
311
|
-
// every epic lookup in this module uses the same key.
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
558
|
-
|
|
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:
|
|
602
|
-
storyCount:
|
|
700
|
+
epicCount: epics.length,
|
|
701
|
+
storyCount: epics.reduce((n, e) => n + e.stories.length, 0),
|
|
603
702
|
},
|
|
604
703
|
reason: null,
|
|
605
704
|
};
|
package/src/harness/state.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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).
|
|
1080
|
-
*
|
|
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
|
|
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
|
-
|
|
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.
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
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
|
-
*
|
|
1557
|
-
* `keepSelector` for why that boundary is
|
|
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
|
-
|
|
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
|
|