@sdd-method/maintain-cli 0.9.0 → 0.10.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.
@@ -64,6 +64,33 @@ export function renamedFrom(text) {
64
64
  }
65
65
  return out;
66
66
  }
67
+ /** The FROM -> TO pairs of a RENAMED section, in file order. */
68
+ export function renamePairs(text) {
69
+ const out = [];
70
+ let op = null;
71
+ let from = null;
72
+ for (const line of text.split("\n")) {
73
+ const section = /^##\s+(ADDED|MODIFIED|REMOVED|RENAMED)\s+Requirements\b/.exec(line);
74
+ if (section) {
75
+ op = section[1];
76
+ from = null;
77
+ continue;
78
+ }
79
+ if (op !== "RENAMED")
80
+ continue;
81
+ const f = /^\s*-\s*FROM:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
82
+ if (f) {
83
+ from = f[1].trim();
84
+ continue;
85
+ }
86
+ const t = /^\s*-\s*TO:\s*`?###\s+Requirement:\s*(.+?)`?\s*$/.exec(line);
87
+ if (t && from !== null) {
88
+ out.push({ from, to: t[1].trim() });
89
+ from = null;
90
+ }
91
+ }
92
+ return out;
93
+ }
67
94
  /** Each archive directory's position in the SDD's history: the index of the first commit that added a file under it. */
68
95
  async function archiveHistory(sddPath, archiveRel) {
69
96
  let stdout;
@@ -100,7 +127,7 @@ export async function orderArchives(sddPath, archiveRel, names) {
100
127
  if (date(a) === date(b) && position(a) === position(b))
101
128
  ties.push([a, b]);
102
129
  }
103
- return { order, ties };
130
+ return { order, ties, history: history !== null };
104
131
  }
105
132
  /** The repositories a baseline was observed in: its evidence file's `**Observed in:** repo[@sha], repo[@sha]` line. */
