@sdd-method/maintain-cli 0.3.0 → 0.5.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 (81) hide show
  1. package/dist/lib/briefing/render.js +1 -2
  2. package/dist/lib/briefing/variables.js +1 -2
  3. package/dist/lib/catalogue/aggregate.js +35 -36
  4. package/dist/lib/catalogue/application-archetype/expectations.js +1 -1
  5. package/dist/lib/catalogue/archetype/wireframe.js +1 -1
  6. package/dist/lib/catalogue/build.js +17 -23
  7. package/dist/lib/catalogue/builders/application-conformance-coverage.js +1 -1
  8. package/dist/lib/catalogue/builders/c4-components.js +4 -5
  9. package/dist/lib/catalogue/builders/c4-containers.js +3 -3
  10. package/dist/lib/catalogue/builders/c4-entity-rollups.js +1 -1
  11. package/dist/lib/catalogue/builders/c4-external-systems.js +1 -1
  12. package/dist/lib/catalogue/builders/c4-l1-candidates.js +1 -1
  13. package/dist/lib/catalogue/builders/capabilities.js +2 -2
  14. package/dist/lib/catalogue/builders/capability-edges.js +4 -4
  15. package/dist/lib/catalogue/builders/capability-spec-walker.js +3 -3
  16. package/dist/lib/catalogue/builders/container-dependency.js +18 -19
  17. package/dist/lib/catalogue/builders/contracts.js +5 -6
  18. package/dist/lib/catalogue/builders/data-store-mapping.js +3 -3
  19. package/dist/lib/catalogue/builders/edge-label-merge.js +2 -2
  20. package/dist/lib/catalogue/builders/entities-manifest.js +1 -1
  21. package/dist/lib/catalogue/builders/entities-mermaid.js +3 -3
  22. package/dist/lib/catalogue/builders/entity-slug.js +11 -12
  23. package/dist/lib/catalogue/builders/event-catalog-walker.js +3 -3
  24. package/dist/lib/catalogue/builders/event-edges.js +1 -1
  25. package/dist/lib/catalogue/builders/evidence-class.js +2 -2
  26. package/dist/lib/catalogue/builders/external-system-exemption.js +1 -1
  27. package/dist/lib/catalogue/builders/feature-contract.js +1 -3
  28. package/dist/lib/catalogue/builders/feature-grounding.js +1 -2
  29. package/dist/lib/catalogue/builders/feature-groups.js +0 -1
  30. package/dist/lib/catalogue/builders/feature-subscription-channels.js +2 -3
  31. package/dist/lib/catalogue/builders/feature-tree-walker.js +1 -1
  32. package/dist/lib/catalogue/builders/features.js +3 -4
  33. package/dist/lib/catalogue/builders/glossary-xref.js +1 -1
  34. package/dist/lib/catalogue/builders/persona-c4system.js +7 -7
  35. package/dist/lib/catalogue/builders/persona-container.js +14 -14
  36. package/dist/lib/catalogue/builders/personas.js +1 -2
  37. package/dist/lib/catalogue/builders/product-platform-dependencies.js +2 -2
  38. package/dist/lib/catalogue/builders/quality-scores.js +1 -1
  39. package/dist/lib/catalogue/builders/repositories.js +2 -2
  40. package/dist/lib/catalogue/builders/screen-tree.js +1 -1
  41. package/dist/lib/catalogue/builders/screens.js +3 -4
  42. package/dist/lib/catalogue/builders/system-external-dependency.js +7 -7
  43. package/dist/lib/catalogue/canonical-schema.yaml +23 -23
  44. package/dist/lib/catalogue/contract-path-resolution.js +3 -3
  45. package/dist/lib/catalogue/coverage-predicates.js +4 -4
  46. package/dist/lib/catalogue/coverage-run.js +1 -1
  47. package/dist/lib/catalogue/dedupe-keyed-tables.js +2 -3
  48. package/dist/lib/catalogue/ext-id.js +4 -4
  49. package/dist/lib/catalogue/feature-grounding-aliases-loader.js +1 -1
  50. package/dist/lib/catalogue/finding-identity.js +3 -3
  51. package/dist/lib/catalogue/freshness.js +1 -1
  52. package/dist/lib/catalogue/manifest-loader.js +2 -2
  53. package/dist/lib/catalogue/scaffold-delta.js +1 -1
  54. package/dist/lib/catalogue/schema-compat.js +1 -2
  55. package/dist/lib/catalogue/screen-validation-loader.js +1 -1
  56. package/dist/lib/catalogue/staleness-verdicts.js +2 -2
  57. package/dist/lib/catalogue/traceability-loader.js +2 -3
  58. package/dist/lib/ci-binding/index.js +108 -33
  59. package/dist/lib/ci-binding/module.js +18 -8
  60. package/dist/lib/init/ci-workflow.js +2 -0
  61. package/dist/lib/lock-file.js +1 -1
  62. package/dist/lib/pack/reference-closure.js +3 -3
  63. package/dist/lib/package-version.js +1 -1
  64. package/dist/lib/snapshot/manifest.js +1 -1
  65. package/dist/lib/sync/adr-transitions.js +1 -1
  66. package/dist/lib/sync/config.js +8 -1
  67. package/dist/lib/sync/diff.js +1 -1
  68. package/dist/lib/sync/index.js +8 -3
  69. package/dist/lib/validate/behaviour-store.js +351 -0
  70. package/dist/lib/validate/dispatch.js +18 -0
  71. package/dist/lib/validate/index.js +1 -1
  72. package/dist/lib/validate/plan.js +3 -1
  73. package/dist/lib/validate/yaml-parse.js +1 -1
  74. package/dist/maintain-version.json +2 -2
  75. package/dist/verbs/sync.js +1 -1
  76. package/dist/verify-version.json +2 -2
  77. package/package.json +4 -6
  78. package/scripts/validate-app-dependencies.sh +5 -5
  79. package/scripts/validate-integration-docs.sh +1 -1
  80. package/LICENSE +0 -201
  81. package/NOTICE +0 -17
