@sdd-method/maintain-cli 0.1.0-rc.0 → 0.2.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.
@@ -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,11 @@ 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";
15
14
  import { syncBriefingFiles } from "./briefing-merge.js";
16
15
  import { DEFAULT_AGENT_HARNESS } from "../agent-harness.js";
17
16
  import { BLOCK_BEGIN, BLOCK_END } from "../init/ci-workflow.js";
18
- import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, syncCiBinding, } from "../ci-binding/index.js";
19
- import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, harnessWrapperOutputPath, } from "../briefing/index.js";
17
+ import { blockSha256, CI_BINDINGS, DEFAULT_CI_BINDING, isCiBinding, parseCiRunner, syncCiBinding, } from "../ci-binding/index.js";
18
+ import { BRIEFING_TEMPLATE_ROOT, WRAPPER_BLOCK_BEGIN, WRAPPER_BLOCK_END, briefingWrappers, } from "../briefing/index.js";
20
19
  const SECTION_RULE = "═".repeat(59);
21
20
  /**
22
21
  * Report the pristine/edited/unclassifiable split for a conflict set.
@@ -97,6 +96,12 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
97
96
  return { exitCode: 1 };
98
97
  }
99
98
  const ciBinding = opts.ciBinding ?? lockBinding ?? DEFAULT_CI_BINDING;
99
+ const lockRunner = prevLock?.ciRunner === undefined ? undefined : parseCiRunner(prevLock.ciRunner);
100
+ if (lockRunner === null) {
101
+ 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.`));
102
+ return { exitCode: 1 };
103
+ }
104
+ const ciRunner = opts.ciRunner ?? lockRunner;
100
105
  const classification = await classifyConflicts(conflicts, opts.targetRepo, prevEntry?.pathDigests);
101
106
  if (conflicts.length > 0) {
102
107
  reportClassification(stdout, conflicts.length, classification, prevEntry?.bundleVersion);
@@ -326,32 +331,15 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
326
331
  if (incomingIsForward && !opts.allowOverwrite && !migrateFrom) {
327
332
  const preflightProfile = profileFromBundleName(incomingBundleName);
328
333
  if (preflightProfile) {
329
- if (opts.agentHarness === "github-copilot-cli") {
330
- const probe = await syncMethodManagedCopilotHooks({
331
- targetRepo: opts.targetRepo,
332
- allowOverwrite: false,
333
- checkOnly: true,
334
- });
335
- if (probe.refused) {
336
- failures.push(`.github/hooks/*.json has adopter-modified method entries (${probe.driftedRefused.join(", ")}). Re-run with --allow-overwrite to rewrite them.`);
337
- }
338
- }
339
- else {
334
+ {
340
335
  const probe = await syncMethodManagedSettings({
341
336
  targetRepo: opts.targetRepo,
342
- profile: preflightProfile,
343
337
  allowOverwrite: false,
344
338
  checkOnly: true,
345
- hadPreviousSync: prevLock !== null,
346
339
  ...(opts.agentHarness ? { agentHarness: opts.agentHarness } : {}),
347
340
  });
348
341
  if (probe.refused) {
349
- const conflicts = [];
350
- if (probe.drifted.length > 0)
351
- conflicts.push(`mcpServers: ${probe.drifted.join(", ")}`);
352
- if (probe.hooksDrifted.length > 0)
353
- conflicts.push(`hooks: ${probe.hooksDrifted.join(", ")}`);
354
- failures.push(`.claude/settings.json has adopter-modified method entries (${conflicts.join("; ")}). Re-run with --allow-overwrite to rewrite them.`);
342
+ failures.push(`.claude/settings.json has adopter-modified method entries (hooks: ${probe.hooksDrifted.join(", ")}). Re-run with --allow-overwrite to rewrite them.`);
355
343
  }
356
344
  }
357
345
  // The CI host wrapper and the Method Validation module (M-ADR 0219): the same check the
@@ -360,6 +348,7 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
360
348
  targetRepo: opts.targetRepo,
361
349
  profile: preflightProfile,
362
350
  binding: ciBinding,
351
+ ...(ciRunner !== undefined ? { runner: ciRunner } : {}),
363
352
  prevLock,
364
353
  allowDrift: false,
365
354
  allowOverwrite: false,
@@ -370,13 +359,18 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
370
359
  // templates; pre-ADR-0148 baselines skip the briefing pass entirely.
371
360
  const shipsBriefing = bundle.managedPaths.some((p) => p.startsWith(`${BRIEFING_TEMPLATE_ROOT}/`));
372
361
  if (shipsBriefing) {
373
- const wrapperRel = harnessWrapperOutputPath(opts.agentHarness ?? DEFAULT_AGENT_HARNESS);
374
- const preflightWrapper = await tryReadFile(join(opts.targetRepo, wrapperRel));
375
- if (preflightWrapper !== null &&
376
- preflightWrapper !== "" &&
377
- !(preflightWrapper.includes(WRAPPER_BLOCK_BEGIN) &&
378
- preflightWrapper.includes(WRAPPER_BLOCK_END))) {
379
- failures.push(`${wrapperRel} has adopter edits outside the method-managed markers. Re-run with --allow-overwrite to replace the file.`);
362
+ // Every wrapper the bundle ships a template for: CLAUDE.md and, from M-ADR 0220, the
363
+ // AGENTS.md pointer. An unmarked file is the adopter's and refuses.
364
+ for (const w of briefingWrappers(opts.agentHarness ?? DEFAULT_AGENT_HARNESS)) {
365
+ if (!bundle.managedPaths.includes(`${BRIEFING_TEMPLATE_ROOT}/${w.templateRel}`))
366
+ continue;
367
+ const preflightWrapper = await tryReadFile(join(opts.targetRepo, w.outputRel));
368
+ if (preflightWrapper !== null &&
369
+ preflightWrapper !== "" &&
370
+ !(preflightWrapper.includes(WRAPPER_BLOCK_BEGIN) &&
371
+ preflightWrapper.includes(WRAPPER_BLOCK_END))) {
372
+ failures.push(`${w.outputRel} has adopter edits outside the method-managed markers. Re-run with --allow-overwrite to replace the file.`);
373
+ }
380
374
  }
381
375
  }
382
376
  }
@@ -501,58 +495,21 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
501
495
  }
502
496
  const repoKind = repoKindFromProfile(profile);
503
497
  await writeRepoKind(opts.targetRepo, repoKind);
504
- // Harness-aware merge of agent-harness wiring per ADR 0141 §4.6.
505
- // claude-code keeps the .claude/settings.json path (MCP + Claude
506
- // hooks). github-copilot-cli writes .github/hooks/*.json instead;
507
- // its MCP wiring lands separately under ADR 0150 Phase 3.
508
- if (opts.agentHarness === "github-copilot-cli") {
509
- const copilotResult = await syncMethodManagedCopilotHooks({
510
- targetRepo: opts.targetRepo,
511
- allowOverwrite: opts.allowOverwrite || migrateFrom !== null,
512
- });
513
- if (copilotResult.refused) {
514
- stdout(fail(`.github/hooks/*.json has adopter-modified method entries (${copilotResult.driftedRefused.join(", ")}). Re-run with --allow-overwrite to rewrite them.`));
515
- return { exitCode: 1 };
516
- }
517
- const written = copilotResult.files.filter((f) => f.wrote);
518
- if (written.length > 0) {
519
- stdout(info(`Refreshed ${written.map((f) => f.relPath).join(", ")}.`));
520
- }
521
- }
522
- else {
498
+ // The harness wiring: .claude/settings.json, the one supported harness's hooks (M-ADR 0220 §4.6).
499
+ {
523
500
  const settingsResult = await syncMethodManagedSettings({
524
501
  targetRepo: opts.targetRepo,
525
- profile,
526
502
  allowOverwrite: opts.allowOverwrite || migrateFrom !== null,
527
- // A lock is the only sound evidence the managed entries were seeded
528
- // before, which is what makes a re-seed distinguishable from a first
529
- // sync into an adopter's own settings file.
530
- hadPreviousSync: prevLock !== null,
531
503
  ...(opts.agentHarness ? { agentHarness: opts.agentHarness } : {}),
532
504
  });
533
505
  if (settingsResult.refused) {
534
- const conflicts = [];
535
- if (settingsResult.drifted.length > 0)
536
- conflicts.push(`mcpServers: ${settingsResult.drifted.join(", ")}`);
537
- if (settingsResult.hooksDrifted.length > 0)
538
- conflicts.push(`hooks: ${settingsResult.hooksDrifted.join(", ")}`);
539
- stdout(fail(`.claude/settings.json has adopter-modified method entries (${conflicts.join("; ")}). Re-run with --allow-overwrite to rewrite them.`));
506
+ stdout(fail(`.claude/settings.json has adopter-modified method entries (hooks: ${settingsResult.hooksDrifted.join(", ")}). Re-run with --allow-overwrite to rewrite them.`));
540
507
  return { exitCode: 1 };
541
508
  }
542
- // Drift-class line of its own, ahead of the refresh summary: these
543
- // entries were seeded by an earlier sync and had been removed since.
544
- // Restoring them is not the news — that they went missing is, and the
545
- // same ids reappearing here every sync means something is removing them
546
- // repeatedly.
547
- if (settingsResult.reseeded.length > 0) {
548
- 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.`));
549
- }
550
509
  if (settingsResult.wroteFile) {
551
510
  const labels = [];
552
- if (settingsResult.added.length > 0)
553
- labels.push(`mcp added: ${settingsResult.added.join(", ")}`);
554
- if (settingsResult.updated.length > 0)
555
- labels.push(`mcp updated: ${settingsResult.updated.join(", ")}`);
511
+ if (settingsResult.retiredServers.length > 0)
512
+ labels.push(`removed the method's retired MCP server entry: ${settingsResult.retiredServers.join(", ")} (M-ADR 0220)`);
556
513
  if (settingsResult.hooksChangedEvents.length > 0)
557
514
  labels.push(`hooks: ${settingsResult.hooksChangedEvents.join(", ")}`);
558
515
  stdout(info(`Refreshed ${settingsResult.path} (${labels.join("; ") || "no changes"}).`));
@@ -564,6 +521,7 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
564
521
  targetRepo: opts.targetRepo,
565
522
  profile,
566
523
  binding: ciBinding,
524
+ ...(ciRunner !== undefined ? { runner: ciRunner } : {}),
567
525
  prevLock,
568
526
  allowDrift: opts.allowOverwrite || migrateFrom !== null,
569
527
  allowOverwrite: opts.allowOverwrite,
@@ -596,10 +554,11 @@ export async function runSync(opts, stdout = (line) => process.stdout.write(line
596
554
  // Record the wrapper block and the module exactly as they now stand on disk, so the next sync
597
555
  // can tell them untouched from edited (M-ADR 0218 §4.7), and the binding when it is not the default.
598
556
  const nextLock = upsertBundle(migrateFrom ? removeBundle(prevLock, migrateFrom.name) : prevLock, newEntry);
599
- const { ciBinding: _previousBinding, ciWorkflowBlockSha256: _previousSha, ...lockBase } = nextLock;
557
+ const { ciBinding: _previousBinding, ciRunner: _previousRunner, ciWorkflowBlockSha256: _previousSha, ...lockBase } = nextLock;
600
558
  await writeLockFile(opts.targetRepo, {
601
559
  ...lockBase,
602
560
  ...(ciBinding !== DEFAULT_CI_BINDING ? { ciBinding } : {}),
561
+ ...(ciRunner !== undefined && ciRunner.length > 0 ? { ciRunner: ciRunner.join(",") } : {}),
603
562
  ...(ci.wrapperSha !== null ? { ciWorkflowBlockSha256: ci.wrapperSha } : {}),
604
563
  ciModuleSha256: ci.moduleSha,
605
564
  });
@@ -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
70
  const ownedHooks = hooksActive ? buildMethodManagedHooks() : 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
@@ -10,7 +10,7 @@
10
10
  // (`context pull`). Plus the whole verify surface, because the personas nest.
11
11
  //
12
12
  // ⚠ As with sdd-verify, the boundary is ABSENCE, not a menu: the package ships only the import
13
- // closure of maintain-bin.js, so authoring verbs (init, scaffold, plan, mcp, conformance,
13
+ // closure of maintain-bin.js, so authoring verbs (init, scaffold, plan, conformance,
14
14
  // contracts, api-surface, snapshot, metamodel, migration-map) are not present to be reached.
15
15
  // That is why every registration below imports a per-subcommand registrar, never a barrel.
16
16
  // Unclassified verbs default to AUTHOR (M-ADR 0218 §4.2) and stay out until classified.
@@ -18,6 +18,7 @@ import { Command, CommanderError } from "commander";
18
18
  import { getPackageVersion } from "./lib/package-version.js";
19
19
  import { registerVerifySurface, rejectOutsideSurface, VERIFY_SURFACE } from "./verify-program.js";
20
20
  import { registerCatalogueAggregate, registerCatalogueBuild, registerCatalogueDiff, registerCatalogueGraph } from "./verbs/catalogue-maintain.js";
21
+ import { registerCatalogueCheck } from "./verbs/catalogue-check.js";
21
22
  import { registerContext } from "./verbs/context.js";
22
23
  import { registerGenerateBriefing } from "./verbs/generate-briefing.js";
23
24
  import { registerPackBuild } from "./verbs/pack-build.js";
@@ -30,6 +31,7 @@ export const MAINTAIN_ONLY_SURFACE = [
30
31
  "generate-briefing",
31
32
  "context pull",
32
33
  "catalogue build",
34
+ "catalogue check",
33
35
  "catalogue aggregate",
34
36
  "catalogue graph",
35
37
  "catalogue diff",
@@ -65,7 +67,7 @@ export function buildMaintainProgram() {
65
67
  .name("sdd-maintain")
66
68
  .description("Bring an SDD repository current and rebuild its derived model — sync, briefing, catalogue build and aggregate, pack build, context pull — plus every read-only verify verb. Cannot author specification content.")
67
69
  .version(getPackageVersion(), "-v, --version")
68
- .addHelpText("after", `\nSpec: ${SPEC_URL}\nAuthoring verbs (init, scaffold, plan, mcp, conformance, …) are in @sdd-method/sdd-cli.`)
70
+ .addHelpText("after", `\nSpec: ${SPEC_URL}\nAuthoring verbs (scaffold, plan, conformance, …) are in @sdd-method/author-cli; init is in @sdd-method/sdd-cli.`)
69
71
  .exitOverride()
70
72
  .configureOutput({
71
73
  writeOut: (str) => process.stdout.write(str),
@@ -80,6 +82,7 @@ export function buildMaintainProgram() {
80
82
  const catalogue = program.commands.find((c) => c.name() === "catalogue");
81
83
  catalogue.description("Build, aggregate, validate and query per-SDD catalogues (ADR 0134) — the derived model");
82
84
  registerCatalogueBuild(catalogue);
85
+ registerCatalogueCheck(catalogue);
83
86
  registerCatalogueAggregate(catalogue);
84
87
  registerCatalogueGraph(catalogue);
85
88
  registerCatalogueDiff(catalogue);
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "0.1.0-rc.0",
3
- "built_against_sdd_cli": "1.42.0"
2
+ "version": "0.2.0",
3
+ "built_against_sdd_cli": "1.43.0"
4
4
  }