@sdd-method/maintain-cli 0.1.0 → 0.3.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 (53) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +1 -1
  3. package/dist/lib/agent-harness.js +17 -8
  4. package/dist/lib/briefing/index.js +1 -1
  5. package/dist/lib/briefing/render.js +17 -8
  6. package/dist/lib/catalogue/aggregate.js +6 -6
  7. package/dist/lib/catalogue/aggregation-manifest-loader.js +4 -4
  8. package/dist/lib/catalogue/build.js +1 -1
  9. package/dist/lib/catalogue/builders/c4-components.js +1 -1
  10. package/dist/lib/catalogue/builders/c4-containers.js +1 -1
  11. package/dist/lib/catalogue/builders/c4-l1-candidates.js +1 -1
  12. package/dist/lib/catalogue/builders/container-dependency.js +1 -1
  13. package/dist/lib/catalogue/builders/data-store-mapping.js +1 -1
  14. package/dist/lib/catalogue/builders/screen-tree.js +1 -1
  15. package/dist/lib/catalogue/contract-path-resolution.js +2 -2
  16. package/dist/lib/catalogue/dedupe-keyed-tables.js +0 -0
  17. package/dist/lib/catalogue/freshness.js +4 -3
  18. package/dist/lib/catalogue/scores-coverage.js +1 -1
  19. package/dist/lib/catalogue/service-contract-aliases-loader.js +1 -1
  20. package/dist/lib/ci-binding/index.js +44 -5
  21. package/dist/lib/ci-binding/module.js +74 -2
  22. package/dist/lib/context-pull/index.js +79 -39
  23. package/dist/lib/context-pull/retired-mcp-setup.js +7 -0
  24. package/dist/lib/hooks-seed/entries.js +55 -4
  25. package/dist/lib/hooks-seed/index.js +4 -7
  26. package/dist/lib/hooks-seed/merge.js +31 -0
  27. package/dist/lib/init/ci-workflow.js +26 -2
  28. package/dist/lib/lock-file.js +10 -0
  29. package/dist/lib/pack/builder.js +56 -4
  30. package/dist/lib/pack/index.js +1 -0
  31. package/dist/lib/pack/publish-transform.js +1 -1
  32. package/dist/lib/sync/briefing-merge.js +50 -34
  33. package/dist/lib/sync/diff.js +1 -1
  34. package/dist/lib/sync/index.js +46 -72
  35. package/dist/lib/sync/settings-merge.js +71 -163
  36. package/dist/lib/validate/plan.js +6 -3
  37. package/dist/maintain-program.js +6 -3
  38. package/dist/maintain-version.json +2 -2
  39. package/dist/verbs/catalogue-check.js +28 -0
  40. package/dist/verbs/context.js +1 -1
  41. package/dist/verbs/pack-build.js +22 -0
  42. package/dist/verbs/sync.js +17 -0
  43. package/dist/verbs/validate.js +6 -1
  44. package/dist/verify-program.js +2 -2
  45. package/dist/verify-version.json +2 -2
  46. package/package.json +2 -6
  47. package/dist/lib/hooks-seed/copilot.js +0 -167
  48. package/dist/lib/hooks-seed/lifecycle-mapping.js +0 -28
  49. package/dist/lib/mcp-seed/entries.js +0 -14
  50. package/dist/lib/mcp-seed/index.js +0 -28
  51. package/dist/lib/mcp-seed/merge.js +0 -99
  52. package/dist/lib/mcp-seed/types.js +0 -13
  53. package/dist/lib/sync/copilot-hooks-merge.js +0 -108
@@ -5,7 +5,7 @@
5
5
  *
6
6
  * - `agent-instructions.md` — fully method-managed; unconditional
7
7
  * overwrite on every sync. No marker-aware merge.
8
- * - Harness wrapper (`CLAUDE.md` / `AGENTS.md` / …) — partially
8
+ * - The wrappers, `CLAUDE.md` and the `AGENTS.md` pointer (M-ADR 0220 §4.6) — partially
9
9
  * method-managed. The `<!-- method-managed: begin/end -->` block
10
10
  * is overwritten; adopter content around it survives via the
