@sdd-method/sdd-cli 1.41.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/index.d.ts.map +1 -1
  2. package/dist/index.js +2 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/api-surface/index.js +2 -2
  5. package/dist/lib/api-surface/index.js.map +1 -1
  6. package/dist/lib/catalogue/cli-version.d.ts +1 -1
  7. package/dist/lib/catalogue/cli-version.d.ts.map +1 -1
  8. package/dist/lib/catalogue/cli-version.js +3 -3
  9. package/dist/lib/catalogue/cli-version.js.map +1 -1
  10. package/dist/lib/catalogue/freshness.d.ts +26 -0
  11. package/dist/lib/catalogue/freshness.d.ts.map +1 -0
  12. package/dist/lib/catalogue/freshness.js +69 -0
  13. package/dist/lib/catalogue/freshness.js.map +1 -0
  14. package/dist/lib/catalogue/graph-run.js +1 -1
  15. package/dist/lib/catalogue/graph-run.js.map +1 -1
  16. package/dist/lib/ci-binding/index.d.ts +63 -0
  17. package/dist/lib/ci-binding/index.d.ts.map +1 -0
  18. package/dist/lib/ci-binding/index.js +220 -0
  19. package/dist/lib/ci-binding/index.js.map +1 -0
  20. package/dist/lib/ci-binding/module.d.ts +36 -0
  21. package/dist/lib/ci-binding/module.d.ts.map +1 -0
  22. package/dist/lib/ci-binding/module.js +204 -0
  23. package/dist/lib/ci-binding/module.js.map +1 -0
  24. package/dist/lib/context-pull/index.d.ts +39 -0
  25. package/dist/lib/context-pull/index.d.ts.map +1 -0
  26. package/dist/lib/context-pull/index.js +151 -0
  27. package/dist/lib/context-pull/index.js.map +1 -0
  28. package/dist/lib/context-pull/resolve.d.ts +46 -0
  29. package/dist/lib/context-pull/resolve.d.ts.map +1 -0
  30. package/dist/lib/context-pull/resolve.js +70 -0
  31. package/dist/lib/context-pull/resolve.js.map +1 -0
  32. package/dist/lib/init/app.d.ts.map +1 -1
  33. package/dist/lib/init/app.js +3 -0
  34. package/dist/lib/init/app.js.map +1 -1
  35. package/dist/lib/init/ci-workflow.d.ts +6 -8
  36. package/dist/lib/init/ci-workflow.d.ts.map +1 -1
  37. package/dist/lib/init/ci-workflow.js +57 -120
  38. package/dist/lib/init/ci-workflow.js.map +1 -1
  39. package/dist/lib/init/integration.d.ts.map +1 -1
  40. package/dist/lib/init/integration.js +3 -0
  41. package/dist/lib/init/integration.js.map +1 -1
  42. package/dist/lib/init/platform.d.ts.map +1 -1
  43. package/dist/lib/init/platform.js +6 -2
  44. package/dist/lib/init/platform.js.map +1 -1
  45. package/dist/lib/lock-file.d.ts +20 -0
  46. package/dist/lib/lock-file.d.ts.map +1 -1
  47. package/dist/lib/lock-file.js +25 -1
  48. package/dist/lib/lock-file.js.map +1 -1
  49. package/dist/lib/mcp/build-info.d.ts.map +1 -1
  50. package/dist/lib/mcp/build-info.js +3 -3
  51. package/dist/lib/mcp/build-info.js.map +1 -1
  52. package/dist/lib/pack/index.js +2 -2
  53. package/dist/lib/pack/index.js.map +1 -1
  54. package/dist/lib/pack/validate.js +2 -2
  55. package/dist/lib/pack/validate.js.map +1 -1
  56. package/dist/lib/package-version.d.ts +23 -0
  57. package/dist/lib/package-version.d.ts.map +1 -1
  58. package/dist/lib/package-version.js +47 -10
  59. package/dist/lib/package-version.js.map +1 -1
  60. package/dist/lib/sync/index.d.ts +13 -0
  61. package/dist/lib/sync/index.d.ts.map +1 -1
  62. package/dist/lib/sync/index.js +69 -46
  63. package/dist/lib/sync/index.js.map +1 -1
  64. package/dist/lib/validate/bundle-coherence.js +2 -2
  65. package/dist/lib/validate/bundle-coherence.js.map +1 -1
  66. package/dist/lib/validate/plan.d.ts +25 -0
  67. package/dist/lib/validate/plan.d.ts.map +1 -0
  68. package/dist/lib/validate/plan.js +54 -0
  69. package/dist/lib/validate/plan.js.map +1 -0
  70. package/dist/maintain-bin.d.ts +3 -0
  71. package/dist/maintain-bin.d.ts.map +1 -0
  72. package/dist/maintain-bin.js +8 -0
  73. package/dist/maintain-bin.js.map +1 -0
  74. package/dist/maintain-program.d.ts +8 -0
  75. package/dist/maintain-program.d.ts.map +1 -0
  76. package/dist/maintain-program.js +91 -0
  77. package/dist/maintain-program.js.map +1 -0
  78. package/dist/maintain-version.json +4 -0
  79. package/dist/verbs/catalogue-maintain.d.ts +6 -0
  80. package/dist/verbs/catalogue-maintain.d.ts.map +1 -0
  81. package/dist/verbs/catalogue-maintain.js +110 -0
  82. package/dist/verbs/catalogue-maintain.js.map +1 -0
  83. package/dist/verbs/catalogue.d.ts +5 -0
  84. package/dist/verbs/catalogue.d.ts.map +1 -1
  85. package/dist/verbs/catalogue.js +14 -80
  86. package/dist/verbs/catalogue.js.map +1 -1
  87. package/dist/verbs/context.d.ts +3 -0
  88. package/dist/verbs/context.d.ts.map +1 -0
  89. package/dist/verbs/context.js +39 -0
  90. package/dist/verbs/context.js.map +1 -0
  91. package/dist/verbs/pack-build.d.ts +3 -0
  92. package/dist/verbs/pack-build.d.ts.map +1 -0
  93. package/dist/verbs/pack-build.js +31 -0
  94. package/dist/verbs/pack-build.js.map +1 -0
  95. package/dist/verbs/pack.d.ts.map +1 -1
  96. package/dist/verbs/pack.js +2 -25
  97. package/dist/verbs/pack.js.map +1 -1
  98. package/dist/verbs/sync.d.ts.map +1 -1
  99. package/dist/verbs/sync.js +21 -0
  100. package/dist/verbs/sync.js.map +1 -1
  101. package/dist/verbs/validate.d.ts.map +1 -1
  102. package/dist/verbs/validate.js +26 -0
  103. package/dist/verbs/validate.js.map +1 -1
  104. package/dist/verify-program.d.ts +6 -1
  105. package/dist/verify-program.d.ts.map +1 -1
  106. package/dist/verify-program.js +11 -4
  107. package/dist/verify-program.js.map +1 -1
  108. package/dist/verify-version.json +2 -2
  109. package/package.json +5 -4
  110. package/scripts/{build-verify-package.mjs → build-persona-package.mjs} +112 -46
  111. package/scripts/copy-yaml-assets.mjs +4 -2
  112. package/scripts/dagger-parity.sh +74 -0
  113. package/scripts/maintain-acceptance.mjs +240 -0
  114. package/scripts/maintain-version.json +4 -0
  115. package/scripts/release-gate.mjs +9 -0
  116. package/scripts/verify-version.json +2 -2
