@sdd-method/author-cli 0.10.0 → 0.12.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.
Files changed (38) hide show
  1. package/dist/lib/behaviour-store/archive.js +13 -0
  2. package/dist/lib/catalogue/build.js +45 -2
  3. package/dist/lib/catalogue/builders/adr-governs.js +39 -0
  4. package/dist/lib/catalogue/builders/adrs.js +11 -0
  5. package/dist/lib/catalogue/builders/capability-spec.js +1 -0
  6. package/dist/lib/catalogue/builders/contract-revisions.js +126 -0
  7. package/dist/lib/catalogue/builders/data-model.js +316 -0
  8. package/dist/lib/catalogue/builders/entities-mermaid.js +18 -85
  9. package/dist/lib/catalogue/builders/feature-grounding.js +1 -0
  10. package/dist/lib/catalogue/builders/features.js +23 -2
  11. package/dist/lib/catalogue/builders/integration-components.js +49 -5
  12. package/dist/lib/catalogue/builders/journey-requirement.js +144 -0
  13. package/dist/lib/catalogue/builders/quality-scores.js +17 -0
  14. package/dist/lib/catalogue/builders/requirements.js +168 -32
  15. package/dist/lib/catalogue/canonical-schema.yaml +845 -28
  16. package/dist/lib/catalogue/manifest-loader.js +13 -0
  17. package/dist/lib/catalogue/retired-catalogues.js +25 -0
  18. package/dist/lib/catalogue/rubric-coverage-map.yaml +108 -2
  19. package/dist/lib/catalogue/schema-compat.js +1 -1
  20. package/dist/lib/catalogue/schema-loader.js +3 -0
  21. package/dist/lib/catalogue/staleness-verdicts.js +75 -6
  22. package/dist/lib/intents/migrate.js +35 -9
  23. package/dist/lib/metamodel/tql-subset-parser.js +35 -2
  24. package/dist/lib/structure/catalogue.js +183 -0
  25. package/dist/lib/structure/compose.js +174 -0
  26. package/dist/lib/structure/effects.js +36 -0
  27. package/dist/lib/structure/model.js +212 -0
  28. package/dist/lib/structure/render.js +242 -0
  29. package/dist/lib/structure/view.js +301 -0
  30. package/dist/lib/validate/agreement-evidence.js +216 -0
  31. package/dist/lib/validate/behaviour-store.js +516 -28
  32. package/dist/lib/validate/dispatch.js +21 -0
  33. package/dist/lib/validate/feature-spec-pointers.js +175 -0
  34. package/dist/lib/validate/journey-impact.js +193 -0
  35. package/dist/lib/validate/structural-model.js +265 -0
  36. package/dist/maintain-version.json +2 -2
  37. package/dist/verify-version.json +2 -2
  38. package/package.json +2 -2
@@ -8,18 +8,37 @@
8
8
  //
9
9
  // 1. records: every agreement record has the shape the checks below read.
10
10
  // 2. agreement: an open change with any completed task has an agreed record and a Gate 0 that
11
- // ends CLEAR (the pre-apply check, M-ADR 0223 §4.3).
12
- // 3. stale: for each open, agreed change that MODIFIES, REMOVES or RENAMES requirements, the
13
- // targeted living specs are unchanged since the agreed commit (M-ADR 0223 §4.5).
14
- // With no such targets the check is vacuous, and says so.
15
- // 4. verdicts: every archived change except a baseline has a verdict.md and a record whose
16
- // verdict is PASS and names it (M-ADR 0223 §4.6).
11
+ // ends CLEAR (the pre-apply check, M-ADR 0223 §4.3). A record for an open change must
12
+ // declare agreement-record schema 1.1.0 or later.
13
+ // 3. stale: for each open, agreed change, each requirement it MODIFIES, REMOVES or RENAMES is, block by
14
+ // block, as it was at the agreed commit (the latest amendment's, else sdd.commit; M-ADR 0223
15
+ // §4.5). Another requirement of the same spec moving does not stale it. With no targets the
16
+ // check is vacuous, and says so.
17
+ // agreed content: the change's proposal, deltas, Gate 0, design, tasks (checkboxes ignored), test
18
+ // plan (its execution results ignored) and structural deltas equal their state at that commit
19
+ // (M-ADR 0222 §4.6; a structural delta's change is a [WARN] while M-ADR 0230 is Proposed).
20
+ // kinds: on a record held to 1.1.0, each test-plan row's Kind (changed or carried) is the one computed
21
+ // from the deltas and the living spec, a row naming no scenario of the deltas fails, and a
22
+ // filled Before is a fail for changed and a pass for carried (M-ADR 0223 §4.4).
23
+ // 4. verdicts: every archived change except a baseline has a verdict.md whose verdict line agrees with
24
+ // its record's PASS (M-ADR 0223 §4.6). A record held to 1.1.0 (by its own history, or by the
25
+ // schema the SDD shipped when the change archived) also needs: an agreed record; archived
26
+ // content equal to the agreed content; its targeted requirements as agreed just before the
27
+ // archive entered HEAD's history; an independent judge; a work order for each tasks.md group
28
+ // and each added or modified requirement; judged and merged commits that are commits, resolve
29
+ // in a checkout beside the SDD where there is one, and match; and a ledger naming each
30
+ // implementing repository. A pin no checkout resolves is a [WARN], failed under --strict.
31
+ // conditions: each agreement condition is recorded in full, and an open one past its date fails unless the
32
+ // change was withdrawn or replaced (M-ADR 0222 §4.6).
17
33
  // 5. surfaces: every archived baseline's evidence lists, under **Surfaces:**, each contract the
18
34
  // repositories it was observed in serve, as covered at transport or excluded with a
19
35
  // reason, and the list is checked against the catalogue (catalogues/repo_contract.csv),