@@ -8,14 +8,21 @@
8
8
  * wrote is pristine and re-renders with no override, and only an edited copy needs --allow-overwrite.
9
9
  * The binding is remembered in the lock too (`ci_binding:`, absent meaning github), so an estate
10
10
  * chooses it once.
11
+ *
12
+ * `local` is the binding with no host: the module is written and nothing calls it but people, for an
13
+ * estate that validates on its own machines only (the module is run with `dagger call` there). Sync
14
+ * removes the wrapper this tool wrote for the previous binding when its block still hashes to the
15
+ * lock's digest; an edited one is drift, and a file without markers is the adopter's and stays.
11
16
  */
12
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
17
+ import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
18
  import { createHash } from "node:crypto";
14
19
  import { dirname, join } from "node:path";
15
20
  import { BLOCK_BEGIN, BLOCK_END, WORKFLOW_REL_PATH, renderMethodManagedWorkflowBlock, renderMethodValidationWorkflow, } from "../init/ci-workflow.js";
16
21
  import { detectBlockDrift, writeMarkedBlockFile } from "../sync/yaml-block-merge.js";
17
22
  import { DAGGER_VERSION, MODULE_CALL, MODULE_DIR, REBUILD_FLAG, REBUILD_SCRIPT, moduleSha256, renderModuleFiles } from "./module.js";
18
- export const CI_BINDINGS = ["github", "gitlab", "jenkins"];
23
+ /** The bindings that render a wrapper for a CI host. */
24
+ export const HOST_BINDINGS = ["github", "gitlab", "jenkins"];
25
+ export const CI_BINDINGS = [...HOST_BINDINGS, "local"];
19
26
  /** M-ADR 0219 §8.2 (ruled 2026-10-16): what every SDD has before it chooses. */
20
27
  export const DEFAULT_CI_BINDING = "github";
