@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.
- package/README.md +1 -1
- package/dist/lib/agent-harness.js +17 -8
- package/dist/lib/briefing/index.js +1 -1
- package/dist/lib/briefing/render.js +17 -8
- package/dist/lib/catalogue/aggregation-manifest-loader.js +4 -4
- package/dist/lib/catalogue/freshness.js +4 -3
- package/dist/lib/ci-binding/index.js +18 -7
- package/dist/lib/context-pull/index.js +79 -39
- package/dist/lib/context-pull/retired-mcp-setup.js +7 -0
- package/dist/lib/hooks-seed/entries.js +30 -1
- package/dist/lib/hooks-seed/index.js +4 -7
- package/dist/lib/hooks-seed/merge.js +31 -0
- package/dist/lib/init/ci-workflow.js +15 -6
- package/dist/lib/lock-file.js +5 -0
- package/dist/lib/sync/briefing-merge.js +50 -34
- package/dist/lib/sync/diff.js +1 -1
- package/dist/lib/sync/index.js +31 -72
- package/dist/lib/sync/settings-merge.js +70 -162
- package/dist/maintain-program.js +5 -2
- package/dist/maintain-version.json +2 -2
- package/dist/verbs/catalogue-check.js +28 -0
- package/dist/verbs/context.js +1 -1
- package/dist/verbs/sync.js +13 -1
- package/dist/verify-program.js +1 -1
- package/dist/verify-version.json +2 -2
- package/package.json +3 -3
- package/dist/lib/hooks-seed/copilot.js +0 -167
- package/dist/lib/hooks-seed/lifecycle-mapping.js +0 -28
- package/dist/lib/mcp-seed/entries.js +0 -14
- package/dist/lib/mcp-seed/index.js +0 -28
- package/dist/lib/mcp-seed/merge.js +0 -99
- package/dist/lib/mcp-seed/types.js +0 -13
- 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
|
-
* -
|
|
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,
|
|
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
|
|
39
|
-
// certainly
|
|
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
|
-
//
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
77
|
-
|
|
93
|
+
catch {
|
|
94
|
+
return null;
|
|
78
95
|
}
|
|
79
|
-
return { log, refused: null };
|
|
80
96
|
}
|
|
81
97
|
async function fileExists(path) {
|
|
82
98
|
try {
|
package/dist/lib/sync/diff.js
CHANGED
|
@@ -139,7 +139,7 @@ export const GENERATED_NEVER_REMOVE = [
|
|
|
139
139
|
"AGENTS.md",
|
|
140
140
|
"agent-instructions.md",
|
|
141
141
|
];
|
|
142
|
-
/** `.github/hooks
|
|
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) {
|
package/dist/lib/sync/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
//
|
|
505
|
-
|
|
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
|
-
|
|
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.
|
|
553
|
-
labels.push(`
|
|
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`.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
-
*
|
|
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 {
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
package/dist/maintain-program.js
CHANGED
|
@@ -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,
|
|
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 (
|
|
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);
|