106
133
  export function observedIn(evidence) {
@@ -0,0 +1,85 @@
1
+ // The method's OpenSpec change schema (M-ADR 0223 §4.2) and its copy in a behaviour store.
2
+ //
3
+ // The method ships the schema inert, at docs/method/foundations/openspec/sdd-change/, because
4
+ // sync writes an SDD's managed paths and a store is created only when its SDD first needs one
5
+ // (M-ADR 0221 §4.2): a managed path at openspec/ would create a store in every SDD.
6
+ // OpenSpec reads a project schema only from <store>/openspec/schemas/<name>/, so the store
7
+ // holds a copy. The copy is method-managed: sync refreshes it, scaffold writes it, and the
8
+ // behaviour-store validator fails when it differs. Adopter guidance goes in the store's
9
+ // config.yaml (`context:`, per-artifact `rules:`), never in the copy.
10
+ import { cp, mkdir, readdir, readFile, rm } from "node:fs/promises";
11
+ import { join, relative } from "node:path";
12
+ import { parseDocument } from "yaml";
13
+ export const SCHEMA_NAME = "sdd-change";
14
+ export const SHIPPED_SCHEMA_DIR = "docs/method/foundations/openspec/sdd-change";
15
+ export const SHIPPED_CONFIG_TEMPLATE = "docs/method/foundations/openspec/config-template.yaml";
16
+ export const STORE_SCHEMA_DIR = `openspec/schemas/${SCHEMA_NAME}`;
17
+ export const STORE_CONFIG = "openspec/config.yaml";
18
+ async function files(dir) {
19
+ const out = [];
20
+ async function walk(d) {
21
+ for (const e of await readdir(d, { withFileTypes: true })) {
22
+ const p = join(d, e.name);
23
+ if (e.isDirectory())
24
+ await walk(p);
25
+ else if (e.isFile())
26
+ out.push(relative(dir, p));
27
+ }
28
+ }
29
+ try {
30
+ await walk(dir);
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ return out.sort();
36
+ }
37
+ async function read(p) {
38
+ try {
39
+ return await readFile(p);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /** The schema the store's config.yaml selects: its name, null without a config, or "" without a `schema:` key. */
46
+ export async function storeSchemaName(repo) {
47
+ const text = await read(join(repo, STORE_CONFIG));
48
+ if (text === null)
49
+ return null;
50
+ const v = parseDocument(text.toString("utf8")).toJS()?.schema;
51
+ return typeof v === "string" ? v : "";
52
+ }
53
+ /** Compare the store's copy of the schema with the one the SDD's method bundle shipped, file by file. */
54
+ export async function compareStoreSchema(repo) {
55
+ const shipped = await files(join(repo, SHIPPED_SCHEMA_DIR));
56
+ if (shipped === null || shipped.length === 0)
57
+ return { shipped: false, missing: [], differing: [], extra: [] };
58
+ const store = (await files(join(repo, STORE_SCHEMA_DIR))) ?? [];
59
+ const missing = shipped.filter((f) => !store.includes(f));
60
+ const extra = store.filter((f) => !shipped.includes(f));
61
+ const differing = [];
62
+ for (const f of shipped.filter((x) => store.includes(x))) {
63
+ const a = await read(join(repo, SHIPPED_SCHEMA_DIR, f));
64
+ const b = await read(join(repo, STORE_SCHEMA_DIR, f));
65
+ if (a === null || b === null || !a.equals(b))
66
+ differing.push(f);
67
+ }
68
+ return { shipped: true, missing, differing, extra };
69
+ }
70
+ /**
71
+ * Replace the store's copy of the schema with the shipped one. Returns what changed, as a
72
+ * comparison taken before the write; a store already current returns empty lists.
73
+ */
74
+ export async function writeStoreSchema(repo) {
75
+ const before = await compareStoreSchema(repo);
76
+ if (!before.shipped)
77
+ return before;
78
+ if (before.missing.length + before.differing.length + before.extra.length === 0)
79
+ return before;
80
+ await rm(join(repo, STORE_SCHEMA_DIR), { recursive: true, force: true });
81
+ await mkdir(join(repo, STORE_SCHEMA_DIR), { recursive: true });
82
+ await cp(join(repo, SHIPPED_SCHEMA_DIR), join(repo, STORE_SCHEMA_DIR), { recursive: true });
83
+ return before;
84
+ }
85
+ //# sourceMappingURL=schema.js.map
@@ -1167,6 +1167,9 @@ async function collectRows(schema, manifest, findings, sddPath, snapshotStore) {
1167
1167
  out.set("requirements", r.requirements);
1168
1168
  out.set("requirement_lineage", r.lineage);
1169
1169
  out.set("requirement_status", r.status);
1170
+ out.set("changes", r.changes);
1171
+ out.set("change_work_order", r.workOrders);
1172
+ out.set("change_work_item", r.workItems);
1170
1173
  }
1171
1174
  // 15. C4 systems + containers + components (L1 + L2 + L3). The L3 walker
1172
1175
  // is the forward-method authoring path reading
@@ -8,7 +8,13 @@
8
8
  // directory name, which is the catalogue feature id; plus one per requirement an open,
9
9
  // agreed change ADDS (stage agreed).
10
10
  // requirement_lineage each archived change, in archive order, whose delta ADDED, MODIFIED or RENAMED a
11
- // requirement that is current now. A change carrying baseline-evidence.md is a baseline.
11
+ // requirement that is current now, under any name a later RENAMED gave it. A change carrying
12
+ // baseline-evidence.md is a baseline. Statuses follow renames the same way.
13
+ // changes each change in the store, open or archived (M-ADR 0223 §4.2): its directory name, its
14
+ // undated name, kind, status (archived, or its agreement record's) and verdict.
15
+ // change_work_order per (change, repository): the record's work order, agreed or completed.
16
+ // change_work_item per (change, work item): the ids its proposal's `## Work Items` section lists, keyed by
17
+ // the undated name so the row survives archive.
12
18
  // requirement_status per (requirement, repository): observed from a baseline's `**Observed in:**` line;
13
19
  // declared-shipped from an archived change whose agreement record's verdict is PASS, through
14
20
  // its work orders' `implements` lists; agreed from an open change whose record is agreed.
@@ -20,7 +26,7 @@
20
26
  import { readdir, readFile, stat } from "node:fs/promises";
21
27
  import { join } from "node:path";
22
28
  import { parse as parseYaml } from "yaml";
23
- import { observedIn, orderArchives, parseRequirements } from "../../behaviour-store/archive.js";
29
+ import { observedIn, orderArchives, parseRequirements, renamePairs } from "../../behaviour-store/archive.js";
24
30
  export { observedIn, parseRequirements };
25
31
  const STORE = "openspec";
26
32
  const AGREEMENTS = join("docs", "agreements");
@@ -62,6 +68,17 @@ async function deltaOf(changeDir) {
62
68
  }
63
69
  return out;
64
70
  }
71
+ async function renamesOf(changeDir) {
72
+ const out = [];
73
+ for (const feature of await dirs(join(changeDir, "specs"))) {
74
+ const text = await readText(join(changeDir, "specs", feature, "spec.md"));
75
+ if (text === null)
76
+ continue;
77
+ for (const r of renamePairs(text))
78
+ out.push({ feature, ...r });
79
+ }
80
+ return out;
81
+ }
65
82
  async function agreementRecord(sddPath, change) {
66
83
  const text = await readText(join(sddPath, AGREEMENTS, `${change}.yaml`));
67
84
  if (text === null)
@@ -82,10 +99,33 @@ export async function archiveOrder(sddPath, names, findings) {
82
99
  }
83
100
  return order;
84
101
  }
102
+ const CHANGE_STATUSES = new Set(["proposed", "agreed", "superseded", "withdrawn"]);
103
+ /**
104
+ * The work-item ids a proposal's `## Work Items` section lists (M-ADR 0223 §4.2): one per bullet, the backticked
105
+ * id if there is one, else the bullet's first word. HTML comments (the template's guidance) are ignored.
106
+ */
107
+ export function parseWorkItems(proposal) {
108
+ const text = proposal.replace(/<!--[\s\S]*?-->/g, "");
109
+ const m = /^##\s+Work Items\s*$/im.exec(text);
110
+ if (!m)
111
+ return [];
112
+ const rest = text.slice(m.index + m[0].length);
113
+ const end = /^##\s/m.exec(rest);
114
+ const ids = [];
115
+ for (const line of (end ? rest.slice(0, end.index) : rest).split("\n")) {
116
+ const b = /^\s*[-*]\s+(.*)$/.exec(line);
117
+ if (!b)
118
+ continue;
119
+ const id = (/`([^`]+)`/.exec(b[1])?.[1] ?? b[1].split(/\s+/)[0] ?? "").replace(/[:,;]+$/, "").trim();
120
+ if (id && !ids.includes(id))
121
+ ids.push(id);
122
+ }
123
+ return ids;
124
+ }
85
125
  const rel = (sddPath, p) => p.slice(sddPath.length + 1).split("\\").join("/");
86
126
  export async function buildRequirements(inputs) {
87
127
  const { sddPath, findings } = inputs;
88
- const empty = { requirements: [], lineage: [], status: [] };
128
+ const empty = { requirements: [], lineage: [], status: [], changes: [], workOrders: [], workItems: [] };
89
129
  const store = join(sddPath, STORE);
90
130
  if (!(await isDir(store)))
91
131
  return empty;
@@ -115,6 +155,28 @@ export async function buildRequirements(inputs) {
115
155
  const events = [];
116
156
  const lineage = [];
117
157
  const unknownRepos = new Set();
158
+ const changes = [];
159
+ const workOrders = [];
160
+ const workItems = [];
161
+ // The change itself, its work orders and its work items (M-ADR 0223 §4.2).
162
+ const recordChange = async (id, name, kind, status, archivedOn, changeDir, record) => {
163
+ const verdict = record?.verdict?.result === "PASS" || record?.verdict?.result === "FAIL" ? record.verdict.result : "";
164
+ changes.push({ id, name, change_kind: kind, status, archived_on: archivedOn, verdict, source_sdd_id: "", platform_sdd_id: "" });
165
+ for (const wo of record?.work_orders ?? []) {
166
+ if (!wo.repo)
167
+ continue;
168
+ if (!inputs.knownRepoIds.has(wo.repo)) {
169
+ unknownRepos.add(wo.repo);
170
+ continue;
171
+ }
172
+ const woStatus = wo.status === "agreed" || wo.status === "completed" ? wo.status : verdict === "PASS" && status === "archived" ? "completed" : "agreed";
173
+ workOrders.push({ id: `${id}__${wo.repo}`, change_id: id, repo_id: wo.repo, status: woStatus, source_sdd_id: "", platform_sdd_id: "" });
174
+ }
175
+ const proposal = await readText(join(changeDir, "proposal.md"));
176
+ for (const item of proposal === null ? [] : parseWorkItems(proposal)) {
177
+ workItems.push({ id: `${name}__${item}`, change_id: id, change_name: name, work_item_id: item, source_sdd_id: "", platform_sdd_id: "" });
178
+ }
179
+ };
118
180
  const pushEvent = (e) => {
119
181
  if (!inputs.knownRepoIds.has(e.repo)) {
120
182
  unknownRepos.add(e.repo);
@@ -123,21 +185,25 @@ export async function buildRequirements(inputs) {
123
185
  if (requirements.has(e.requirementId))
124
186
  events.push(e);
125
187
  };
126
- // 2. Archived changes, in archive order.
127
- for (const dir of await archiveOrder(sddPath, await dirs(join(store, "changes", "archive")), findings)) {
188
+ // 2. Archived changes, in archive order. A requirement is named as it was when each change archived, so its
189
+ // rows are carried forward through every later RENAMED (and one in the same change) to the name it has now;
190
+ // otherwise a rename would read as a requirement born at the rename, with no baseline.
191
+ const archived = [];
192
+ const renames = [];
193
+ const archives = await archiveOrder(sddPath, await dirs(join(store, "changes", "archive")), findings);
194
+ for (const [index, dir] of archives.entries()) {
128
195
  const changeDir = join(store, "changes", "archive", dir);
196
+ renames.push(await renamesOf(changeDir));
129
197
  const change = dir.replace(/^\d{4}-\d{2}-\d{2}-/, "");
130
198
  const archivedOn = /^(\d{4}-\d{2}-\d{2})-/.exec(dir)?.[1] ?? "";
131
199
  const evidence = await readText(join(changeDir, "baseline-evidence.md"));
132
200
  const kind = evidence !== null ? "baseline" : "change";
201
+ await recordChange(dir, change, kind, "archived", archivedOn, changeDir, await agreementRecord(sddPath, change));
133
202
  const delta = await deltaOf(changeDir);
134
203
  for (const d of delta) {
135
204
  if (d.operation === "REMOVED")
136
205
  continue;
137
- const requirementId = `${d.feature}::${d.name}`;
138
- if (!requirements.has(requirementId))
139
- continue;
140
- lineage.push({ id: `${requirementId}__${dir}`, requirement_id: requirementId, change_id: dir, change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" });
206
+ archived.push({ index, feature: d.feature, name: d.name, lineage: { change_id: dir, change_kind: kind, operation: d.operation, archived_on: archivedOn, source_sdd_id: "", platform_sdd_id: "" } });
141
207
  }
142
208
  if (evidence !== null) {
143
209
  const repos = observedIn(evidence);
@@ -147,7 +213,7 @@ export async function buildRequirements(inputs) {
147
213
  for (const repo of repos)
148
214
  for (const d of delta)
149
215
  if (d.operation !== "REMOVED")
150
- pushEvent({ requirementId: `${d.feature}::${d.name}`, repo, status: "observed", basis: dir });
216
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo, status: "observed", basis: dir } });
151
217
  continue;
152
218
  }
153
219
  const record = await agreementRecord(sddPath, change);
@@ -162,13 +228,43 @@ export async function buildRequirements(inputs) {
162
228
  findings.add("convention", `${AGREEMENTS}/${change}.yaml: work order for ${wo.repo} implements "${name}", which the change's delta does not name`);
163
229
  }
164
230
  for (const d of match)
165
- pushEvent({ requirementId: `${d.feature}::${d.name}`, repo: wo.repo, status: "declared-shipped", basis: dir });
231
+ archived.push({ index, feature: d.feature, name: d.name, event: { repo: wo.repo, status: "declared-shipped", basis: dir } });
166
232
  }
167
233
  }
168
234
  }
