@sdd-method/author-cli 0.8.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.
@@ -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 = [
@@ -0,0 +1,60 @@
1
+ // Create an SDD's behaviour store, inside the SDD (M-ADR 0221 §4.2): an OpenSpec project at
2
+ // openspec/ that selects the method's sdd-change schema (M-ADR 0223 §4.2). A store is created when
3
+ // its SDD first drafts a proposed change, agrees a change or baselines behaviour, not in advance, so this is an authoring step,
4
+ // never part of sync. Sync keeps the schema copy current afterwards.
5
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { parseDocument } from "yaml";
8
+ import { SHIPPED_CONFIG_TEMPLATE, SHIPPED_SCHEMA_DIR, STORE_CONFIG, STORE_SCHEMA_DIR, writeStoreSchema } from "../behaviour-store/schema.js";
9
+ import { MANIFEST_REL_PATH } from "../catalogue/manifest-loader.js";
10
+ export const STORE_KEEP_FILES = ["openspec/specs/.gitkeep", "openspec/changes/archive/.gitkeep", "docs/agreements/.gitkeep"];
11
+ async function exists(p) {
12
+ try {
13
+ await access(p);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ /** The refusal, or null when the store can be created. */
21
+ export async function storeScaffoldRefusal(repo) {
22
+ if (await exists(join(repo, "openspec"))) {
23
+ return "openspec/ already exists: this SDD has a behaviour store. Sync keeps its schema copy current.";
24
+ }
25
+ if (!(await exists(join(repo, SHIPPED_SCHEMA_DIR, "schema.yaml"))) || !(await exists(join(repo, SHIPPED_CONFIG_TEMPLATE)))) {
26
+ return `this SDD holds no shipped ${SHIPPED_SCHEMA_DIR}/ and ${SHIPPED_CONFIG_TEMPLATE}: sync a method bundle that carries them first.`;
27
+ }
28
+ return null;
29
+ }
30
+ export async function scaffoldBehaviourStore(repo) {
31
+ const refusal = await storeScaffoldRefusal(repo);
32
+ if (refusal !== null)
33
+ throw new Error(refusal);
34
+ let sddId = null;
35
+ try {
36
+ const m = parseDocument(await readFile(join(repo, MANIFEST_REL_PATH), "utf8")).toJS();
37
+ if (typeof m?.sdd_id === "string" && m.sdd_id.length > 0)
38
+ sddId = m.sdd_id;
39
+ }
40
+ catch {
41
+ sddId = null;
42
+ }
43
+ const template = await readFile(join(repo, SHIPPED_CONFIG_TEMPLATE), "utf8");
44
+ const config = sddId === null ? template : template.replaceAll("<sdd-id>", sddId);
45
+ const filesWritten = [];
46
+ await mkdir(dirname(join(repo, STORE_CONFIG)), { recursive: true });
47
+ await writeFile(join(repo, STORE_CONFIG), config);
48
+ filesWritten.push(STORE_CONFIG);
49
+ for (const rel of STORE_KEEP_FILES) {
50
+ if (await exists(join(repo, dirname(rel))))
51
+ continue;
52
+ await mkdir(join(repo, dirname(rel)), { recursive: true });
53
+ await writeFile(join(repo, rel), "");
54
+ filesWritten.push(rel);
55
+ }
56
+ const copied = await writeStoreSchema(repo);
57
+ filesWritten.push(...copied.missing.map((f) => `${STORE_SCHEMA_DIR}/${f}`));
58
+ return { filesWritten, sddId };
59
+ }
60
+ //# sourceMappingURL=behaviour-store.js.map
@@ -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,6 +1,7 @@
1
1
  import { checkArchetypeCitations } from "./archetype-citations.js";
2
2
  import { checkBehaviourStore, checkBehaviourStoreOpenspec } from "./behaviour-store.js";
3
3
  import { checkBundleCoherence } from "./bundle-coherence.js";
4
+ import { checkIntents } from "./intents.js";
4
5
  import { checkYamlParse } from "./yaml-parse.js";
5
6
  // The behaviour store (M-ADR 0221-0223) can sit in an SDD of any profile, so every profile carries
6
7
  // its two checks; without a store both report [N/A], an empty population, not a pass.
@@ -21,6 +22,12 @@ const BEHAVIOUR_STORE_VALIDATORS = [
21
22
  source: { kind: "native", run: async (ctx) => checkArchetypeCitations(ctx.targetRepo) },
22
23
  build: () => ({ status: "run", args: [] }),
23
24
  },
25
+ // Intents (M-ADR 0227) can sit in an SDD of any profile; without any the check reports [N/A].
26
+ {
27
+ name: "intents",
28
+ source: { kind: "native", run: async (ctx) => checkIntents(ctx.targetRepo) },
29
+ build: () => ({ status: "run", args: [] }),
30
+ },
24
31
  ];
25
32
  // Validators that accept --app-root / --repo-root flags work fine from the
26
33
  // bundled location because they override their internal ROOT_DIR via the
@@ -0,0 +1,76 @@
1
+ // intents: every intent file (M-ADR 0227 §4.1) has the template's shape, carries no planning state, names its
2
+ // work item, and every path its "Where it went" names in this SDD exists.
3
+ //
4
+ // Read from content/method/intents/ and content/product/intents/. A path line in "Where it went" is
5
+ // "- `<path>` (<sdd-id>; <source>)"; only paths labelled with this SDD's id are checked here, since another
6
+ // SDD's paths resolve in that SDD. Commit and plan lines are records, not paths, and are not checked. An
7
+ // intent with nothing yet in "Where it went" is captured but not explored: counted, not failed. With no
8
+ // intents the check reports [N/A], an empty population, not a pass.
9
+ import { existsSync } from "node:fs";
10
+ import { readdir, readFile } from "node:fs/promises";
11
+ import { basename, join } from "node:path";
12
+ import { parseDocument } from "yaml";
13
+ import { INTENT_DIRS, INTENT_SECTIONS, PLANNING_FIELDS, WHERE_PATH_LINE, parseMarkdown } from "../intents/format.js";
14
+ async function sddId(repo) {
15
+ try {
16
+ const m = parseDocument(await readFile(join(repo, ".sdd/sdd-manifest.yaml"), "utf8")).toJS();
17
+ if (typeof m?.sdd_id === "string" && m.sdd_id)
18
+ return m.sdd_id;
19
+ }
20
+ catch {
21
+ // fall through
22
+ }
23
+ return basename(repo);
24
+ }
25
+ export async function checkIntents(repo) {
26
+ const files = [];
27
+ for (const d of INTENT_DIRS) {
28
+ try {
29
+ for (const e of await readdir(join(repo, d), { withFileTypes: true })) {
30
+ if (e.isFile() && e.name.endsWith(".md") && e.name.toLowerCase() !== "readme.md")
31
+ files.push(`${d}/${e.name}`);
32
+ }
33
+ }
34
+ catch {
35
+ // no such directory
36
+ }
37
+ }
38
+ if (files.length === 0)
39
+ return { exitCode: 0, stdout: "[N/A] no intents (content/{method,product}/intents/*.md) in this repository\n" };
40
+ const self = await sddId(repo);
41
+ const problems = [];
42
+ let unexplored = 0;
43
+ let pathsChecked = 0;
44
+ for (const rel of files.sort()) {
45
+ const text = await readFile(join(repo, rel), "utf8");
46
+ const doc = parseMarkdown(text);
47
+ if (!/^Intent:\s*\S/.test(doc.title))
48
+ problems.push(`${rel}: no "# Intent: <title>" line`);
49
+ for (const s of INTENT_SECTIONS)
50
+ if (!doc.sections.has(s.toLowerCase()))
51
+ problems.push(`${rel}: no "## ${s}" section`);
52
+ const wi = doc.fields.get("Work item")?.trim() ?? "";
53
+ if (!wi || /^<.*>$/.test(wi))
54
+ problems.push(`${rel}: no work item named (**Work item:**)`);
55
+ for (const f of PLANNING_FIELDS)
56
+ if (doc.fields.has(f))
57
+ problems.push(`${rel}: planning field **${f}:** belongs in the work item (M-ADR 0227 §4.3)`);
58
+ const where = (doc.sections.get("where it went") ?? "").split("\n").map((l) => l.trim()).filter((l) => l.startsWith("- "));
59
+ if (where.length === 0)
60
+ unexplored++;
61
+ for (const line of where) {
62
+ const m = WHERE_PATH_LINE.exec(line);
63
+ if (!m || m[2].trim() !== self)
64
+ continue;
65
+ pathsChecked++;
66
+ const p = m[1].replace(/[#?].*$/, "");
67
+ if (!existsSync(join(repo, p)))
68
+ problems.push(`${rel}: "Where it went" names ${p}, which does not exist`);
69
+ }
70
+ }
71
+ const head = `intents: ${files.length} intent(s); ${pathsChecked} "Where it went" path(s) in this SDD checked; ${unexplored} not yet explored\n`;
72
+ if (problems.length === 0)
73
+ return { exitCode: 0, stdout: `${head}[PASS] intents: ok\n` };
74
+ return { exitCode: 1, stdout: `${head}${problems.map((p) => `[FAIL] ${p}\n`).join("")}[FAIL] intents: ${problems.length} problem(s)\n` };
75
+ }
76
+ //# sourceMappingURL=intents.js.map
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.9.0",
3
- "built_against_sdd_cli": "2.3.0"
2
+ "version": "0.11.0",
3
+ "built_against_sdd_cli": "2.5.0"
4
4
  }
@@ -1,4 +1,5 @@
1
1
  import { resolve } from "node:path";
2
+ import { migrateIntents } from "../lib/intents/migrate.js";
2
3
  import { migrateVocabulary } from "../lib/vocabulary/migrate.js";
3
4
  /**
4
5
  * `migrate vocabulary` (M-ADR 0222 §4.1): rewrite the SDD's own files from the former tool-read values to the
@@ -28,5 +29,48 @@ export function registerMigrate(program) {
28
29
  if (r.rewrites.length > 0)
29
30
  process.exit(1);
30
31
  });
32
+ const many = (v, prev) => [...prev, v];
33
+ migrate
34
+ .command("intents")
35
+ .description("Turn Ideas and Epic Briefs into intents (M-ADR 0227), one per request id: an Idea and its Epic Brief are one " +
36
+ "request at two stages. Rebuilds each intent's \"Where it went\" from evidence (paths the sources cite, SDD files " +
37
+ "and commits naming the id, plan files naming it) and writes the planning state to a tool-neutral work-item " +
38
+ "import file, with a table of old paths to intents for re-pointing citations. Dry run unless --write; never " +
39
+ "overwrites an existing intent")
40
+ .requiredOption("--source <dir>", "a directory of Idea and Epic Brief files, searched recursively (repeatable)", many, [])
41
+ .option("--sdd <dir>", "an SDD searched for evidence and offered as a home; the first is the default home (repeatable; default: the current directory)", many, [])
42
+ .option("--plans <dir>", "a directory of plan files: evidence only, never converted (repeatable)", many, [])
43
+ .option("--idea-map <file>", "the idea map, for status, priority and dependency edges")
44
+ .option("--out <dir>", "where the report, work-items.json and citations.tsv go", "intent-migration")
45
+ .option("--index-threshold <n>", "a file naming more request ids than this is an index, not evidence", "5")
46
+ .option("--home <id=sdd-id>", "a reviewer's home for a request id (repeatable)", many, [])
47
+ .option("--status <id=open|closed>", "a reviewer's status for a request id (repeatable)", many, [])
48
+ .option("--write", "write each intent into the SDD it is placed in, under every --sdd named (default: a dry run that writes them under --out)")
49
+ .action(async (o) => {
50
+ const pairs = (list, name) => new Map(list.map((x) => {
51
+ const [k, v] = x.split("=");
52
+ if (!k || !v)
53
+ throw new Error(`--${name} takes <id>=<value>, not "${x}"`);
54
+ return [k.trim(), v.trim()];
55
+ }));
56
+ const statuses = pairs(o.status, "status");
57
+ for (const [k, v] of statuses)
58
+ if (v !== "open" && v !== "closed")
59
+ throw new Error(`--status ${k}=${v}: the status is open or closed`);
60
+ const r = await migrateIntents({
61
+ sources: o.source,
62
+ sdds: o.sdd.length ? o.sdd : [process.cwd()],
63
+ plans: o.plans,
64
+ ...(o.ideaMap ? { ideaMap: o.ideaMap } : {}),
65
+ out: resolve(o.out),
66
+ write: o.write === true,
67
+ indexThreshold: Number(o.indexThreshold),
68
+ homes: pairs(o.home, "home"),
69
+ statuses: statuses,
70
+ });
71
+ process.stdout.write(`[INFO] migrate intents: ${r.ids} request id(s); ${r.flags.size} flagged; ${r.unrecognised.length} file(s) not read as an Idea or Epic Brief\n` +
72
+ (o.write ? `[INFO] migrate intents: wrote ${r.written.length} intent(s); ${r.skippedExisting.length} not written (a file exists)\n` : "") +
73
+ `[INFO] migrate intents: report, work-items.json and citations.tsv in ${resolve(o.out)}\n`);
74
+ });
31
75
  }
32
76
  //# sourceMappingURL=migrate.js.map
@@ -2,12 +2,14 @@ import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { isMethodDomainArchetype, METHOD_DOMAIN_ARCHETYPES, scaffoldDomain, } from "../lib/scaffold/domain.js";
4
4
  import { resolveArchetype } from "../lib/scaffold/registry.js";
5
+ import { scaffoldBehaviourStore, storeScaffoldRefusal } from "../lib/scaffold/behaviour-store.js";
5
6
  import { resolvePathOption } from "./path-option.js";
6
7
  const DOMAIN_NAME = /^[a-z][a-z0-9-]*$/;
7
8
  export function registerScaffold(program) {
8
9
  const scaffold = program
9
10
  .command("scaffold")
10
11
  .description("Scaffold method-shaped artefacts into an existing SDD repo (M-ADR 0173)");
12
+ registerScaffoldBehaviourStore(scaffold);
11
13
  scaffold
12
14
  .command("domain <name>")
13
15
  .description("Scaffold a platform domain (docs/domains/<name>/) into a platform-root SDD. " +
@@ -65,6 +67,33 @@ export function registerScaffold(program) {
65
67
  process.stdout.write(` ${f}\n`);
66
68
  });
67
69
  }
70
+ function registerScaffoldBehaviourStore(scaffold) {
71
+ scaffold
72
+ .command("behaviour-store")
73
+ .description("Create this SDD's behaviour store at openspec/ (M-ADR 0221 §4.2): openspec/config.yaml from the method's " +
74
+ "template, selecting the sdd-change schema, the schema's copy at openspec/schemas/sdd-change/, and empty " +
75
+ "specs/, changes/archive/ and docs/agreements/. Create it when the SDD first drafts a proposed change, agrees a change or " +
76
+ "baselines behaviour; sync keeps the schema copy current afterwards.")
77
+ .option("--target-repo <dir>", "the SDD repo", resolvePathOption, process.cwd())
78
+ .option("--dry-run", "report the refusal or the planned store without writing")
79
+ .action(async (opts) => {
80
+ const targetRepo = opts.targetRepo ?? process.cwd();
81
+ const refusal = await storeScaffoldRefusal(targetRepo);
82
+ if (refusal !== null) {
83
+ process.stderr.write(`[FAIL] scaffold behaviour-store: ${refusal}\n`);
84
+ process.exit(1);
85
+ }
86
+ if (opts.dryRun === true) {
87
+ process.stdout.write("[DRY-RUN] would create openspec/ (config.yaml, schemas/sdd-change/, specs/, changes/archive/) and docs/agreements/\n");
88
+ return;
89
+ }
90
+ const { filesWritten, sddId } = await scaffoldBehaviourStore(targetRepo);
91
+ process.stdout.write(`[PASS] created the behaviour store (${filesWritten.length} files):\n`);
92
+ for (const f of filesWritten)
93
+ process.stdout.write(` ${f}\n`);
94
+ process.stdout.write(`[INFO] fill in openspec/config.yaml's context${sddId === null ? " (the SDD id, no sdd_id in .sdd/sdd-manifest.yaml)" : ""}: the code repositories beside this SDD and where they are checked out\n`);
95
+ });
96
+ }
68
97
  function fail(message) {
69
98
  process.stderr.write(`[FAIL] scaffold domain: ${message}\n`);
70
99
  process.exit(1);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "2.3.0",
3
- "built_against_sdd_cli": "2.3.0"
2
+ "version": "2.5.0",
3
+ "built_against_sdd_cli": "2.5.0"
4
4
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sdd-method/author-cli",
3
- "version": "0.8.0",
4
- "sddCliVersion": "2.3.0",
3
+ "version": "0.10.0",
4
+ "sddCliVersion": "2.5.0",
5
5
  "description": "Re-author one SDD's content — scaffold, plan, contracts, conformance, api-surface, snapshots, briefings, catalogue check — plus every read-only verify verb. Ships only the code those verbs reach; it cannot sync the baseline or rebuild the catalogues. Maintenance is in @sdd-method/maintain-cli, administration in @sdd-method/sdd-cli.",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",