cadet-agent 0.49.0 → 0.54.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 +27 -1
- package/src/harness/reconcile.mjs +117 -18
- package/src/harness/state.mjs +81 -6
- 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
|
@@ -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
|
-
+ (
|
|
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
|
|
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
|
@@ -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
|
-
*
|
|
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).
|
|
1129
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|