235
+ const current = (feature, name, from) => {
236
+ let n = name;
237
+ for (let j = from; j < renames.length; j++)
238
+ for (const r of renames[j])
239
+ if (r.feature === feature && r.from === n)
240
+ n = r.to;
241
+ return `${feature}::${n}`;
242
+ };
243
+ // One lineage row per (requirement, change): a change that renames and modifies a requirement records MODIFIED,
244
+ // the behaviour change, and its delta still shows the rename.
245
+ const lineageById = new Map();
246
+ for (const a of archived) {
247
+ const requirementId = current(a.feature, a.name, a.index);
248
+ if (a.event)
249
+ pushEvent({ requirementId, ...a.event });
250
+ else if (a.lineage && requirements.has(requirementId)) {
251
+ const id = `${requirementId}__${a.lineage["change_id"]}`;
252
+ if (lineageById.get(id)?.["operation"] === "MODIFIED")
253
+ continue;
254
+ lineageById.set(id, { id, requirement_id: requirementId, ...a.lineage });
255
+ }
256
+ }
257
+ lineage.push(...lineageById.values());
169
258
  // 3. Open changes whose agreement record is agreed.
170
259
  for (const dir of (await dirs(join(store, "changes"))).filter((d) => d !== "archive")) {
171
260
  const record = await agreementRecord(sddPath, dir);
261
+ const openKind = (await readText(join(store, "changes", dir, "baseline-evidence.md"))) !== null ? "baseline" : "change";
262
+ let openStatus = record?.status ?? "proposed";
263
+ if (!CHANGE_STATUSES.has(openStatus)) {
264
+ findings.add("convention", `${AGREEMENTS}/${dir}.yaml: status "${openStatus}" is not a change status; catalogued as proposed`);
265
+ openStatus = "proposed";
266
+ }
267
+ await recordChange(dir, dir, openKind, openStatus, "", join(store, "changes", dir), record);
172
268
  if (record?.status !== "agreed")
173
269
  continue;
174
270
  const delta = await deltaOf(join(store, "changes", dir));
@@ -203,6 +299,9 @@ export async function buildRequirements(inputs) {
203
299
  requirements: [...requirements.values()].sort(byId),
204
300
  lineage,
205
301
  status: [...status.values()].sort(byId),
302
+ changes: changes.sort(byId),
303
+ workOrders: workOrders.sort(byId),
304
+ workItems: workItems.sort(byId),
206
305
  };
207
306
  }
208
307
  //# sourceMappingURL=requirements.js.map
@@ -7,7 +7,7 @@
7
7
  # Emitted by sdd-cli's YAML emitter with no line folding; the three header scalars are quoted for the
8
8
  # textual readers downstream. The one deliberate difference from the authored form is schema_status:
9
9
  # the authored form says canonical; a projection never does.
10
- schema_version: "4.1.0"
10
+ schema_version: "4.2.0"
11
11
  schema_owner: "sdd-method"
12
12
  schema_status: "generated"
13
13
  enums:
@@ -643,6 +643,31 @@ enums:
643
643
  - value: observed
644
644
  description: Independent evidence from this repository's code matches (a baseline's characterization tests or a reverse snapshot).
645
645
  description: A requirement's status in one implementing repository (M-ADR 0221 §4.5).
646
+ change_status:
647
+ values:
648
+ - value: proposed
649
+ description: Open, with a proposed agreement record or none yet.
650
+ - value: agreed
651
+ description: "Open, with an agreed record: being built."
652
+ - value: superseded
653
+ description: Open, its record superseded by another change.
654
+ - value: withdrawn
655
+ description: Open, its record withdrawn.
656
+ - value: archived
657
+ description: Archived into the living specs.
658
+ description: Where a change stands (M-ADR 0222 §4.6, M-ADR 0223 §4.6).
659
+ judge_verdict:
660
+ values:
661
+ - value: PASS
662
+ - value: FAIL
663
+ description: The judge's verdict on a change (M-ADR 0223 §4.5).
664
+ work_order_status:
665
+ values:
666
+ - value: agreed
667
+ description: Agreed; the repository's work is not yet judged.
668
+ - value: completed
669
+ description: Marked completed by a passing judge.
670
+ description: A work order's status in its change's agreement record (M-ADR 0223 §4.2, §4.6).
646
671
  catalogues:
647
672
  domains:
648
673
  primary_key: id
@@ -4200,7 +4225,7 @@ catalogues:
4200
4225
  - name: change_id
4201
4226
  type: string
4202
4227
  nullable: false
4203
- description: The archived change's directory name (its archive date prefix included).
4228
+ description: The archived change's directory name (its archive date prefix included); a changes.id.
4204
4229
  - name: change_kind
4205
4230
  type: string
4206
4231
  nullable: false
@@ -4226,6 +4251,9 @@ catalogues:
4226
4251
  - column: requirement_id
4227
4252
  references: requirements.id
4228
4253
  nullable: false
4254
+ - column: change_id
4255
+ references: changes.id
4256
+ nullable: false
4229
4257
  column_enums:
4230
4258
  change_kind: enums.requirement_change_kind
4231
4259
  operation: enums.requirement_operation
@@ -4269,3 +4297,111 @@ catalogues:
4269
4297
  nullable: false
4270
4298
  column_enums:
4271
4299
  status: enums.requirement_status_value