@@ -1,9 +1,11 @@
1
- // Assemble the publishable @sdd-method/verify-cli package — M-ADR 0212 §4.3b.
1
+ // Assemble a publishable persona package — @sdd-method/verify-cli (M-ADR 0212 §4.3b) or
2
+ // @sdd-method/maintain-cli (M-ADR 0218). Usage: build-persona-package.mjs [verify|maintain]
3
+ // (default verify). One script, so both packages hold the same boundary discipline.
2
4
  //
3
5
  // ONE SOURCE, and this time a genuinely smaller ARTEFACT. The conformance
4
6
  // package copied the whole of dist/ under a second manifest, so its "excluded"
5
7
  // verbs were one `node dist/cli.js …` away. This script ships only the import
6
- // CLOSURE of dist/verify-bin.js: an excluded verb's code is not in the package
8
+ // CLOSURE of the persona's entrypoint (dist/verify-bin.js, dist/maintain-bin.js): an excluded verb's code is not in the package
7
9
  // to be reached by any path.
8
10
  //
9
11
  // Fails closed on four conditions, each of which would otherwise let the
@@ -29,29 +31,85 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
29
31
  // session on the machine (2026-10-01, about nine hours). `npm run verify:boundary` builds
30
32
  // into build/check-dist and checks that instead; only a release should write dist/.
31
33
  const DIST = process.env.SDD_CLI_DIST ? resolve(process.env.SDD_CLI_DIST) : join(ROOT, "dist");