21
28
  /**
@@ -31,6 +38,9 @@ export function parseCiRunner(s) {
31
38
  export function isCiBinding(s) {
32
39
  return CI_BINDINGS.includes(s);
33
40
  }
41
+ export function isHostBinding(s) {
42
+ return HOST_BINDINGS.includes(s);
43
+ }
34
44
  const HASH = { begin: BLOCK_BEGIN, end: BLOCK_END };
35
45
  // Groovy has no '#' comments.
36
46
  const SLASH = { begin: "// method-managed: begin", end: "// method-managed: end" };
@@ -84,6 +94,8 @@ function gitlabBlock(profile, runner) {
84
94
  ' DOCKER_TLS_CERTDIR: "/certs"',
85
95
  ' DOCKER_TLS_VERIFY: "1"',
86
96
  ' DOCKER_CERT_PATH: "/certs/client"',
97
+ // Full history: the behaviour-store stale check diffs living specs against each change's agreed commit.
98
+ ' GIT_DEPTH: "0"',
87
99
  " rules:",
88
100
  ' - if: $CI_PIPELINE_SOURCE == "merge_request_event"',
89
101
  " - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH",
@@ -168,8 +180,41 @@ export function blockSha256(text, markers) {
168
180
  export async function syncCiBinding(o) {
169
181
  const failures = [];
170
182
  const log = [];
183
+ const was = o.prevLock?.ciBinding;
184
+ const sameBinding = (was ?? DEFAULT_CI_BINDING) === o.binding;
185
+ if (o.binding === "local") {
186
+ // No host. A switch removes the wrapper this tool wrote for the previous binding, but only when its
187
+ // block still hashes to the lock's digest: an edited block is drift, a file without markers is the
188
+ // adopter's and stays.
189
+ if (o.runner !== undefined && o.runner.length > 0) {
190
+ failures.push("--ci-runner names where a CI host runs the job; the local binding has no host. Drop --ci-runner (or choose a host binding).");
191
+ }
192
+ let removal = null;
193
+ const previous = sameBinding ? null : was === undefined ? DEFAULT_CI_BINDING : isHostBinding(was) ? was : null;
194
+ if (previous !== null) {
195
+ const prevHost = HOST_WRAPPERS[previous];
196
+ const abs = join(o.targetRepo, prevHost.relPath);
197
+ const text = await tryRead(abs);
198
+ const sha = blockSha256(text, prevHost.markers);
199
+ if (text !== null && sha === null) {
200
+ log.push(`${prevHost.relPath} has no method-managed markers, so it is the adopter's: left in place.`);
201
+ }
202
+ else if (text !== null && (sha === o.prevLock?.ciWorkflowBlockSha256 || o.allowDrift)) {
203
+ removal = { abs, rel: prevHost.relPath, text, host: prevHost };
204
+ }
205
+ else if (text !== null) {
206
+ failures.push(`${prevHost.relPath}: the method-managed block was edited since the last sync (it no longer matches the lock's digest), so switching to the local binding will not remove it. Re-run with --allow-overwrite to remove the block; adopter content outside the markers is kept.`);
207
+ }
208
+ }
209
+ const mod = await checkModule(o, failures, log);
210
+ if (o.checkOnly || failures.length > 0)
211
+ return { failures, log, wrapperSha: null, moduleSha: mod.shaOnDisk };
212
+ await writeModule(o, mod, log, "; the local binding calls it from no CI host");
213
+ if (removal !== null)
214
+ await removeWrapper(removal, o.profile, log);
215
+ return { failures, log, wrapperSha: null, moduleSha: mod.shaRendered };
216
+ }
171
217
  const host = HOST_WRAPPERS[o.binding];
172
- const sameBinding = (o.prevLock?.ciBinding ?? DEFAULT_CI_BINDING) === o.binding;
173
218
  // 1. The host wrapper.
174
219
  const wrapperAbs = join(o.targetRepo, host.relPath);
175
220
  const existing = await tryRead(wrapperAbs);
@@ -187,16 +232,44 @@ export async function syncCiBinding(o) {
187
232
  else if (markerless && !o.allowOverwrite) {
188
233
  failures.push(`${host.relPath} exists without method-managed markers — refusing to overwrite adopter content. Re-run with --allow-overwrite to replace the file.`);
189
234
  }
190
- // 2. The module: every file it renders, compared as one set.
235
+ // 2. The module.
236
+ const mod = await checkModule(o, failures, log);
237
+ if (o.checkOnly || failures.length > 0) {
238
+ return { failures, log, wrapperSha: null, moduleSha: mod.shaOnDisk };
239
+ }
240
+ const written = await writeMarkedBlockFile(wrapperAbs, existing === null ? host.file(o.profile, o.runner) : block, host.markers, o.allowOverwrite);
241
+ if (written.changed)
242
+ log.push(`${existing === null ? "Wrote" : "Refreshed"} ${host.relPath}.`);
243
+ await writeModule(o, mod, log, "");
244
+ // A binding switch between hosts leaves the previous host's file alone: it is the adopter's to delete
245
+ // once the new host runs, and deleting it here would remove a job some branch protection may still
246
+ // require. (From local there is no previous host file.)
247
+ if (!sameBinding && (was === undefined || isHostBinding(was))) {
248
+ const prevHost = HOST_WRAPPERS[was ?? DEFAULT_CI_BINDING];
249
+ if ((await tryRead(join(o.targetRepo, prevHost.relPath))) !== null) {
250
+ log.push(`CI binding is now ${o.binding}; ${prevHost.relPath} (the previous binding) is left in place. Delete it once ${host.relPath} runs.`);
251
+ }
252
+ }
253
+ return {
254
+ failures,
255
+ log,
256
+ wrapperSha: blockSha256(await tryRead(wrapperAbs), host.markers),
257
+ moduleSha: mod.shaRendered,
258
+ };
259
+ }
260
+ /**
261
+ * The module: every file it renders, compared as one set. Pristine when the files on disk hash to the
262
+ * lock's digest. The render may list a file the last sync never wrote (the generator gained one): it is
263
+ * absent, not edited, so the digest over the files present decides. A file the adopter deleted still
264
+ * changes that digest, so it is still drift.
265
+ */
266
+ async function checkModule(o, failures, log) {
191
267
  const rendered = renderModuleFiles();
192
268
  const onDisk = [];
193
269
  for (const [rel] of rendered)
194
270
  onDisk.push([rel, await tryRead(join(o.targetRepo, MODULE_DIR, rel))]);
195
271
  const present = onDisk.some(([, c]) => c !== null);
196
272
  const matches = rendered.every(([rel, c], i) => onDisk[i][0] === rel && onDisk[i][1] === c);
197
- // Pristine when the files on disk hash to the lock's digest. The render may list a file the last sync
198
- // never wrote (the generator gained one): it is absent, not edited, so the digest over the files present
199
- // decides. A file the adopter deleted still changes that digest, so it is still drift.
200
273
  const lockSha = o.prevLock?.ciModuleSha256;
201
274
  const modulePristine = lockSha !== undefined &&
202
275
  (moduleSha256(onDisk) === lockSha || moduleSha256(onDisk.filter(([, c]) => c !== null)) === lockSha);
@@ -206,35 +279,37 @@ export async function syncCiBinding(o) {
206
279
  if (present && !matches && !modulePristine && !o.allowDrift) {
207
280
  failures.push(`${MODULE_DIR}/: the method-managed module differs from what this sdd-cli renders. Either the generator changed since the last sync (expected after a CLI upgrade) or a file was hand-edited. Re-run with --allow-overwrite to re-render it.`);
208
281
  }
209
- if (o.checkOnly || failures.length > 0) {
210
- return { failures, log, wrapperSha: null, moduleSha: moduleSha256(onDisk) };
282
+ return { rendered, present, matches, shaOnDisk: moduleSha256(onDisk), shaRendered: moduleSha256(rendered) };
283
+ }
284
+ async function writeModule(o, mod, log, note) {
285
+ if (mod.matches)
286
+ return;
287
+ for (const [rel, content] of mod.rendered) {
288
+ const abs = join(o.targetRepo, MODULE_DIR, rel);
289
+ await mkdir(dirname(abs), { recursive: true });
290
+ await writeFile(abs, content, "utf8");
211
291
  }
212
- const written = await writeMarkedBlockFile(wrapperAbs, existing === null ? host.file(o.profile, o.runner) : block, host.markers, o.allowOverwrite);
213
- if (written.changed)
214
- log.push(`${existing === null ? "Wrote" : "Refreshed"} ${host.relPath}.`);
215
- if (!matches) {
216
- for (const [rel, content] of rendered) {
217
- const abs = join(o.targetRepo, MODULE_DIR, rel);
218
- await mkdir(dirname(abs), { recursive: true });
219
- await writeFile(abs, content, "utf8");
220
- }
221
- log.push(`${present ? "Refreshed" : "Wrote"} ${MODULE_DIR}/ (the Method Validation module, M-ADR 0219).`);
292
+ log.push(`${mod.present ? "Refreshed" : "Wrote"} ${MODULE_DIR}/ (the Method Validation module, M-ADR 0219)${note}.`);
293
+ }
294
+ /**
295
+ * Remove a wrapper's method-managed block. The file goes with it when what remains is this tool's own
296
+ * template text (or nothing), since then no line of it is the adopter's; otherwise the rest is kept.
297
+ */
298
+ async function removeWrapper(r, profile, log) {
299
+ const cut = (text) => {
300
+ const b = text.indexOf(r.host.markers.begin);
301
+ const e = text.indexOf(r.host.markers.end, b) + r.host.markers.end.length;
302
+ return (text.slice(0, b) + text.slice(e)).replace(/^\n+/, "");
303
+ };
304
+ const rest = cut(r.text);
305
+ if (rest.trim() === "" || rest.trim() === cut(r.host.file(profile)).trim()) {
306
+ await rm(r.abs);
307
+ log.push(`Removed ${r.rel}: the local binding has no CI host, and the file held only this tool's wrapper.`);
222
308
  }
223
- // A binding switch leaves the previous host's file alone: it is the adopter's to delete once the
224
- // new host runs, and deleting it here would remove a job some branch protection may still require.
225
- if (!sameBinding) {
226
- const was = o.prevLock?.ciBinding;
227
- const previous = HOST_WRAPPERS[was !== undefined && isCiBinding(was) ? was : DEFAULT_CI_BINDING];
228
- if ((await tryRead(join(o.targetRepo, previous.relPath))) !== null) {
229
- log.push(`CI binding is now ${o.binding}; ${previous.relPath} (the previous binding) is left in place. Delete it once ${host.relPath} runs.`);
230
- }
309
+ else {
310
+ await writeFile(r.abs, rest, "utf8");
311
+ log.push(`Removed the method-managed block from ${r.rel}; the adopter content outside it is kept.`);
231
312
  }
232
- return {
233
- failures,
234
- log,
235
- wrapperSha: blockSha256(await tryRead(wrapperAbs), host.markers),
236
- moduleSha: moduleSha256(rendered),
237
- };
238
313
  }
239
314
  async function tryRead(path) {
240
315
  try {
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { createHash } from "node:crypto";
14
14
  import { getMaintainCliVersion, getVerifyCliVersion } from "../package-version.js";
15
+ import { OPENSPEC_VERSION } from "../validate/behaviour-store.js";
15
16
  /** Where the module lives in an SDD (M-ADR 0219 §4.3, ruled 2026-10-16). */
16
17
  export const MODULE_DIR = ".dagger/method-validation";
17
18
  /**
@@ -27,7 +28,7 @@ export const REBUILD_SCRIPT = `${MODULE_DIR}/catalogue-rebuild.sh`;
27
28
  /**
28
29
  * Left in the git directory by REBUILD_SCRIPT when the rebuild ran, whether or not it pushed. Every
29
30
  * wrapper turns it into the module's --catalogue-rebuild, so the freshness step gates exactly where
30
- * the rebuild ran (sdm-jts.9's ruling) and the wrapper holds no condition of its own.
31
+ * the rebuild ran and the wrapper holds no condition of its own.
31
32
  */
32
33
  export const REBUILD_RAN_MARKER = "sdd-catalogue-rebuild-ran";
33
34
  /** What every wrapper appends to MODULE_CALL: --catalogue-rebuild when, and only when, the rebuild ran. */
@@ -37,8 +38,9 @@ const VERIFY_PIN = "@@VERIFY_CLI_VERSION@@";
37
38
  // scan would read this text as an import of the CLI's own and refuse a dependency it does not declare.
38
39
  const DAGGER_SDK = "@@DAGGER_SDK@@";
39
40
  const MAINTAIN_PIN = "@@MAINTAIN_CLI_VERSION@@";
41
+ const OPENSPEC_PIN = "@@OPENSPEC_VERSION@@";
40
42
  // The module's own source. String.raw keeps it byte-for-byte, so it holds no backtick; its only
41
- // substitutions are the three markers above, which renderModuleFiles replaces.
43
+ // substitutions are the four markers above, which renderModuleFiles replaces.
42
44
  const INDEX_TS = String.raw `/**
43
45
  * Method Validation (M-ADR 0219): the method-managed validation job, as one Dagger module. Every CI
44
46
  * host calls it, and none keeps a step list of its own. Written by sdd-cli sync and method-managed:
@@ -57,6 +59,8 @@ import { dag, Container, Directory, File, object, func, ReturnType } from ${DAGG
57
59
  // Pinned, so a run is reproducible and every tool the validators shell out to is named here.
58
60
  const NODE_IMAGE = "node:20-bookworm"
59
61
  const YQ_VERSION = "v4.44.3"
62
+ // The behaviour-store checks run OpenSpec at exactly the version the method depends on (M-ADR 0223 ruling 2), telemetry off.
63
+ const OPENSPEC_VERSION = "${OPENSPEC_PIN}"
60
64
 
61
65
  interface PlanStep {
62
66
  id: string
@@ -69,8 +73,8 @@ interface PlanStep {
69
73
  @object()
70
74
  export class MethodValidation {
71
75
  /**
72
- * The toolchain: Node, git, bash, python3, ripgrep, a pinned yq, and the exact-pinned verify and
73
- * maintain CLIs. A tarball, when given, replaces the published package (a release candidate).
76
+ * The toolchain: Node, git, bash, python3, ripgrep, a pinned yq, the pinned OpenSpec CLI, and the
77
+ * exact-pinned verify and maintain CLIs. A tarball, when given, replaces the published package (a release candidate).
74
78
  */
75
79
  @func()
76
80
  toolchain(
@@ -99,7 +103,11 @@ export class MethodValidation {
99
103
  c = c.withFile("/pkgs/maintain-cli.tgz", maintainTarball)
100
104
  pkgs.push("/pkgs/maintain-cli.tgz")
101
105
  } else pkgs.push("@sdd-method/maintain-cli@" + maintainVersion)
102
- return c.withExec(["npm", "install", "-g", "--silent", ...pkgs])
106
+ pkgs.push("@fission-ai/openspec@" + OPENSPEC_VERSION)
107
+ return c
108
+ .withEnvVariable("OPENSPEC_TELEMETRY", "0")
109
+ .withEnvVariable("DO_NOT_TRACK", "1")
110
+ .withExec(["npm", "install", "-g", "--silent", ...pkgs])
103
111
  }
104
112
 
105
113
  /**
@@ -130,10 +138,12 @@ export class MethodValidation {
130
138
  for (const step of plan.steps) {
131
139
  const ran = base.withExec([step.tool, ...step.args], { expect: ReturnType.Any })
132
140
  const code = await ran.exitCode()
133
- const verdict = code === 0 ? "PASS" : step.advisory ? "ADVISORY" : "FAIL"
141
+ // A step that ran over an empty population (no behaviour store, say) reports N/A, never PASS.
142
+ const measuredNothing = code === 0 && /^\[(N\/A|SKIP)\] /m.test(await ran.stdout()) && !/^\[PASS\] (?!All validators)/m.test(await ran.stdout())
143
+ const verdict = code === 0 ? (measuredNothing ? "N/A" : "PASS") : step.advisory ? "ADVISORY" : "FAIL"
134
144
  if (code !== 0 && !step.advisory) failed++
135
145
  lines.push(verdict.padEnd(8) + " exit " + code + " " + step.id + " (" + step.title + ")")
136
- if (code !== 0) {
146
+ if (code !== 0 || measuredNothing) {
137
147
  const out = (await ran.stdout()) + (await ran.stderr())
138
148
  lines.push(...out.trim().split("\n").slice(-6).map((l) => " " + l))
139
149
  }
@@ -257,7 +267,7 @@ export function renderModuleFiles(pins = { verify: getVerifyCliVersion(), mainta
257
267
  ["catalogue-rebuild.sh", REBUILD_SH],
258
268
  ["dagger.json", DAGGER_JSON],
259
269
  ["package.json", PACKAGE_JSON],
260
- ["src/index.ts", INDEX_TS.split(VERIFY_PIN).join(pins.verify).split(MAINTAIN_PIN).join(pins.maintain).split(DAGGER_SDK).join('"@dagger.io' + '/dagger"')],
270
+ ["src/index.ts", INDEX_TS.split(VERIFY_PIN).join(pins.verify).split(MAINTAIN_PIN).join(pins.maintain).split(OPENSPEC_PIN).join(OPENSPEC_VERSION).split(DAGGER_SDK).join('"@dagger.io' + '/dagger"')],
261
271
  ["tsconfig.json", TSCONFIG_JSON],
262
272
  ["yarn.lock", YARN_LOCK],
263
273
  ];
@@ -93,6 +93,8 @@ function buildMethodManagedBlock(profile, runner) {
93
93
  // The branch itself when rebuilding, so the rebuild lands on it; otherwise the event's own ref.
94
94
  " ref: ${{ steps.catalogue-token.outputs.token != '' && (github.head_ref || github.ref_name) || '' }}",
95
95
  " persist-credentials: false",
96
+ // Full history: the behaviour-store stale check diffs living specs against each change's agreed commit.
97
+ " fetch-depth: 0",
96
98
  " - name: Install Dagger",
97
99
  ` run: curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR="$RUNNER_TEMP/dagger" DAGGER_VERSION=${DAGGER_VERSION} sh && echo "$RUNNER_TEMP/dagger" >> "$GITHUB_PATH"`,
98
100
  " - name: Rebuild catalogues",
@@ -215,7 +215,7 @@ function parseVersioned(raw) {
215
215
  // a YAML reader parses 113 of 325 entries in one real lock as MAPPINGS
216
216
  // rather than strings — silently, with no error. Quoting on write is the
217
217
  // fix; reading both forms ships first so no lock can be written that an
218
- // older reader cannot parse. See method-open-items §3l.
218
+ // older reader cannot parse.
219
219
  const entry = stripQuotes(line.slice(" - ".length));
220
220
  const withDigest = DIGEST_ENTRY_RE.exec(entry);
221
221
  if (withDigest) {
@@ -14,10 +14,10 @@
14
14
  * ENFORCEMENT (validate-link-closure.sh). A subset cannot satisfy closure
15
15
  * without ceasing to be a subset.
16
16
  *
17
- * Parser hardening comes from the estate that measured this problem — the
18
- * four traps their link audit hit are this module's controls: markdown
17
+ * Parser hardening covers the four traps a real link audit hit, which are
18
+ * this module's controls: markdown
19
19
  * targets containing SPACES (unencoded paths must not truncate at the first
20
- * space), targets containing PARENTHESES (balance them; "…(ReBAC).md" broke
20
+ * space), targets containing PARENTHESES (balance them; "…(v2).md" broke
21
21
  * every naive regex), URL-ENCODED targets (%20, %3A… decode before
22
22
  * resolving), and never scanning outside the payload set (no cache dirs —
23
23
  * guaranteed here by construction, since only staged payload paths are read).
@@ -83,7 +83,7 @@ export function getVerifyCliVersion() {
83
83
  }
84
84
  /**
85
85
  * The @sdd-method/maintain-cli version released WITH this sdd-cli. The managed CI workflow's
86
- * catalogue freshness step installs exactly this version (M-ADR 0218 §5; ruled 2026-10-15, sdm-jts.6).
86
+ * catalogue freshness step installs exactly this version (M-ADR 0218 §5).
87
87
  * Read the same way as the verify version, for the same reason (dist/ copy, never scripts/ live).
88
88
  */
89
89
  export function getMaintainCliVersion() {
@@ -82,7 +82,7 @@ export async function readManifest(areaRoot, id) {
82
82
  }
83
83
  if (!Array.isArray(raw.members))
84
84
  problems.push("members must be a list");
85
- // A hand-written revision of digits only (`50834756022`) parses as a NUMBER, and
85
+ // A hand-written revision of digits only (`12345678901`) parses as a NUMBER, and
86
86
  // a strict comparison with the folder's string suffix then calls an unchanged
87
87
  // member changed. Identity fields are strings, whatever YAML made of them.
88
88
  for (const m of raw.members ?? []) {
@@ -90,7 +90,7 @@ export async function planAdrTransitions(bundleRootDir, targetRepo, previousVers
90
90
  const protectedFromRemoval = [];
91
91
  const movedPaths = [];
92
92
  /**
93
- * FIRST ADOPTION replays the bundle's whole transition history (ledger §3ae).
93
+ * FIRST ADOPTION replays the bundle's whole transition history.
94
94
  * A repo adopting a bundle for the first time receives its CURRENT state, so
95
95
  * every transition in the log happened before this repo existed as an
96
96
  * adopter — yet the log is filtered only by "newer than the last applied
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { readFile } from "node:fs/promises";
2
3
  import { dirname, isAbsolute, resolve } from "node:path";
3
4
  import { parse as parseYaml } from "yaml";
@@ -57,7 +58,7 @@ export async function loadSyncConfig(configPath) {
57
58
  const rel = `${name}/${name}-${ver}.tar.gz`;
58
59
  source = isUrl(resolvedBase)
59
60
  ? `${resolvedBase.replace(/\/$/, "")}/${rel}`
60
- : resolve(resolvedBase, name, `${name}-${ver}.tar.gz`);
61
+ : localBundle(resolve(resolvedBase, name), `${name}-${ver}`);
61
62
  }
62
63
  else {
63
64
  throw new Error(`Sync config ${absPath} bundles[${i}] missing required field: source ` +
@@ -69,4 +70,10 @@ export async function loadSyncConfig(configPath) {
69
70
  ? { version, bundles }
70
71
  : { version, bundleSource, bundles };
71
72
  }
73
+ // <dir>/<stem>.tar.gz, or <dir>/<stem>.tgz when only that exists (same bytes, another name).
74
+ function localBundle(dir, stem) {
75
+ const tarGz = resolve(dir, `${stem}.tar.gz`);
76
+ const tgz = resolve(dir, `${stem}.tgz`);
77
+ return !existsSync(tarGz) && existsSync(tgz) ? tgz : tarGz;
78
+ }
72
79
  //# sourceMappingURL=config.js.map
@@ -154,7 +154,7 @@ export function computeRemovals(prevPaths, incomingPaths, options) {
154
154
  }
155
155
  /**
156
156
  * SHADOW COLLISIONS — an incoming managed file whose basename already exists at
157
- * a SHALLOWER, unmanaged path in the adopter's repo (ledger §3ap).
157
+ * a SHALLOWER, unmanaged path in the adopter's repo.
158
158
  *
159
159
  * When the distributed surface MOVES a file, copies previously delivered at the
160
160
  * old location are stranded as unmanaged files. They keep resolving, so nothing
@@ -102,7 +102,12 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
102
102
  stdout(fail(`method-baseline.lock names ci_runner '${prevLock.ciRunner}', which is not a list of runner labels. Re-run with --ci-runner <label[,label]> or --ci-runner default.`));
103
103
  return { exitCode: 1 };
104
104
  }
105
- const ciRunner = opts.ciRunner ?? lockRunner;
105
+ // The local binding has no host to run on: a runner the lock recorded for an earlier host binding is
106
+ // dropped (and the lock line with it); only an explicit --ci-runner with local is refused.
107
+ const droppedRunner = ciBinding === "local" && opts.ciRunner === undefined && lockRunner !== undefined && lockRunner.length > 0;
108
+ const ciRunner = ciBinding === "local" && opts.ciRunner === undefined ? undefined : opts.ciRunner ?? lockRunner;
109
+ if (droppedRunner)
110
+ stdout(info(`ci_runner '${prevLock.ciRunner}' dropped: the local binding has no CI host to run on.`));
106
111
  const lockSource = prevLock?.cliSource;
107
112
  if (lockSource !== undefined && !CLI_SOURCES.includes(lockSource)) {
108
113
  stdout(fail(`method-baseline.lock names cli_source '${lockSource}', which is not one of ${CLI_SOURCES.join(", ")}. Re-run with --cli-source <source> to choose one.`));
@@ -239,7 +244,7 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
239
244
  if (cacheOnlyPaths.length > 0) {
240
245
  stdout(info(`Cache-only (scaffold-source/registry) files: ${cacheOnlyPaths.length} — extracted to .sdd/bundle-cache, not materialised`));
241
246
  }
242
- // SHADOW-COLLISION FLAG (ledger §3ap, the durable move-rule's machinery).
247
+ // SHADOW-COLLISION FLAG (the durable move-rule's machinery).
243
248
  // When the distributed surface MOVES a file, previously-delivered copies at
244
249
  // the old location are stranded as unmanaged files: they keep resolving, so
245
250
  // nothing breaks loudly, and they silently go stale as their managed twins
@@ -270,7 +275,7 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
270
275
  }
271
276
  }
272
277
  stdout(info(`Conflicts: ${conflicts.length}`));
273
- // RELABEL (ledger §3y.2): the raw count mixes two different things and an
278
+ // RELABEL: the raw count mixes two different things and an
274
279
  // adopter's dry-run reported 31 where the artefacts on disk numbered 5.
275
280
  // Nothing was wrong with the mechanism — the LABEL claimed deletions for
276
281
  // lock entries whose paths were never materialised (cache-only payload