20
36
  // never against itself (M-ADR 0222 §4.3, §4.4). A list the catalogue gives nothing to
21
37
  // check against (no Observed in line, or a repository with no catalogue contracts) fails:
22
38
  // a cross-check of zero contracts is unresolved, not a pass.
39
+ // A baseline whose every requirement has since left the living specs (removed, or its
40
+ // spec retired) is history: its list is not checked, so a retired surface can drop its
41
+ // contract (M-ADR 0222 §4.4).
23
42
  // 7. features: every living spec is keyed by a catalogue feature (M-ADR 0221 §4.7). A spec keyed by no
24
43
  // feature is dropped from the catalogue by the requirements builder, which reports it only as a
25
44
  // build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
@@ -48,8 +67,10 @@ import { tmpdir } from "node:os";
48
67
  import { basename, join } from "node:path";
49
68
  import { promisify } from "node:util";
50
69
  import { parseDocument } from "yaml";
51
- import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, surfaceMatches } from "../behaviour-store/archive.js";
70
+ import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, renamePairs, resolveImplements, surfaceMatches } from "../behaviour-store/archive.js";
52
71
  import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
72
+ import { boundFile, conditionProblems, structuralFile, isCommit, normaliseBound, requirementBlock, sameCommit, scenarioKind, taskGroups, testPlanHeader, testPlanMapping, verdictLine, versionAtLeast } from "./agreement-evidence.js";
73
+ import { ENFORCED as STRUCTURE_ENFORCED } from "./structural-model.js";
53
74
  import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
54
75
  const execFileAsync = promisify(execFile);
