@sdd-method/maintain-cli 0.1.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.
@@ -1,167 +0,0 @@
1
- /**
2
- * Builders + drift detection for the method-managed GitHub Copilot CLI
3
- * hooks, per `docs/method/methods/hook-contracts.md` (Hook 1, Hook 2)
4
- * and the portable contracts in `hook-portable-contract-schema.yaml`.
5
- *
6
- * The Copilot CLI surface differs from Claude Code in three ways
7
- * (per ADR 0141 §4.3 + hook-contracts.md):
8
- *
9
- * 1. Hook configs live in per-event JSON files (`.github/hooks/<event>.json`),
10
- * not a single settings.json.
11
- * 2. No per-tool matcher — every `preToolUse` hook fires for every
12
- * tool, with in-script filtering instead.
13
- * 3. Approval semantic is declarative (`approval: deny-on-nonzero`)
14
- * rather than exit-code based.
15
- *
16
- * Ownership is sentinel-based, same as Claude Code: the `bash` and
17
- * `powershell` command strings carry `# sdd-method-managed:true`.
18
- * Adopter-added events in the same `events[]` array lack the sentinel
19
- * and survive sync.
20
- */
21
- import { METHOD_MANAGED_SENTINEL } from "./types.js";
22
- import { nativeEventName } from "./lifecycle-mapping.js";
23
- const HARNESS = "github-copilot-cli";
24
- /** Filename per portable lifecycle class. */
25
- const HOOK_FILE_BY_PORTABLE = {
26
- pre_tool_use: "pre-tool-use.json",
27
- session_start: "session-start.json",
28
- };
29
- export function copilotHookFilePath(portable) {
30
- const name = HOOK_FILE_BY_PORTABLE[portable];
31
- if (!name) {
32
- throw new Error(`No Copilot CLI hook file mapped for portable class "${portable}"`);
33
- }
34
- return `.github/hooks/${name}`;
35
- }
36
- function withSentinel(command) {
37
- if (command.includes(METHOD_MANAGED_SENTINEL))
38
- return command;
39
- return `${command} ${METHOD_MANAGED_SENTINEL}`;
40
- }
41
- /**
42
- * Hook 1 — `block-edits-to-method-managed-paths` (pre_tool_use).
43
- * Copilot CLI has no per-tool matcher, so the script
44
- * (orchestration/scripts/copilot-method-guard.{sh,ps1}) does the
45
- * filtering on tool name in addition to the path check. Method-side
46
- * authors the scripts; we just emit the wiring.
47
- */
48
- export function buildCopilotPreToolUseEvent() {
49
- return {
50
- type: nativeEventName(HARNESS, "pre_tool_use"),
51
- command: {
52
- bash: withSentinel("./orchestration/scripts/copilot-method-guard.sh"),
53
- powershell: withSentinel("./orchestration/scripts/copilot-method-guard.ps1"),
54
- },
55
- timeout_ms: 5000,
56
- approval: "deny-on-nonzero",
57
- };
58
- }
59
- /**
60
- * Hook 2 — `validate-adr-structure-on-session-start` (session_start).
61
- * Output is surfaced as session-baseline context; non-blocking
62
- * (`approval: always-allow`) to match the portable contract's `warn`
63
- * exit-code semantic.
64
- */
65
- export function buildCopilotSessionStartEvent() {
66
- return {
67
- type: nativeEventName(HARNESS, "session_start"),
68
- command: {
69
- bash: withSentinel("sdd-cli validate --validator adr-structure 2>&1 | head -20"),
70
- powershell: withSentinel("sdd-cli validate --validator adr-structure 2>&1 | Select-Object -First 20"),
71
- },
72
- timeout_ms: 30000,
73
- approval: "always-allow",
74
- };
75
- }
76
- export function buildMethodManagedCopilotHooks() {
77
- return {
78
- pre_tool_use: {
79
- filePath: copilotHookFilePath("pre_tool_use"),
80
- events: [buildCopilotPreToolUseEvent()],
81
- },
82
- session_start: {
83
- filePath: copilotHookFilePath("session_start"),
84
- events: [buildCopilotSessionStartEvent()],
85
- },
86
- };
87
- }
88
- /**
89
- * An event entry is method-managed iff its `command.bash` (or
90
- * `command.powershell`) carries the sentinel.
91
- */
92
- export function isMethodManagedEvent(event) {
93
- const bash = typeof event.command?.bash === "string" ? event.command.bash : "";
94
- const ps = typeof event.command?.powershell === "string"
95
- ? event.command.powershell
96
- : "";
97
- return (bash.includes(METHOD_MANAGED_SENTINEL) ||
98
- ps.includes(METHOD_MANAGED_SENTINEL));
99
- }
100
- /**
101
- * Strip the sentinel and trailing whitespace from a command string so
102
- * two commands can be compared modulo ownership marker. Symmetric to
103
- * the Claude Code merge logic.
104
- */
105
- function normaliseCommandString(command) {
106
- return command.replace(METHOD_MANAGED_SENTINEL, "").trimEnd();
107
- }
108
- function eventsEquivalentExcludingSentinel(a, b) {
109
- if (a.type !== b.type)
110
- return false;
111
- if (a.approval !== b.approval)
112
- return false;
113
- if (a.timeout_ms !== b.timeout_ms)
114
- return false;
115
- const aBash = a.command?.bash ?? "";
116
- const bBash = b.command?.bash ?? "";
117
- const aPs = a.command?.powershell ?? "";
118
- const bPs = b.command?.powershell ?? "";
119
- return (normaliseCommandString(aBash) === normaliseCommandString(bBash) &&
120
- normaliseCommandString(aPs) === normaliseCommandString(bPs));
121
- }
122
- /**
123
- * Upsert method-managed events into an existing hook file. Adopter-
124
- * added events (no sentinel) survive. An unmarked adopter event that
125
- * is equivalent (modulo sentinel) to a method-managed event we're
126
- * adding gets de-duped — same safety net as the Claude Code merge
127
- * post-v0.12.2.
128
- */
129
- export function upsertCopilotHookFile(existing, owned) {
130
- const currentEvents = existing?.events ?? [];
131
- let dedupedAny = false;
132
- const adopterEvents = currentEvents.filter((evt) => {
133
- if (isMethodManagedEvent(evt))
134
- return false;
135
- const equivalent = owned.some((o) => eventsEquivalentExcludingSentinel(evt, o));
136
- if (equivalent) {
137
- dedupedAny = true;
138
- return false;
139
- }
140
- return true;
141
- });
142
- const before = currentEvents.filter((e) => isMethodManagedEvent(e));
143
- const after = owned;
144
- const methodChanged = JSON.stringify(before) !== JSON.stringify(after);
145
- const nextFile = {
146
- version: 1,
147
- events: [...adopterEvents, ...owned],
148
- };
149
- return {
150
- file: nextFile,
151
- changed: methodChanged || dedupedAny,
152
- };
153
- }
154
- /**
155
- * Detect adopter drift: an event still carrying the sentinel but whose
156
- * content no longer matches the canonical version we'd write. Returns
157
- * `true` iff drift is present.
158
- */
159
- export function detectCopilotHookDrift(existing, owned) {
160
- if (!existing)
161
- return false;
162
- const existingMethod = existing.events.filter((e) => isMethodManagedEvent(e));
163
- if (existingMethod.length === 0)
164
- return false;
165
- return JSON.stringify(existingMethod) !== JSON.stringify(owned);
166
- }
167
- //# sourceMappingURL=copilot.js.map
@@ -1,28 +0,0 @@
1
- /**
2
- * Portable lifecycle event class → harness-native event mapping per
3
- * ADR 0141 §4.3 and the table in `docs/method/foundations/lifecycle-event-classes.yaml`.
4
- *
5
- * Adding a new harness binding is mechanical: extend `AgentHarness`
6
- * in src/lib/agent-harness.ts and add a row here. Hook builders
7
- * consume this mapping rather than hand-coding native event names.
8
- */
9
- const MAPPINGS = {
10
- "claude-code": {
11
- pre_tool_use: "PreToolUse",
12
- post_tool_use: "PostToolUse",
13
- user_prompt_submit: "UserPromptSubmit",
14
- session_start: "SessionStart",
15
- session_stop: "Stop",
16
- },
17
- "github-copilot-cli": {
18
- pre_tool_use: "preToolUse",
19
- post_tool_use: "postToolUse",
20
- user_prompt_submit: "userPromptSubmitted",
21
- session_start: "sessionStart",
22
- session_stop: "agentStop",
23
- },
24
- };
25
- export function nativeEventName(harness, portable) {
26
- return MAPPINGS[harness][portable];
27
- }
28
- //# sourceMappingURL=lifecycle-mapping.js.map
@@ -1,14 +0,0 @@
1
- /**
2
- * Entry builders — produce the McpServerEntry blocks this plan owns.
3
- */
4
- export function buildSddMcpEntry(opts = {}) {
5
- const entry = {
6
- command: "sdd-cli",
7
- args: ["mcp", "serve"],
8
- ...(opts.sddRepoPath !== undefined
9
- ? { env: { SDD_REPO_PATH: opts.sddRepoPath } }
10
- : {}),
11
- };
12
- return entry;
13
- }
14
- //# sourceMappingURL=entries.js.map
@@ -1,28 +0,0 @@
1
- /**
2
- * Public facade for the MCP settings-seed library. Consumers:
3
- *
4
- * - `sdd-cli init` scaffolds (platform/app/integration) call
5
- * `buildMethodManagedServers()` + `upsertMethodManagedServers()`
6
- * to produce a correctly-seeded `.claude/settings.json`.
7
- * - `sdd-cli sync` routes the same file through the merge path to
8
- * upsert method-owned entries without clobbering adopter content.
9
- *
10
- * Scope: this module owns `mcpServers.<method-id>`. Hooks and other
11
- * top-level keys belong to the enforcement-automation plan; we pass
12
- * them through untouched.
13
- */
14
- export { METHOD_MANAGED_SERVER_IDS } from "./types.js";
15
- export { buildSddMcpEntry } from "./entries.js";
16
- export { detectManagedDrift, upsertMethodManagedServers, } from "./merge.js";
17
- import { buildSddMcpEntry } from "./entries.js";
18
- /**
19
- * Produce the full owned-servers map. Adding a new method-managed
20
- * server id is a one-place change: add it here and to
21
- * METHOD_MANAGED_SERVER_IDS.
22
- */
23
- export function buildMethodManagedServers(opts = {}) {
24
- return {
25
- "sdd-mcp": buildSddMcpEntry(opts.sddRepoPath !== undefined ? { sddRepoPath: opts.sddRepoPath } : {}),
26
- };
27
- }
28
- //# sourceMappingURL=index.js.map
@@ -1,99 +0,0 @@
1
- /**
2
- * Merge logic for the method-managed portion of `.claude/settings.json`.
3
- *
4
- * Ownership model: the method owns specific server ids (listed in
5
- * `METHOD_MANAGED_SERVER_IDS`). On upsert, those ids are rewritten
6
- * with the method-supplied entry; any other server ids the adopter
7
- * has added under `mcpServers` survive untouched, as do all other
8
- * top-level keys (hooks, permissions, env, …).
9
- *
10
- * The merge is conservative: if a method id exists with a value that
11
- * is byte-identical to the method's current seed, we return the
12
- * original object unmodified (stable output → no spurious writes).
13
- */
14
- export function upsertMethodManagedServers(existing, owned) {
15
- const added = [];
16
- const updated = [];
17
- const unchanged = [];
18
- const currentServers = isPlainObject(existing["mcpServers"])
19
- ? existing["mcpServers"]
20
- : {};
21
- const nextServers = { ...currentServers };
22
- for (const [id, entry] of Object.entries(owned)) {
23
- const prior = currentServers[id];
24
- if (prior === undefined) {
25
- nextServers[id] = entry;
26
- added.push(id);
27
- }
28
- else if (!entriesEqual(prior, entry)) {
29
- nextServers[id] = entry;
30
- updated.push(id);
31
- }
32
- else {
33
- unchanged.push(id);
34
- }
35
- }
36
- const changed = added.length > 0 || updated.length > 0;
37
- if (!changed) {
38
- return {
39
- settings: existing,
40
- added,
41
- updated,
42
- unchanged,
43
- changed: false,
44
- };
45
- }
46
- const nextSettings = {
47
- ...existing,
48
- mcpServers: nextServers,
49
- };
50
- return {
51
- settings: nextSettings,
52
- added,
53
- updated,
54
- unchanged,
55
- changed: true,
56
- };
57
- }
58
- /**
59
- * Detect adopter-edited method-managed entries. Returns the ids whose
60
- * current value differs from the method-supplied seed — these are the
61
- * ones that a refuse-or-overwrite sync should flag unless
62
- * `--allow-overwrite` is passed.
63
- */
64
- export function detectManagedDrift(existing, owned) {
65
- const currentServers = isPlainObject(existing["mcpServers"])
66
- ? existing["mcpServers"]
67
- : {};
68
- const drifted = [];
69
- for (const [id, entry] of Object.entries(owned)) {
70
- const prior = currentServers[id];
71
- if (prior !== undefined && !entriesEqual(prior, entry)) {
72
- drifted.push(id);
73
- }
74
- }
75
- return drifted;
76
- }
77
- function entriesEqual(a, b) {
78
- // Stable-order JSON comparison. The entry shape is small and flat
79
- // enough that stringifying is cheaper than writing a structural
80
- // walker.
81
- return stableStringify(a) === stableStringify(b);
82
- }
83
- function stableStringify(value) {
84
- if (value === null || typeof value !== "object")
85
- return JSON.stringify(value);
86
- if (Array.isArray(value)) {
87
- return "[" + value.map((v) => stableStringify(v)).join(",") + "]";
88
- }
89
- const keys = Object.keys(value).sort();
90
- return ("{" +
91
- keys
92
- .map((k) => JSON.stringify(k) + ":" + stableStringify(value[k]))
93
- .join(",") +
94
- "}");
95
- }
96
- function isPlainObject(v) {
97
- return v !== null && typeof v === "object" && !Array.isArray(v);
98
- }
99
- //# sourceMappingURL=merge.js.map
@@ -1,13 +0,0 @@
1
- /**
2
- * Types for the MCP settings-seed library. Mirrors the shape Claude
3
- * Code expects at `.claude/settings.json`, restricted to the fields
4
- * we read or write.
5
- */
6
- /**
7
- * Server ids owned by this method plan. Sync upserts these and leaves
8
- * everything else alone. When Phases 3+4 ship adopter servers, extend
9
- * the list — or better, let each plan contribute its own list and
10
- * combine at merge time.
11
- */
12
- export const METHOD_MANAGED_SERVER_IDS = ["sdd-mcp"];
13
- //# sourceMappingURL=types.js.map
@@ -1,108 +0,0 @@
1
- /**
2
- * `.github/hooks/*.json` merge pass during `sdd-cli sync` when the
3
- * adopter opted into `--agent-harness github-copilot-cli`.
4
- *
5
- * Method-managed event entries are sentinel-tagged via their
6
- * `command.bash` / `command.powershell` strings. Adopter-added events
7
- * in the same `events[]` array survive sync. Drift inside a method-
8
- * managed event refuses without `--allow-overwrite`, symmetric to the
9
- * Claude Code settings-merge path.
10
- */
11
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
12
- import { dirname, join } from "node:path";
13
- import { buildMethodManagedCopilotHooks, detectCopilotHookDrift, upsertCopilotHookFile, } from "../hooks-seed/copilot.js";
14
- export async function syncMethodManagedCopilotHooks(opts) {
15
- const surface = buildMethodManagedCopilotHooks();
16
- const entries = Object.entries(surface);
17
- // First pass: detect drift across all files. If any drifted file
18
- // would be refused (no --allow-overwrite), we refuse the whole
19
- // merge without writing — same all-or-nothing posture as the
20
- // Claude Code settings merge.
21
- const reads = [];
22
- for (const [portableClass, { filePath, events }] of entries) {
23
- const absPath = join(opts.targetRepo, filePath);
24
- const existing = await readHookFile(absPath);
25
- const drifted = detectCopilotHookDrift(existing, events);
26
- reads.push({
27
- portableClass,
28
- relPath: filePath,
29
- absPath,
30
- existing,
31
- owned: events,
32
- drifted,
33
- });
34
- }
35
- const driftedFiles = reads.filter((r) => r.drifted).map((r) => r.relPath);
36
- if (driftedFiles.length > 0 && !opts.allowOverwrite) {
37
- return {
38
- files: reads.map((r) => ({
39
- relPath: r.relPath,
40
- portableClass: r.portableClass,
41
- drifted: r.drifted,
42
- wrote: false,
43
- })),
44
- driftedRefused: driftedFiles,
45
- refused: true,
46
- };
47
- }
48
- if (opts.checkOnly) {
49
- return {
50
- files: reads.map((r) => ({
51
- relPath: r.relPath,
52
- portableClass: r.portableClass,
53
- drifted: r.drifted,
54
- wrote: false,
55
- })),
56
- driftedRefused: [],
57
- refused: false,
58
- };
59
- }
60
- // Second pass: upsert + write.
61
- const fileResults = [];
62
- for (const r of reads) {
63
- const merge = upsertCopilotHookFile(r.existing, r.owned);
64
- let wrote = false;
65
- if (merge.changed || r.existing === null) {
66
- await mkdir(dirname(r.absPath), { recursive: true });
67
- await writeFile(r.absPath, JSON.stringify(merge.file, null, 2) + "\n", "utf8");
68
- wrote = true;
69
- }
70
- fileResults.push({
71
- relPath: r.relPath,
72
- portableClass: r.portableClass,
73
- drifted: r.drifted,
74
- wrote,
75
- });
76
- }
77
- return { files: fileResults, driftedRefused: [], refused: false };
78
- }
79
- async function readHookFile(absPath) {
80
- if (!(await fileExists(absPath)))
81
- return null;
82
- const raw = await readFile(absPath, "utf8");
83
- if (raw.trim().length === 0)
84
- return null;
85
- try {
86
- const parsed = JSON.parse(raw);
87
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
88
- const obj = parsed;
89
- if (Array.isArray(obj["events"])) {
90
- return obj;
91
- }
92
- }
93
- }
94
- catch {
95
- /* fall through — treat malformed as empty so the next write reseats it */
96
- }
97
- return null;
98
- }
99
- async function fileExists(path) {
100
- try {
101
- await access(path);
102
- return true;
103
- }
104
- catch {
105
- return false;
106
- }
107
- }
108
- //# sourceMappingURL=copilot-hooks-merge.js.map