4300
+ changes:
4301
+ primary_key: id
4302
+ columns:
4303
+ - name: id
4304
+ type: string
4305
+ nullable: false
4306
+ description: "The change's directory name: the open change's name, or the archived change's directory (its archive date prefix included). requirement_lineage.change_id references it."
4307
+ - name: name
4308
+ type: string
4309
+ nullable: false
4310
+ description: "The change's name without the archive date prefix: the same before and after archive, so a work item that cites the change keeps its link."
4311
+ - name: change_kind
4312
+ type: string
4313
+ nullable: false
4314
+ description: "Bound to requirement_change_kind: baseline (it carries baseline-evidence.md) or change."
4315
+ - name: status
4316
+ type: string
4317
+ nullable: false
4318
+ description: "Bound to change_status: archived for an archived change; otherwise the agreement record's status, or proposed when there is no record yet."
4319
+ - name: archived_on
4320
+ type: string
4321
+ nullable: true
4322
+ description: The archive date (YYYY-MM-DD) from the archived change's directory name; empty for an open change.
4323
+ - name: verdict
4324
+ type: string
4325
+ nullable: true
4326
+ description: "Bound to judge_verdict: the agreement record's verdict result (M-ADR 0223 §4.6). Empty for a baseline, and for a change not yet judged."
4327
+ - name: source_sdd_id
4328
+ type: string
4329
+ nullable: true
4330
+ description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
4331
+ - name: platform_sdd_id
4332
+ type: string
4333
+ nullable: true
4334
+ description: The platform SDD the row belongs to (stamped at aggregation).
4335
+ description: "A change in the SDD's behaviour store (M-ADR 0223 §4.2), open or archived, including baseline changes. Derived, never authored: from the store's change directories and their agreement records (M-ADR 0222 §4.6)."
4336
+ column_enums:
4337
+ change_kind: enums.requirement_change_kind
4338
+ status: enums.change_status
4339
+ verdict: enums.judge_verdict
4340
+ change_work_order:
4341
+ primary_key: id
4342
+ columns:
4343
+ - name: id
4344
+ type: string
4345
+ nullable: false
4346
+ description: <change_id>__<repo_id>.
4347
+ - name: change_id
4348
+ type: string
4349
+ nullable: false
4350
+ - name: repo_id
4351
+ type: string
4352
+ nullable: false
4353
+ description: The repository the work order is for.
4354
+ - name: status
4355
+ type: string
4356
+ nullable: false
4357
+ description: "Bound to work_order_status: the record's status for the work order; a judge PASS marks it completed (M-ADR 0223 §4.6)."
4358
+ - name: source_sdd_id
4359
+ type: string
4360
+ nullable: true
4361
+ description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
4362
+ - name: platform_sdd_id
4363
+ type: string
4364
+ nullable: true
4365
+ description: The platform SDD the row belongs to (stamped at aggregation).
4366
+ description: "Edge: a change's work order for one implementing repository (M-ADR 0223 §4.2), from the change's agreement record. Derived, never authored."
4367
+ foreign_keys:
4368
+ - column: change_id
4369
+ references: changes.id
4370
+ nullable: false
4371
+ - column: repo_id
4372
+ references: repositories.id
4373
+ nullable: false
4374
+ column_enums:
4375
+ status: enums.work_order_status
4376
+ change_work_item:
4377
+ primary_key: id
4378
+ columns:
4379
+ - name: id
4380
+ type: string
4381
+ nullable: false
4382
+ description: "<change_name>__<work_item_id>: keyed by the change's undated name, so the row is the same before and after archive."
4383
+ - name: change_id
4384
+ type: string
4385
+ nullable: false
4386
+ description: "The change as it stands now: its open name, or its archive directory."
4387
+ - name: change_name
4388
+ type: string
4389
+ nullable: false
4390
+ description: The change's undated name.
4391
+ - name: work_item_id
4392
+ type: string
4393
+ nullable: false
4394
+ description: The work item's id in the adopter's work-item tool, as the proposal's Work Items section gives it.
4395
+ - name: source_sdd_id
4396
+ type: string
4397
+ nullable: true
4398
+ description: Originating SDD (stamped at aggregation; empty in a per-SDD build).
4399
+ - name: platform_sdd_id
4400
+ type: string
4401
+ nullable: true
4402
+ description: The platform SDD the row belongs to (stamped at aggregation).
4403
+ description: "Annotation: a work item a change's proposal says it delivers (M-ADR 0223 §4.2), giving the roll-up from work item to changes to requirements. The work item's id is the adopter's work-item tool's (M-ADR 0035), so it has no foreign key."
4404
+ foreign_keys:
4405
+ - column: change_id
4406
+ references: changes.id
4407
+ nullable: false
@@ -905,3 +905,31 @@ coverage:
905
905
  kind: enum
906
906
  scoring: not-applicable
907
907
  reason: closed vocabulary, not an artefact
908
+ changes:
909
+ kind: entity
910
+ scoring: not-applicable
911
+ reason: projected from the behaviour store, whose change gate and judge hold its quality (M-ADR 0222, M-ADR 0223)
912
+ change_work_order:
913
+ kind: edge
914
+ scoring: derived-edge
915
+ class: realisation
916
+ impact_flow: both
917
+ reason: derived from agreement records and verdicts
918
+ change_work_item:
919
+ kind: annotation
920
+ scoring: derived-edge
921
+ class: traceability
922
+ impact_flow: none
923
+ reason: derived from each change's proposal
924
+ change_status:
925
+ kind: enum
926
+ scoring: not-applicable
927
+ reason: closed vocabulary, not an artefact
928
+ judge_verdict:
929
+ kind: enum
930
+ scoring: not-applicable
931
+ reason: closed vocabulary, not an artefact
932
+ work_order_status:
933
+ kind: enum
934
+ scoring: not-applicable
935
+ reason: closed vocabulary, not an artefact
@@ -144,8 +144,10 @@ export class MethodValidation {
144
144
  if (code !== 0 && !step.advisory) failed++
145
145
  lines.push(verdict.padEnd(8) + " exit " + code + " " + step.id + " (" + step.title + ")")
146
146
  if (code !== 0 || measuredNothing) {
147
- const out = (await ran.stdout()) + (await ran.stderr())
148
- lines.push(...out.trim().split("\n").slice(-6).map((l) => " " + l))
147
+ const out = ((await ran.stdout()) + (await ran.stderr())).trim().split("\n")
148
+ // Every failure line, then the step's last lines: a long report never hides a finding behind its tail.
149
+ const failures = out.filter((l) => /^\s*\[FAIL\] /.test(l))
150
+ lines.push(...[...failures, ...out.slice(-6).filter((l) => !failures.includes(l))].map((l) => " " + l))
149
151
  }
150
152
  }
151
153
  lines.push(failed === 0 ? "RESULT: PASS" : "RESULT: FAIL (" + failed + " failing step(s))")
@@ -14,13 +14,6 @@ const FORWARD_BASE_BUNDLES = new Set([
14
14
  "reverse-baseline",
15
15
  ]);
16
16
  export const REPO_KIND_FILE = ".sdd-repo-kind";
