cadet-agent 0.46.0 → 0.48.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 +4 -2
- package/package.json +1 -1
- package/src/cli.mjs +183 -4
- package/src/harness/changes.mjs +234 -0
- package/src/harness/commands.mjs +14 -0
- package/src/harness/index.mjs +7 -0
- package/src/harness/reconcile.mjs +606 -0
- package/src/harness/state.mjs +20 -3
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Cadet-Agent is **not a one-shot code generator**. It won't spit out a finished g
|
|
|
9
9
|
- `cadet-agent.md` is the thin global directive: identity, non-negotiable rules, workflow routing, hard-gate protocol, and skill dispatch.
|
|
10
10
|
- `Harness.md` is the canonical harness contract: budgets, evidence-backed gates, retries, context tiers, tool routing, privacy, and escalation.
|
|
11
11
|
- `harness.schema.json` and `state.schema.json` are the machine-readable schemas for harness records and session state.
|
|
12
|
-
- `skills/` contains scoped workflow-phase skills (PlanningReview, Requirements, Architecture, Spike, StoryBreakdown, TDD, Debugging, CodeReview, Resume, MCPSetup, AgentReviewer).
|
|
12
|
+
- `skills/` contains scoped workflow-phase skills (PlanningReview, Requirements, Architecture, Spike, StoryBreakdown, TDD, Debugging, CodeReview, Resume, MCPSetup, AgentReviewer, Handoff, Reconciliation).
|
|
13
13
|
- `templates/` contains runtime templates for planning artifacts.
|
|
14
14
|
- `.cadet/harness.json` holds repository-local budget/policy overrides (preserved by sync).
|
|
15
15
|
- `.cadet/runs/` holds sanitized run ledgers (preserved by sync; no secrets or raw prompts by default).
|
|
@@ -28,7 +28,7 @@ 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
|
|
31
|
+
Cadet-Agent provides full workflow parity across five IDEs. The same 11 skills + reviewer are available in each:
|
|
32
32
|
|
|
33
33
|
| Feature | GitHub Copilot | Cursor | Continue | Claude Code | Deep Code |
|
|
34
34
|
|---|---|---|---|---|---|
|
|
@@ -44,6 +44,7 @@ Cadet-Agent provides full workflow parity across five IDEs. The same 10 skills +
|
|
|
44
44
|
| Code Review | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
45
45
|
| Resume | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
46
46
|
| MCP Setup | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
47
|
+
| Reconciliation | ✅ | ✅ | ✅ | ✅ | ✅ |
|
|
47
48
|
| Reviewer mode | Agent picker | Rule toggle | `/cadet-agent-reviewer` | `/cadet-agent-reviewer` | `cadet-agent-reviewer` skill |
|
|
48
49
|
| Git guard | PreToolUse hook | Manual | Manual | Manual | `permissions.ask` (`mutate-git-log`) |
|
|
49
50
|
|
|
@@ -199,6 +200,7 @@ cadet-agent state seal # write the active work item's
|
|
|
199
200
|
cadet-agent state transition --to review # enforce the matrix + evidence
|
|
200
201
|
cadet-agent harness verify --gate testsPassed --files src/a.cs # bounded, classified loop
|
|
201
202
|
cadet-agent harness report # budget consumption and failures (no secrets)
|
|
203
|
+
cadet-agent harness reconcile # reconcile the planning chain against state.json (read-only)
|
|
202
204
|
cadet-agent harness cleanup --older-than-ms <n> # apply the retention policy (bound required)
|
|
203
205
|
cadet-agent harness capabilities # available CLI/Unity/MCP/hook/token/cost telemetry
|
|
204
206
|
```
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -6,7 +6,9 @@ import {
|
|
|
6
6
|
validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
|
|
7
7
|
workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
|
|
8
8
|
runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
|
|
9
|
-
detectRepoRole, describeRepoRole, GATES, manualConfirmation,
|
|
9
|
+
detectRepoRole, describeRepoRole, GATES, PHASES, manualConfirmation,
|
|
10
|
+
gitChangeSet, DEFAULT_REPORT_DIR,
|
|
11
|
+
reconcileArtifacts, PLANS_DEFAULT_DIR,
|
|
10
12
|
parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
|
|
11
13
|
parseReachabilityDeclaration, validateReachabilityDeclaration, collectWorkItems,
|
|
12
14
|
findDeferralCycles, readSiblingDeclarations, normalizeWorkItemRef, describeReachabilityGaps,
|
|
@@ -58,6 +60,8 @@ function showHelp() {
|
|
|
58
60
|
cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
|
|
59
61
|
cadet-agent harness verify-reachability Verify a story's declared reachability (opt-in)
|
|
60
62
|
cadet-agent harness report Summarize budget consumption and failures
|
|
63
|
+
cadet-agent harness changes List the files a story changed, with status, counts, and links
|
|
64
|
+
cadet-agent harness reconcile Reconcile the planning chain against state.json (read-only)
|
|
61
65
|
cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
|
|
62
66
|
cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
|
|
63
67
|
|
|
@@ -70,6 +74,7 @@ function showHelp() {
|
|
|
70
74
|
--command Command override (harness verify)
|
|
71
75
|
--files Comma-separated relevant files to bind evidence to (harness verify|confirm)
|
|
72
76
|
--commit Revision the gate attests, as a hex SHA (harness verify|confirm)
|
|
77
|
+
--expect-phase Refuse to record a gate unless the current phase matches (harness verify|confirm|verify-acs|verify-reachability)
|
|
73
78
|
--reason Why automation was unavailable (harness confirm)
|
|
74
79
|
--expires-at ISO-8601 expiry bounding the confirmation (harness confirm)
|
|
75
80
|
--environment key=value,... describing what was verified (harness confirm)
|
|
@@ -78,6 +83,10 @@ function showHelp() {
|
|
|
78
83
|
--report Test report to derive the inventory from (harness verify-acs|matrix-check)
|
|
79
84
|
--matrix TDD matrix markdown to check (harness matrix-check)
|
|
80
85
|
--inventory Newline-separated test names, when no report is available (harness matrix-check)
|
|
86
|
+
--range Base revision to diff instead of the working tree (harness changes)
|
|
87
|
+
--relative-to Directory the emitted links are relative to (harness changes; default .cadet/reports)
|
|
88
|
+
--include-cadet Keep .cadet/ bookkeeping among the listed files (harness changes)
|
|
89
|
+
--plans-dir Directory holding the planning artifacts (harness reconcile; default .cadet/agent/project-plans)
|
|
81
90
|
--agents-md keep|overwrite|merge for an existing AGENTS.md (init/sync)
|
|
82
91
|
--older-than-ms Age bound, in ms, for records cleanup may delete (harness cleanup; required)
|
|
83
92
|
--keep always|active|<work-item ids> for what stays in state.json (state compact; required)
|
|
@@ -146,6 +155,9 @@ function parseArgs(argv) {
|
|
|
146
155
|
case '--command': opts.command = value(a); break;
|
|
147
156
|
case '--work-item': opts.workItemId = value(a); break;
|
|
148
157
|
case '--phase': opts.phase = value(a); break;
|
|
158
|
+
// --expect-phase: a guard against recording a gate into a phase the caller
|
|
159
|
+
// did not intend. See assertExpectedPhase.
|
|
160
|
+
case '--expect-phase': opts.expectPhase = value(a); break;
|
|
149
161
|
case '--run': opts.runId = value(a); break;
|
|
150
162
|
case '--type': opts.type = value(a); break;
|
|
151
163
|
case '--reason': opts.reason = value(a); break;
|
|
@@ -164,6 +176,10 @@ function parseArgs(argv) {
|
|
|
164
176
|
case '--commit': opts.commitGiven = true; opts.commit = value(a); break;
|
|
165
177
|
case '--matrix': opts.matrix = value(a); break;
|
|
166
178
|
case '--inventory': opts.inventory = value(a); break;
|
|
179
|
+
case '--range': opts.range = value(a); break;
|
|
180
|
+
case '--relative-to': opts.relativeTo = value(a); break;
|
|
181
|
+
case '--include-cadet': opts.includeCadet = true; break;
|
|
182
|
+
case '--plans-dir': opts.plansDir = value(a); break;
|
|
167
183
|
case '--write-coverage': opts.writeCoverage = true; break;
|
|
168
184
|
case '--strict-orphans': opts.strictOrphans = true; break;
|
|
169
185
|
case '--dry-run': opts.dryRun = true; break;
|
|
@@ -224,6 +240,40 @@ function fail(opts, message, code = json => json.exitCode || 1, json = {}) {
|
|
|
224
240
|
process.exit(exitCode);
|
|
225
241
|
}
|
|
226
242
|
|
|
243
|
+
/**
|
|
244
|
+
* `--expect-phase <phase>` — refuse to record gate evidence into a phase the
|
|
245
|
+
* caller did not intend.
|
|
246
|
+
*
|
|
247
|
+
* The failure this closes is a caller error, not a framework one: `state
|
|
248
|
+
* transition` already reports `allowed: false` and exits 1, but an agent that
|
|
249
|
+
* chains commands with `;` and filters the output reads the *next* command's
|
|
250
|
+
* success as the transition's, and goes on to record the following gates into the
|
|
251
|
+
* phase it never left. The verdict was correct and ignored; the record was then
|
|
252
|
+
* written anyway. This is a check because the mistake recurred after being
|
|
253
|
+
* documented, and a check is what the framework's own doctrine asks for at that
|
|
254
|
+
* point.
|
|
255
|
+
*
|
|
256
|
+
* The flag is opt-in and cheap: omitting it changes nothing. A mismatch is
|
|
257
|
+
* refused before any write, so a stray `--expect-phase` cannot corrupt state —
|
|
258
|
+
* it can only stop the command.
|
|
259
|
+
*/
|
|
260
|
+
function assertExpectedPhase(opts, state) {
|
|
261
|
+
if (!opts.expectPhase) return;
|
|
262
|
+
if (!PHASES.includes(opts.expectPhase)) {
|
|
263
|
+
fail(opts, `--expect-phase "${opts.expectPhase}" is not a known phase. Valid phases: ${PHASES.join(', ')}.`, () => 1, { ok: false, code: 'unknown-phase', expectedPhase: opts.expectPhase });
|
|
264
|
+
}
|
|
265
|
+
const actual = state?.session?.currentPhase ?? null;
|
|
266
|
+
if (actual === opts.expectPhase) return;
|
|
267
|
+
fail(
|
|
268
|
+
opts,
|
|
269
|
+
`--expect-phase ${opts.expectPhase}, but the current phase is "${actual ?? '(none)'}". `
|
|
270
|
+
+ 'Refusing to record evidence for a phase the caller did not intend — re-read .cadet/state.json '
|
|
271
|
+
+ '(or run `state transition --dry-run`) and retry once the phase is what you expected.',
|
|
272
|
+
() => 1,
|
|
273
|
+
{ ok: false, code: 'phase-mismatch', expectedPhase: opts.expectPhase, actualPhase: actual },
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
227
277
|
// ── evidence archive (contract v5) ──────────────────────────────────────────
|
|
228
278
|
|
|
229
279
|
/**
|
|
@@ -660,6 +710,7 @@ async function cmdHarness(opts) {
|
|
|
660
710
|
|
|
661
711
|
const { exists, state } = readState(opts.targetDir);
|
|
662
712
|
if (!exists) fail(opts, 'No .cadet/state.json found. Initialise state before recording confirmation.', () => 2);
|
|
713
|
+
assertExpectedPhase(opts, state);
|
|
663
714
|
|
|
664
715
|
const strict = policy.strictClosure?.enabled === true ? policy.strictClosure : null;
|
|
665
716
|
const mc = strict?.manualConfirmation || null;
|
|
@@ -817,6 +868,7 @@ async function cmdHarness(opts) {
|
|
|
817
868
|
const gate = opts.gate;
|
|
818
869
|
if (!gate) fail(opts, 'harness verify requires --gate <gate>');
|
|
819
870
|
const { state } = readState(opts.targetDir);
|
|
871
|
+
assertExpectedPhase(opts, state);
|
|
820
872
|
const caps = detectCapabilities({ targetDir: opts.targetDir });
|
|
821
873
|
const descriptor = opts.command
|
|
822
874
|
? { command: opts.command, tool: 'custom', automated: true }
|
|
@@ -961,6 +1013,7 @@ async function cmdHarness(opts) {
|
|
|
961
1013
|
// never written cannot be asserted into coverage.
|
|
962
1014
|
if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
|
|
963
1015
|
const { exists, state } = readState(opts.targetDir);
|
|
1016
|
+
assertExpectedPhase(opts, state);
|
|
964
1017
|
const strict = policy.strictClosure?.enabled === true;
|
|
965
1018
|
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
966
1019
|
const phase = state?.session?.currentPhase || 'implementation';
|
|
@@ -1070,6 +1123,26 @@ async function cmdHarness(opts) {
|
|
|
1070
1123
|
const at = new Date();
|
|
1071
1124
|
const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
|
|
1072
1125
|
const nowIso = at.toISOString();
|
|
1126
|
+
// The story is the only INPUT to the AC claim: it carries the declared
|
|
1127
|
+
// AC→test mapping, and `criteriaHash` binds those names (C12), so editing the
|
|
1128
|
+
// mapping invalidates the record.
|
|
1129
|
+
//
|
|
1130
|
+
// The test report is an OUTPUT of the run that satisfied `testsPassed`, not an
|
|
1131
|
+
// input, and binding it was a defect: a repository whose test script rewrites a
|
|
1132
|
+
// fixed report path (e.g. `test-results-junit.xml`) staled this record the
|
|
1133
|
+
// moment it re-ran the tests, because the file the record had just read changed
|
|
1134
|
+
// underneath it. This is the same class Harness §5 already excludes
|
|
1135
|
+
// (`.cadet/state.json`, `.cadet/runs/**`) — "binding evidence to either would
|
|
1136
|
+
// make a gate stale the instant it was written" — so a generated report gets
|
|
1137
|
+
// the same treatment and is kept as `artifactPath` for audit, where nothing
|
|
1138
|
+
// re-hashes it.
|
|
1139
|
+
//
|
|
1140
|
+
// The story path is made repo-relative for the same reason verify-reachability
|
|
1141
|
+
// does it: an absolute path never resolves under the root when freshness is
|
|
1142
|
+
// re-derived at transition time, so both hashes would be computed over a
|
|
1143
|
+
// missing file and match — a binding that is silently inert.
|
|
1144
|
+
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1145
|
+
const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
|
|
1073
1146
|
const evidence = createEvidence({
|
|
1074
1147
|
evidenceId: newId(),
|
|
1075
1148
|
workItemId,
|
|
@@ -1080,9 +1153,11 @@ async function cmdHarness(opts) {
|
|
|
1080
1153
|
command: `harness verify-acs --story ${opts.story}`,
|
|
1081
1154
|
result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
|
|
1082
1155
|
exitCode: 0,
|
|
1083
|
-
|
|
1156
|
+
// Audit pointer only. Not a relevant file: see above.
|
|
1157
|
+
artifactPath: reportPath ? reportPath.replace(/\\/g, '/') : null,
|
|
1158
|
+
inputTreeHash: computeInputTreeHash(opts.targetDir, [storyRel]),
|
|
1084
1159
|
criteriaHash: hashCriteria(criteriaStrings),
|
|
1085
|
-
relevantFiles: [
|
|
1160
|
+
relevantFiles: [storyRel],
|
|
1086
1161
|
createdAt: at,
|
|
1087
1162
|
expiresAt: null,
|
|
1088
1163
|
// Schema + validator require an object carrying a `scope`, not a bare
|
|
@@ -1150,6 +1225,7 @@ async function cmdHarness(opts) {
|
|
|
1150
1225
|
const storyPath = resolve(opts.targetDir, opts.story);
|
|
1151
1226
|
const storyRel = relative(opts.targetDir, storyPath).replace(/\\/g, '/') || basename(storyPath);
|
|
1152
1227
|
const { exists, state } = readState(opts.targetDir);
|
|
1228
|
+
assertExpectedPhase(opts, state);
|
|
1153
1229
|
const enabled = policy.reachability?.enabled === true;
|
|
1154
1230
|
const probeCommand = policy.reachability?.command || null;
|
|
1155
1231
|
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
@@ -1293,6 +1369,109 @@ async function cmdHarness(opts) {
|
|
|
1293
1369
|
return;
|
|
1294
1370
|
}
|
|
1295
1371
|
|
|
1372
|
+
// Read-only. Produces the deterministic half of a Change Report — which files
|
|
1373
|
+
// changed, how, and by how much — so the agent never assembles that table from
|
|
1374
|
+
// memory. The other half, why each file changed, is not knowable from git and
|
|
1375
|
+
// stays the agent's job. See .cadet/agent/core/skills/CodeReview.md.
|
|
1376
|
+
//
|
|
1377
|
+
// A missing git is NOT a usage error here. This command informs a review that
|
|
1378
|
+
// can still be completed, so it reports the limitation and exits 0 rather than
|
|
1379
|
+
// blocking the review; the report records it under Limits.
|
|
1380
|
+
if (sub === 'changes') {
|
|
1381
|
+
const relativeTo = opts.relativeTo || DEFAULT_REPORT_DIR;
|
|
1382
|
+
const changes = gitChangeSet(opts.targetDir, {
|
|
1383
|
+
range: opts.range || null,
|
|
1384
|
+
relativeTo,
|
|
1385
|
+
includeCadet: opts.includeCadet === true,
|
|
1386
|
+
});
|
|
1387
|
+
|
|
1388
|
+
const { exists, state } = readState(opts.targetDir);
|
|
1389
|
+
const item = exists ? state?.activeWorkItem ?? null : null;
|
|
1390
|
+
const workItem = item ? { epicId: item.epicId ?? null, storyId: item.storyId ?? null } : null;
|
|
1391
|
+
|
|
1392
|
+
const payload = {
|
|
1393
|
+
ok: true,
|
|
1394
|
+
available: changes.available,
|
|
1395
|
+
workItem,
|
|
1396
|
+
range: opts.range || 'working-tree',
|
|
1397
|
+
relativeTo,
|
|
1398
|
+
files: changes.files,
|
|
1399
|
+
counts: changes.counts,
|
|
1400
|
+
reason: changes.reason,
|
|
1401
|
+
};
|
|
1402
|
+
if (opts.format === 'json') {
|
|
1403
|
+
emit(opts, '', payload);
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (!changes.available) {
|
|
1408
|
+
console.log(`\n⚠️ Change inventory unavailable: ${changes.reason}`);
|
|
1409
|
+
console.log(' Do not list files from memory — record this as a limit of the report.');
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
const c = changes.counts;
|
|
1414
|
+
console.log(`\nChange inventory (${payload.range}) — ${changes.files.length} file(s)`);
|
|
1415
|
+
console.log(` ${c.added} added · ${c.modified} modified · ${c.renamed} renamed · ${c.deleted} deleted`);
|
|
1416
|
+
if (changes.files.length === 0) {
|
|
1417
|
+
console.log('\n (no changes)');
|
|
1418
|
+
} else {
|
|
1419
|
+
console.log('');
|
|
1420
|
+
for (const f of changes.files) {
|
|
1421
|
+
const lines = f.added === null && f.deleted === null ? 'new' : `+${f.added ?? 0}/-${f.deleted ?? 0}`;
|
|
1422
|
+
console.log(` ${f.status} ${lines.padEnd(10)} ${f.path}`);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
const label = workItem ? `${workItem.epicId || 'none'}::${workItem.storyId || 'none'}` : 'none';
|
|
1426
|
+
console.log(`\n Links relative to ${relativeTo} · work item: ${label}`);
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Read-only. Reconciles the planning chain against state.json: the mechanical
|
|
1431
|
+
// half of the Reconciliation skill. It reports the inconsistencies it can prove
|
|
1432
|
+
// from the artifacts and never repairs one — the skill proposes repairs for the
|
|
1433
|
+
// user to approve. See .cadet/agent/core/skills/Reconciliation.md.
|
|
1434
|
+
//
|
|
1435
|
+
// Exit 0 whatever the verdict: the verdict is the payload, and a caller reading
|
|
1436
|
+
// `--format json` must not have to tolerate a failure exit to get it. A run with
|
|
1437
|
+
// no planning artifacts at all is a legitimate state (a framework-source repo, a
|
|
1438
|
+
// small change), not an error.
|
|
1439
|
+
if (sub === 'reconcile') {
|
|
1440
|
+
const { exists, state } = readState(opts.targetDir);
|
|
1441
|
+
const result = reconcileArtifacts(opts.targetDir, {
|
|
1442
|
+
state: exists ? state : null,
|
|
1443
|
+
plansDir: opts.plansDir || PLANS_DEFAULT_DIR,
|
|
1444
|
+
story: opts.story || null,
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
if (opts.format === 'json') {
|
|
1448
|
+
emit(opts, '', result);
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
if (!result.available) {
|
|
1452
|
+
console.log(`\nℹ️ Nothing to reconcile: ${result.reason}`);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const s = result.summary;
|
|
1457
|
+
console.log(`\nReconcile ${result.plansDir}${result.scopedEpic ? ` (${result.scopedEpic})` : ''} — verdict: ${result.verdict}`);
|
|
1458
|
+
console.log(` ${result.artifacts.epicCount} epic(s), ${result.artifacts.storyCount} story file(s)`);
|
|
1459
|
+
console.log(` ${s.total} finding(s): ${s.blocking} blocking · ${s.warning} warning · ${s.info} info`);
|
|
1460
|
+
if (s.total === 0) {
|
|
1461
|
+
console.log('\n ✅ The chain is internally consistent.');
|
|
1462
|
+
} else {
|
|
1463
|
+
console.log('');
|
|
1464
|
+
for (const f of result.findings) {
|
|
1465
|
+
console.log(` [${f.severity}] ${f.id} ${f.code} — ${f.subject}`);
|
|
1466
|
+
console.log(` ${f.detail}${f.evidence ? ` (${f.evidence})` : ''}`);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
if (result.verdict === 'unknown') {
|
|
1470
|
+
console.log('\n ⚠️ At least one artifact could not be read, so consistency cannot be certified.');
|
|
1471
|
+
}
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1296
1475
|
// AR-5. Reconcile a TDD matrix's DELIVERED test-name claims against a compiled
|
|
1297
1476
|
// inventory. Read-only: it reports, and never writes state, so it can be run at
|
|
1298
1477
|
// authoring time (before anything has been implemented) as well as in a gate.
|
|
@@ -1390,7 +1569,7 @@ async function cmdHarness(opts) {
|
|
|
1390
1569
|
return;
|
|
1391
1570
|
}
|
|
1392
1571
|
|
|
1393
|
-
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|cleanup|capabilities.`);
|
|
1572
|
+
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|verify-reachability|matrix-check|report|changes|reconcile|cleanup|capabilities.`);
|
|
1394
1573
|
}
|
|
1395
1574
|
|
|
1396
1575
|
export async function run(argv) {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Change inventory — what a story actually touched, and how much.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the Change Report's file table has to be *the same table*
|
|
5
|
+
* every run. When the agent assembled that list by hand — a `git status` here, a
|
|
6
|
+
* remembered path there — the result varied by run: a file dropped, a status
|
|
7
|
+
* guessed, a link that did not resolve, a line count invented. The rows are the
|
|
8
|
+
* part of the report that is mechanically knowable, so they are computed here
|
|
9
|
+
* and the agent supplies only the prose that is not.
|
|
10
|
+
*
|
|
11
|
+
* This is a read-only probe. It runs `git` and parses output; it never writes.
|
|
12
|
+
*
|
|
13
|
+
* Availability contract: mirrors `gitChangedFiles` (`util.mjs`). Returns
|
|
14
|
+
* `{ available: false, reason }` when git cannot be asked — "not a repository"
|
|
15
|
+
* and "no changes" must not look alike, so callers state the limitation rather
|
|
16
|
+
* than reporting an empty change set as a clean one.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
21
|
+
|
|
22
|
+
/** Default report directory, from which file links are made relative. */
|
|
23
|
+
export const DEFAULT_REPORT_DIR = '.cadet/reports';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cadet's own bookkeeping. A story's change report should not lead with the
|
|
27
|
+
* ledger and state files its own gate checks rewrote. Same set and same reason
|
|
28
|
+
* as `util.mjs#CADET_MACHINERY`.
|
|
29
|
+
*/
|
|
30
|
+
const CADET_MACHINERY = ['.cadet/state.json', '.cadet/runs/', '.cadet/archive/'];
|
|
31
|
+
|
|
32
|
+
function isCadetMachinery(relPath) {
|
|
33
|
+
return CADET_MACHINERY.some((p) => (p.endsWith('/') ? relPath.startsWith(p) : relPath === p));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Collapse porcelain's two status columns (`XY`) to the single letter the
|
|
38
|
+
* report shows. The staged column wins when it says something, because that is
|
|
39
|
+
* the status the eventual commit will carry; the worktree column is the
|
|
40
|
+
* fallback. `??` is an untracked file, which a reader reads as "added".
|
|
41
|
+
*/
|
|
42
|
+
const STATUS_LETTERS = { R: 'R', C: 'R', A: 'A', D: 'D', M: 'M', T: 'M' };
|
|
43
|
+
|
|
44
|
+
function statusFromPorcelain(xy) {
|
|
45
|
+
if (xy === '??') return 'A';
|
|
46
|
+
const [staged, unstaged] = xy;
|
|
47
|
+
return STATUS_LETTERS[staged] || STATUS_LETTERS[unstaged] || 'M';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize a `--numstat` path field, which spells renames three ways:
|
|
52
|
+
* `new`, `old => new`, and `dir/{old => new}/file`. Only the new path is kept,
|
|
53
|
+
* matching the name-status side, so the two maps join on the same key.
|
|
54
|
+
*/
|
|
55
|
+
function normalizeNumstatPath(raw) {
|
|
56
|
+
const path = String(raw || '').trim();
|
|
57
|
+
if (!path.includes(' => ')) return path;
|
|
58
|
+
const braced = path.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
|
|
59
|
+
if (braced) return `${braced[1]}${braced[3]}${braced[4]}`.replace(/\/{2,}/g, '/');
|
|
60
|
+
return path.split(' => ').pop().trim();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultGitRunner(cmd, args) {
|
|
64
|
+
try {
|
|
65
|
+
return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Run one git command and return its stdout, or `{ error }` describing why the
|
|
73
|
+
* question could not be asked. Every call goes through here so a single probe
|
|
74
|
+
* failure is reported the same way regardless of which command failed.
|
|
75
|
+
*/
|
|
76
|
+
function runGit(cwd, args, runner) {
|
|
77
|
+
let res;
|
|
78
|
+
try {
|
|
79
|
+
res = runner('git', ['-C', cwd, ...args]);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return { error: `git invocation failed: ${err.message}` };
|
|
82
|
+
}
|
|
83
|
+
if (!res) return { error: 'git is not available' };
|
|
84
|
+
if (res.error || res.status === null) {
|
|
85
|
+
return { error: 'git is not installed or could not be executed' };
|
|
86
|
+
}
|
|
87
|
+
if (res.status !== 0) {
|
|
88
|
+
return { error: String(res.stderr || '').trim() || `git exited ${res.status}` };
|
|
89
|
+
}
|
|
90
|
+
return { stdout: String(res.stdout || '') };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Files and statuses for the change set. Untracked files appear only in the
|
|
95
|
+
* working-tree form; a `--range` diff is a commit-to-commit question and cannot
|
|
96
|
+
* see them.
|
|
97
|
+
*/
|
|
98
|
+
function readNameStatus(cwd, { runner, range }) {
|
|
99
|
+
if (range) {
|
|
100
|
+
const res = runGit(cwd, ['diff', '--name-status', range, '--'], runner);
|
|
101
|
+
if (res.error) return { error: res.error };
|
|
102
|
+
const files = [];
|
|
103
|
+
for (const line of res.stdout.split(/\r?\n/)) {
|
|
104
|
+
if (!line.trim()) continue;
|
|
105
|
+
// "M\tpath" — or "R100\told\tnew", where the new path is the one that exists.
|
|
106
|
+
const fields = line.split('\t');
|
|
107
|
+
if (fields.length < 2) continue;
|
|
108
|
+
const path = fields[fields.length - 1].trim();
|
|
109
|
+
if (!path) continue;
|
|
110
|
+
files.push({ path: path.replace(/\\/g, '/'), status: fields[0][0] });
|
|
111
|
+
}
|
|
112
|
+
return { files };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const res = runGit(cwd, ['status', '--porcelain', '--untracked-files=all'], runner);
|
|
116
|
+
if (res.error) return { error: res.error };
|
|
117
|
+
const files = [];
|
|
118
|
+
for (const line of res.stdout.split(/\r?\n/)) {
|
|
119
|
+
if (!line.trim()) continue;
|
|
120
|
+
// Porcelain v1: XY<space>path, with renames written "old -> new".
|
|
121
|
+
const xy = line.slice(0, 2);
|
|
122
|
+
let path = line.slice(3).trim();
|
|
123
|
+
if (path.includes(' -> ')) path = path.split(' -> ').pop().trim();
|
|
124
|
+
path = path.replace(/^"|"$/g, '');
|
|
125
|
+
if (!path) continue;
|
|
126
|
+
files.push({ path: path.replace(/\\/g, '/'), status: statusFromPorcelain(xy) });
|
|
127
|
+
}
|
|
128
|
+
return { files };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Accumulate one count onto a prior one. A file can appear in both the staged
|
|
133
|
+
* and the unstaged numstat, and those counts add. `null` means git reported no
|
|
134
|
+
* number (a binary file); it stays unknown unless a real number was seen.
|
|
135
|
+
*/
|
|
136
|
+
function addCount(prior, value) {
|
|
137
|
+
if (value === null) return prior === undefined ? null : prior;
|
|
138
|
+
return (prior ?? 0) + value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Added/deleted counts per path, merged across the staged and unstaged diffs. */
|
|
142
|
+
function mergeNumstat(target, stdout) {
|
|
143
|
+
for (const line of String(stdout || '').split(/\r?\n/)) {
|
|
144
|
+
if (!line.trim()) continue;
|
|
145
|
+
const [added, deleted, ...rest] = line.split('\t');
|
|
146
|
+
if (rest.length === 0) continue;
|
|
147
|
+
const path = normalizeNumstatPath(rest.join('\t'));
|
|
148
|
+
if (!path) continue;
|
|
149
|
+
// A binary file reports "-" for both; that is genuinely unknown, not zero.
|
|
150
|
+
const a = /^\d+$/.test(added) ? Number(added) : null;
|
|
151
|
+
const d = /^\d+$/.test(deleted) ? Number(deleted) : null;
|
|
152
|
+
const prior = target.get(path);
|
|
153
|
+
target.set(path, {
|
|
154
|
+
added: addCount(prior?.added, a),
|
|
155
|
+
deleted: addCount(prior?.deleted, d),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readNumstat(cwd, { runner, range }) {
|
|
161
|
+
const counts = new Map();
|
|
162
|
+
if (range) {
|
|
163
|
+
const res = runGit(cwd, ['diff', '--numstat', range, '--'], runner);
|
|
164
|
+
if (res.error) return { error: res.error };
|
|
165
|
+
mergeNumstat(counts, res.stdout);
|
|
166
|
+
return { counts };
|
|
167
|
+
}
|
|
168
|
+
const unstaged = runGit(cwd, ['diff', '--numstat', '--'], runner);
|
|
169
|
+
if (unstaged.error) return { error: unstaged.error };
|
|
170
|
+
mergeNumstat(counts, unstaged.stdout);
|
|
171
|
+
const staged = runGit(cwd, ['diff', '--cached', '--numstat', '--'], runner);
|
|
172
|
+
if (staged.error) return { error: staged.error };
|
|
173
|
+
mergeNumstat(counts, staged.stdout);
|
|
174
|
+
return { counts };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The change inventory for a directory.
|
|
179
|
+
*
|
|
180
|
+
* @param {string} cwd Repository root to ask about.
|
|
181
|
+
* @param {object} [options]
|
|
182
|
+
* @param {Function} [options.runner] Injectable git runner, for tests.
|
|
183
|
+
* @param {string|null} [options.range] Diff a base revision instead of the working tree.
|
|
184
|
+
* @param {string} [options.relativeTo] Directory the `link` fields are made relative to.
|
|
185
|
+
* @param {boolean} [options.includeCadet] Keep `.cadet/` bookkeeping in the list.
|
|
186
|
+
* @returns {{available: boolean, files: Array, counts: object, reason: string|null}}
|
|
187
|
+
*/
|
|
188
|
+
export function gitChangeSet(cwd, {
|
|
189
|
+
runner = defaultGitRunner,
|
|
190
|
+
range = null,
|
|
191
|
+
relativeTo = DEFAULT_REPORT_DIR,
|
|
192
|
+
includeCadet = false,
|
|
193
|
+
} = {}) {
|
|
194
|
+
const empty = { added: 0, modified: 0, deleted: 0, renamed: 0 };
|
|
195
|
+
|
|
196
|
+
const named = readNameStatus(cwd, { runner, range });
|
|
197
|
+
if (named.error) return { available: false, files: [], counts: empty, reason: named.error };
|
|
198
|
+
|
|
199
|
+
const counted = readNumstat(cwd, { runner, range });
|
|
200
|
+
if (counted.error) return { available: false, files: [], counts: empty, reason: counted.error };
|
|
201
|
+
|
|
202
|
+
// Links are relative to the report, not the repository root: a report at
|
|
203
|
+
// `.cadet/reports/x.md` must point at `../../Assets/Foo.cs` or the click does
|
|
204
|
+
// nothing. Computing it here is the whole reason the link is not hand-written.
|
|
205
|
+
const baseDir = isAbsolute(relativeTo) ? relativeTo : join(cwd, relativeTo);
|
|
206
|
+
|
|
207
|
+
const seen = new Map();
|
|
208
|
+
for (const file of named.files) {
|
|
209
|
+
if (!includeCadet && isCadetMachinery(file.path)) continue;
|
|
210
|
+
if (seen.has(file.path)) continue;
|
|
211
|
+
const count = counted.counts.get(file.path);
|
|
212
|
+
const linkTarget = relative(baseDir, join(cwd, file.path)).replace(/\\/g, '/');
|
|
213
|
+
seen.set(file.path, {
|
|
214
|
+
path: file.path,
|
|
215
|
+
status: file.status,
|
|
216
|
+
// Untracked files have no diff, so no count exists. Null, not zero: a
|
|
217
|
+
// zero would read as "changed nothing", which is a different claim.
|
|
218
|
+
added: count?.added ?? null,
|
|
219
|
+
deleted: count?.deleted ?? null,
|
|
220
|
+
link: `[${file.path}](${linkTarget})`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const files = [...seen.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
225
|
+
const counts = { ...empty };
|
|
226
|
+
for (const f of files) {
|
|
227
|
+
if (f.status === 'A') counts.added += 1;
|
|
228
|
+
else if (f.status === 'D') counts.deleted += 1;
|
|
229
|
+
else if (f.status === 'R') counts.renamed += 1;
|
|
230
|
+
else counts.modified += 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return { available: true, files, counts, reason: null };
|
|
234
|
+
}
|
package/src/harness/commands.mjs
CHANGED
|
@@ -123,6 +123,20 @@ export const COMMANDS = {
|
|
|
123
123
|
mutates: false,
|
|
124
124
|
summary: 'Summarize budget consumption and failures.',
|
|
125
125
|
},
|
|
126
|
+
'harness changes': {
|
|
127
|
+
mutates: false,
|
|
128
|
+
summary: 'List the files a story changed, with status, line counts, and links.',
|
|
129
|
+
// Read-only by construction: it runs `git status`/`git diff` and parses the
|
|
130
|
+
// output. It does not author the Change Report — the agent does, from the
|
|
131
|
+
// template — so there is no artifact for it to write and nothing to dry-run.
|
|
132
|
+
},
|
|
133
|
+
'harness reconcile': {
|
|
134
|
+
mutates: false,
|
|
135
|
+
summary: 'Reconcile the planning chain (requirements, design, plan, epics, stories) against state.json.',
|
|
136
|
+
// Read-only, and deliberately so: it reports the provable inconsistencies and
|
|
137
|
+
// never repairs one. An agent that could reconcile artifacts unattended could
|
|
138
|
+
// rewrite the design it is meant to be checking against.
|
|
139
|
+
},
|
|
126
140
|
'harness matrix-check': {
|
|
127
141
|
mutates: false,
|
|
128
142
|
summary: 'Reconcile a TDD matrix against a compiled test inventory.',
|
package/src/harness/index.mjs
CHANGED
|
@@ -23,6 +23,13 @@ export {
|
|
|
23
23
|
newId, isUuid, sha256, sha256Bytes, hashFile, hashTree, hashCriteria, timestamp, canonicalJson, changedFiles, gitChangedFiles,
|
|
24
24
|
} from './util.mjs';
|
|
25
25
|
|
|
26
|
+
export { DEFAULT_REPORT_DIR, gitChangeSet } from './changes.mjs';
|
|
27
|
+
|
|
28
|
+
export {
|
|
29
|
+
PLANS_DEFAULT_DIR, REQUIRED_ARTIFACTS, RECONCILE_SEVERITIES, RECONCILE_VERDICTS,
|
|
30
|
+
DEFAULT_MAX_DOC_BYTES, parseStoryHeader, parseEpicHeader, collectArtifacts, reconcileArtifacts,
|
|
31
|
+
} from './reconcile.mjs';
|
|
32
|
+
|
|
26
33
|
export {
|
|
27
34
|
STATE_VERSION, READABLE_STATE_VERSIONS, HISTORY_EXTERNAL_SINCE, isHistoryExternal,
|
|
28
35
|
validateState, migrateStateV1toV2, migrateStateDocument, migrateStateFile, parseTargetVersion,
|
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Artifact reconciliation — does the planning chain still agree with itself?
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: every per-story check can pass while the chain as a whole
|
|
5
|
+
* stops making sense. A story gets renamed and its neighbours still point at the
|
|
6
|
+
* old file; a story is marked done in state while its markdown still says
|
|
7
|
+
* planned; a deferral names a work item that finished three stories ago; an epic
|
|
8
|
+
* directory exists that no plan mentions. Each is a claim in one artifact that
|
|
9
|
+
* another artifact contradicts, and nothing looked at more than one document at a
|
|
10
|
+
* time — `designArtifactSyncConfirmed` ("Requirements, design, plan, epics
|
|
11
|
+
* mutually consistent") is the one gate on `validation -> closed` and, before
|
|
12
|
+
* this module, nothing in the codebase could back it.
|
|
13
|
+
*
|
|
14
|
+
* Scope. This module reads the planning tree and reports what it can *prove*
|
|
15
|
+
* from the artifacts themselves. It does not and cannot judge whether a design
|
|
16
|
+
* decision is still honoured, whether two requirements contradict each other, or
|
|
17
|
+
* whether the project drifted from its intent — that is the Reconciliation
|
|
18
|
+
* skill's semantic pass. The split is deliberate: a check that cannot be
|
|
19
|
+
* mechanised must not be dressed up as one, because a prose assertion that
|
|
20
|
+
* nothing verifies is the exact shape `docs/core/HarnessContract-v4.md` §0.1
|
|
21
|
+
* names as the anti-pattern.
|
|
22
|
+
*
|
|
23
|
+
* Gaps are reported for work that is still OPEN, not for history. A field added
|
|
24
|
+
* to a template in one release is not retroactively owed by every document
|
|
25
|
+
* written before it, and a check that says so fires on a correct project — which
|
|
26
|
+
* is how a report teaches its reader to ignore it. That is not a theory: the
|
|
27
|
+
* first version of this module was run against a real 88-story project and
|
|
28
|
+
* produced 15 blocking findings for epics that merely lived one directory deeper,
|
|
29
|
+
* two more for documents that existed under other names, and ~120 warnings for
|
|
30
|
+
* fields that predated the templates. So: a reachability declaration is owed by a
|
|
31
|
+
* story in flight (it is written during implementation), a witness checkpoint by
|
|
32
|
+
* an epic that is not closed, and a `done` story's evidence ALWAYS — a completion
|
|
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.
|
|
35
|
+
*
|
|
36
|
+
* Discovery is by content, not by path: an epic is a directory containing
|
|
37
|
+
* `epic.md` wherever it sits, and a required document is matched by filename
|
|
38
|
+
* pattern wherever it sits, because a real project nests its artifacts under a
|
|
39
|
+
* named project folder and calls its requirements `mvp-requirements.md`.
|
|
40
|
+
*
|
|
41
|
+
* Read-only. It writes nothing: no state, no ledger, no report. The skill authors
|
|
42
|
+
* the report from this verdict.
|
|
43
|
+
*
|
|
44
|
+
* The honesty rule. `verdict` is `unknown` whenever any artifact or required
|
|
45
|
+
* field could not be read, because a clean verdict must never be reachable from
|
|
46
|
+
* input the module could not parse — "an unparseable report proves nothing"
|
|
47
|
+
* (`src/cli.mjs` verify-acs).
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
51
|
+
import { basename, isAbsolute, join, relative } from 'node:path';
|
|
52
|
+
|
|
53
|
+
import { PHASES } from './policy.mjs';
|
|
54
|
+
import {
|
|
55
|
+
collectWorkItems,
|
|
56
|
+
parseReachabilityDeclaration,
|
|
57
|
+
validateReachabilityDeclaration,
|
|
58
|
+
} from './reachability.mjs';
|
|
59
|
+
|
|
60
|
+
/** Where the framework puts planning artifacts when a policy does not relocate them. */
|
|
61
|
+
export const PLANS_DEFAULT_DIR = '.cadet/agent/project-plans';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The documents a large change is expected to produce, matched by filename
|
|
65
|
+
* PATTERN rather than by a fixed path.
|
|
66
|
+
*
|
|
67
|
+
* The fixed-path version of this check was wrong in practice. A real project
|
|
68
|
+
* (`dolven-tactics`) keeps its artifacts under a named project folder with its
|
|
69
|
+
* own document names — `.cadet/agent/project-plans/dolven-tactics-mvp/
|
|
70
|
+
* mvp-requirements.md` — so looking for `requirements.md` at the plans root
|
|
71
|
+
* reported two blocking findings against a project that had both documents. A
|
|
72
|
+
* check that fires on a correct project is worse than no check: it teaches the
|
|
73
|
+
* reader to ignore the output. Discovery is bounded and the path that satisfied
|
|
74
|
+
* each one is reported, so the reader can see which file counted.
|
|
75
|
+
*
|
|
76
|
+
* `project-plan.md` is advisory rather than required: no skill in the dispatch
|
|
77
|
+
* table produces one (Requirements, Architecture and StoryBreakdown cover the
|
|
78
|
+
* others), so demanding it would report a gap for a document the workflow never
|
|
79
|
+
* asked for.
|
|
80
|
+
*/
|
|
81
|
+
export const REQUIRED_ARTIFACTS = [
|
|
82
|
+
{ name: 'requirements', pattern: /requirements[^/\\]*\.md$/i, fromPhase: 'architectureComplete', severity: 'blocking' },
|
|
83
|
+
{ name: 'technical-design', pattern: /technical-design[^/\\]*\.md$/i, fromPhase: 'story-breakdown', severity: 'blocking' },
|
|
84
|
+
{ name: 'project-plan', pattern: /project-plan[^/\\]*\.md$/i, fromPhase: null, severity: 'info' },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
export const RECONCILE_SEVERITIES = ['blocking', 'warning', 'info'];
|
|
88
|
+
export const RECONCILE_VERDICTS = ['consistent', 'findings', 'unknown'];
|
|
89
|
+
|
|
90
|
+
/** A planning document larger than this is not read; the bound keeps a runaway file from stalling a run. */
|
|
91
|
+
export const DEFAULT_MAX_DOC_BYTES = 256 * 1024;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How deep the planning tree is walked. Real layouts nest: a project folder, then
|
|
95
|
+
* `epics/`, then `epic-N/`. Three levels is not enough to assume, so the walk is
|
|
96
|
+
* bounded rather than shallow — and bounded rather than unbounded so a symlink
|
|
97
|
+
* loop or a stray `node_modules` cannot turn a read into a crawl.
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_SCAN_DEPTH = 5;
|
|
100
|
+
|
|
101
|
+
/** Directories never worth scanning in a game repo. */
|
|
102
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'Library', 'obj', 'Temp', 'Logs', 'Build', 'UserSettings']);
|
|
103
|
+
|
|
104
|
+
const SEVERITY_RANK = { blocking: 0, warning: 1, info: 2 };
|
|
105
|
+
|
|
106
|
+
function toPosix(p) {
|
|
107
|
+
return String(p).replace(/\\/g, '/');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function repoRelative(targetDir, abs) {
|
|
111
|
+
const rel = toPosix(relative(targetDir, abs));
|
|
112
|
+
return rel || '.';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 1-based line of the first match, for citing a finding back to its source. */
|
|
116
|
+
function lineOf(text, pattern) {
|
|
117
|
+
const lines = text.split(/\r?\n/);
|
|
118
|
+
for (let i = 0; i < lines.length; i++) {
|
|
119
|
+
if (pattern.test(lines[i])) return i + 1;
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The body of a `## Heading` section, up to the next `## ` heading. */
|
|
125
|
+
function sectionBody(text, heading) {
|
|
126
|
+
const lines = text.split(/\r?\n/);
|
|
127
|
+
const wanted = heading.toLowerCase();
|
|
128
|
+
let start = -1;
|
|
129
|
+
for (let i = 0; i < lines.length; i++) {
|
|
130
|
+
const m = lines[i].match(/^##\s+(.+?)\s*$/);
|
|
131
|
+
if (m && m[1].toLowerCase() === wanted) { start = i + 1; break; }
|
|
132
|
+
}
|
|
133
|
+
if (start === -1) return null;
|
|
134
|
+
const out = [];
|
|
135
|
+
for (let i = start; i < lines.length; i++) {
|
|
136
|
+
if (/^##\s+/.test(lines[i])) break;
|
|
137
|
+
out.push(lines[i]);
|
|
138
|
+
}
|
|
139
|
+
return out.join('\n').trim();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The path a link field points at. The templates allow either a bare path or a
|
|
144
|
+
* markdown link (`[text](../epic.md)`), and a consumer may write either, so both
|
|
145
|
+
* are accepted rather than one being silently unresolvable.
|
|
146
|
+
*/
|
|
147
|
+
function linkTarget(value) {
|
|
148
|
+
if (!value) return null;
|
|
149
|
+
const md = String(value).match(/\]\(([^)]+)\)/);
|
|
150
|
+
const raw = (md ? md[1] : String(value)).trim().replace(/^<|>$/g, '');
|
|
151
|
+
if (!raw || /^(none|n\/a|todo|tbd)$/i.test(raw)) return null;
|
|
152
|
+
return raw;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Every path-like candidate in a link field.
|
|
157
|
+
*
|
|
158
|
+
* The template's `fmt="link"` promises one path, but a real epic wrote three
|
|
159
|
+
* separated by `·` with parenthetical annotations — `a.md (note) · b.md (note)`
|
|
160
|
+
* — and treating that whole string as one filename reported a perfectly good
|
|
161
|
+
* link as dangling. A link field is satisfied when ANY candidate resolves.
|
|
162
|
+
*/
|
|
163
|
+
function linkCandidates(value) {
|
|
164
|
+
if (!value) return [];
|
|
165
|
+
const text = String(value);
|
|
166
|
+
const out = [];
|
|
167
|
+
for (const m of text.matchAll(/\]\(([^)]+)\)/g)) out.push(m[1]);
|
|
168
|
+
const withoutMarkdown = text.replace(/\[[^\]]*\]\([^)]*\)/g, ' · ');
|
|
169
|
+
for (const part of withoutMarkdown.split(/[·,;|]/)) {
|
|
170
|
+
const token = part.replace(/\([^)]*\)/g, ' ').trim().split(/\s+/)[0];
|
|
171
|
+
if (token && /\.md$/i.test(token)) out.push(token);
|
|
172
|
+
}
|
|
173
|
+
return [...new Set(out.map((s) => s.trim().replace(/^<|>$/g, '')))]
|
|
174
|
+
.filter((s) => s && !/^(none|n\/a|todo|tbd)$/i.test(s));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Does a story's declared link resolve?
|
|
179
|
+
*
|
|
180
|
+
* The story template writes `Parent Epic: ../epic.md`, while the documented
|
|
181
|
+
* layout puts `epic.md` in the *same* directory as its stories (see
|
|
182
|
+
* `docs/templates/EpicTemplate.md`'s directory listing, and
|
|
183
|
+
* `reachability.readSiblingDeclarations`, which finds siblings in that same
|
|
184
|
+
* directory). Both readings are accepted here, so neither convention is reported
|
|
185
|
+
* as broken — but a wrong *filename* still is, which is the case worth catching.
|
|
186
|
+
*/
|
|
187
|
+
function resolvesStoryLink(storyPath, target) {
|
|
188
|
+
if (isAbsolute(target)) return existsSync(target);
|
|
189
|
+
if (existsSync(join(storyPath, '..', target))) return true;
|
|
190
|
+
return existsSync(join(storyPath, '..', basename(target)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function readBounded(path, maxBytes) {
|
|
194
|
+
try {
|
|
195
|
+
const stat = statSync(path);
|
|
196
|
+
if (stat.size > maxBytes) return { error: `file is ${stat.size} bytes, above the ${maxBytes}-byte read bound` };
|
|
197
|
+
return { text: readFileSync(path, 'utf-8') };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
return { error: `cannot read: ${err.message}` };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The fields a story must carry for the structural checks to run at all. A field
|
|
205
|
+
* that cannot be read is reported, never defaulted away: a missing `Status` must
|
|
206
|
+
* not silently read as "no mismatch".
|
|
207
|
+
*/
|
|
208
|
+
export function parseStoryHeader(text) {
|
|
209
|
+
const field = (label) => {
|
|
210
|
+
const m = text.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'));
|
|
211
|
+
return m ? m[1].trim() : null;
|
|
212
|
+
};
|
|
213
|
+
return {
|
|
214
|
+
storyId: (text.match(/^(EPIC-\d+-STORY-\d+)\s*$/m) || [])[1] ?? null,
|
|
215
|
+
status: field('Status') || null,
|
|
216
|
+
parentEpic: field('Parent Epic') || null,
|
|
217
|
+
reachability: field('Reachability') || null,
|
|
218
|
+
designRefs: field('Design refs') || null,
|
|
219
|
+
statusLine: lineOf(text, /^Status:/),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function parseEpicHeader(text) {
|
|
224
|
+
const field = (label) => {
|
|
225
|
+
const m = text.match(new RegExp(`^${label}:[ \\t]*(.*)$`, 'm'));
|
|
226
|
+
return m ? m[1].trim() : null;
|
|
227
|
+
};
|
|
228
|
+
const stories = sectionBody(text, 'Stories');
|
|
229
|
+
return {
|
|
230
|
+
epicId: (text.match(/^(EPIC-\d+)\s*$/m) || [])[1] ?? null,
|
|
231
|
+
status: field('Status') || null,
|
|
232
|
+
requirementsLinks: linkCandidates(field('Requirements')),
|
|
233
|
+
technicalDesignLinks: linkCandidates(field('Technical Design')),
|
|
234
|
+
witnessCheckpoint: sectionBody(text, 'Witness checkpoint'),
|
|
235
|
+
declaredStoryCount: stories
|
|
236
|
+
? (stories.split(/\r?\n/).filter((l) => /^\s*-\s+\[/.test(l)).length || null)
|
|
237
|
+
: null,
|
|
238
|
+
statusLine: lineOf(text, /^Status:/),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Every directory under `root`, breadth-first and depth-bounded. Skipping the
|
|
244
|
+
* usual game-repo ballast keeps a walk of a real project cheap.
|
|
245
|
+
*/
|
|
246
|
+
function walkDirs(root, maxDepth = MAX_SCAN_DEPTH) {
|
|
247
|
+
const found = [];
|
|
248
|
+
let level = [root];
|
|
249
|
+
for (let depth = 0; depth <= maxDepth && level.length > 0; depth++) {
|
|
250
|
+
const next = [];
|
|
251
|
+
for (const dir of level) {
|
|
252
|
+
found.push(dir);
|
|
253
|
+
let entries;
|
|
254
|
+
try {
|
|
255
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
256
|
+
} catch { continue; }
|
|
257
|
+
for (const entry of entries) {
|
|
258
|
+
if (!entry.isDirectory()) continue;
|
|
259
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
260
|
+
next.push(join(dir, entry.name));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
level = next;
|
|
264
|
+
}
|
|
265
|
+
return found;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The first file under `root` whose name matches, or null. */
|
|
269
|
+
function findDoc(root, pattern) {
|
|
270
|
+
for (const dir of walkDirs(root)) {
|
|
271
|
+
let entries;
|
|
272
|
+
try {
|
|
273
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
274
|
+
} catch { continue; }
|
|
275
|
+
for (const entry of entries) {
|
|
276
|
+
if (!entry.isFile() || !pattern.test(entry.name)) continue;
|
|
277
|
+
return join(dir, entry.name);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Walk the planning tree. Returns what exists and what could be read — nothing is
|
|
285
|
+
* interpreted here, so a caller can report an unreadable tree as such.
|
|
286
|
+
*
|
|
287
|
+
* Both the epics and the documents are DISCOVERED rather than assumed to sit at a
|
|
288
|
+
* fixed depth. Real layouts nest (`<project>/epics/epic-N/`) and name their own
|
|
289
|
+
* documents, and a reconciler that assumes the packaged template's layout reports
|
|
290
|
+
* a correct project as broken.
|
|
291
|
+
*/
|
|
292
|
+
export function collectArtifacts(targetDir, {
|
|
293
|
+
plansDir = PLANS_DEFAULT_DIR,
|
|
294
|
+
story = null,
|
|
295
|
+
maxBytes = DEFAULT_MAX_DOC_BYTES,
|
|
296
|
+
} = {}) {
|
|
297
|
+
const root = isAbsolute(plansDir) ? plansDir : join(targetDir, plansDir);
|
|
298
|
+
if (!existsSync(root)) {
|
|
299
|
+
return { available: false, reason: `no planning artifacts at ${toPosix(plansDir)}`, root, docs: [], epics: [], scopedEpic: null };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const dirs = walkDirs(root);
|
|
303
|
+
|
|
304
|
+
const docs = REQUIRED_ARTIFACTS.map(({ name, pattern }) => {
|
|
305
|
+
const path = findDoc(root, pattern);
|
|
306
|
+
return { name, path, relPath: path ? repoRelative(targetDir, path) : null, present: path !== null };
|
|
307
|
+
});
|
|
308
|
+
|
|
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
|
+
}
|
|
317
|
+
|
|
318
|
+
const epics = [];
|
|
319
|
+
for (const dir of dirs) {
|
|
320
|
+
const name = basename(dir);
|
|
321
|
+
const epicFile = join(dir, 'epic.md');
|
|
322
|
+
// An epic is identified by content, not by its directory name: a consumer may
|
|
323
|
+
// name the folder anything, and `adr/`, `spikes/` and `evidence/` live in the
|
|
324
|
+
// same tree — at whatever depth the project chose.
|
|
325
|
+
if (!existsSync(epicFile)) continue;
|
|
326
|
+
if (scopedEpic && name !== scopedEpic) continue;
|
|
327
|
+
|
|
328
|
+
const epicRead = readBounded(epicFile, maxBytes);
|
|
329
|
+
let storyFiles = [];
|
|
330
|
+
try {
|
|
331
|
+
storyFiles = readdirSync(dir)
|
|
332
|
+
.filter((f) => /^story-.*\.md$/i.test(f))
|
|
333
|
+
.sort();
|
|
334
|
+
} catch { storyFiles = []; }
|
|
335
|
+
|
|
336
|
+
const stories = storyFiles.map((file) => {
|
|
337
|
+
const path = join(dir, file);
|
|
338
|
+
const read = readBounded(path, maxBytes);
|
|
339
|
+
return { file, path, relPath: repoRelative(targetDir, path), ...read };
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
epics.push({
|
|
343
|
+
// `key` is the directory name, which is what state.json and work-item ids
|
|
344
|
+
// use (`epic-1-foo::story-2.md`). `dir` is the repo-relative path, for
|
|
345
|
+
// display only — conflating the two makes every lookup silently miss.
|
|
346
|
+
key: name,
|
|
347
|
+
dir: repoRelative(targetDir, dir),
|
|
348
|
+
dirPath: dir,
|
|
349
|
+
epicFile: { path: epicFile, relPath: repoRelative(targetDir, epicFile), ...epicRead },
|
|
350
|
+
stories,
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
epics.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0));
|
|
355
|
+
return { available: true, reason: null, root, docs, epics, scopedEpic };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Reconcile the planning tree against `state.json`.
|
|
360
|
+
*
|
|
361
|
+
* @returns {{ok: boolean, available: boolean, plansDir: string, verdict: string|null,
|
|
362
|
+
* findings: Array, summary: object, reason: string|null}}
|
|
363
|
+
*/
|
|
364
|
+
export function reconcileArtifacts(targetDir, {
|
|
365
|
+
state = null,
|
|
366
|
+
plansDir = PLANS_DEFAULT_DIR,
|
|
367
|
+
story = null,
|
|
368
|
+
maxBytes = DEFAULT_MAX_DOC_BYTES,
|
|
369
|
+
} = {}) {
|
|
370
|
+
const collected = collectArtifacts(targetDir, { plansDir, story, maxBytes });
|
|
371
|
+
const plansDirRel = toPosix(plansDir);
|
|
372
|
+
|
|
373
|
+
if (!collected.available) {
|
|
374
|
+
return {
|
|
375
|
+
ok: true, available: false, plansDir: plansDirRel, verdict: null,
|
|
376
|
+
findings: [], summary: { total: 0, blocking: 0, warning: 0, info: 0 },
|
|
377
|
+
reason: collected.reason,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const findings = [];
|
|
382
|
+
const add = (code, severity, subject, artifact, detail, evidence = null) => {
|
|
383
|
+
findings.push({ code, severity, subject, artifact, detail, evidence });
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const stateEpics = state?.epics && typeof state.epics === 'object' ? state.epics : {};
|
|
387
|
+
const phase = state?.session?.currentPhase ?? null;
|
|
388
|
+
const workflowPath = state?.session?.workflowPath ?? null;
|
|
389
|
+
const phaseIndex = PHASES.indexOf(phase);
|
|
390
|
+
|
|
391
|
+
// ── 1. Required top-level documents ───────────────────────────────────────
|
|
392
|
+
for (const { name, fromPhase, severity } of REQUIRED_ARTIFACTS) {
|
|
393
|
+
const doc = collected.docs.find((d) => d.name === name);
|
|
394
|
+
if (!doc || doc.present) continue;
|
|
395
|
+
// Only expect a document once the workflow has reached the phase that
|
|
396
|
+
// produces it, so an early-phase run does not report the future as a gap.
|
|
397
|
+
const expected = fromPhase === null
|
|
398
|
+
? workflowPath === 'large'
|
|
399
|
+
: phaseIndex >= 0 && phaseIndex >= PHASES.indexOf(fromPhase);
|
|
400
|
+
if (!expected) continue;
|
|
401
|
+
add('missing-artifact', severity, `${name}.md`, `${plansDirRel}/**`,
|
|
402
|
+
fromPhase === null
|
|
403
|
+
? 'no project plan was found anywhere under the plans directory. No skill in the dispatch produces one, so this is advisory — but a large change is expected to have one.'
|
|
404
|
+
: `no ${name} document was found anywhere under the plans directory, though the workflow reached \`${phase}\`. The chain has no root to reconcile against.`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ── 2. Epics: state vs disk, both directions ──────────────────────────────
|
|
408
|
+
const onDisk = new Set(collected.epics.map((e) => e.key));
|
|
409
|
+
for (const epicDir of Object.keys(stateEpics).sort()) {
|
|
410
|
+
if (!onDisk.has(epicDir)) {
|
|
411
|
+
add('missing-epic-dir', 'blocking', epicDir, `${plansDirRel}/${epicDir}`,
|
|
412
|
+
'state.json tracks this epic, but no `epic.md` exists for it on disk. Every story under it is unreachable as an artifact.');
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
for (const epic of collected.epics) {
|
|
417
|
+
const stateEpic = stateEpics[epic.key];
|
|
418
|
+
|
|
419
|
+
if (!stateEpic) {
|
|
420
|
+
add('orphan-epic-dir', 'warning', epic.key, epic.epicFile.relPath,
|
|
421
|
+
'this epic exists on disk but state.json does not track it. Nothing will ever mark its stories done.');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (epic.epicFile.error) {
|
|
425
|
+
add('unparsable-artifact', 'warning', epic.key, epic.epicFile.relPath,
|
|
426
|
+
`the epic could not be read (${epic.epicFile.error}), so its status and links were not checked.`);
|
|
427
|
+
} else {
|
|
428
|
+
const header = parseEpicHeader(epic.epicFile.text);
|
|
429
|
+
if (!header.status) {
|
|
430
|
+
add('unparsable-artifact', 'warning', epic.key, epic.epicFile.relPath,
|
|
431
|
+
'the epic has no readable `Status:` field, so its status was not reconciled.');
|
|
432
|
+
}
|
|
433
|
+
if (header.epicId === null) {
|
|
434
|
+
add('unparsable-artifact', 'warning', epic.key, epic.epicFile.relPath,
|
|
435
|
+
'the epic declares no `EPIC-N` id, so its identity was not checked.');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Epic -> requirements / design links must resolve. A field may carry more
|
|
439
|
+
// than one link, and it is satisfied when any one of them resolves.
|
|
440
|
+
for (const [label, targets] of [['Requirements', header.requirementsLinks], ['Technical Design', header.technicalDesignLinks]]) {
|
|
441
|
+
if (targets.length === 0) continue;
|
|
442
|
+
const resolved = targets.some((t) => existsSync(isAbsolute(t) ? t : join(epic.dirPath, t)));
|
|
443
|
+
if (!resolved) {
|
|
444
|
+
add('dangling-epic-link', 'warning', epic.key, epic.epicFile.relPath,
|
|
445
|
+
`the epic's \`${label}\` points at ${targets.map((t) => `\`${t}\``).join(', ')}, and none of them exist. The chain cannot be followed past this epic.`,
|
|
446
|
+
`line ${lineOf(epic.epicFile.text, new RegExp(`^${label}:`)) ?? '?'}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const epicStatus = String(stateEpic?.status ?? '').toLowerCase();
|
|
451
|
+
const epicClosed = epicStatus === 'complete' || epicStatus === 'done';
|
|
452
|
+
if (!header.witnessCheckpoint && !epicClosed) {
|
|
453
|
+
add('missing-witness-checkpoint', 'warning', epic.key, epic.epicFile.relPath,
|
|
454
|
+
'the epic declares no Witness checkpoint. The template marks it REQUIRED: without it, nothing states which story first makes the epic reachable.');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (epic.stories.length === 0) {
|
|
459
|
+
add('epic-without-stories', 'warning', epic.key, epic.epicFile.relPath,
|
|
460
|
+
'this epic directory contains no `story-*.md` files. An epic with no stories delivers nothing and cannot be reviewed.');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const stateStories = stateEpic?.stories && typeof stateEpic.stories === 'object' ? stateEpic.stories : {};
|
|
464
|
+
const diskStories = new Set(epic.stories.map((s) => s.file));
|
|
465
|
+
|
|
466
|
+
for (const file of Object.keys(stateStories).sort()) {
|
|
467
|
+
if (!diskStories.has(file)) {
|
|
468
|
+
add('missing-story-file', 'blocking', `${epic.key}::${file}`, `${plansDirRel}/${epic.key}/${file}`,
|
|
469
|
+
'state.json tracks this story, but its markdown file is absent. Its acceptance criteria are no longer written down anywhere.');
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
for (const storyFile of epic.stories) {
|
|
473
|
+
if (!Object.hasOwn(stateStories, storyFile.file)) {
|
|
474
|
+
add('orphan-story-file', 'warning', `${epic.key}::${storyFile.file}`, storyFile.relPath,
|
|
475
|
+
'this story file is not tracked in state.json. It will never be marked done, and no gate refers to it.');
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// ── 3. Per-story checks ─────────────────────────────────────────────────
|
|
480
|
+
for (const storyFile of epic.stories) {
|
|
481
|
+
const subject = `${epic.key}::${storyFile.file}`;
|
|
482
|
+
|
|
483
|
+
if (storyFile.error) {
|
|
484
|
+
add('unparsable-artifact', 'warning', subject, storyFile.relPath,
|
|
485
|
+
`the story could not be read (${storyFile.error}), so none of its fields were reconciled.`);
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const header = parseStoryHeader(storyFile.text);
|
|
490
|
+
|
|
491
|
+
if (!header.status) {
|
|
492
|
+
add('unparsable-artifact', 'warning', subject, storyFile.relPath,
|
|
493
|
+
'the story has no readable `Status:` field, so it was not reconciled against state.json.');
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Story status vs state status.
|
|
497
|
+
const stateStatus = stateStories[storyFile.file];
|
|
498
|
+
const stateStatusLower = String(stateStatus).toLowerCase();
|
|
499
|
+
const isDone = stateStatusLower === 'done';
|
|
500
|
+
const isInFlight = stateStatusLower === 'in-progress';
|
|
501
|
+
if (header.status && stateStatus) {
|
|
502
|
+
const md = header.status.toLowerCase();
|
|
503
|
+
const st = String(stateStatus).toLowerCase();
|
|
504
|
+
const agree = md === st || (md === 'done' && st === 'done') || (md === 'in progress' && st === 'in-progress');
|
|
505
|
+
if (!agree) {
|
|
506
|
+
add('status-mismatch', 'warning', subject, storyFile.relPath,
|
|
507
|
+
`the story file says \`${header.status}\` while state.json says \`${stateStatus}\`. One of the two is the record and the other is stale, and a reader cannot tell which.`,
|
|
508
|
+
`line ${header.statusLine ?? '?'}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Parent Epic link must resolve.
|
|
513
|
+
const parent = linkTarget(header.parentEpic);
|
|
514
|
+
if (parent) {
|
|
515
|
+
if (!resolvesStoryLink(storyFile.path, parent)) {
|
|
516
|
+
add('dangling-parent-epic', 'blocking', subject, storyFile.relPath,
|
|
517
|
+
`\`Parent Epic: ${header.parentEpic}\` does not resolve. The story has no epic, so nothing owns its completion.`,
|
|
518
|
+
`line ${lineOf(storyFile.text, /^Parent Epic:/) ?? '?'}`);
|
|
519
|
+
}
|
|
520
|
+
} else {
|
|
521
|
+
add('unparsable-artifact', 'warning', subject, storyFile.relPath,
|
|
522
|
+
'the story declares no readable `Parent Epic:`, so its place in the chain was not checked.');
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Reachability: reuse the shipped validator so reconcile and
|
|
526
|
+
// verify-reachability cannot disagree about the same declaration.
|
|
527
|
+
const declaration = parseReachabilityDeclaration(storyFile.path);
|
|
528
|
+
const workItems = state ? collectWorkItems(state) : null;
|
|
529
|
+
const verdict = validateReachabilityDeclaration(declaration, { workItems, self: subject });
|
|
530
|
+
if (!verdict.ok) {
|
|
531
|
+
if (verdict.code === 'malformed') {
|
|
532
|
+
add('unparsable-artifact', 'warning', subject, storyFile.relPath,
|
|
533
|
+
`the reachability declaration could not be parsed: ${verdict.message}`);
|
|
534
|
+
} else if (verdict.code === 'not-declared') {
|
|
535
|
+
// The declaration is written DURING implementation, so only a story in
|
|
536
|
+
// flight owes one. A story that has not started has nothing truthful to
|
|
537
|
+
// declare yet, and a story that closed before the field existed is
|
|
538
|
+
// history — reporting either buries the findings that are real.
|
|
539
|
+
if (isInFlight) {
|
|
540
|
+
add('missing-reachability', 'warning', subject, storyFile.relPath,
|
|
541
|
+
'this story is in progress but declares no reachability. The declaration is required before it can pass review, and nothing yet states how its deliverable is reached.');
|
|
542
|
+
}
|
|
543
|
+
} else if (verdict.code === 'deferral-target-done') {
|
|
544
|
+
add('expired-deferral', 'blocking', subject, storyFile.relPath, verdict.message);
|
|
545
|
+
} else {
|
|
546
|
+
add('unresolved-deferral', 'warning', subject, storyFile.relPath, verdict.message);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// A story state calls done must own evidence, or "done" is a claim with
|
|
551
|
+
// nothing behind it.
|
|
552
|
+
if (isDone) {
|
|
553
|
+
const row = state?.evidenceCoverage?.[subject]
|
|
554
|
+
?? Object.values(state?.evidenceCoverage ?? {}).find((r) => r?.workItemId === subject);
|
|
555
|
+
const count = Number(row?.recordCount ?? 0);
|
|
556
|
+
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.');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ── 4. Sort, number, and summarise ────────────────────────────────────────
|
|
565
|
+
findings.sort((a, b) => {
|
|
566
|
+
const s = (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9);
|
|
567
|
+
if (s !== 0) return s;
|
|
568
|
+
if (a.code !== b.code) return a.code < b.code ? -1 : 1;
|
|
569
|
+
return a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0;
|
|
570
|
+
});
|
|
571
|
+
findings.forEach((f, i) => { f.id = `R-${i + 1}`; });
|
|
572
|
+
|
|
573
|
+
const summary = {
|
|
574
|
+
total: findings.length,
|
|
575
|
+
blocking: findings.filter((f) => f.severity === 'blocking').length,
|
|
576
|
+
warning: findings.filter((f) => f.severity === 'warning').length,
|
|
577
|
+
info: findings.filter((f) => f.severity === 'info').length,
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// An unreadable input cannot yield a clean verdict, whatever else was found.
|
|
581
|
+
//
|
|
582
|
+
// Only blocking and warning findings make the chain inconsistent. An `info`
|
|
583
|
+
// finding is advisory — the project-plan check is one — and folding it into the
|
|
584
|
+
// verdict would mean no project could ever be called consistent without a
|
|
585
|
+
// document no skill produces, which would make the verdict useless rather than
|
|
586
|
+
// strict.
|
|
587
|
+
const anythingUnparsable = findings.some((f) => f.code === 'unparsable-artifact');
|
|
588
|
+
const inconsistent = summary.blocking > 0 || summary.warning > 0;
|
|
589
|
+
const verdict = anythingUnparsable ? 'unknown' : inconsistent ? 'findings' : 'consistent';
|
|
590
|
+
|
|
591
|
+
return {
|
|
592
|
+
ok: true,
|
|
593
|
+
available: true,
|
|
594
|
+
plansDir: plansDirRel,
|
|
595
|
+
scopedEpic: collected.scopedEpic,
|
|
596
|
+
verdict,
|
|
597
|
+
findings,
|
|
598
|
+
summary,
|
|
599
|
+
artifacts: {
|
|
600
|
+
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),
|
|
603
|
+
},
|
|
604
|
+
reason: null,
|
|
605
|
+
};
|
|
606
|
+
}
|
package/src/harness/state.mjs
CHANGED
|
@@ -1177,8 +1177,17 @@ export function isUngatedForwardEdge(fromPhase, toPhase) {
|
|
|
1177
1177
|
* must have been created at or after that instant. Without it, "fresh" would mean
|
|
1178
1178
|
* only "not yet expired", which lets a long phase carry evidence that predates
|
|
1179
1179
|
* the work it is meant to attest.
|
|
1180
|
+
*
|
|
1181
|
+
* `phaseScoped` is false for the strict-closure `revalidate` set. A revalidated
|
|
1182
|
+
* gate asks "is this still true *now*?" — answered by the input-tree hash, the
|
|
1183
|
+
* criteria hash, and the expiry — not "was it recorded in the phase I am leaving?".
|
|
1184
|
+
* Enforcing the phase stamp on a revalidated gate made the answer "no" for every
|
|
1185
|
+
* record written in an earlier phase, so the whole suite had to be re-recorded in
|
|
1186
|
+
* `review` and again in `validation` on a tree that had not changed by a byte.
|
|
1187
|
+
* Primary gates keep the phase scope: a record still has to be written in the
|
|
1188
|
+
* phase it belongs to.
|
|
1180
1189
|
*/
|
|
1181
|
-
function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null }) {
|
|
1190
|
+
function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase, rootDir, computeTreeHash, inputTreeHash, critHash, recencyFloor = null, phaseScoped = true }) {
|
|
1182
1191
|
const missingGates = [];
|
|
1183
1192
|
const staleEvidence = [];
|
|
1184
1193
|
|
|
@@ -1204,7 +1213,7 @@ function checkGate({ gate, state, gates, exceptions, now, workItemId, fromPhase,
|
|
|
1204
1213
|
const { fresh, reasons } = evidenceFreshness(evidence, {
|
|
1205
1214
|
now,
|
|
1206
1215
|
workItemId,
|
|
1207
|
-
phase: fromPhase,
|
|
1216
|
+
phase: phaseScoped ? fromPhase : null,
|
|
1208
1217
|
inputTreeHash: currentTreeHash,
|
|
1209
1218
|
criteriaHash: critHash,
|
|
1210
1219
|
});
|
|
@@ -1344,6 +1353,14 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1344
1353
|
}
|
|
1345
1354
|
|
|
1346
1355
|
// Strict closure: re-derive the earlier gates at this transition.
|
|
1356
|
+
//
|
|
1357
|
+
// A revalidated gate is checked WITHOUT the phase scope (`phaseScoped: false`).
|
|
1358
|
+
// Its question is "is this still true now?", which the input-tree hash, the
|
|
1359
|
+
// criteria hash, and the expiry answer; the phase stamp answers only "which
|
|
1360
|
+
// phase wrote it down", which is exactly the fact revalidation is not doubting.
|
|
1361
|
+
// With the phase scope on, every record written in an earlier phase was rejected
|
|
1362
|
+
// as stale, so an unchanged tree still forced the whole suite to be re-recorded
|
|
1363
|
+
// in `review` and again in `validation`. See checkGate.
|
|
1347
1364
|
const strict = resolveStrict(context);
|
|
1348
1365
|
const revalidated = [];
|
|
1349
1366
|
if (strict && strict.revalidateOnClosure !== false) {
|
|
@@ -1353,7 +1370,7 @@ export function evaluateTransition(state, toPhase, context = {}) {
|
|
|
1353
1370
|
for (const gate of spec.revalidate) {
|
|
1354
1371
|
if (spec.gates.includes(gate)) continue; // already checked as a primary gate
|
|
1355
1372
|
revalidated.push(gate);
|
|
1356
|
-
const r = checkGate({ ...shared, gate, recencyFloor });
|
|
1373
|
+
const r = checkGate({ ...shared, gate, recencyFloor, phaseScoped: false });
|
|
1357
1374
|
missingGates.push(...r.missingGates);
|
|
1358
1375
|
staleEvidence.push(...r.staleEvidence);
|
|
1359
1376
|
}
|