32
- const OUT = join(ROOT, "build", "verify-cli");
33
- const ENTRY = join(DIST, "verify-bin.js");
34
-
35
- // Authoring and maintenance code that must never reach this package. Entry
36
- // modules of excluded verbs plus the full CLI's own entrypoints.
37
- const CANARIES = [
34
+ const PERSONA = process.argv[2] ?? "verify";
35
+ // Modules that must never reach a package. Authoring for both; maintain's own verbs are
36
+ // canaries only for verify (they are maintain's payload). Each list names modules that EXIST,
37
+ // or the check below fails rather than passing vacuously.
38
+ const AUTHOR_CANARIES = [
38
39
  "cli.js",
39
40
  "index.js",
40
- "verbs/sync.js",
41
- "verbs/catalogue.js",
42
- "verbs/pack.js",
43
41
  "verbs/mcp.js",
44
42
  "verbs/init.js",
45
- "verbs/render.js",
46
43
  "verbs/metamodel.js",
47
44
  "verbs/conformance.js",
48
- "lib/sync/index.js",
49
- "lib/catalogue/build.js",
50
- "lib/catalogue/aggregate.js",
51
- "lib/catalogue/graph-run.js",
45
+ "verbs/scaffold.js",
46
+ "verbs/plan.js",
47
+ "verbs/contracts.js",
48
+ "verbs/api-surface.js",
49
+ "verbs/snapshot.js",
50
+ "verbs/catalogue.js",
52
51
  "lib/catalogue/migration-map.js",
53
- "lib/pack/builder.js",
52
+ "lib/mcp/server.js",
53
+ "lib/snapshot/land.js",
54
54
  ];
55
+ const PERSONAS = {
56
+ verify: {
57
+ entry: "verify-bin.js",
58
+ out: "verify-cli",
59
+ versionFile: "verify-version.json",
60
+ versionEnv: "VERIFY_VERSION",
61
+ name: "@sdd-method/verify-cli",
62
+ bin: "sdd-verify",
63
+ canaries: [
64
+ ...AUTHOR_CANARIES,
65
+ "verbs/sync.js",
66
+ "verbs/pack.js",
67
+ "verbs/render.js",
68
+ "lib/sync/index.js",
69
+ "lib/catalogue/build.js",
70
+ "lib/catalogue/aggregate.js",
71
+ "lib/catalogue/graph-run.js",
72
+ "lib/pack/builder.js",
73
+ ],
74
+ description:
75
+ "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
76
+ readme:
77
+ `# @sdd-method/verify-cli\n\n` +
78
+ `\`sdd-verify\` checks an SDD repository and **cannot change it**.\n\n` +
79
+ `Verbs: \`validate\`, \`list\`, \`pack validate\`, \`catalogue validate\` (incl. \`--coverage\`), ` +
80
+ `\`catalogue impact\`, \`catalogue baseline-check\`, \`catalogue version\`.\n\n` +
81
+ `The package contains only the code those verbs reach. Authoring and maintenance verbs — ` +
82
+ `\`sync\`, \`catalogue build\`, \`init\`, \`scaffold\`, \`mcp\` and the rest — are not present in it, ` +
83
+ `and are in \`@sdd-method/sdd-cli\`. Invoking one fails with exit 1.\n`,
84
+ },
85
+ maintain: {
86
+ entry: "maintain-bin.js",
87
+ out: "maintain-cli",
88
+ versionFile: "maintain-version.json",
89
+ versionEnv: "MAINTAIN_VERSION",
90
+ name: "@sdd-method/maintain-cli",
91
+ bin: "sdd-maintain",
92
+ canaries: AUTHOR_CANARIES,
93
+ description:
94
+ "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.",
95
+ readme:
96
+ `# @sdd-method/maintain-cli\n\n` +
97
+ `\`sdd-maintain\` brings an SDD repository current and rebuilds its derived model, and **cannot author specification content** (M-ADR 0218).\n\n` +
98
+ `Verbs: everything in \`@sdd-method/verify-cli\`, plus \`sync\`, \`generate-briefing\`, \`context pull\`, ` +
99
+ `\`catalogue build\`, \`catalogue aggregate\`, \`catalogue graph\`, \`catalogue diff\`, \`pack build\`, \`render screen\`.\n\n` +
100
+ `Each writes only derived or method-managed content, and two runs on the same commit write the same bytes. ` +
101
+ `Authoring verbs — \`init\`, \`scaffold\`, \`plan\`, \`mcp\`, \`conformance\` and the rest — are not present in it, ` +
102
+ `and are in \`@sdd-method/sdd-cli\`. Invoking one fails with exit 1.\n`,
103
+ },
104
+ };
105
+ const P = PERSONAS[PERSONA];
106
+ if (!P) {
107
+ process.stderr.write(`build-persona-package: FAIL — unknown persona '${PERSONA}' (verify | maintain)\n`);
108
+ process.exit(1);
109
+ }
110
+ const OUT = join(ROOT, "build", P.out);
111
+ const ENTRY = join(DIST, P.entry);
112
+
55
113
 
56
114
  const die = (msg) => {
57
115
  process.stderr.write(`build-verify-package: FAIL — ${msg}\n`);
@@ -112,10 +170,10 @@ for (const spec of bare) {
112
170
  }
113
171
 
114
172
  // ---- 3 + 4. canaries -------------------------------------------------------
115
- for (const c of CANARIES) {
173
+ for (const c of P.canaries) {
116
174
  const abs = join(DIST, c);
117
175
  if (!existsSync(abs)) die(`canary '${c}' no longer exists in dist — the check would pass vacuously; update CANARIES`);
118
- if (closure.has(abs)) die(`authoring module '${c}' is reachable from verify-bin.js — the boundary does not hold`);
176
+ if (closure.has(abs)) die(`excluded module '${c}' is reachable from ${P.entry} — the ${PERSONA} boundary does not hold`);
119
177
  }
120
178
 
121
179
  // ---- bundled shell scripts -------------------------------------------------
@@ -136,24 +194,42 @@ for (const f of closure) {
136
194
  }
137
195
 
138
196
  // ---- assets: yaml/json data beside a closure module ------------------------
197
+ // Data a module reads by path at runtime is invisible to the import closure. Beside the module, and
198
+ // in DATA-ONLY subdirectories under it (no .js inside): maintain-cli's first build missed
199
+ // lib/catalogue/archetype/maps/*.yaml, so `render screen` crashed with ENOENT where sdd-cli rendered.
139
200
  const assets = new Set();
201
+ const dataOnlyFiles = (dir) => {
202
+ const entries = readdirSync(dir, { withFileTypes: true });
203
+ if (entries.some((e) => e.isFile() && e.name.endsWith(".js"))) return [];
204
+ return entries.flatMap((e) => {
205
+ const p = join(dir, e.name);
206
+ if (e.isDirectory()) return dataOnlyFiles(p);
207
+ return /\.(ya?ml|json)$/.test(e.name) ? [p] : [];
208
+ });
209
+ };
140
210
  for (const f of closure) {
141
211
  const dir = dirname(f);
142
- for (const name of readdirSync(dir)) {
143
- const p = join(dir, name);
144
- if (/\.(ya?ml|json)$/.test(name) && statSync(p).isFile()) assets.add(p);
212
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
213
+ const p = join(dir, e.name);
214
+ if (e.isFile() && /\.(ya?ml|json)$/.test(e.name)) assets.add(p);
215
+ else if (e.isDirectory()) for (const d of dataOnlyFiles(p)) assets.add(d);
145
216
  }
146
217
  }
147
218
 
148
219
  // ---- assemble --------------------------------------------------------------
149
- // The version is COMMITTED (scripts/verify-version.json), not defaulted. Defaulting to "0.1.0" let a build
220
+ // The version is COMMITTED (scripts/<persona>-version.json), not defaulted. Defaulting to "0.1.0" let a build
150
221
  // of new source carry an old version silently; verify-cli then fell two sdd-cli releases behind unnoticed.
151
222
  // Bump the file in every sdd-cli release commit (minor with the CLI's minor; patch for fixes).
152
- const versionFile = JSON.parse(readFileSync(join(ROOT, "scripts", "verify-version.json"), "utf8"));
223
+ const versionFile = JSON.parse(readFileSync(join(ROOT, "scripts", P.versionFile), "utf8"));
153
224
  const version = versionFile.version;
154
- if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) die("scripts/verify-version.json has no semver `version`");
155
- if (process.env.VERIFY_VERSION && process.env.VERIFY_VERSION !== version)
156
- die(`VERIFY_VERSION=${process.env.VERIFY_VERSION} disagrees with scripts/verify-version.json (${version}); edit the file`);
225
+ if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) die(`scripts/${P.versionFile} has no semver \`version\``);
226
+ // The engine this package is built from. Every engine-meaning check reads it (getSddCliVersion):
227
+ // a bundle's floor, catalogue stamps, the MCP identity. A stale built_against in the version file
228
+ // would ship a package claiming an engine it is not, so the build refuses it.
229
+ if (versionFile.built_against_sdd_cli !== main.version)
230
+ die(`scripts/${P.versionFile} says built_against_sdd_cli ${versionFile.built_against_sdd_cli}, but this sdd-cli is ${main.version}; update the file`);
231
+ if (process.env[P.versionEnv] && process.env[P.versionEnv] !== version)
232
+ die(`${P.versionEnv}=${process.env[P.versionEnv]} disagrees with scripts/${P.versionFile} (${version}); edit the file`);
157
233
  // Empty the directory, never remove it: a shell sitting in build/verify-cli
158
234
  // (to run `npm publish`) keeps a handle on the directory itself, and deleting
159
235
  // it leaves that shell in a path that no longer exists — npm then dies with
@@ -171,40 +247,30 @@ for (const p of bundled) {
171
247
  }
172
248
  for (const f of ["LICENSE", "NOTICE"]) await cp(join(ROOT, f), join(OUT, f));
173
249
 
174
- if (main.publishConfig?.access !== "public") die("sdd-cli's package.json has no publishConfig.access=public — the verify package would publish private");
250
+ if (main.publishConfig?.access !== "public") die(`sdd-cli's package.json has no publishConfig.access=public — ${P.name} would publish private`);
175
251
 
176
252
  const pkg = {
177
- name: "@sdd-method/verify-cli",
253
+ name: P.name,
178
254
  version,
179
- description:
180
- "Verify an SDD repository without changing it — validate, list, and read-only catalogue and pack checks. Ships only the code those verbs reach; it cannot write to the repository it is pointed at. Authoring and maintenance are in @sdd-method/sdd-cli.",
255
+ sddCliVersion: main.version,
256
+ description: P.description,
181
257
  license: main.license,
182
258
  type: main.type,
183
- bin: { "sdd-verify": "dist/verify-bin.js" },
259
+ bin: { [P.bin]: `dist/${P.entry}` },
184
260
  engines: main.engines,
185
261
  dependencies: deps,
186
262
  repository: main.repository,
187
263
  // Inherited, not assumed: a scoped package publishes PRIVATE by default, and
188
- // the first publish of this one failed with E402 (private packages need a
189
- // paid plan) because this field was missing. sdd-cli carries it; so must this.
264
+ // the first publish of verify-cli failed with E402 because this was missing.
190
265
  publishConfig: main.publishConfig,
191
266
  files: ["dist", "scripts", "README.md", "LICENSE", "NOTICE"],
192
267
  };
193
268
  await writeFile(join(OUT, "package.json"), `${JSON.stringify(pkg, null, 2)}\n`);
194
- await writeFile(
195
- join(OUT, "README.md"),
196
- `# @sdd-method/verify-cli\n\n` +
197
- `\`sdd-verify\` checks an SDD repository and **cannot change it**.\n\n` +
198
- `Verbs: \`validate\`, \`list\`, \`pack validate\`, \`catalogue validate\` (incl. \`--coverage\`), ` +
199
- `\`catalogue impact\`, \`catalogue baseline-check\`, \`catalogue version\`.\n\n` +
200
- `The package contains only the code those verbs reach. Authoring and maintenance verbs — ` +
201
- `\`sync\`, \`catalogue build\`, \`init\`, \`scaffold\`, \`mcp\` and the rest — are not present in it, ` +
202
- `and are in \`@sdd-method/sdd-cli\`. Invoking one fails with exit 1.\n`,
203
- );
269
+ await writeFile(join(OUT, "README.md"), P.readme);
204
270
 
205
271
  const kb = (fs) => Math.round(fs.reduce((n, f) => n + statSync(f).size, 0) / 1024);
206
272
  process.stdout.write(
207
- `assembled ${pkg.name}@${version} at build/verify-cli\n` +
273
+ `assembled ${pkg.name}@${version} at build/${P.out}\n` +
208
274
  ` modules ${closure.size} (${kb([...closure])} kB) · assets ${assets.size} · bundled scripts ${bundled.size} · deps ${Object.keys(deps).join(", ")}\n` +
209
- ` canaries absent: ${CANARIES.length}/${CANARIES.length}\n`,
275
+ ` canaries absent: ${P.canaries.length}/${P.canaries.length}\n`,
210
276
  );
@@ -37,6 +37,8 @@ for await (const srcFile of walk(SRC)) {
37
37
  }
38
38
  // The verify-cli version the CI workflow pins (getVerifyCliVersion). Copied, not read live from
39
39
  // scripts/, so it moves with dist/ and never ahead of it.
40
- await copyFile(join(__dirname, "verify-version.json"), join(DEST, "verify-version.json"));
41
- copied++;
40
+ for (const persona of ["verify", "maintain"]) {
41
+ await copyFile(join(__dirname, `${persona}-version.json`), join(DEST, `${persona}-version.json`));
42
+ copied++;
43
+ }
42
44
  console.log(`copy-yaml-assets: ${copied} file(s) copied to dist/`);
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # dagger-parity.sh — per-step parity between the Method Validation Dagger module and the same plan
3
+ # (sdd-verify validate --list) run directly with the same CLIs (sdm-dd6.3; Dagger ruling sdm-s48).
4
+ # Run it on a release candidate: build the persona packages (npm run verify:boundary), then
5
+ # scripts/dagger-parity.sh <sdd-repo>... It packs them unless PKGS names a directory already
6
+ # holding the tarballs. Needs the dagger CLI and a container engine (M-ADR 0219 §4.5).
7
+ set -u
8
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
+ if [ -n "${SCRATCH:-}" ]; then SP=$SCRATCH; else SP=$(mktemp -d); trap 'rm -rf "$SP"' EXIT; fi
10
+ CLI=$ROOT/build
11
+ PKGS=${PKGS:-$SP/pkgs}
12
+ if [ ! -d "$PKGS" ]; then
13
+ mkdir -p "$PKGS"
14
+ (cd "$CLI/verify-cli" && npm pack --silent --pack-destination "$PKGS" >/dev/null)
15
+ (cd "$CLI/maintain-cli" && npm pack --silent --pack-destination "$PKGS" >/dev/null)
16
+ fi
17
+ VV=$(node -p "require('$CLI/verify-cli/package.json').version"); MV=$(node -p "require('$CLI/maintain-cli/package.json').version")
18
+ # The module exactly as sync renders it (M-ADR 0219 §6), from the built maintain-cli, with the
19
+ # candidate versions as its pins.
20
+ MOD=$SP/module
21
+ node --input-type=module -e "
22
+ import { mkdirSync, writeFileSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ const { renderModuleFiles } = await import('$CLI/maintain-cli/dist/lib/ci-binding/module.js');
25
+ for (const [rel, c] of renderModuleFiles({ verify: '$VV', maintain: '$MV' })) {
26
+ const p = join('$MOD', rel); mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, c);
27
+ }"
28
+ VERIFY="node $CLI/verify-cli/dist/verify-bin.js"
29
+ MAINTAIN="node $CLI/maintain-cli/dist/maintain-bin.js"
30
+ OUT=$SP/dagger-spike-out; mkdir -p "$OUT"
31
+ FAIL=0
32
+ for repo in "$@"; do
33
+ name=$(basename "$repo")
34
+ W=$(mktemp -d -p "$SP")
35
+ git clone -q --no-hardlinks "$repo" "$W/$name"
36
+ sha=$(git -C "$W/$name" rev-parse --short=8 HEAD)
37
+ echo "=== $name @ $sha"
38
+ # 1. Dagger, on Docker
39
+ start=$(date +%s)
40
+ ( cd "$MOD" && DAGGER_NO_NAG=1 timeout 1500 dagger -q call method-validation \
41
+ --source="$W/$name" --verify-version=$VV --maintain-version=$MV \
42
+ --verify-tarball="$PKGS/sdd-method-verify-cli-$VV.tgz" --maintain-tarball="$PKGS/sdd-method-maintain-cli-$MV.tgz" ) > "$OUT/$name.dagger.log" 2>&1
43
+ drc=$?; secs=$(( $(date +%s) - start ))
44
+ sed "s/\x1b\[[0-9;]*m//g" "$OUT/$name.dagger.log" | grep -oE "^ *(PASS|FAIL|ADVISORY) +exit [0-9]+ [a-z0-9-]+" | sed "s/^ *//" | awk '{print $4, $3}' | sort > "$OUT/$name.dagger.steps"
45
+ # 2. The same plan, directly on the host (a second clone, so neither run sees the other)
46
+ git clone -q --no-hardlinks "$repo" "$W/direct"
47
+ ( cd "$W/direct" && $VERIFY validate --list ) > "$OUT/$name.plan.json"
48
+ python3 - "$OUT/$name.plan.json" > "$OUT/$name.cmds" <<'PY'
49
+ import json, sys
50
+ for s in json.load(open(sys.argv[1]))["steps"]:
51
+ print(s["id"], s["tool"], " ".join(s["args"]))
52
+ PY
53
+ : > "$OUT/$name.direct.steps"
54
+ while read -r id tool args; do
55
+ case "$tool" in
56
+ sdd-verify) cmd="$VERIFY $args" ;;
57
+ sdd-maintain) cmd="$MAINTAIN $args" ;;
58
+ bash) cmd="bash $args" ;;
59
+ esac
60
+ ( cd "$W/direct" && eval "$cmd" ) > "$OUT/$name.direct.$id.log" 2>&1
61
+ echo "$id $?" >> "$OUT/$name.direct.steps"
62
+ done < "$OUT/$name.cmds"
63
+ sort -o "$OUT/$name.direct.steps" "$OUT/$name.direct.steps"
64
+ n=$(wc -l < "$OUT/$name.direct.steps")
65
+ if [ "$(wc -l < "$OUT/$name.dagger.steps")" -ne "$n" ]; then
66
+ echo " FAIL dagger reported $(wc -l < "$OUT/$name.dagger.steps") steps, the plan has $n (dagger exit $drc; see $name.dagger.log)"; FAIL=$((FAIL+1))
67
+ elif diff -q "$OUT/$name.dagger.steps" "$OUT/$name.direct.steps" >/dev/null; then
68
+ echo " PARITY $n/$n steps, identical exit codes (dagger exit $drc, ${secs}s); $(awk '$2!=0' "$OUT/$name.direct.steps" | wc -l) step(s) non-zero in both"
69
+ else
70
+ echo " FAIL step exit codes differ:"; diff "$OUT/$name.dagger.steps" "$OUT/$name.direct.steps" | sed 's/^/ /'; FAIL=$((FAIL+1))
71
+ fi
72
+ rm -rf "$W" # the logs are in $OUT; two clones per repo add up across an estate
73
+ done
74
+ [ $FAIL -eq 0 ] && echo "SPIKE PARITY: PASS" || echo "SPIKE PARITY: FAIL ($FAIL)"
@@ -0,0 +1,240 @@
1
+ #!/usr/bin/env node
2
+ // Acceptance for the maintain CLI — M-ADR 0218 §4.2–4.3.
3
+ //
4
+ // The property: every write sdd-maintain makes is DERIVED or METHOD-MANAGED, and is a
5
+ // deterministic function of its inputs. Tested, not asserted. For each workspace: fresh clones,
6
+ // each maintain verb run twice, and after each run
7
+ // (a) SCOPE: every path `git status --porcelain --ignored` reports is in the
8
+ // derived-and-managed set (the lock's own managed paths included);
9
+ // (b) DETERMINISM: the second run on the same commit leaves exactly the bytes the first did.
10
+ // Fails closed on: an out-of-set write, a second run that changes anything, a verb that crashes,
11
+ // and the CONTROL: a deliberate out-of-set write must be SEEN, or every PASS is vacuous.
12
+ // Verbs it cannot exercise in a workspace are listed under COVERAGE, never passed silently.
13
+ //
14
+ // (c) PARITY: the same verb run by the full sdd-cli (--control-bin) on a twin workspace leaves a
15
+ // byte-identical tree. Scope and determinism cannot see a verb that crashes or renders
16
+ // differently; verify-cli learned the same lesson (0.1.0 shipped missing bundled scripts).
17
+ // usage: maintain-acceptance.mjs --bin <maintain-bin.js> --control-bin <sdd-cli cli.js> --dist <sdd-method dist dir> <sdd-repo>...
18
+ // Each <sdd-repo> is cloned with the siblings its aggregation manifest names (paths kept),
19
+ // so `catalogue aggregate` runs on a platform root.
20
+
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
+ import { createHash } from "node:crypto";
23
+ import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, mkdirSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { basename, dirname, join, relative, resolve } from "node:path";
26
+
27
+ const args = process.argv.slice(2);
28
+ const opt = (k) => { const i = args.indexOf(k); if (i === -1) return undefined; const v = args[i + 1]; args.splice(i, 2); return v; };
29
+ const BIN = resolve(opt("--bin") ?? die("--bin required"));
30
+ const DIST = resolve(opt("--dist") ?? die("--dist required"));
31
+ const CONTROL = resolve(opt("--control-bin") ?? die("--control-bin required (the full sdd-cli, for parity)"));
32
+ const REPOS = args.map((a) => resolve(a));
33
+ if (REPOS.length === 0) die("give at least one SDD repository");
34
+
35
+ function die(m) { process.stderr.write(`maintain-acceptance: ${m}\n`); process.exit(2); }
36
+ let FAIL = 0;
37
+ const fail = (m) => { FAIL++; process.stdout.write(` FAIL ${m}\n`); };
38
+ const ok = (m) => process.stdout.write(` ok ${m}\n`);
39
+ const exercised = new Set();
40
+
41
+ // ---- the derived-and-managed set (M-ADR 0218 §4.3) -------------------------------------------
42
+ const FIXED = [
43
+ /^method-baseline\.lock$/, /^\.sdd-repo-kind$/, // item 2
44
+ /^\.github\/workflows\/method-validation\.yml$/, // item 3 (block-merged)
45
+ /^\.gitlab-ci\.yml$/, /^\.jenkins\/method-validation\.Jenkinsfile$/, // item 3, the other CI bindings (M-ADR 0219 §4.4)
46
+ /^\.dagger\/method-validation\//, // item 3, the module every binding calls (M-ADR 0219 §4.3)
47
+ /^agent-instructions\.md$/, /^CLAUDE\.md$/, /^AGENTS\.md$/, // item 4
48
+ /^\.claude\/settings\.json$/, /^\.github\/hooks\//, /^\.github\/copilot\//, // item 5
49
+ /^catalogues\//, /^catalogues-aggregated\//, /^catalogues(-aggregated)?-graph\.jsonl$/, // item 7
50
+ /^\.sdd\/bundle-cache\//, // sync's cache of the last-applied bundle (managed by sync itself)
51
+ /^MCP_SETUP\.md$/, // item 9 (code repo)
52
+ ];
53
+ function lockManaged(repo) {
54
+ const p = join(repo, "method-baseline.lock");
55
+ if (!existsSync(p)) return new Set();
56
+ const out = new Set();
57
+ for (const line of readFileSync(p, "utf8").split("\n")) {
58
+ const m = /^ {6}- "?(?:[0-9a-f]{64} {2})?([^"]+)"?$/.exec(line);
59
+ if (m) out.add(m[1]);
60
+ }
61
+ return out;
62
+ }
63
+ function outOfSet(repo, managed) {
64
+ const st = spawnSync("git", ["-C", repo, "status", "--porcelain", "--ignored", "--untracked-files=all"], { encoding: "utf8" });
65
+ const bad = [];
66
+ for (const line of st.stdout.split("\n").filter(Boolean)) {
67
+ let p = line.slice(3);
68
+ if (p.includes(" -> ")) p = p.split(" -> ")[1];
69
+ p = p.replace(/^"|"$/g, "");
70
+ if (managed.has(p) || FIXED.some((rx) => rx.test(p))) continue;
71
+ bad.push(p);
72
+ }
73
+ return bad;
74
+ }
75
+ // A digest of every file git reports as changed, for the determinism check.
76
+ // Workspace roots are normalised so the twin (another temp path) can be compared: context pull, for
77
+ // one, writes the SDD's absolute path into what it renders.
78
+ function snapshot(repo, roots = []) {
79
+ const st = spawnSync("git", ["-C", repo, "status", "--porcelain", "--ignored", "--untracked-files=all"], { encoding: "utf8" });
80
+ const h = createHash("sha256");
81
+ for (const line of st.stdout.split("\n").filter(Boolean).sort()) {
82
+ const p = line.slice(3).split(" -> ").pop().replace(/^"|"$/g, "");
83
+ h.update(line.slice(0, 3) + p + "\0");
84
+ const abs = join(repo, p);
85
+ if (existsSync(abs) && statSync(abs).isFile()) {
86
+ let text = readFileSync(abs, "latin1");
87
+ for (const r of roots) text = text.split(r).join("<WS>");
88
+ h.update(text, "latin1");
89
+ }
90
+ }
91
+ return h.digest("hex");
92
+ }
93
+
94
+ // ---- workspace -------------------------------------------------------------------------------
95
+ // Each workspace clones a whole manifest tree (up to ~55k inodes), and /tmp is a tmpfs whose inode
96
+ // table is the real limit: one release's runs leaked ~450k and filled it. Removed on every exit,
97
+ // failures included; MAINTAIN_ACC_KEEP=1 keeps them for inspection.
98
+ const workspaces = [];
99
+ process.on("exit", () => {
100
+ if (process.env.MAINTAIN_ACC_KEEP) return void process.stderr.write(`maintain-acceptance: kept ${workspaces.join(" ")}\n`);
101
+ for (const ws of workspaces) rmSync(ws, { recursive: true, force: true });
102
+ });
103
+ function cloneTree(root) {
104
+ const ws = mkdtempSync(join(tmpdir(), "maintain-acc-"));
105
+ workspaces.push(ws);
106
+ const base = dirname(root);
107
+ const todo = [root];
108
+ const seen = new Set();
109
+ const missing = [];
110
+ while (todo.length) {
111
+ const src = todo.pop();
112
+ if (seen.has(src)) continue;
113
+ seen.add(src);
114
+ const dest = join(ws, relative(base, src));
115
+ mkdirSync(dirname(dest), { recursive: true });
116
+ execFileSync("git", ["clone", "-q", "--no-hardlinks", src, dest]);
117
+ const man = join(src, "orchestration", "aggregation-manifest.yaml");
118
+ if (existsSync(man)) {
119
+ for (const m of readFileSync(man, "utf8").matchAll(/path: "([^"]+)"/g)) {
120
+ let p = resolve(src, m[1]);
121
+ if (["catalogues", "catalogues-aggregated"].includes(basename(p))) p = dirname(p);
122
+ if (existsSync(join(p, ".git"))) todo.push(p);
123
+ else missing.push(relative(base, p));
124
+ }
125
+ }
126
+ }
127
+ return { ws, clone: join(ws, basename(root)), base, missing };
128
+ }
129
+ const run = (repo, verbArgs, bin = BIN) => spawnSync("node", [bin, ...verbArgs], { cwd: repo, encoding: "utf8" });
130
+ // The twin workspace's path for a path in the primary one (parity runs mirror every call).
131
+ let TWIN = null;
132
+ const twinOf = (p) => (TWIN && p.startsWith(TWIN.from) ? TWIN.to + p.slice(TWIN.from.length) : p);
133
+ function twice(label, repo, verbArgs, managedFn, { allowNonZero = false } = {}) {
134
+ const r1 = run(repo, verbArgs);
135
+ // An uncaught error (maintain-bin prints "sdd-maintain: <message>") is a CRASH, never a finding,
136
+ // whatever the exit code allowance: allowNonZero once let an ENOENT pass as render screen's "exit 1".
137
+ const crashed = /^sdd-maintain: /m.test(r1.stderr ?? "");
138
+ if (crashed || (r1.status !== 0 && !allowNonZero)) { fail(`${label}: ${crashed ? "CRASHED" : `exit ${r1.status}`} — ${(r1.stderr || r1.stdout).trim().split("\n").pop()}`); return; }
139
+ const bad1 = outOfSet(repo, managedFn());
140
+ if (bad1.length) fail(`${label}: wrote outside the derived-and-managed set: ${bad1.slice(0, 5).join(", ")}`);
141
+ const roots = TWIN ? [TWIN.from, TWIN.to] : [];
142
+ const s1 = snapshot(repo, roots);
143
+ const r2 = run(repo, verbArgs);
144
+ const s2 = snapshot(repo, roots);
145
+ // parity: the full CLI, same verb, same arguments, on the twin
146
+ const twinArgs = verbArgs.map((a) => twinOf(a));
147
+ const rc = run(twinOf(repo), twinArgs, CONTROL);
148
+ run(twinOf(repo), twinArgs, CONTROL); // keep the twin in step with the primary's second run
149
+ const sc = snapshot(twinOf(repo), roots);
150
+ const parity = sc === s2 && rc.status === r1.status;
151
+ if (!parity) fail(`${label}: PARITY — sdd-cli ${sc === s2 ? "" : "left a different tree, "}exited ${rc.status} where sdd-maintain exited ${r1.status}`);
152
+ if (s1 !== s2) fail(`${label}: NOT deterministic — the second run on the same commit changed the tree`);
153
+ else if (!bad1.length && parity) ok(`${label} (exit ${r1.status}): in scope, second run byte-identical, = sdd-cli`);
154
+ exercised.add(label.split(" @")[0]);
155
+ void r2;
156
+ }
157
+
158
+ // ---- control: the detector must see an out-of-set write --------------------------------------
159
+ {
160
+ const { clone } = cloneTree(REPOS[0]);
161
+ writeFileSync(join(clone, "docs", "CONTROL-out-of-set.md"), "x\n");
162
+ if (outOfSet(clone, lockManaged(clone)).includes("docs/CONTROL-out-of-set.md")) ok("CONTROL: an out-of-set write is seen");
163
+ else fail("CONTROL: an out-of-set write was NOT seen — every scope PASS below would be vacuous");
164
+ }
165
+
166
+ for (const repo of REPOS) {
167
+ const { clone, ws, missing } = cloneTree(repo);
168
+ const twin = cloneTree(repo);
169
+ TWIN = { from: ws, to: twin.ws };
170
+ const name = basename(repo);
171
+ process.stdout.write(`=== ${name} ===\n`);
172
+ const managed = () => lockManaged(clone);
173
+ // Bundle the lock is on: re-applying it is a real sync with nothing upstream changed.
174
+ const lock = readFileSync(join(clone, "method-baseline.lock"), "utf8");
175
+ const fwd = /- name: ([\w-]+)\n\s+bundle_version: "([^"]+)"(?:[\s\S]*?)is_forward_base: true/.exec(lock);
176
+ if (fwd) {
177
+ const tb = join(DIST, fwd[1], `${fwd[1]}-${fwd[2]}.tar.gz`);
178
+ // A lock that predates the CI digest (written before M-ADR 0218 §4.7) refuses its first sync on
179
+ // a newer CI render by design, and the wave passes --allow-overwrite exactly once; do the same.
180
+ const firstDigest = /^ci_workflow_block_sha256:/m.test(lock) ? [] : ["--allow-overwrite"];
181
+ if (existsSync(tb)) twice(`sync @${name}`, clone, ["sync", "--bundle", tb, "--target-repo", ".", ...firstDigest], managed);
182
+ else process.stdout.write(` skip sync: ${tb} not on disk\n`);
183
+ }
184
+ twice(`generate-briefing @${name}`, clone, ["generate-briefing", "--target-dir", "."], managed);
185
+ // A build may exit 1 on pre-existing estate findings; scope and determinism hold either way.
186
+ twice(`catalogue build @${name}`, clone, ["catalogue", "build", "--sdd-path", "."], managed, { allowNonZero: true });
187
+ twice(`catalogue graph @${name}`, clone, ["catalogue", "graph", "--sdd-path", "."], managed, { allowNonZero: true });
188
+ if (existsSync(join(clone, "orchestration", "aggregation-manifest.yaml")) && missing.length > 0) {
189
+ process.stdout.write(` skip catalogue aggregate: ${missing.length} manifest sibling(s) not on disk to clone (e.g. ${missing[0]})\n`);
190
+ } else if (existsSync(join(clone, "orchestration", "aggregation-manifest.yaml"))) {
191
+ // members first, so the aggregate reads current member catalogues
192
+ for (const d of readdirSync(ws, { recursive: true }).filter((p) => p.endsWith(".sdd-repo-kind"))) {
193
+ const member = join(ws, dirname(d));
194
+ if (member !== clone) {
195
+ run(member, ["catalogue", "build", "--sdd-path", "."]);
196
+ run(twinOf(member), ["catalogue", "build", "--sdd-path", "."], CONTROL);
197
+ }
198
+ }
199
+ twice(`catalogue aggregate @${name}`, clone, ["catalogue", "aggregate", "--sdd-path", "."], managed, { allowNonZero: true });
200
+ }
201
+ // catalogue diff: from HEAD with an empty delta, so attribution is trivially complete; it writes nothing into the SDD.
202
+ {
203
+ const delta = join(ws, `${name}-delta.yaml`);
204
+ writeFileSync(delta, "findings: []\n");
205
+ writeFileSync(twinOf(delta), "findings: []\n");
206
+ twice(`catalogue diff @${name}`, clone, ["catalogue", "diff", "--from", "HEAD", "--attribute", delta, "--sdd-path", "."], managed, { allowNonZero: true });
207
+ }
208
+ // pack build: every seed the SDD produces, to an output OUTSIDE its tracked content (§4.3 item 8).
209
+ const distDir = join(clone, "orchestration", "distribution");
210
+ if (existsSync(distDir)) {
211
+ const seed = readdirSync(distDir).find((f) => f.endsWith("-seed.yaml"));
212
+ if (seed) twice(`pack build @${name}`, clone, ["pack", "build", "--seed", join(distDir, seed), "--producer-repo", ".", "--out-dir", join(ws, "pack-out")], managed, { allowNonZero: true });
213
+ }
214
+ // render screen: the first screen spec, to an explicit --out outside the tree (§4.3 item 8).
215
+ const screens = join(clone, "screens");
216
+ if (existsSync(screens)) {
217
+ const spec = readdirSync(screens).find((f) => f.endsWith(".md"));
218
+ if (spec) twice(`render screen @${name}`, clone, ["render", "screen", join("screens", spec), "--out", join(ws, "screen.svg")], managed);
219
+ }
220
+ // context pull: a scratch code repo beside the clone, named for a repository this SDD catalogues.
221
+ const repoCsv = join(clone, "catalogues", "repositories.csv");
222
+ if (existsSync(repoCsv)) {
223
+ const id = readFileSync(repoCsv, "utf8").split("\n")[1]?.split(",")[0];
224
+ if (id && existsSync(join(clone, "orchestration/templates/briefing/code-repo/agent-instructions.md.hbs"))) {
225
+ const code = join(dirname(clone), id);
226
+ for (const c of [code, twinOf(code)]) {
227
+ mkdirSync(c, { recursive: true });
228
+ execFileSync("git", ["-C", c, "init", "-q"]);
229
+ }
230
+ twice(`context pull @${name}`, code, ["context", "pull", "--sdd", clone], () => new Set());
231
+ } else process.stdout.write(` skip context pull: ${id ? "the SDD's baseline has no code-repo templates yet" : "no repositories catalogued"}\n`);
232
+ }
233
+ }
234
+
235
+ const SURFACE = ["sync", "generate-briefing", "catalogue build", "catalogue graph", "catalogue aggregate", "context pull", "catalogue diff", "pack build", "render screen"];
236
+ const missing = SURFACE.filter((v) => !exercised.has(v));
237
+ process.stdout.write(`COVERAGE exercised: ${[...exercised].join(", ")}\n`);
238
+ if (missing.length) process.stdout.write(`COVERAGE NOT exercised here (needs inputs this run lacks): ${missing.join(", ")}\n`);
239
+ process.stdout.write(FAIL === 0 ? "ACCEPTANCE PASS — every maintain write in scope and deterministic\n" : `ACCEPTANCE FAIL (${FAIL})\n`);
240
+ process.exit(FAIL === 0 ? 0 : 1);
@@ -0,0 +1,4 @@
1
+ {
2
+ "version": "0.1.0",
3
+ "built_against_sdd_cli": "1.42.0"
4
+ }
@@ -39,6 +39,15 @@ if (process.argv.includes("--audit")) {
39
39
  process.exit(0);
40
40
  }
41
41
 
42
+ // M-ADR 0218 §4.6: the persona packages are released WITH sdd-cli, in one change-set. A release that
43
+ // bumps sdd-cli without re-stamping a persona's version file would build a package that claims an
44
+ // engine it is not, so the publish refuses (the build refuses it too).
45
+ for (const persona of ["verify", "maintain"]) {
46
+ const f = JSON.parse(readFileSync(join(root, "scripts", `${persona}-version.json`), "utf-8"));
47
+ if (f.built_against_sdd_cli !== pkg.version)
48
+ fail(`scripts/${persona}-version.json says built_against_sdd_cli ${f.built_against_sdd_cli}, but this release is ${pkg.version}; bump the persona's version and re-stamp it in the release commit`);
49
+ }
50
+
42
51
  const tag = `v${pkg.version}`;
43
52
  if (run("git", ["status", "--porcelain", "--untracked-files=no"]) !== "") fail("the working tree has uncommitted changes to tracked files");
44
53
  const head = run("git", ["rev-parse", "HEAD"]);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.0.0",
3
- "built_against_sdd_cli": "1.41.0"
2
+ "version": "1.1.0",
3
+ "built_against_sdd_cli": "1.42.0"
4
4
  }