11
11
  * existing `writeMarkedBlockFile` machinery (same pattern as
@@ -20,9 +20,9 @@
20
20
  * pre-dating ADR 0148 won't carry the templates; this module skips
21
21
  * gracefully with a log line in that case.
22
22
  */
23
- import { access, mkdir, writeFile } from "node:fs/promises";
23
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
24
24
  import { dirname, join } from "node:path";
25
- import { AGENT_INSTRUCTIONS_REL_PATH, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, harnessWrapperOutputPath, renderAgentInstructions, renderHarnessWrapper, resolveBriefingVariables, splitWrapperTemplate, } from "../briefing/index.js";
25
+ import { AGENT_INSTRUCTIONS_REL_PATH, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, briefingWrappers, renderAgentInstructions, renderWrapper, resolveBriefingVariables, splitWrapperTemplate, } from "../briefing/index.js";
26
26
  import { writeMarkedBlockFile } from "./yaml-block-merge.js";
27
27
  export async function syncBriefingFiles(opts) {
28
28
  const log = [];
@@ -31,14 +31,36 @@ export async function syncBriefingFiles(opts) {
31
31
  repoKindOverride: opts.repoKind,
32
32
  baselineVersionOverride: opts.baselineVersion,
33
33
  });
34
- // 1. agent-instructions.md — unconditional overwrite.
34
+ // 1. agent-instructions.md — unconditional overwrite, once no wrapper will refuse.
35
35
  const aiRender = await renderAgentInstructions(opts.targetRepo, vars);
36
36
  if (aiRender === null) {
37
37
  log.push(`Skipped ${AGENT_INSTRUCTIONS_REL_PATH}: bundle does not ship a briefing template (pre-ADR-0148 baseline).`);
38
- // If the agent-instructions template is missing the wrapper one almost
39
- // certainly is too — skip the rest as well.
38
+ // If the agent-instructions template is missing the wrapper ones almost
39
+ // certainly are too — skip the rest as well.
40
40
  return { log, refused: null };
41
41
  }
42
+ // 2. The wrappers, rendered first so a refusal leaves nothing written. A baseline that does not
43
+ // ship a wrapper's template (the AGENTS.md pointer arrived with M-ADR 0220) skips that wrapper.
44
+ const wrappers = [];
45
+ for (const w of briefingWrappers(opts.agentHarness)) {
46
+ const render = await renderWrapper(opts.targetRepo, w.templateRel, vars);
47
+ if (render === null) {
48
+ log.push(`Skipped ${w.outputRel}: the baseline does not ship ${w.templateRel}.`);
49
+ continue;
50
+ }
51
+ if (render.unresolved.length > 0) {
52
+ log.push(`Warning: ${w.outputRel} has unresolved placeholders: ${render.unresolved.join(", ")}`);
53
+ }
54
+ wrappers.push({ rel: w.outputRel, content: render.content });
55
+ }
56
+ if (!opts.allowOverwrite) {
57
+ for (const w of wrappers) {
58
+ const existing = await readIfExists(join(opts.targetRepo, w.rel));
59
+ if (existing !== null && existing !== "" && !(existing.includes(WRAPPER_BLOCK_BEGIN) && existing.includes(WRAPPER_BLOCK_END))) {
60
+ return { log, refused: w.rel };
61
+ }
62
+ }
63
+ }
42
64
  if (aiRender.unresolved.length > 0) {
43
65
  log.push(`Warning: agent-instructions.md has unresolved placeholders: ${aiRender.unresolved.join(", ")}`);
44
66
  }
@@ -46,37 +68,31 @@ export async function syncBriefingFiles(opts) {
46
68
  await mkdir(dirname(aiPath), { recursive: true });
47
69
  await writeFile(aiPath, aiRender.content, "utf8");
48
70
  log.push(`Refreshed ${AGENT_INSTRUCTIONS_REL_PATH}.`);
49
- // 2. Harness wrapper — marker-aware merge.
50
- const wrapperRender = await renderHarnessWrapper(opts.targetRepo, opts.agentHarness, vars);
51
- if (wrapperRender === null) {
52
- log.push(`Skipped ${harnessWrapperOutputPath(opts.agentHarness)}: bundle does not ship a wrapper template for ${opts.agentHarness}.`);
53
- return { log, refused: null };
71
+ // 3. Each wrapper — marker-aware merge. Mirror the ci-workflow sync pattern: when the target
72
+ // file does not yet exist, write the full rendered template (block + adopter section template).
73
+ // When it exists, write the method-managed block and scaffold-fill blank regions around it from
74
+ // the template — writeMarkedBlockFile preserves non-blank adopter content verbatim.
75
+ for (const w of wrappers) {
76
+ const wrapperPath = join(opts.targetRepo, w.rel);
77
+ const wrapperExists = await fileExists(wrapperPath);
78
+ const templateParts = splitWrapperTemplate(w.content);
79
+ const mergeResult = await writeMarkedBlockFile(wrapperPath, wrapperExists ? templateParts.block : w.content, { begin: WRAPPER_BLOCK_BEGIN, end: WRAPPER_BLOCK_END }, opts.allowOverwrite, wrapperExists ? { pre: templateParts.pre, post: templateParts.post } : undefined);
80
+ if (mergeResult.refused) {
81
+ return { log, refused: w.rel };
82
+ }
83
+ if (mergeResult.changed) {
84
+ log.push(`Refreshed ${w.rel}.`);
85
+ }
54
86
  }
55
- if (wrapperRender.unresolved.length > 0) {
56
- log.push(`Warning: ${harnessWrapperOutputPath(opts.agentHarness)} has unresolved placeholders: ${wrapperRender.unresolved.join(", ")}`);
57
- }
58
- const wrapperRel = harnessWrapperOutputPath(opts.agentHarness);
59
- const wrapperPath = join(opts.targetRepo, wrapperRel);
60
- // Mirror the ci-workflow sync pattern: when the target file does not
61
- // yet exist, write the full rendered template (block + adopter
62
- // section template). When it exists, write the method-managed block
63
- // and scaffold-fill blank regions around it from the template —
64
- // writeMarkedBlockFile preserves non-blank adopter content verbatim.
65
- const wrapperExists = await fileExists(wrapperPath);
66
- const templateParts = splitWrapperTemplate(wrapperRender.content);
67
- const incoming = wrapperExists
68
- ? templateParts.block
69
- : wrapperRender.content;
70
- const mergeResult = await writeMarkedBlockFile(wrapperPath, incoming, { begin: WRAPPER_BLOCK_BEGIN, end: WRAPPER_BLOCK_END }, opts.allowOverwrite, wrapperExists
71
- ? { pre: templateParts.pre, post: templateParts.post }
72
- : undefined);
73
- if (mergeResult.refused) {
74
- return { log, refused: wrapperRel };
87
+ return { log, refused: null };
88
+ }
89
+ async function readIfExists(path) {
90
+ try {
91
+ return await readFile(path, "utf8");
75
92
  }
76
- if (mergeResult.changed) {
77
- log.push(`Refreshed ${wrapperRel}.`);
93
+ catch {
94
+ return null;
78
95
  }
79
- return { log, refused: null };
80
96
  }
81
97
  async function fileExists(path) {
82
98
  try {
@@ -139,7 +139,7 @@ export const GENERATED_NEVER_REMOVE = [
139
139
  "AGENTS.md",
140
140
  "agent-instructions.md",
141
141
  ];
142
- /** `.github/hooks/<event>.json` — rendered per-event by hooks-seed/copilot.ts. */
142
+ /** `.github/hooks/` — written by the retired Copilot harness (M-ADR 0220 §4.6); an adopter keeps those files, unmanaged, so sync never removes them. */
143
143
  const GENERATED_PREFIXES = [".github/hooks/"];
144
144
  /** True when this path's existence is owned by a generator, not by a payload. */
145
145
  export function isGeneratedPath(p) {
@@ -11,12 +11,12 @@ import { cacheBundle, ensureCacheGitignored, splitConsumptionModes, } from "./bu
11
11
  import { PROTECTED_PREFIXES, allPristine, classifyConflicts, computeRemovals, detectShadowCollisions, digestManagedPaths, findConflicts, findMissingPayloadFiles, findProtectedPaths, } from "./diff.js";
12
12
  import { applyAdrRenames, findStrandedCitations, planAdrTransitions, } from "./adr-transitions.js";
13
13
  import { syncMethodManagedSettings } from "./settings-merge.js";
14
- import { syncMethodManagedCopilotHooks } from "./copilot-hooks-merge.js";
14
+ import { CLI_SOURCES, NPM_PACKAGE_NAME } from "../hooks-seed/index.js";
15
15
  import { syncBriefingFiles } from "./briefing-merge.js";
16
16
  import { DEFAULT_AGENT_HARNESS } from "../agent-harness.js";
17
17
  import { BLOCK_BEGIN, BLOCK_END } from "../init/ci-workflow.js";
18
18
  import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, parseCiRunner, syncCiBinding, } from "../ci-binding/index.js";
19
- import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, harnessWrapperOutputPath, } from "../briefing/index.js";
19
+ import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, briefingWrappers, } from "../briefing/index.js";
20
20
  const SECTION_RULE = "═".repeat(59);
21
21
  /**
22
22
  * Report the pristine/edited/unclassifiable split for a conflict set.
@@ -103,6 +103,24 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
103
103
  return { exitCode: 1 };
104
104
  }
105
105
  const ciRunner = opts.ciRunner ?? lockRunner;
106
+ const lockSource = prevLock?.cliSource;
107
+ if (lockSource !== undefined && !CLI_SOURCES.includes(lockSource)) {
108
+ 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.`));
109
+ return { exitCode: 1 };
110
+ }
111
+ const lockPackage = prevLock?.cliPackage;
112
+ if (lockPackage !== undefined && !NPM_PACKAGE_NAME.test(lockPackage)) {
113
+ stdout(fail(`method-baseline.lock names cli_package '${lockPackage}', which is not an npm package name. Re-run with --cli-package <name> or --cli-package default.`));
114
+ return { exitCode: 1 };
115
+ }
116
+ const source = opts.cliSource ?? lockSource ?? "registry";
117
+ if (source === "installed" && typeof opts.cliPackage === "string") {
118
+ stdout(fail("--cli-package names an npm package for the registry source; the installed source runs sdd-verify from PATH. Drop --cli-package or choose --cli-source registry."));
119
+ return { exitCode: 1 };
120
+ }
121
+ // The installed source has no package; switching to it drops a recorded name.
122
+ const cliPackage = source === "installed" ? undefined : opts.cliPackage === undefined ? lockPackage : (opts.cliPackage ?? undefined);
123
+ const cliSource = source === "installed" ? { source } : { source, ...(cliPackage ? { package: cliPackage } : {}) };
106
124
  const classification = await classifyConflicts(conflicts, opts.targetRepo, prevEntry?.pathDigests);
107
125
  if (conflicts.length > 0) {
108
126
  reportClassification(stdout, conflicts.length, classification, prevEntry?.bundleVersion);
@@ -332,32 +350,16 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
332
350
  if (incomingIsForward && !opts.allowOverwrite && !migrateFrom) {
333
351
  const preflightProfile = profileFromBundleName(incomingBundleName);
334
352
  if (preflightProfile) {
335
- if (opts.agentHarness === "github-copilot-cli") {
336
- const probe = await syncMethodManagedCopilotHooks({
337
- targetRepo: opts.targetRepo,
338
- allowOverwrite: false,
339
- checkOnly: true,
340
- });
341
- if (probe.refused) {
342
- failures.push(`.github/hooks/*.json has adopter-modified method entries (${probe.driftedRefused.join(", ")}). Re-run with --allow-overwrite to rewrite them.`);
343
- }
344
- }
345
- else {
353
+ {
346
354
  const probe = await syncMethodManagedSettings({
347
355
  targetRepo: opts.targetRepo,
348
- profile: preflightProfile,
349
356
  allowOverwrite: false,
350
357
  checkOnly: true,
351
- hadPreviousSync: prevLock !== null,
358
+ cliSource,
352
359
  ...(opts.agentHarness ? { agentHarness: opts.agentHarness } : {}),
353
360
  });
354
361
  if (probe.refused) {
355
- const conflicts = [];
356
- if (probe.drifted.length > 0)
357
- conflicts.push(`mcpServers: ${probe.drifted.join(", ")}`);
358
- if (probe.hooksDrifted.length > 0)
359
- conflicts.push(`hooks: ${probe.hooksDrifted.join(", ")}`);
360
- failures.push(`.claude/settings.json has adopter-modified method entries (${conflicts.join("; ")}). Re-run with --allow-overwrite to rewrite them.`);
362
+ failures.push(`.claude/settings.json has adopter-modified method entries (hooks: ${probe.hooksDrifted.join(", ")}). Re-run with --allow-overwrite to rewrite them.`);
361
363
  }
362
364
  }
363
365
  // The CI host wrapper and the Method Validation module (M-ADR 0219): the same check the
@@ -377,13 +379,18 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
377
379
  // templates; pre-ADR-0148 baselines skip the briefing pass entirely.
378
380
  const shipsBriefing = bundle.managedPaths.some((p) => p.startsWith(`${BRIEFING_TEMPLATE_ROOT}/`));
379
381
  if (shipsBriefing) {
380
- const wrapperRel = harnessWrapperOutputPath(opts.agentHarness ?? DEFAULT_AGENT_HARNESS);
381
- const preflightWrapper = await tryReadFile(join(opts.targetRepo, wrapperRel));
382
- if (preflightWrapper !== null &&
383
- preflightWrapper !== "" &&
384
- !(preflightWrapper.includes(WRAPPER_BLOCK_BEGIN) &&
385
- preflightWrapper.includes(WRAPPER_BLOCK_END))) {
386
- failures.push(`${wrapperRel} has adopter edits outside the method-managed markers. Re-run with --allow-overwrite to replace the file.`);
382
+ // Every wrapper the bundle ships a template for: CLAUDE.md and, from M-ADR 0220, the
383
+ // AGENTS.md pointer. An unmarked file is the adopter's and refuses.
384
+ for (const w of briefingWrappers(opts.agentHarness ?? DEFAULT_AGENT_HARNESS)) {
385
+ if (!bundle.managedPaths.includes(`${BRIEFING_TEMPLATE_ROOT}/${w.templateRel}`))
386
+ continue;
387
+ const preflightWrapper = await tryReadFile(join(opts.targetRepo, w.outputRel));
388
+ if (preflightWrapper !== null &&
389
+ preflightWrapper !== "" &&
390
+ !(preflightWrapper.includes(WRAPPER_BLOCK_BEGIN) &&
391
+ preflightWrapper.includes(WRAPPER_BLOCK_END))) {
392
+ failures.push(`${w.outputRel} has adopter edits outside the method-managed markers. Re-run with --allow-overwrite to replace the file.`);
393
+ }
387
394
  }
388
395
  }
389
396
  }
@@ -508,58 +515,22 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
508
515
  }
509
516
  const repoKind = repoKindFromProfile(profile);
510
517
  await writeRepoKind(opts.targetRepo, repoKind);
511
- // Harness-aware merge of agent-harness wiring per ADR 0141 §4.6.
512
- // claude-code keeps the .claude/settings.json path (MCP + Claude
513
- // hooks). github-copilot-cli writes .github/hooks/*.json instead;
514
- // its MCP wiring lands separately under ADR 0150 Phase 3.
515
- if (opts.agentHarness === "github-copilot-cli") {
516
- const copilotResult = await syncMethodManagedCopilotHooks({
517
- targetRepo: opts.targetRepo,
518
- allowOverwrite: opts.allowOverwrite || migrateFrom !== null,
519
- });
520
- if (copilotResult.refused) {
521
- stdout(fail(`.github/hooks/*.json has adopter-modified method entries (${copilotResult.driftedRefused.join(", ")}). Re-run with --allow-overwrite to rewrite them.`));
522
- return { exitCode: 1 };
523
- }
524
- const written = copilotResult.files.filter((f) => f.wrote);
525
- if (written.length > 0) {
526
- stdout(info(`Refreshed ${written.map((f) => f.relPath).join(", ")}.`));
527
- }
528
- }
529
- else {
518
+ // The harness wiring: .claude/settings.json, the one supported harness's hooks (M-ADR 0220 §4.6).
519
+ {
530
520
  const settingsResult = await syncMethodManagedSettings({
531
521
  targetRepo: opts.targetRepo,
532
- profile,
533
522
  allowOverwrite: opts.allowOverwrite || migrateFrom !== null,
534
- // A lock is the only sound evidence the managed entries were seeded
535
- // before, which is what makes a re-seed distinguishable from a first
536
- // sync into an adopter's own settings file.
537
- hadPreviousSync: prevLock !== null,
523
+ cliSource,
538
524
  ...(opts.agentHarness ? { agentHarness: opts.agentHarness } : {}),
539
525
  });
540
526
  if (settingsResult.refused) {
541
- const conflicts = [];
542
- if (settingsResult.drifted.length > 0)
543
- conflicts.push(`mcpServers: ${settingsResult.drifted.join(", ")}`);
544
- if (settingsResult.hooksDrifted.length > 0)
545
- conflicts.push(`hooks: ${settingsResult.hooksDrifted.join(", ")}`);
546
- stdout(fail(`.claude/settings.json has adopter-modified method entries (${conflicts.join("; ")}). Re-run with --allow-overwrite to rewrite them.`));
527
+ stdout(fail(`.claude/settings.json has adopter-modified method entries (hooks: ${settingsResult.hooksDrifted.join(", ")}). Re-run with --allow-overwrite to rewrite them.`));
547
528
  return { exitCode: 1 };
548
529
  }
549
- // Drift-class line of its own, ahead of the refresh summary: these
550
- // entries were seeded by an earlier sync and had been removed since.
551
- // Restoring them is not the news — that they went missing is, and the
552
- // same ids reappearing here every sync means something is removing them
553
- // repeatedly.
554
- if (settingsResult.reseeded.length > 0) {
555
- stdout(info(`${settingsResult.path}: RE-SEEDED ${settingsResult.reseeded.length} method-managed entr${settingsResult.reseeded.length === 1 ? "y" : "ies"} (${settingsResult.reseeded.join(", ")}) — present after a previous sync, absent now. Restored; if this recurs, something in this repo is removing them.`));
556
- }
557
530
  if (settingsResult.wroteFile) {
558
531
  const labels = [];
559
- if (settingsResult.added.length > 0)
560
- labels.push(`mcp added: ${settingsResult.added.join(", ")}`);
561
- if (settingsResult.updated.length > 0)
562
- labels.push(`mcp updated: ${settingsResult.updated.join(", ")}`);
532
+ if (settingsResult.retiredServers.length > 0)
533
+ labels.push(`removed the method's retired MCP server entry: ${settingsResult.retiredServers.join(", ")} (M-ADR 0220)`);
563
534
  if (settingsResult.hooksChangedEvents.length > 0)
564
535
  labels.push(`hooks: ${settingsResult.hooksChangedEvents.join(", ")}`);
565
536
  stdout(info(`Refreshed ${settingsResult.path} (${labels.join("; ") || "no changes"}).`));
@@ -602,15 +573,18 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
602
573
  for (const line of briefingResult.log)
603
574
  stdout(info(line));
604
575
  // Record the wrapper block and the module exactly as they now stand on disk, so the next sync
605
- // can tell them untouched from edited (M-ADR 0218 §4.7), and the binding when it is not the default.
576
+ // can tell them untouched from edited (M-ADR 0218 §4.7), and the binding and the CLI source
577
+ // when they are not the defaults.
606
578
  const nextLock = upsertBundle(migrateFrom ? removeBundle(prevLock, migrateFrom.name) : prevLock, newEntry);
607
- const { ciBinding: _previousBinding, ciRunner: _previousRunner, ciWorkflowBlockSha256: _previousSha, ...lockBase } = nextLock;
579
+ const { ciBinding: _previousBinding, ciRunner: _previousRunner, ciWorkflowBlockSha256: _previousSha, cliSource: _previousSource, cliPackage: _previousPackage, ...lockBase } = nextLock;
608
580
  await writeLockFile(opts.targetRepo, {
609
581
  ...lockBase,
610
582
  ...(ciBinding !== DEFAULT_CI_BINDING ? { ciBinding } : {}),
611
583
  ...(ciRunner !== undefined && ciRunner.length > 0 ? { ciRunner: ciRunner.join(",") } : {}),
612
584
  ...(ci.wrapperSha !== null ? { ciWorkflowBlockSha256: ci.wrapperSha } : {}),
613
585
  ciModuleSha256: ci.moduleSha,
586
+ ...(cliSource.source !== "registry" ? { cliSource: cliSource.source } : {}),
587
+ ...(cliPackage !== undefined ? { cliPackage } : {}),
614
588
  });
615
589
  stdout(pass(`Method baseline synced to: ${opts.targetRepo}`));
616
590
  stdout(info(`Updated lock file: method-baseline.lock (lock_version: ${LOCK_VERSION})`));
@@ -1,145 +1,99 @@
1
1
  /**
2
- * `.claude/settings.json` merge pass during `sdd-cli sync`. Method-
3
- * managed `mcpServers` entries (see src/lib/mcp-seed/) get upserted;
4
- * any adopter-added entries or other top-level keys are preserved.
2
+ * `.claude/settings.json` merge pass during `sdd-cli sync`. The method's hooks (see
3
+ * src/lib/hooks-seed/) are upserted when the repo runs the claude-code harness; every adopter
4
+ * entry and every other top-level key is preserved.
5
5
  *
6
- * The file cannot ship in the method baseline bundle because it
7
- * carries per-adopter absolute paths. That's why this is a separate
8
- * sync step, outside the bundle-driven `copyManagedFiles` path.
6
+ * The method seeds no MCP server (M-ADR 0220 §4.5). Sync removes the `sdd-mcp` entry it seeded
7
+ * before, without --allow-overwrite, because an entry whose command is the method's own
8
+ * `sdd-cli mcp serve` is the method's output: the same rule by which sync retired its own
9
+ * SDD_REPO_PATH value. An adopter's other servers, and an `sdd-mcp` entry that runs anything
10
+ * else, are left alone.
9
11
  *
10
- * Conflict handling mirrors ADR 0114: if the adopter has manually
11
- * modified a method-owned entry, sync refuses unless
12
- * `--allow-overwrite` is passed.
12
+ * Conflict handling mirrors ADR 0114: if the adopter has modified a method-owned hook, sync
13
+ * refuses unless `--allow-overwrite` is passed.
13
14
  */
14
15
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
15
16
  import { dirname, join, resolve } from "node:path";
16
- import { buildMethodManagedServers, detectManagedDrift, upsertMethodManagedServers, } from "../mcp-seed/index.js";
17
- import { buildMethodManagedHooks, detectHooksDrift, upsertMethodManagedHooks, } from "../hooks-seed/index.js";
17
+ import { buildMethodManagedHooks, detectHooksDrift, normaliseOwnHookRenders, upsertMethodManagedHooks, } from "../hooks-seed/index.js";
18
18
  export const SETTINGS_REL_PATH = ".claude/settings.json";
19
+ /** The server id the method used to seed. */
20
+ export const RETIRED_SERVER_ID = "sdd-mcp";
21
+ /**
22
+ * Whether an `mcpServers` entry is one the method seeded: `sdd-cli mcp serve` and no other field,
23
+ * with no env or only an SDD_REPO_PATH that resolves to the repo itself (the value older syncs
24
+ * wrote for a platform repo). An SDD_REPO_PATH pointing anywhere else was the adopter's own
25
+ * configuration, as is any other change, and the entry stays.
26
+ */
27
+ export function isOwnSddMcpEntry(entry, targetRepo) {
28
+ if (!entry || typeof entry !== "object" || Array.isArray(entry))
29
+ return false;
30
+ const e = entry;
31
+ if (Object.keys(e).some((k) => k !== "command" && k !== "args" && k !== "env"))
32
+ return false;
33
+ if (e["command"] !== "sdd-cli")
34
+ return false;
35
+ const args = e["args"];
36
+ if (!Array.isArray(args) || args.length !== 2 || args[0] !== "mcp" || args[1] !== "serve")
37
+ return false;
38
+ const env = e["env"];
39
+ if (env === undefined)
40
+ return true;
41
+ if (!env || typeof env !== "object" || Array.isArray(env))
42
+ return false;
43
+ const keys = Object.keys(env);
44
+ if (keys.length !== 1 || keys[0] !== "SDD_REPO_PATH")
45
+ return false;
46
+ const path = env["SDD_REPO_PATH"];
47
+ return typeof path === "string" && resolve(targetRepo, path) === resolve(targetRepo);
48
+ }
49
+ /** The settings with the method's own `sdd-mcp` entry removed, and which ids went. */
50
+ export function retireOwnMcpServers(settings, targetRepo) {
51
+ const servers = settings["mcpServers"];
52
+ if (!servers || typeof servers !== "object" || Array.isArray(servers))
53
+ return { settings, retired: [] };
54
+ const current = servers;
55
+ if (!(RETIRED_SERVER_ID in current) || !isOwnSddMcpEntry(current[RETIRED_SERVER_ID], targetRepo)) {
56
+ return { settings, retired: [] };
57
+ }
58
+ const { [RETIRED_SERVER_ID]: _retired, ...rest } = current;
59
+ const { mcpServers: _servers, ...others } = settings;
60
+ return {
61
+ settings: Object.keys(rest).length > 0 ? { ...others, mcpServers: rest } : others,
62
+ retired: [RETIRED_SERVER_ID],
63
+ };
64
+ }
19
65
  export async function syncMethodManagedSettings(opts) {
20
66
  const absPath = join(opts.targetRepo, SETTINGS_REL_PATH);
67
+ const fileExisted = await fileExists(absPath);
21
68
  const existing = await readSettings(absPath);
22
- // No profile gets an SDD_REPO_PATH any more (ruled 2026-10-01). It was written for platform
23
- // repos only because the resolver treated a marker-bearing repo as one candidate among its
24
- // marker-bearing siblings (MULTIPLE_SDDS_FOUND in the reverse estate's layout). The resolver now
25
- // lets a platform-product repo serve itself, so the env var, an absolute, machine-specific
26
- // path in a COMMITTED file, is no longer needed. A value left by an older sync that points at
27
- // the repo itself is this tool's own output and is removed (normaliseSddRepoPathForDrift); a
28
- // value pointing anywhere else is the adopter's and is still drift. App/integration repos
29
- // configure their sibling platform SDD as before.
30
- const owned = buildMethodManagedServers({});
31
- // Drift is detected against a PATH-NORMALISED view of the existing file,
32
- // not the raw bytes. Reason, measured on a live estate: the F27 defect
33
- // wrote the literal CLI argument (".") as SDD_REPO_PATH. Once the
34
- // generator was fixed to emit the absolute form, a strict comparison
35
- // classified the defect's own historical output as an ADOPTER
36
- // modification and refused to correct it — while the only offered remedy,
37
- // --allow-overwrite, is the flag that rewrites genuinely adopter-owned
38
- // keys. A repair that requires the dangerous flag is not a repair. So: an
39
- // existing SDD_REPO_PATH that RESOLVES (from the target repo) to the same
40
- // absolute path as the generated value is the tool's own output in an
41
- // older costume, and is treated as regenerable, never as drift. The raw
42
- // `existing` still flows into the upsert below, so the merge rewrites the
43
- // stale form to the canonical one — the sync self-heals instead of
44
- // wedging. A path that resolves DIFFERENTLY is still drift.
45
- const drifted = detectManagedDrift(normaliseSddRepoPathForDrift(existing, owned, opts.targetRepo), owned);
46
- // Computed from the state as READ, before any upsert: which method-managed ids
47
- // were absent from the file this run opened. Combined with `hadPreviousSync`
48
- // that is a re-seed; without it, a first seeding. Deliberately not derived
49
- // from the upsert's `added`, which cannot tell the two apart — that conflation
50
- // IS the defect.
51
- const absentOnRead = absentManagedIds(existing, owned);
52
- const reseeded = opts.hadPreviousSync === true ? absentOnRead : [];
53
69
  const hooksActive = opts.agentHarness === "claude-code";
54
- const ownedHooks = hooksActive ? buildMethodManagedHooks() : undefined;
70
+ const ownedHooks = hooksActive ? buildMethodManagedHooks(opts.cliSource) : undefined;
55
71
  const hooksDrifted = hooksActive && ownedHooks
56
- ? detectHooksDrift(existing, ownedHooks)
72
+ ? detectHooksDrift(normaliseOwnHookRenders(existing, ownedHooks), ownedHooks)
57
73
  : [];
58
- if ((drifted.length > 0 || hooksDrifted.length > 0) && !opts.allowOverwrite) {
59
- return {
60
- path: SETTINGS_REL_PATH,
61
- drifted,
62
- added: [],
63
- // Reported even on the refusal path: the re-seed was OBSERVED when the
64
- // file was read, and it stays true whether or not this run went on to
65
- // write. Zeroing it here would make a refusal hide a second finding.
66
- reseeded,
67
- updated: [],
68
- unchanged: [],
69
- hooksChangedEvents: [],
70
- hooksDrifted,
71
- wroteFile: false,
72
- refused: true,
73
- };
74
+ const base = { path: SETTINGS_REL_PATH, hooksDrifted };
75
+ if (hooksDrifted.length > 0 && !opts.allowOverwrite) {
76
+ return { ...base, retiredServers: [], hooksChangedEvents: [], wroteFile: false, refused: true };
74
77
  }
78
+ const retirement = retireOwnMcpServers(existing, opts.targetRepo);
75
79
  if (opts.checkOnly) {
76
- return {
77
- path: SETTINGS_REL_PATH,
78
- drifted,
79
- added: [],
80
- reseeded,
81
- updated: [],
82
- unchanged: [],
83
- hooksChangedEvents: [],
84
- hooksDrifted,
85
- wroteFile: false,
86
- refused: false,
87
- };
80
+ return { ...base, retiredServers: retirement.retired, hooksChangedEvents: [], wroteFile: false, refused: false };
88
81
  }
89
- const mcpResult = upsertMethodManagedServers(existing, owned);
90
- let nextSettings = mcpResult.settings;
82
+ let nextSettings = retirement.settings;
91
83
  let hooksChangedEvents = [];
92
84
  if (hooksActive && ownedHooks) {
93
85
  const hooksResult = upsertMethodManagedHooks(nextSettings, ownedHooks);
94
86
  nextSettings = hooksResult.settings;
95
87
  hooksChangedEvents = [...hooksResult.changedEvents];
96
88
  }
97
- const changed = mcpResult.changed || hooksChangedEvents.length > 0;
98
- if (!changed) {
99
- return {
100
- path: SETTINGS_REL_PATH,
101
- drifted,
102
- added: mcpResult.added,
103
- reseeded,
104
- updated: mcpResult.updated,
105
- unchanged: mcpResult.unchanged,
106
- hooksChangedEvents: [],
107
- hooksDrifted,
108
- wroteFile: false,
109
- refused: false,
110
- };
89
+ const changed = retirement.retired.length > 0 || hooksChangedEvents.length > 0;
90
+ // Nothing to write: leave the file as it is, and never create one just to hold nothing.
91
+ if (!changed || (!fileExisted && Object.keys(nextSettings).length === 0)) {
92
+ return { ...base, retiredServers: [], hooksChangedEvents: [], wroteFile: false, refused: false };
111
93
  }
112
94
  await mkdir(dirname(absPath), { recursive: true });
113
95
  await writeFile(absPath, JSON.stringify(nextSettings, null, 2) + "\n", "utf8");
114
- return {
115
- path: SETTINGS_REL_PATH,
116
- drifted,
117
- added: mcpResult.added,
118
- reseeded,
119
- updated: mcpResult.updated,
120
- unchanged: mcpResult.unchanged,
121
- hooksChangedEvents,
122
- hooksDrifted,
123
- wroteFile: true,
124
- refused: false,
125
- };
126
- }
127
- /**
128
- * Method-managed ids absent from the settings file as read.
129
- *
130
- * A missing or malformed `mcpServers` map counts every managed id as absent —
131
- * `readSettings` already collapses an unparseable file to `{}`, and a file whose
132
- * managed block was destroyed rather than edited is exactly the re-seed case
133
- * this exists to name.
134
- */
135
- function absentManagedIds(existing, owned) {
136
- const servers = existing["mcpServers"];
137
- const present = servers && typeof servers === "object" && !Array.isArray(servers)
138
- ? servers
139
- : {};
140
- return Object.keys(owned)
141
- .filter((id) => !(id in present))
142
- .sort();
96
+ return { ...base, retiredServers: retirement.retired, hooksChangedEvents, wroteFile: true, refused: false };
143
97
  }
144
98
  async function readSettings(absPath) {
145
99
  if (!(await fileExists(absPath)))
@@ -167,50 +121,4 @@ async function fileExists(path) {
167
121
  return false;
168
122
  }
169
123
  }
170
- /**
171
- * A view of `existing` for drift detection ONLY: where a method-managed
172
- * entry's `env.SDD_REPO_PATH` resolves (from the target repo) to the same
173
- * absolute path as the generated value, substitute the generated form.
174
- *
175
- * This makes "the tool's own older output" (the F27 relative-path defect,
176
- * or a lexically different spelling of the same location) compare as
177
- * pristine, so the merge proceeds and REWRITES it — while a path that
178
- * resolves somewhere else entirely remains drift and still refuses.
179
- * Never used for the write: the raw `existing` flows into the upsert, so
180
- * the correction actually lands on disk.
181
- */
182
- function normaliseSddRepoPathForDrift(existing, owned, targetRepo) {
183
- const servers = existing["mcpServers"];
184
- if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
185
- return existing;
186
- }
187
- const current = servers;
188
- const out = {};
189
- let touched = false;
190
- for (const [id, entry] of Object.entries(current)) {
191
- const existingPath = entry?.env?.["SDD_REPO_PATH"];
192
- const ownedPath = owned[id]?.env?.["SDD_REPO_PATH"];
193
- if (typeof existingPath === "string" &&
194
- ownedPath === undefined &&
195
- resolve(targetRepo, existingPath) === resolve(targetRepo)) {
196
- // The retired platform SDD_REPO_PATH, pointing at the repo itself: regenerable, so the
197
- // drift view drops it and the write (which replaces the entry whole) removes it.
198
- const { SDD_REPO_PATH: _dropped, ...rest } = entry.env ?? {};
199
- const { env: _env, ...bare } = entry;
200
- out[id] = Object.keys(rest).length > 0 ? { ...bare, env: rest } : bare;
201
- touched = true;
202
- }
203
- else if (typeof existingPath === "string" &&
204
- typeof ownedPath === "string" &&
205
- existingPath !== ownedPath &&
206
- resolve(targetRepo, existingPath) === resolve(ownedPath)) {
207
- out[id] = { ...entry, env: { ...entry.env, SDD_REPO_PATH: ownedPath } };
208
- touched = true;
209
- }
210
- else {
211
- out[id] = entry;
212
- }
213
- }
214
- return touched ? { ...existing, mcpServers: out } : existing;
215
- }
216
124
  //# sourceMappingURL=settings-merge.js.map
@@ -25,7 +25,7 @@ const TITLES = {
25
25
  // CI has no platform checkout, so this step runs the app-only checks; the met gate needs the platform beside it.
26
26
  "app-dependencies": "Validate app dependencies (app-only checks; no platform checkout in CI)",
27
27
  };
28
- export function methodValidationPlan(profile) {
28
+ export function methodValidationPlan(profile, opts = {}) {
29
29
  const steps = validatorsForProfile(profile)
30
30
  .filter((v) => v.optional !== true)
31
31
  .map((v) => ({
@@ -38,12 +38,15 @@ export function methodValidationPlan(profile) {
38
38
  if (profile === "platform") {
39
39
  steps.push({ id: "cascade-completeness", title: "Validate cascade completeness", tool: "bash", args: ["orchestration/scripts/validate-cascade-completeness.sh", "all"], advisory: false }, { id: "architecture-alignment", title: "Validate architecture alignment", tool: "bash", args: ["orchestration/scripts/validate-architecture-alignment.sh"], advisory: false });
40
40
  }
41
+ const gating = opts.catalogueRebuild === true;
41
42
  steps.push({
42
43
  id: "catalogue-freshness",
43
- title: "Check catalogues reproduce from the specs (advisory)",
44
+ title: gating
45
+ ? "Check catalogues reproduce from the specs (gating: the pipeline rebuilt them)"
46
+ : "Check catalogues reproduce from the specs (advisory)",
44
47
  tool: "sdd-maintain",
45
48
  args: ["catalogue", "build", "--check", "--sdd-path", "."],
46
- advisory: true,
49
+ advisory: !gating,
47
50
  });
48
51
  return steps;
49
52
  }