17
- /**
18
- * Legacy marker filename from the pre-rebrand era. Read-only fallback during
19
- * the rebrand transition: `readRepoKind` accepts either filename; `sync`
20
- * auto-migrates legacy → canonical. New writes always use `REPO_KIND_FILE`.
21
- * Drop after one minor-version overlap.
22
- */
23
- export const LEGACY_REPO_KIND_FILE = ".iteraitive-repo-kind";
24
17
  const PROFILE_BY_REPO_KIND = {
25
18
  "platform-product": "platform",
26
19
  "application-product": "application",
@@ -103,8 +96,7 @@ export function isBundleName(value) {
103
96
  return ALL_BUNDLE_NAMES.has(value);
104
97
  }
105
98
  export async function readRepoKind(repoRoot) {
106
- // Canonical first; fall back to legacy marker during the rebrand transition.
107
- for (const filename of [REPO_KIND_FILE, LEGACY_REPO_KIND_FILE]) {
99
+ for (const filename of [REPO_KIND_FILE]) {
108
100
  const path = join(repoRoot, filename);
109
101
  let raw;
110
102
  try {
@@ -1,4 +1,4 @@
1
- // Archetype behaviour, seeded once and then cited (reference architecture sdm-d93u.4.1).
1
+ // Archetype behaviour, seeded once and then cited.
2
2
  //
3
3
  // A reference-architecture archetype ships its behaviour as canonical rows (behaviour.tql, rows of the
4
4
  // behaviour schema in the M-ADR 0210 subset). An estate does not sync them: it takes them ONCE, when it
@@ -131,7 +131,7 @@ async function writeAt(path, content) {
131
131
  export async function seedArchetypeBehaviour(input) {
132
132
  const store = join(input.targetRepo, "openspec");
133
133
  if (!(await exists(store)))
134
- return { filesWritten: [], skipped: "no behaviour store (openspec/) in this repository" };
134
+ return { filesWritten: [], skipped: "no behaviour store (openspec/) in this repository; `sdd-cli scaffold behaviour-store` creates one" };
135
135
  const reqs = await loadArchetypeBehaviour(input.bundleCacheDir, input.seeds);
136
136
  if (!reqs || reqs.length === 0)
137
137
  return { filesWritten: [], skipped: `archetype ${input.archetypeId} ships no behaviour rows` };
@@ -154,8 +154,10 @@ export async function seedArchetypeBehaviour(input) {
154
154
  `# ${CITATIONS_FILE} — the ${input.domain} domain's requirements that answer to reference-architecture archetype requirements.`,
155
155
  "# Seeded once from the archetype; the requirements are this estate's. Per citation: `requirement` is this estate's",
156
156
  "# requirement title, `cites` the archetype requirement id, `digest` that requirement at `version`, and `relation` one of",
157
- "# derived | extended | narrowed | departed (departed needs `reason` and `decision`). Checked by",
158
- "# `sdd-cli validate --validator archetype-citations`: a changed source is reported until reviewed and re-pinned.",
157
+ "# derived | extended | narrowed | departed (departed needs `reason` and `decision`; narrowed and extended may give",
158
+ "# a `reason`). Keep `digest` quoted. An archetype requirement this estate decides not to take goes under",
159
+ "# `not_adopted:` as {cites, reason, decision}. Checked by `sdd-cli validate --validator archetype-citations`: a",
160
+ "# changed source is reported until reviewed and re-pinned, and requirements neither cited nor not adopted are listed.",
159
161
  "",
160
162
  ].join("\n");
161
163
  const files = [
@@ -187,14 +187,16 @@ const CITATION_LINK = /\]\(([^)\s#]+)(?:#[^)\s]*)?\)/g;
187
187
  *
188
188
  * A citation is the moved path written literally (repo-relative), or a Markdown
189
189
  * link whose target resolves to it from the citing file. Method-managed files are
190
- * excluded: the bundle already ships them corrected.
190
+ * excluded: the bundle already ships them corrected. So is a moved file itself, at
191
+ * its old path: the sync renames it and replaces its content with the shipped copy.
191
192
  */
192
193
  export async function findStrandedCitations(targetRepo, moved, managed, trackedTextFiles) {
193
194
  if (moved.length === 0)
194
195
  return [];
196
+ const leaving = new Set(moved.map((m) => m.fromPath));
195
197
  const out = [];
196
198
  for (const file of trackedTextFiles) {
197
- if (managed.has(file))
199
+ if (managed.has(file) || leaving.has(file))
198
200
  continue;
199
201
  let text;
200
202
  try {
@@ -16,6 +16,7 @@ import { syncBriefingFiles } from "./briefing-merge.js";
16
16
  import { DEFAULT_AGENT_HARNESS } from "../agent-harness.js";
17
17
  import { BLOCK_BEGIN, BLOCK_END } from "../init/ci-workflow.js";
18
18
  import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, parseCiRunner, syncCiBinding, } from "../ci-binding/index.js";
19
+ import { SCHEMA_NAME, STORE_SCHEMA_DIR, storeSchemaName, writeStoreSchema } from "../behaviour-store/schema.js";
19
20
  import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, briefingWrappers, } from "../briefing/index.js";
20
21
  const SECTION_RULE = "═".repeat(59);
21
22
  /**
@@ -236,9 +237,19 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
236
237
  ...(migrateFrom?.managedPaths ?? []),
237
238
  ];
238
239
  const withdrawnKeep = new Set(adrTransitions.protectedFromRemoval);
239
- const removals = computeRemovals(prevPaths, materialisePaths, {
240
- noDelete: !opts.delete,
241
- }).filter((p) => !withdrawnKeep.has(p));
240
+ // A path the incoming bundle still manages is never a removal, whether it materialises into the tree or
241
+ // only into the bundle cache: the lock records cache-only paths too, so comparing against the
242
+ // materialised set alone scheduled every cache-only path as a removal on every sync.
243
+ // A cache-only path that IS in the tree was left there by a sync from before cache-only mode
244
+ // (M-ADR 0176 §4.3); it is removed, as before, but named as what it is.
245
+ const prevSet = new Set(prevPaths);
246
+ const cacheOnlyStrays = opts.delete
247
+ ? cacheOnlyPaths.filter((p) => prevSet.has(p) && existsSync(join(opts.targetRepo, p)))
248
+ : [];
249
+ const removals = [
250
+ ...computeRemovals(prevPaths, bundle.managedPaths, { noDelete: !opts.delete }),
251
+ ...cacheOnlyStrays,
252
+ ].filter((p) => !withdrawnKeep.has(p));
242
253
  stdout(info(`Bundle: ${incomingBundleName} (version: ${bundle.manifest.bundleVersion})`));
243
254
  stdout(info(`Incoming managed files: ${bundle.managedPaths.length}`));
244
255
  if (cacheOnlyPaths.length > 0) {
@@ -290,8 +301,12 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
290
301
  stdout(info(`Removals from previous sync of this bundle: ${removals.length}` +
291
302
  (lockOnly > 0
292
303
  ? ` — ${removalsOnDisk.length} file(s) on disk to delete, ${lockOnly} lock entr(ies) only ` +
293
- `(paths never materialised here, e.g. cache-only payload members; nothing is deleted for these)`
304
+ `(no file at the path in this tree; nothing is deleted for these)`
294
305
  : ` (all present on disk)`)));
306
+ if (cacheOnlyStrays.length > 0) {
307
+ stdout(info(`Of these, ${cacheOnlyStrays.length} cache-only path(s) were left in the tree by a sync from before cache-only mode; ` +
308
+ `the bundle cache holds them now, so the tree copies are removed`));
309
+ }
295
310
  stdout(info(`ADR renames to apply: ${adrTransitions.renames.length}`));
296
311
  stdout(info(`ADR transition warnings: ${adrTransitions.warnings.length}`));
297
312
  if (adrTransitions.warnings.length > 0) {
@@ -303,7 +318,8 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
303
318
  // each citation in the adopter's own documents to a moved path. Never
304
319
  // rewritten; printed in dry-run too, so the figure a reviewer approves is the
305
320
  // figure the run states.
306
- const stranded = await findStrandedCitations(opts.targetRepo, adrTransitions.movedPaths, new Set(materialisePaths), await trackedTextFiles(opts.targetRepo));
321
+ // The managed set includes the paths this sync removes: they are method files too.
322
+ const stranded = await findStrandedCitations(opts.targetRepo, adrTransitions.movedPaths, new Set([...materialisePaths, ...removals]), await trackedTextFiles(opts.targetRepo));
307
323
  if (stranded.length > 0) {
308
324
  stdout(warn(`Citations to moved paths: ${stranded.length} — the adopter's own documents still name ` +
309
325
  `a path the method moved. Update each (not rewritten automatically):`));
@@ -577,6 +593,17 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
577
593
  }
578
594
  for (const line of briefingResult.log)
579
595
  stdout(info(line));
596
+ // The store's copy of the method's change schema (M-ADR 0223 §4.2). The bundle ships it inert under
597
+ // docs/method/foundations/openspec/, and OpenSpec reads only the store's copy, so a store inside this
598
+ // SDD that selects the schema gets the shipped one. The copy is method-managed (the behaviour-store
599
+ // validator fails a drifted copy); adopter guidance belongs in the store's config.yaml rules.
600
+ if ((await storeSchemaName(opts.targetRepo)) === SCHEMA_NAME) {
601
+ const c = await writeStoreSchema(opts.targetRepo);
602
+ const changed = [...c.missing, ...c.differing, ...c.extra];
603
+ if (c.shipped && changed.length > 0) {
604
+ stdout(info(`Refreshed ${STORE_SCHEMA_DIR}/ from the shipped schema: ${changed.length} file(s) changed (${changed.join(", ")})`));
605
+ }
606
+ }
580
607
  // Record the wrapper block and the module exactly as they now stand on disk, so the next sync
581
608
  // can tell them untouched from edited (M-ADR 0218 §4.7), and the binding and the CLI source
582
609
  // when they are not the defaults.
@@ -11,6 +11,13 @@
11
11
  // (move `digest` and `version`), or record that the estate departs.
12
12
  // A departed citation is never stale: departing is a decision, recorded with its reason, not drift. With no
13
13
  // citations anywhere the check reports [N/A], an empty population, not a pass.
14
+ //
15
+ // A digest must be a quoted string: YAML reads an unquoted all-digit value, or digits around one `e`, as a
16
+ // number, which would compare as a false stale. A narrowed or extended citation may say why in `reason`
17
+ // (`note` is read as the same). `not_adopted` records archetype requirements the estate has decided not to
18
+ // take, each with its reason and decision; every id must resolve, and none may be both cited and not adopted.
19
+ // A coverage line names the archetype requirements neither cited nor recorded as not adopted: the gaps, which
20
+ // are reported, not failed, since an estate may be part-way through adopting.
14
21
  import { readdir, readFile } from "node:fs/promises";
15
22
  import { join } from "node:path";
16
23
  import { parse as parseYaml } from "yaml";
@@ -92,12 +99,34 @@ export async function checkArchetypeCitations(repo) {
92
99
  const byId = new Map((reqs ?? []).map((r) => [r.id, r]));
93
100
  const at = cachedVersion(archetype.bundleCacheDir) ?? "the cached bundle";
94
101
  const cites = Array.isArray(doc.citations) ? doc.citations : [];
95
- if (cites.length === 0)
102
+ const notAdopted = Array.isArray(doc.not_adopted) ? doc.not_adopted : [];
103
+ if (cites.length === 0 && notAdopted.length === 0)
96
104
  bad("unresolved", `${rel}: lists no citations`);
105
+ const citedIds = new Set(cites.map((c) => String(c.cites)));
106
+ for (const n of notAdopted) {
107
+ total++;
108
+ const tag = `${rel}: not adopted ${String(n.cites)}`;
109
+ if (!byId.has(String(n.cites))) {
110
+ bad("unresolved", `${tag}: ${archetype.bundleName} ${at} has no archetype requirement ${String(n.cites)}`);
111
+ }
112
+ else if (!n.reason || !n.decision) {
113
+ bad("unresolved", `${tag}: a non-adoption needs its reason and the decision that took it`);
114
+ }
115
+ else if (citedIds.has(String(n.cites))) {
116
+ bad("unresolved", `${tag}: it is also cited; an archetype requirement is either answered or not adopted`);
117
+ }
118
+ else {
119
+ lines.push(`[ok] ${tag} (${String(n.reason)})`);
120
+ }
121
+ }
97
122
  for (const c of cites) {
98
123
  total++;
99
124
  const tag = `${rel}: '${String(c.requirement)}' -> ${String(c.cites)}`;
100
125
  const relation = String(c.relation);
126
+ if (typeof c.digest !== "string") {
127
+ bad("unresolved", `${tag}: digest must be a quoted string (YAML read ${String(c.digest)} as a ${typeof c.digest}); quote it`);
128
+ continue;
129
+ }
101
130
  if (!RELATIONS.includes(relation)) {
102
131
  bad("unresolved", `${tag}: relation must be one of ${RELATIONS.join(", ")} (got ${relation})`);
103
132
  continue;
@@ -122,9 +151,15 @@ export async function checkArchetypeCitations(repo) {
122
151
  bad("stale", `${tag}: the archetype requirement changed (${String(c.digest)} at ${String(doc.version)} -> ${src.digest} at ${at}); review it and re-pin, or record a departure`);
123
152
  }
124
153
  else {
125
- lines.push(`[ok] ${tag} (${relation})`);
154
+ const why = c.reason ?? c.note;
155
+ lines.push(`[ok] ${tag} (${relation}${why && relation !== "derived" ? `: ${String(why)}` : ""})`);
126
156
  }
127
157
  }
158
+ const answered = new Set([...citedIds, ...notAdopted.map((n) => String(n.cites))]);
159
+ const gaps = [...byId.keys()].filter((id) => !answered.has(id));
160
+ lines.push(`[coverage] ${rel}: ${byId.size - gaps.length} of ${byId.size} ${archetype.id} requirements answered ` +
161
+ `(${[...citedIds].filter((id) => byId.has(id)).length} cited, ${notAdopted.length} not adopted)` +
162
+ (gaps.length ? `; not yet answered: ${gaps.join(", ")}` : ""));
128
163
  }
129
164
  const verdict = problems
130
165
  ? `[FAIL] archetype citations: ${problems} of ${total} unresolved or stale`
@@ -25,6 +25,11 @@
25
25
  // build finding; this check makes it a failure, so a shipped requirement cannot vanish from the
26
26
  // catalogue with every check green. Without catalogues/features.csv the check is unresolved, not
27
27
  // passed.
28
+ // 8. overlap: no requirement of a living spec is named by two open, agreed changes, whether added,
29
+ // modified, removed or renamed (M-ADR 0223 §4.10). Proposals may overlap.
30
+ // 9. schema: the store's config.yaml selects the method's sdd-change schema, and the store's copy of it
31
+ // (openspec/schemas/sdd-change/) is identical to the one the SDD's bundle shipped
32
+ // (docs/method/foundations/openspec/sdd-change/). Sync refreshes the copy (M-ADR 0223 §4.2).
28
33
  // 6. scope: a baseline ADDS requirements; it REMOVES, MODIFIES or RENAMES only a requirement whose
29
34
  // latest archived change was a baseline, which is how a corrective baseline withdraws
30
35
  // what a baseline admitted wrongly. A requirement a judged change shipped is changed only
@@ -34,14 +39,18 @@
34
39
  //
35
40
  // OPENSPEC'S OWN VALIDATION is the separate validator `behaviour-store-openspec`, because it needs
36
41
  // the OpenSpec CLI at OPENSPEC_VERSION (the Method Validation module installs it). Without a store
37
- // both report [N/A], an empty population, never a pass.
42
+ // both report [N/A], an empty population, never a pass. It also archives each open, agreed change in a
43
+ // scratch copy of the store and validates the specs it would leave (M-ADR 0223 §4.5): a change's own
44
+ // --strict run exempts its MODIFIED requirements from the 500-character limit the specs are held to.
38
45
  import { execFile } from "node:child_process";
39
- import { access, readdir, readFile } from "node:fs/promises";
46
+ import { access, cp, mkdtemp, readdir, readFile, rm } from "node:fs/promises";
47
+ import { tmpdir } from "node:os";
40
48
  import { basename, join } from "node:path";
41
49
  import { promisify } from "node:util";
42
50
  import { parseDocument } from "yaml";
43
51
  import { observedIn, orderArchives, parseRequirements, parseSurfaces, renamedFrom, surfaceMatches } from "../behaviour-store/archive.js";
44
52
  import { parseCatalogueCsv } from "../catalogue/csv-reader.js";
53
+ import { compareStoreSchema, SCHEMA_NAME, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, storeSchemaName } from "../behaviour-store/schema.js";
45
54
  const execFileAsync = promisify(execFile);
46
55
  /** The OpenSpec release the method depends on (M-ADR 0223 ruling 2). The module installs exactly this. */
47
56
  export const OPENSPEC_VERSION = "1.14.3";
@@ -241,7 +250,9 @@ export async function checkBehaviourStore(repo) {
241
250
  const record = records.get(change);
242
251
  if (tasks !== null && TASK_DONE_RE.test(tasks)) {
243
252
  started++;
244
- if (!record)
253
+ if (!record && recordFiles.some((f) => f.replace(/\.ya?ml$/, "") === change))
254
+ failures.push(`${dir}: has completed tasks but its agreement record (${AGREEMENTS_DIR}/${change}.yaml) is malformed; see its record failures`);
255
+ else if (!record)
245
256
  failures.push(`${dir}: has completed tasks but no agreement record (${AGREEMENTS_DIR}/${change}.yaml)`);
246
257
  else if (record.status !== "agreed")
247
258
  failures.push(`${dir}: has completed tasks but its agreement record is ${record.status}, not agreed`);
@@ -264,6 +275,29 @@ export async function checkBehaviourStore(repo) {
264
275
  }
265
276
  lines.push(`agreement: ${open.length} open change(s), ${started} with completed tasks`);
266
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`);
278
+ // 8. overlap: two open, agreed changes never name the same requirement of one living spec (M-ADR 0223 §4.10).
279
+ // A name counts whether the delta adds, modifies, removes or renames it (both FROM and TO). Proposals may overlap.
280
+ const claimed = new Map();
281
+ let agreedOpen = 0;
282
+ for (const change of open) {
283
+ if (records.get(change)?.status !== "agreed")
284
+ continue;
285
+ agreedOpen++;
286
+ const specsDir = join(repo, STORE_DIR, "changes", change, "specs");
287
+ for (const spec of await dirs(specsDir)) {
288
+ const text = (await readText(join(specsDir, spec, "spec.md"))) ?? "";
289
+ const names = new Set([...parseRequirements(text).filter((r) => r.operation).map((r) => r.name), ...renamedFrom(text)]);
290
+ for (const name of names) {
291
+ const key = `${spec}::${name}`;
292
+ const other = claimed.get(key);
293
+ if (other)
294
+ failures.push(`${STORE_DIR}/changes/${change}: OVERLAP: requirement "${name}" of ${spec} is also named by agreed change ${other}; one waits for the other to archive, or the two become one change (M-ADR 0223 §4.10)`);
295
+ else
296
+ claimed.set(key, change);
297
+ }
298
+ }
299
+ }
300
+ lines.push(agreedOpen < 2 ? `overlap: vacuous (${agreedOpen} open agreed change)` : `overlap: ${claimed.size} requirement name(s) across ${agreedOpen} open agreed changes`);
267
301
  // 4. archived changes
268
302
  const archived = await dirs(join(repo, STORE_DIR, "changes", "archive"));
269
303
  let baselines = 0;
@@ -290,7 +324,31 @@ export async function checkBehaviourStore(repo) {
290
324
  lines.push(`verdicts: ${archived.length} archived change(s), ${baselines} baseline(s), ${archived.length - baselines} needing a PASS verdict`);
291
325
  // 5. surfaces, and 6. what each baseline may change, in archive order
292
326
  const archiveRel = `${STORE_DIR}/changes/archive`;
293
- const { order, ties } = await orderArchives(repo, archiveRel, archived);
327
+ const { order, ties, history } = await orderArchives(repo, archiveRel, archived);
328
+ // Same-day archives the history cannot order sit in one group. A scope verdict that a group peer could reverse
329
+ // (it touches the same requirement) depends on an order nobody knows, so it is unresolved, not a violation. Without
330
+ // git history (a worktree upload, a plain copy, an export) every same-day pair is such a group.
331
+ const peers = new Map();
332
+ for (const [a, b] of ties) {
333
+ const group = peers.get(a) ?? peers.get(b) ?? new Set();
334
+ group.add(a).add(b);
335
+ peers.set(a, group).set(b, group);
336
+ }
337
+ const touches = new Map();
338
+ for (const name of peers.keys()) {
339
+ const keys = new Set();
340
+ for (const feature of await dirs(join(repo, archiveRel, name, "specs"))) {
341
+ const text = (await readText(join(repo, archiveRel, name, "specs", feature, "spec.md"))) ?? "";
342
+ for (const r of parseRequirements(text))
343
+ if (r.operation)
344
+ keys.add(`${feature}::${r.name}`);
345
+ for (const from of renamedFrom(text))
346
+ keys.add(`${feature}::${from}`);
347
+ }
348
+ touches.set(name, keys);
349
+ }
350
+ const orderDecides = (name, key) => [...(peers.get(name) ?? [])].some((p) => p !== name && touches.get(p)?.has(key));
351
+ let scopeUnresolved = 0;
294
352
  const servedBy = baselines > 0 ? await catalogueContracts(repo) : null;
295
353
  let surfacesChecked = 0;
296
354
  const lastTouch = new Map();
@@ -338,7 +396,11 @@ export async function checkBehaviourStore(repo) {
338
396
  if (isBaseline) {
339
397
  scopeChecked++;
340
398
  const last = lastTouch.get(key);
341
- if (last !== "baseline") {
399
+ if (last !== "baseline" && orderDecides(name, key)) {
400
+ scopeUnresolved++;
401
+ failures.push(`${dir}: scope unresolved for "${t}" (${feature}): the verdict depends on the order of same-day archives the history cannot give${history ? "" : " (no git history here: run from a clone)"}`);
402
+ }
403
+ else if (last !== "baseline") {
342
404
  failures.push(`${dir}: a baseline changes "${t}" (${feature}), which ${last === "change" ? "a judged change last shipped" : "no earlier archived change admitted"}; a baseline may remove or modify only what a baseline admitted, and anything else takes a change (M-ADR 0222 §4.4)`);
343
405
  }
344
406
  }
@@ -357,6 +419,10 @@ export async function checkBehaviourStore(repo) {
357
419
  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"}`;
358
420
  lines.push(baselines === 0 ? "surfaces: vacuous (no baseline)" : `surfaces: ${baselines} baseline(s), ${surfacesChecked} served contract(s) checked against catalogues/repo_contract.csv${unseen}`);
359
421
  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
+ if (!history && archived.length > 0)
423
+ lines.push("order: no git history (not a git work tree, or git log failed), so same-day archives cannot be ordered");
424
+ if (scopeUnresolved > 0)
425
+ lines.push(`scope: ${scopeUnresolved} verdict(s) unresolved, depending on an order the history cannot give`);
360
426
  for (const [a, b] of ties)
361
427
  lines.push(`order: ${a} and ${b} were archived the same day and the history does not order them; ordered by name`);
362
428
  // 7. living specs keyed by a catalogue feature
@@ -378,6 +444,30 @@ export async function checkBehaviourStore(repo) {
378
444
  else {
379
445
  lines.push("features: vacuous (no living spec)");
380
446
  }
447
+ // 9. schema: the store uses the method's sdd-change schema, and its copy is the one the SDD's bundle
448
+ // shipped (M-ADR 0223 §4.2). OpenSpec reads the copy, so an edited or stale copy changes what every
449
+ // change is asked for, with nothing else noticing.
450
+ const selected = await storeSchemaName(repo);
451
+ if (selected !== SCHEMA_NAME) {
452
+ failures.push(`${STORE_CONFIG}: ${selected === null ? "missing" : selected === "" ? "names no schema" : `selects schema "${selected}"`}; a behaviour store uses the method's ${SCHEMA_NAME} schema (M-ADR 0223 §4.2)`);
453
+ }
454
+ else {
455
+ const c = await compareStoreSchema(repo);
456
+ if (!c.shipped) {
457
+ failures.push(`schema unresolved: this SDD holds no shipped ${SCHEMA_NAME} schema (${SHIPPED_SCHEMA_DIR}/); sync a method bundle that carries it`);
458
+ }
459
+ else {
460
+ const fix = "sdd-cli sync refreshes it; put adopter guidance in the store's config.yaml rules, not in the schema";
461
+ for (const f of c.missing)
462
+ failures.push(`${STORE_SCHEMA_DIR}/${f}: missing from the store's copy of the schema; ${fix}`);
463
+ for (const f of c.differing)
464
+ failures.push(`${STORE_SCHEMA_DIR}/${f}: differs from the shipped ${SHIPPED_SCHEMA_DIR}/${f}; ${fix}`);
465
+ for (const f of c.extra)
466
+ failures.push(`${STORE_SCHEMA_DIR}/${f}: not in the shipped schema; ${fix}`);
467
+ const drift = c.missing.length + c.differing.length + c.extra.length;
468
+ lines.push(drift === 0 ? `schema: ${SCHEMA_NAME}, identical to the shipped copy` : `schema: ${SCHEMA_NAME}, ${drift} file(s) differ from the shipped copy`);
469
+ }
470
+ }
381
471
  const population = recordFiles.length + open.length + archived.length;
382
472
  const body = lines.map((l) => ` ${l}`).join("\n") + "\n";
383
473
  if (failures.length > 0) {
@@ -385,7 +475,7 @@ export async function checkBehaviourStore(repo) {
385
475
  }
386
476
  if (population === 0)
387
477
  return { exitCode: 0, stdout: body + `[N/A] the behaviour store has no changes and no agreement records yet\n` };
388
- return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, verdict, surfaces, scope and feature checks hold\n" };
478
+ return { exitCode: 0, stdout: body + "[PASS] behaviour store: records, agreement, stale, overlap, verdict, surfaces, scope, feature and schema checks hold\n" };
389
479
  }
390
480
  /** Per repository, the contracts the catalogue says it serves (repo_contract ⋈ contracts.spec_path); null when either file is missing. */
391
481
  async function catalogueContracts(repo) {
@@ -476,6 +566,47 @@ export async function checkBehaviourStoreOpenspec(repo) {
476
566
  if (r.code !== 0 && (t.failed ?? 0) === 0)
477
567
  failures.push(`${label}: OpenSpec exited ${r.code} with no failed item reported`);
478
568
  }
569
+ // Each open, agreed change, archived in a scratch copy: the specs it would leave must validate --strict.
570
+ let dryArchived = 0;
571
+ for (const change of (await dirs(join(repo, STORE_DIR, "changes"))).filter((d) => d !== "archive")) {
572
+ const rel = `${AGREEMENTS_DIR}/${change}.yaml`;
573
+ const text = await readText(join(repo, rel));
574
+ if (text === null || parseAgreementRecord(rel, text).record?.status !== "agreed")
575
+ continue;
576
+ const scratch = await mkdtemp(join(tmpdir(), "sdd-dry-archive-"));
577
+ try {
578
+ await cp(join(repo, STORE_DIR), join(scratch, STORE_DIR), { recursive: true });
579
+ const label = `specs after archiving ${change} (dry, in a copy)`;
580
+ const a = await runOpenspec(scratch, ["archive", change, "--yes", "--json"]);
581
+ if (a.code !== 0) {
582
+ const said = [...a.raw.matchAll(/"(?:message|error)":\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
583
+ failures.push(`${label}: the change does not archive (exit ${a.code}): ${said.length > 0 ? said.join("; ") : a.raw.trim().split("\n").slice(-3).join(" | ")}`);
584
+ continue;
585
+ }
586
+ dryArchived++;
587
+ const v = await runOpenspec(scratch, ["validate", "--specs", "--strict", "--json", "--no-interactive"]);
588
+ const t = v.json?.summary?.totals;
589
+ if (!t) {
590
+ failures.push(`${label}: OpenSpec returned no report (exit ${v.code}): ${v.raw.trim().split("\n").slice(-3).join(" | ")}`);
591
+ continue;
592
+ }
593
+ lines.push(`${label}: ${t.items ?? 0} spec(s), ${t.passed ?? 0} passed, ${t.failed ?? 0} failed`);
594
+ for (const item of v.json?.items ?? []) {
595
+ if (item.valid)
596
+ continue;
597
+ for (const i of item.issues ?? [])
598
+ failures.push(`${label}: ${item.id}${i.path ? ` (${i.path})` : ""}: ${i.level} ${i.message}`);
599
+ if ((item.issues ?? []).length === 0)
600
+ failures.push(`${label}: ${item.id}: invalid`);
601
+ }
602
+ if (v.code !== 0 && (t.failed ?? 0) === 0)
603
+ failures.push(`${label}: OpenSpec exited ${v.code} with no failed spec reported`);
604
+ }
605
+ finally {
606
+ await rm(scratch, { recursive: true, force: true });
607
+ }
608
+ }
609
+ lines.push(`dry archive: ${dryArchived} open, agreed change(s) archived in a copy`);
479
610
  const body = [` OpenSpec ${version}`, ...lines.map((l) => ` ${l}`)].join("\n") + "\n";
480
611
  if (failures.length > 0)
481
612
  return { exitCode: 1, stdout: body + failures.map((f) => `[FAIL] ${f}`).join("\n") + "\n" };
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.9.0",
3
- "built_against_sdd_cli": "2.3.0"
2
+ "version": "0.10.0",
3
+ "built_against_sdd_cli": "2.4.0"
4
4
  }
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "2.3.0",
3
- "built_against_sdd_cli": "2.3.0"
2
+ "version": "2.4.0",
3
+ "built_against_sdd_cli": "2.4.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/maintain-cli",
3
- "version": "0.9.0",
4
- "sddCliVersion": "2.3.0",
3
+ "version": "0.10.0",
4
+ "sddCliVersion": "2.4.0",
5
5
  "description": "Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Ships only the code those verbs reach; it cannot author specification content. Authoring is in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",