55
76
  /** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
@@ -138,6 +159,33 @@ export function parseAgreementRecord(file, text) {
138
159
  }
139
160
  }
140
161
  const verdict = r.verdict && typeof r.verdict === "object" ? r.verdict : undefined;
162
+ if (r.schema_version !== undefined && typeof r.schema_version !== "string")
163
+ problems.push(`${file}: schema_version must be a quoted string`);
164
+ const amendments = Array.isArray(r.amendments) ? r.amendments : [];
165
+ let amendCommit;
166
+ for (const a of amendments) {
167
+ if (a && typeof a.commit === "string" && typeof a.approved_by === "string" && a.approved_by.trim() !== "")
168
+ amendCommit = a.commit;
169
+ }
170
+ const workOrders = (Array.isArray(r.work_orders) ? r.work_orders : [])
171
+ .filter((w) => w && typeof w.repo === "string")
172
+ .map((w) => ({ repo: w.repo, implements: Array.isArray(w.implements) ? w.implements.map(String) : [] }));
173
+ const conditions = Array.isArray(r.conditions) ? r.conditions : [];
174
+ if (r.conditions !== undefined && !Array.isArray(r.conditions))
175
+ problems.push(`${file}: conditions must be a list`);
176
+ const noLongerImplements = [];
177
+ if (r.no_longer_implements !== undefined) {
178
+ if (!Array.isArray(r.no_longer_implements))
179
+ problems.push(`${file}: no_longer_implements must be a list of {repo, requirement}`);
180
+ else
181
+ for (const [i, x] of r.no_longer_implements.entries()) {
182
+ const e = x;
183
+ if (!e || typeof e.repo !== "string" || typeof e.requirement !== "string")
184
+ problems.push(`${file}: no_longer_implements[${i}] names a repo and a requirement`);
185
+ else
186
+ noLongerImplements.push({ repo: e.repo, requirement: e.requirement });
187
+ }
188
+ }
141
189
  if (problems.length > 0)
142
190
  return { problems };
143
191
  return {
@@ -145,9 +193,14 @@ export function parseAgreementRecord(file, text) {
145
193
  file,
146
194
  change: expected,
147
195
  status,
196
+ ...(typeof r.schema_version === "string" ? { schemaVersion: r.schema_version } : {}),
148
197
  ...(typeof sdd.commit === "string" ? { commit: sdd.commit } : {}),
198
+ ...(amendCommit ? { amendCommit } : {}),
149
199
  ...(typeof gate0?.result === "string" ? { gate0: gate0.result } : {}),
150
200
  ...(verdict ? { verdict } : {}),
201
+ workOrders,
202
+ conditions,
203
+ noLongerImplements,
151
204
  },
152
205
  problems,
153
206
  };
@@ -176,21 +229,39 @@ function lastGateLine(text) {
176
229
  const lines = text.split("\n").map((l) => l.trim()).filter((l) => l !== "");
177
230
  return lines.at(-1) ?? "";
178
231
  }
179
- async function gitDiffQuiet(repo, commit, path) {
232
+ async function commitResolves(repo, commit) {
180
233
  try {
181
234
  await execFileAsync("git", ["-C", repo, "cat-file", "-e", `${commit}^{commit}`]);
235
+ return true;
182
236
  }
183
237
  catch {
184
- return "unresolved";
238
+ return false;
185
239
  }
240
+ }
241
+ /** A file's text at a commit; null when the commit holds no such file. The caller has resolved the commit. */
242
+ async function gitShow(repo, commit, path) {
186
243
  try {
187
- await execFileAsync("git", ["-C", repo, "diff", "--quiet", commit, "HEAD", "--", path]);
188
- return "same";
244
+ return (await execFileAsync("git", ["-C", repo, "show", `${commit}:${path}`], { maxBuffer: 64 * 1024 * 1024 })).stdout;
189
245
  }
190
- catch (err) {
191
- return err.code === 1 ? "changed" : "unresolved";
246
+ catch {
247
+ return null;
248
+ }
249
+ }
250
+ /** Paths under a directory at a commit, relative to that directory. */
251
+ async function gitFiles(repo, commit, dir) {
252
+ try {
253
+ const out = (await execFileAsync("git", ["-C", repo, "ls-tree", "-r", "--name-only", commit, "--", `${dir}/`], { maxBuffer: 64 * 1024 * 1024 })).stdout;
254
+ return out.split("\n").filter(Boolean).map((p) => p.slice(dir.length + 1));
255
+ }
256
+ catch {
257
+ return [];
192
258
  }
193
259
  }
260
+ /** Requirement names a delta targets: MODIFIED and REMOVED, and the former name of each RENAMED. */
261
+ function targetNames(delta) {
262
+ const names = parseRequirements(delta).filter((r) => r.operation === "MODIFIED" || r.operation === "REMOVED").map((r) => r.name);
263
+ return [...new Set([...names, ...renamedFrom(delta)])];
264
+ }
194
265
  /** The living specs an open change's deltas modify, remove or rename: openspec/specs/<capability>/spec.md. */
195
266
  async function targetedSpecs(repo, changeDir) {
196
267
  const out = [];
@@ -217,12 +288,116 @@ async function targetedSpecs(repo, changeDir) {
217
288
  await walk("");
218
289
  return out.sort();
219
290
  }
220
- export async function checkBehaviourStore(repo) {
291
+ /**
292
+ * The highest schema_version a record has ever been committed with, from every commit that touched it, or null when
293
+ * the record has no history here. Applicability is monotonic: once a record entered the 1.1.0 rules, no later
294
+ * declaration takes it out, and a record with no history is unresolved rather than taken to be legacy.
295
+ */
296
+ async function recordVersionEver(repo, file) {
297
+ let commits;
298
+ try {
299
+ commits = (await execFileAsync("git", ["-C", repo, "log", "--format=%H", "--", file], { maxBuffer: 64 * 1024 * 1024 })).stdout.split("\n").filter(Boolean);
300
+ }
301
+ catch {
302
+ return null;
303
+ }
304
+ if (commits.length === 0)
305
+ return null;
306
+ let highest;
307
+ for (const c of commits) {
308
+ const text = await gitShow(repo, c, file);
309
+ const m = text ? /^\s*schema_version:\s*["']?([0-9.]+)["']?/m.exec(text) ?? /"schema_version":\s*"([0-9.]+)"/.exec(text) : null;
310
+ if (m && (highest === undefined || versionAtLeast(m[1], highest)))
311
+ highest = m[1];
312
+ }
313
+ return { highest };
314
+ }
315
+ /**
316
+ * The commit at which an archive entered the history HEAD integrates: the oldest commit on HEAD's first-parent chain
317
+ * that adds it. A change archived on a branch and merged in enters at the merge, so its first parent is the target
318
+ * state the change was integrated over, not the branch's own older parent. Null when the archive is not committed.
319
+ */
320
+ async function integratedAt(repo, path) {
321
+ try {
322
+ const out = (await execFileAsync("git", ["-C", repo, "log", "--first-parent", "--diff-filter=A", "--format=%H", "--", path], { maxBuffer: 64 * 1024 * 1024 })).stdout.trim();
323
+ return out === "" ? null : out.split("\n").at(-1);
324
+ }
325
+ catch {
326
+ return null;
327
+ }
328
+ }
329
+ /** The agreement-record schema this SDD shipped at a commit (its working tree when the commit is null). */
330
+ async function shippedRecordSchema(repo, commit) {
331
+ const path = "docs/method/foundations/schemas/agreement-record-schema.yaml";
332
+ const text = commit ? await gitShow(repo, commit, path) : await readText(join(repo, path));
333
+ return text ? /^schema_version:\s*"([0-9.]+)"/m.exec(text)?.[1] : undefined;
334
+ }
335
+ /** Files under a directory, relative to it. */
336
+ async function listFiles(dir, rel = "") {
337
+ const out = [];
338
+ let entries;
339
+ try {
340
+ entries = await readdir(join(dir, rel), { withFileTypes: true });
341
+ }
342
+ catch {
343
+ return out;
344
+ }
345
+ for (const e of entries) {
346
+ const r = rel === "" ? e.name : `${rel}/${e.name}`;
347
+ if (e.isDirectory())
348
+ out.push(...(await listFiles(dir, r)));
349
+ else
350
+ out.push(r);
351
+ }
352
+ return out;
353
+ }
354
+ /**
355
+ * Where a work order's repository is checked out: the SDD itself for the SDD's own work order (its repository id is
356
+ * the SDD's sdd_id), else a checkout beside the SDD (../<repo>). The SDD's own commits are always at hand, wherever
357
+ * the SDD is mounted, so its work order never depends on a sibling directory's name.
358
+ */
359
+ function checkoutDir(sddRepo, codeRepo, ownId) {
360
+ return ownId !== "" && codeRepo === ownId ? sddRepo : join(sddRepo, "..", codeRepo);
361
+ }
362
+ /** A work order repository's checkout, when there is one. */
363
+ async function codeCheckout(sddRepo, codeRepo, ownId) {
364
+ const dir = checkoutDir(sddRepo, codeRepo, ownId);
365
+ try {
366
+ await execFileAsync("git", ["-C", dir, "rev-parse", "--git-dir"]);
367
+ return dir;
368
+ }
369
+ catch {
370
+ return null;
371
+ }
372
+ }
373
+ /** Whether two commits of a work order's repository, in its checkout, have one tree. */
374
+ async function sameTree(sddRepo, codeRepo, ownId, a, b) {
375
+ const dir = checkoutDir(sddRepo, codeRepo, ownId);
376
+ try {
377
+ const [x, y] = await Promise.all([a, b].map(async (c) => (await execFileAsync("git", ["-C", dir, "rev-parse", `${c}^{tree}`])).stdout.trim()));
378
+ return x === y ? "same" : "differs";
379
+ }
380
+ catch {
381
+ return "unresolved";
382
+ }
383
+ }
384
+ export async function checkBehaviourStore(repo, options = {}) {
385
+ const today = options.today ?? new Date().toISOString().slice(0, 10);
221
386
  if (!(await exists(join(repo, STORE_DIR)))) {
222
387
  return { exitCode: 0, stdout: `[N/A] no behaviour store (${STORE_DIR}/) in this repository\n` };
223
388
  }
224
389
  const lines = [];
225
390
  const failures = [];
391
+ const warnings = [];
392
+ // The SDD's own id: a work order for the SDD's own repository is checked in the SDD itself.
393
+ let ownId = "";
394
+ try {
395
+ const m = parseDocument((await readText(join(repo, ".sdd", "sdd-manifest.yaml"))) ?? "").toJS();
396
+ ownId = String(m?.sdd_id ?? "");
397
+ }
398
+ catch {
399
+ // the manifest check reports an unreadable manifest
400
+ }
226
401
  // 1. records
227
402
  const records = new Map();
228
403
  let recordFiles = [];
@@ -244,6 +419,8 @@ export async function checkBehaviourStore(repo) {
244
419
  const open = (await dirs(join(repo, STORE_DIR, "changes"))).filter((d) => d !== "archive");
245
420
  let started = 0;
246
421
  let staleChecked = 0;
422
+ let contentChecked = 0;
423
+ let kindsChecked = 0;
247
424
  for (const change of open) {
248
425
  const dir = `${STORE_DIR}/changes/${change}`;
249
426
  const tasks = await readText(join(repo, dir, "tasks.md"));
@@ -262,19 +439,115 @@ export async function checkBehaviourStore(repo) {
262
439
  else if (lastGateLine(gate) !== "GATE: CLEAR")
263
440
  failures.push(`${dir}: has completed tasks but gate-0.md ends "${lastGateLine(gate)}", not "GATE: CLEAR"`);
264
441
  }
442
+ if (record && !versionAtLeast(record.schemaVersion, "1.1.0") && (record.status === "agreed" || record.status === "proposed")) {
443
+ failures.push(`${record.file}: an open change's record must declare schema_version "1.1.0" or later (it has ${JSON.stringify(record.schemaVersion ?? null)}); the agreed-content, test-plan and verdict-evidence rules read it (agreement-record schema 1.1.0)`);
444
+ }
265
445
  if (record?.status === "agreed" && record.commit) {
266
- for (const spec of await targetedSpecs(repo, dir)) {
267
- staleChecked++;
268
- const r = await gitDiffQuiet(repo, record.commit, spec);
269
- if (r === "changed")
270
- failures.push(`${dir}: STALE: ${spec} changed since the agreed commit ${record.commit}`);
271
- if (r === "unresolved")
272
- failures.push(`${dir}: stale check unresolved: commit ${record.commit} is not in this clone's history (a shallow clone cannot run it)`);
446
+ // One baseline for both checks: what was agreed, at the latest amendment's commit where one re-bound the content.
447
+ const agreedAt = record.amendCommit ?? record.commit;
448
+ const resolves = await commitResolves(repo, agreedAt);
449
+ const targeted = await targetedSpecs(repo, dir);
450
+ if (!resolves && targeted.length > 0) {
451
+ failures.push(`${dir}: stale check unresolved: commit ${agreedAt} is not in this clone's history (a shallow clone cannot run it)`);
452
+ }
453
+ // 3. stale, per requirement (M-ADR 0223 §4.5, §4.10): a change is stale when a requirement it targets moved since
454
+ // agreement, not when another requirement of the same spec did.
455
+ if (resolves) {
456
+ for (const spec of targeted) {
457
+ const feature = spec.split("/").at(-2);
458
+ const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
459
+ const then = (await gitShow(repo, agreedAt, spec)) ?? "";
460
+ const now = (await gitShow(repo, "HEAD", spec)) ?? "";
461
+ for (const name of targetNames(delta)) {
462
+ staleChecked++;
463
+ const before = requirementBlock(then, name);
464
+ const after = requirementBlock(now, name);
465
+ if (before === null)
466
+ failures.push(`${dir}: stale check unresolved: requirement "${name}" of ${feature} is not in the living spec at the agreed commit ${agreedAt}`);
467
+ else if (after !== before)
468
+ failures.push(`${dir}: STALE: requirement "${name}" of ${feature} ${after === null ? "has left" : "changed in"} ${spec} since the agreed commit ${agreedAt}`);
469
+ }
470
+ }
471
+ }
472
+ // Agreement binds the content approved (M-ADR 0222 §4.6 as amended): the change's own artefacts equal their state
473
+ // at the pinned commit, the latest recorded amendment's or the agreement's.
474
+ const base = record.amendCommit ?? record.commit;
475
+ if (!(await commitResolves(repo, base))) {
476
+ failures.push(`${dir}: agreed content unresolved: commit ${base} is not in this clone's history`);
477
+ }
478
+ else {
479
+ const then = (await gitFiles(repo, base, dir)).filter(boundFile);
480
+ const now = (await gitFiles(repo, "HEAD", dir)).filter(boundFile);
481
+ if (then.length === 0) {
482
+ failures.push(`${dir}: agreed content unresolved: the pinned commit ${base} holds no ${dir}/; pin the commit that holds the agreed change`);
483
+ }
484
+ else {
485
+ contentChecked++;
486
+ for (const f of [...new Set([...then, ...now])].sort()) {
487
+ const a = await gitShow(repo, base, `${dir}/${f}`);
488
+ const b = await gitShow(repo, "HEAD", `${dir}/${f}`);
489
+ if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
490
+ const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} since ${base}, with no amendment recording a commit and an approver; amend the agreement or renew it (M-ADR 0222 §4.6)`;
491
+ if (structuralFile(f) && !STRUCTURE_ENFORCED)
492
+ warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
493
+ else
494
+ failures.push(what);
495
+ }
496
+ }
497
+ }
498
+ }
499
+ }
500
+ // A test plan's kinds (M-ADR 0223 §4.4 as amended): changed scenarios go red then green, carried ones stay green.
501
+ if (versionAtLeast(record?.schemaVersion, "1.1.0") && record?.status === "agreed") {
502
+ const plan = await readText(join(repo, dir, "test-plan.md"));
503
+ if (plan !== null) {
504
+ const header = testPlanHeader(plan);
505
+ const col = (name) => header.indexOf(name);
506
+ if (col("kind") < 0)
507
+ failures.push(`${dir}/test-plan.md: the scenario table has no Kind column (changed | carried)`);
508
+ else {
509
+ for (const row of testPlanMapping(plan)) {
510
+ const cell = row[col("requirement")] ?? "";
511
+ const scen = row[col("scenario")] ?? "";
512
+ const kind = (row[col("kind")] ?? "").toLowerCase();
513
+ // `<feature>::<name>` names one feature's requirement; a bare name is read in every feature the change touches.
514
+ const q = /^(.+?)::(.+)$/.exec(cell);
515
+ const req = q ? q[2] : cell;
516
+ let expected = null;
517
+ const kinds = new Map();
518
+ for (const feature of await dirs(join(repo, dir, "specs"))) {
519
+ if (q && feature !== q[1])
520
+ continue;
521
+ const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
522
+ const k = scenarioKind(delta, await readText(join(repo, STORE_DIR, "specs", feature, "spec.md")), req, scen);
523
+ if (k)
524
+ kinds.set(feature, k);
525
+ }
526
+ if (kinds.size > 1) {
527
+ failures.push(`${dir}/test-plan.md: "${cell}" / "${scen}" names a requirement ${kinds.size} features in the change have (${[...kinds.keys()].join(", ")}); qualify it as <feature>::<name>`);
528
+ continue;
529
+ }
530
+ expected = [...kinds.values()][0] ?? null;
531
+ if (expected === null) {
532
+ failures.push(`${dir}/test-plan.md: "${cell}" / "${scen}" names no scenario the change's deltas hold; a misnamed row is not checked, so it fails`);
533
+ continue;
534
+ }
535
+ kindsChecked++;
536
+ if (kind !== expected)
537
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is ${expected} against the living spec, and the plan says ${JSON.stringify(kind)}`);
538
+ const before = col("before") >= 0 ? (row[col("before")] ?? "") : "";
539
+ if (before !== "" && expected === "changed" && !/fail|red/i.test(before))
540
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is changed, so its test fails before the code; the scenario table's Before cell says ${JSON.stringify(before)}`);
541
+ if (before !== "" && expected === "carried" && !/pass|green/i.test(before))
542
+ failures.push(`${dir}/test-plan.md: "${req}" / "${scen}" is carried, so its test passes before the code as regression; the scenario table's Before cell says ${JSON.stringify(before)}`);
543
+ }
544
+ }
273
545
  }
274
546
  }
275
547
  }
276
548
  lines.push(`agreement: ${open.length} open change(s), ${started} with completed tasks`);
277
- lines.push(staleChecked === 0 ? "stale: vacuous (no open agreed change modifies, removes or renames a requirement)" : `stale: ${staleChecked} targeted living spec(s) checked against their agreed commits`);
549
+ lines.push(staleChecked === 0 ? "stale: vacuous (no open agreed change modifies, removes or renames a requirement)" : `stale: ${staleChecked} targeted requirement(s) checked against their agreed commits`);
550
+ lines.push(`agreed content: ${contentChecked} open agreed change(s) compared with their pinned commits; ${kindsChecked} test-plan scenario kind(s) checked`);
278
551
  // 8. overlap: two open, agreed changes never name the same requirement of one living spec (M-ADR 0223 §4.10).
279
552
  // A name counts whether the delta adds, modifies, removes or renames it (both FROM and TO). Proposals may overlap.
280
553
  const claimed = new Map();
@@ -301,6 +574,8 @@ export async function checkBehaviourStore(repo) {
301
574
  // 4. archived changes
302
575
  const archived = await dirs(join(repo, STORE_DIR, "changes", "archive"));
303
576
  let baselines = 0;
577
+ let verdictsEvidenced = 0;
578
+ let pinsUnverified = 0;
304
579
  for (const name of archived) {
305
580
  const dir = `${STORE_DIR}/changes/archive/${name}`;
306
581
  if (await exists(join(repo, dir, "baseline-evidence.md"))) {
@@ -318,10 +593,167 @@ export async function checkBehaviourStore(repo) {
318
593
  if (record.verdict?.result !== "PASS")
319
594
  failures.push(`${record.file}: change is archived but verdict.result is ${JSON.stringify(record.verdict?.result)}, not PASS`);
320
595
  const vr = record.verdict?.record;
321
- if (typeof vr !== "string" || !(await exists(join(repo, vr))))
596
+ const verdictText = typeof vr === "string" ? await readText(join(repo, vr)) : null;
597
+ if (verdictText === null)
322
598
  failures.push(`${record.file}: verdict.record ${JSON.stringify(vr)} does not name an existing file`);
599
+ // The record's verdict and the verdict file's agree (M-ADR 0223 §4.6 as amended), for every archived change.
600
+ const stated = verdictText === null ? null : verdictLine(verdictText);
601
+ if (stated !== null && stated !== record.verdict?.result)
602
+ failures.push(`${record.file}: the record says ${JSON.stringify(record.verdict?.result)} and ${vr} says ${stated}`);
603
+ // Which rules hold this record is decided from history, not from what the record says of itself now: the highest
604
+ // version any committed version of it declared, and the agreement-record schema the SDD shipped when the change
605
+ // was archived. A record cannot leave the 1.1.0 rules by declaring an older version.
606
+ const archivedAt = await integratedAt(repo, dir);
607
+ const ever = await recordVersionEver(repo, record.file);
608
+ if (ever === null) {
609
+ failures.push(`${record.file}: has no history in this clone, so which record rules hold it is unresolved (a record with no history is not taken to be legacy); run from a full clone`);
610
+ continue;
611
+ }
612
+ const everVersion = ever.highest;
613
+ const shipped = await shippedRecordSchema(repo, archivedAt);
614
+ if (versionAtLeast(everVersion, "1.1.0") && !versionAtLeast(record.schemaVersion, "1.1.0")) {
615
+ failures.push(`${record.file}: must declare schema_version "1.1.0" or later (it declares ${JSON.stringify(record.schemaVersion ?? null)}), because a committed version of it declared ${everVersion}; once a record has entered the 1.1.0 rules, declaring an older version does not take it out`);
616
+ }
617
+ else if (versionAtLeast(shipped, "1.1.0") && !versionAtLeast(record.schemaVersion, "1.1.0")) {
618
+ failures.push(`${record.file}: the change was archived when this SDD shipped agreement-record schema ${shipped}, so its record must declare schema_version "1.1.0" or later and carry the evidence`);
619
+ }
620
+ if (!(versionAtLeast(record.schemaVersion, "1.1.0") || versionAtLeast(everVersion, "1.1.0") || versionAtLeast(shipped, "1.1.0")))
621
+ continue;
622
+ verdictsEvidenced++;
623
+ // An archived change was agreed: the archive is not a way round the agreement checks.
624
+ if (record.status !== "agreed")
625
+ failures.push(`${record.file}: the change is archived, but its record is ${JSON.stringify(record.status)}, not agreed; an archive needs an approval, a pinned commit and a CLEAR Gate 0`);
626
+ const base = record.amendCommit ?? record.commit;
627
+ if (base && (await commitResolves(repo, base))) {
628
+ // The archived content is the agreed content (M-ADR 0222 §4.6): an edit made after agreement and carried into the
629
+ // archive fails as it would have while the change was open.
630
+ const openDir = `${STORE_DIR}/changes/${change}`;
631
+ const then = (await gitFiles(repo, base, openDir)).filter(boundFile);
632
+ const nowFiles = (await listFiles(join(repo, dir))).filter(boundFile);
633
+ if (then.length === 0)
634
+ failures.push(`${dir}: agreed content unresolved: the pinned commit ${base} holds no ${openDir}/`);
635
+ for (const f of [...new Set([...then, ...nowFiles])].sort()) {
636
+ if (then.length === 0)
637
+ break;
638
+ const a = await gitShow(repo, base, `${openDir}/${f}`);
639
+ const b = await readText(join(repo, dir, f));
640
+ if (a === null || b === null || normaliseBound(f, a) !== normaliseBound(f, b)) {
641
+ const what = `${dir}: AGREED CONTENT CHANGED: ${f} ${a === null ? "was added" : b === null ? "was removed" : "differs"} from what was agreed at ${base}, with no amendment recording a commit and an approver (M-ADR 0222 §4.6)`;
642
+ if (structuralFile(f) && !STRUCTURE_ENFORCED)
643
+ warnings.push(`${what}; a structural delta, report only while M-ADR 0230 is Proposed`);
644
+ else
645
+ failures.push(what);
646
+ }
647
+ }
648
+ // Stale at archive: the requirements the change targets, just before it archived, are as they were agreed. After
649
+ // the archive the living specs hold the change's own deltas, so the comparison is with the state before it.
650
+ const before = archivedAt ? `${archivedAt}^` : "HEAD";
651
+ for (const feature of await dirs(join(repo, dir, "specs"))) {
652
+ const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
653
+ const spec = `${STORE_DIR}/specs/${feature}/spec.md`;
654
+ const agreedSpec = (await gitShow(repo, base, spec)) ?? "";
655
+ const priorSpec = (await gitShow(repo, before, spec)) ?? "";
656
+ for (const name of targetNames(delta)) {
657
+ const x = requirementBlock(agreedSpec, name);
658
+ const y = requirementBlock(priorSpec, name);
659
+ if (x !== null && y !== x)
660
+ failures.push(`${dir}: STALE AT ARCHIVE: requirement "${name}" of ${feature} had changed since the agreed commit ${base} when the change archived`);
661
+ }
662
+ }
663
+ }
664
+ else if (base) {
665
+ failures.push(`${dir}: agreed content unresolved: commit ${base} is not in this clone's history`);
666
+ }
667
+ // Records on schema 1.1.0 carry the evidence, checked here, not declared.
668
+ if (verdictText !== null && stated === null)
669
+ failures.push(`${vr}: states no verdict line (\`Verdict: PASS\` or \`Verdict: FAIL\`)`);
670
+ const v = record.verdict ?? {};
671
+ const by = typeof v.judged_by === "string" ? v.judged_by.trim() : "";
672
+ const applier = typeof v.applied_by === "string" ? v.applied_by.trim() : "";
673
+ if (by === "" || applier === "")
674
+ failures.push(`${record.file}: verdict names ${by === "" ? "no judged_by" : ""}${by === "" && applier === "" ? " and " : ""}${applier === "" ? "no applied_by" : ""} (M-ADR 0223 §4.5: the judge is independent of the applier)`);
675
+ else if (by.toLowerCase() === applier.toLowerCase())
676
+ failures.push(`${record.file}: verdict.judged_by and verdict.applied_by are both "${by}"; the judge is independent of the applier (M-ADR 0223 §4.5)`);
677
+ // The repositories whose evidence is required come from the change itself, not from the record's list: each task
678
+ // group's repository, and a work order for every requirement the deltas add or modify.
679
+ const groups = taskGroups((await readText(join(repo, dir, "tasks.md"))) ?? "");
680
+ const recorded = new Set(record.workOrders.map((w) => w.repo));
681
+ for (const g of groups)
682
+ if (!recorded.has(g.repo))
683
+ failures.push(`${record.file}: tasks.md has a work order for ${g.repo} and the record's work_orders do not; its evidence cannot be checked`);
684
+ // Requirement names are unique within a feature, not across a change: a work order names `<feature>::<name>`,
685
+ // or a bare name only one feature in the change has (schema 5.0.0). An ambiguous bare name implements nothing.
686
+ const built = [];
687
+ for (const feature of await dirs(join(repo, dir, "specs"))) {
688
+ const delta = (await readText(join(repo, dir, "specs", feature, "spec.md"))) ?? "";
689
+ for (const r of parseRequirements(delta))
690
+ if (r.operation === "ADDED" || r.operation === "MODIFIED")
691
+ built.push({ feature, name: r.name, operation: r.operation });
692
+ }
693
+ const covers = (entries, d) => entries.some((e) => resolveImplements(built, e).matches.some((m) => m.feature === d.feature && m.name === d.name));
694
+ for (const w of record.workOrders) {
695
+ for (const e of w.implements) {
696
+ const { ambiguous } = resolveImplements(built, e);
697
+ if (ambiguous.length > 0)
698
+ failures.push(`${record.file}: the work order for ${w.repo} implements "${e}", a name ${ambiguous.length} features in the change share (${ambiguous.join(", ")}); qualify it as <feature>::<name>`);
699
+ }
700
+ }
701
+ for (const d of built) {
702
+ if (!covers(record.workOrders.flatMap((w) => w.implements), d)) {
703
+ failures.push(`${record.file}: requirement "${d.name}" (${d.feature}) is ${d.operation} by the change and no work order implements it${covers(groups.flatMap((g) => g.implements), d) ? " (tasks.md says one does; the record's work_orders do not)" : ""}`);
704
+ }
705
+ }
706
+ const workOrders = [...record.workOrders, ...groups.filter((g) => !recorded.has(g.repo))];
707
+ const judged = (v.judged && typeof v.judged === "object" ? v.judged : {});
708
+ const merged = (v.merged && typeof v.merged === "object" ? v.merged : {});
709
+ const plan = (await readText(join(repo, dir, "test-plan.md"))) ?? "";
710
+ const ledger = plan.split(/^##\s+Ledger\b/m)[1]?.split(/^##\s/m)[0] ?? "";
711
+ for (const wo of workOrders) {
712
+ const j = judged[wo.repo];
713
+ const mList = merged[wo.repo];
714
+ const m = Array.isArray(mList) ? mList.at(-1) : mList;
715
+ if (wo.implements.length > 0 && !ledger.includes(wo.repo))
716
+ failures.push(`${dir}/test-plan.md: the ledger does not name ${wo.repo}, which implements requirements; the verdict has no red/green evidence for it`);
717
+ if (!isCommit(j)) {
718
+ failures.push(`${record.file}: verdict.judged ${j === undefined ? "names no commit" : `names ${JSON.stringify(j)}, not a commit`} for ${wo.repo}; the judge judges each repository's candidate commit before it merges (M-ADR 0223 §4.5)`);
719
+ continue;
720
+ }
721
+ if (!isCommit(m)) {
722
+ failures.push(`${record.file}: verdict.merged ${m === undefined ? "names no commit" : `names ${JSON.stringify(m)}, not a commit`} for ${wo.repo}`);
723
+ continue;
724
+ }
725
+ const checkout = await codeCheckout(repo, wo.repo, ownId);
726
+ if (checkout) {
727
+ for (const c of [j, m])
728
+ if (!(await commitResolves(checkout, c)))
729
+ failures.push(`${record.file}: ${wo.repo} has no commit ${c} in the checkout beside the SDD`);
730
+ }
731
+ else if (sameCommit(j, m))
732
+ pinsUnverified++;
733
+ if (!sameCommit(j, m)) {
734
+ const cmp = await sameTree(repo, wo.repo, ownId, j, m);
735
+ if (cmp === "differs")
736
+ failures.push(`${record.file}: ${wo.repo} merged ${m}, whose tree differs from the judged ${j}: what merged is not what was judged`);
737
+ if (cmp === "unresolved")
738
+ failures.push(`${record.file}: ${wo.repo} merged ${m}, not the judged ${j}, and their trees could not be compared (no checkout beside the SDD holds both): unresolved`);
739
+ }
740
+ }
323
741
  }
324
- lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict`);
742
+ lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict, ${verdictsEvidenced} held to record schema 1.1.0 with their evidence checked`);
743
+ // A pin that cannot be resolved is a partial result, never a pass: a [WARN] the run counts as warned, and a failure
744
+ // under --strict, which is how the judge runs it, with the code repositories checked out (M-ADR 0223 §4.6).
745
+ if (pinsUnverified > 0)
746
+ warnings.push(`verdict pins: ${pinsUnverified} repository pin pair(s) are well-formed and consistent, but no checkout beside the SDD resolves them, so the verified-commit claim is unconfirmed here; run with the code repositories checked out beside the SDD, and with --strict where the claim must hold`);
747
+ // Conditions an agreement was made on (M-ADR 0222 §4.6 as amended): each recorded in full, none open past its date.
748
+ let conditions = 0;
749
+ for (const rec of records.values()) {
750
+ for (const [i, c] of rec.conditions.entries()) {
751
+ conditions++;
752
+ for (const p of conditionProblems(c ?? {}, today, rec.status))
753
+ failures.push(`${rec.file}: conditions[${i}]: ${p}`);
754
+ }
755
+ }
756
+ lines.push(`conditions: ${conditions} agreement condition(s)`);
325
757
  // 5. surfaces, and 6. what each baseline may change, in archive order
326
758
  const archiveRel = `${STORE_DIR}/changes/archive`;
327
759
  const { order, ties, history } = await orderArchives(repo, archiveRel, archived);
@@ -351,6 +783,10 @@ export async function checkBehaviourStore(repo) {
351
783
  let scopeUnresolved = 0;
352
784
  const servedBy = baselines > 0 ? await catalogueContracts(repo) : null;
353
785
  let surfacesChecked = 0;
786
+ // A baseline whose every requirement has since left the living specs (removed, or its spec retired) records a
787
+ // surface that no longer exists; its Surfaces list is history, not checked against today's catalogue, or a
788
+ // retired surface could never drop its contract.
789
+ const historical = await historicalBaselines(repo, archiveRel, order);
354
790
  const lastTouch = new Map();
355
791
  let scopeChecked = 0;
356
792
  for (const name of order) {
@@ -363,7 +799,10 @@ export async function checkBehaviourStore(repo) {
363
799
  failures.push(`${dir}/baseline-evidence.md: no **Surfaces:** list; name every contract the observed repositories serve, covered at transport or excluded with a reason`);
364
800
  for (const p of parsed.problems)
365
801
  failures.push(`${dir}/baseline-evidence.md: Surfaces: ${p}`);
366
- if (parsed.present) {
802
+ if (parsed.present && historical.has(name)) {
803
+ // history: see historicalBaselines
804
+ }
805
+ else if (parsed.present) {
367
806
  if (servedBy === null) {
368
807
  failures.push(`${dir}/baseline-evidence.md: Surfaces cannot be cross-checked: catalogues/repo_contract.csv or catalogues/contracts.csv is missing; build the catalogue`);
369
808
  }
@@ -417,7 +856,7 @@ export async function checkBehaviourStore(repo) {
417
856
  }
418
857
  }
419
858
  const unseen = servedBy === null ? "" : `; repositories with no catalogue contracts: ${[...new Set(await observedInAll(repo, archiveRel, order))].filter((r) => (servedBy.get(r) ?? []).length === 0).join(", ") || "none"}`;
420
- lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}`);
859
+ lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}${historical.size ? `; ${historical.size} historical baseline(s) not checked (every requirement they baselined has left the living specs)` : ""}`);
421
860
  lines.push(scopeChecked === 0 ? "scope: vacuous (no baseline removes, modifies or renames a requirement)" : `scope: ${scopeChecked} requirement(s) a baseline removes, modifies or renames, checked against their lineage`);
422
861
  if (!history && archived.length > 0)
423
862
  lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
@@ -471,11 +910,60 @@ export async function checkBehaviourStore(repo) {
471
910
  const population = recordFiles.length + open.length + archived.length;
472
911
  const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
473
912
  if (failures.length > 0) {
474
- return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
913
+ return { exitCode: 1, stdout: body + warnings.map((w) => `[WARN] ${w}\n`).join("") + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
475
914
  }
476
915
  if (population === 0)
477
916
  return { exitCode: 0, stdout: body + `[N/A] the behaviour store has no changes and no agreement records yet\n` };
478
- return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, overlap, verdict, surfaces, scope, feature and schema checks hold\n" };
917
+ const warned = warnings.map((w) => `[WARN] ${w}\n`).join("");
918
+ return { exitCode: 0, stdout: body + warned + "[PASS] behaviour store: records, agreement, stale, overlap, verdict, surfaces, scope, feature and schema checks hold" + (warnings.length ? ", with the warnings above" : "") + "\n" };
919
+ }
920
+ /**
921
+ * Baselines whose requirements have all left the living specs. Each baseline's requirements are followed forward
922
+ * through every later archive in order (a REMOVED drops one, a RENAMED carries it to its new name); the baseline is
923
+ * historical when none of what remains is a requirement of a living spec today. A baseline that names no
924
+ * requirement is never historical.
925
+ */
926
+ async function historicalBaselines(repo, archiveRel, order) {
927
+ const living = new Set();
928
+ for (const feature of await dirs(join(repo, STORE_DIR, "specs"))) {
929
+ const text = (await readText(join(repo, STORE_DIR, "specs", feature, "spec.md"))) ?? "";
930
+ for (const r of parseRequirements(text))
931
+ living.add(`${feature}::${r.name}`);
932
+ }
933
+ const deltas = [];
934
+ for (const name of order) {
935
+ const specs = [];
936
+ for (const feature of await dirs(join(repo, archiveRel, name, "specs"))) {
937
+ specs.push({ feature, text: (await readText(join(repo, archiveRel, name, "specs", feature, "spec.md"))) ?? "" });
938
+ }
939
+ deltas.push({ name, baseline: await exists(join(repo, archiveRel, name, "baseline-evidence.md")), specs });
940
+ }
941
+ const out = new Set();
942
+ deltas.forEach((d, i) => {
943
+ if (!d.baseline)
944
+ return;
945
+ const keys = new Set();
946
+ for (const sp of d.specs)
947
+ for (const r of parseRequirements(sp.text))
948
+ if (r.operation !== "REMOVED")
949
+ keys.add(`${sp.feature}::${r.name}`);
950
+ if (keys.size === 0)
951
+ return;
952
+ for (const later of deltas.slice(i + 1)) {
953
+ for (const sp of later.specs) {
954
+ for (const r of parseRequirements(sp.text))
955
+ if (r.operation === "REMOVED")
956
+ keys.delete(`${sp.feature}::${r.name}`);
957
+ for (const p of renamePairs(sp.text)) {
958
+ if (keys.delete(`${sp.feature}::${p.from}`))
959
+ keys.add(`${sp.feature}::${p.to}`);
960
+ }
961
+ }
962
+ }
963
+ if (![...keys].some((k) => living.has(k)))
964
+ out.add(d.name);
965
+ });
966
+ return out;
479
967
  }
480
968
  /** Per repository, the contracts the catalogue says it serves (repo_contract ⋈ contracts.spec_path); null when either file is missing. */
481
969
  async function catalogueContracts(repo) {