doctrina-cli 0.18.0 → 0.21.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 (68) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/src/commands/adapter.js +84 -0
  4. package/src/commands/analyze.js +14 -3
  5. package/src/commands/change.js +164 -17
  6. package/src/commands/clarify.js +1 -204
  7. package/src/commands/close.js +18 -1
  8. package/src/commands/context.js +81 -9
  9. package/src/commands/contract.js +1 -0
  10. package/src/commands/delivery.js +356 -0
  11. package/src/commands/doctor.js +21 -1
  12. package/src/commands/handoff.js +11 -2
  13. package/src/commands/index-rebuild.js +23 -0
  14. package/src/commands/init.js +28 -1
  15. package/src/commands/intake.js +60 -3
  16. package/src/commands/metrics.js +43 -3
  17. package/src/commands/next.js +41 -6
  18. package/src/commands/prime.js +125 -7
  19. package/src/commands/review.js +39 -3
  20. package/src/commands/skill.js +2 -1
  21. package/src/commands/status.js +28 -0
  22. package/src/commands/upgrade.js +33 -0
  23. package/src/commands/validate.js +18 -0
  24. package/src/commands/verify.js +32 -4
  25. package/src/commands/work.js +126 -7
  26. package/src/commands/workspace.js +419 -0
  27. package/src/index.js +100 -66
  28. package/src/lib/actions.js +44 -4
  29. package/src/lib/adapters.js +143 -1
  30. package/src/lib/args.js +33 -1
  31. package/src/lib/clarify-scan.js +221 -0
  32. package/src/lib/code-inventory.js +770 -0
  33. package/src/lib/commands.js +52 -5
  34. package/src/lib/context-slice.js +149 -0
  35. package/src/lib/delivery.js +286 -0
  36. package/src/lib/docs-impact.js +36 -1
  37. package/src/lib/fs-ops.js +23 -0
  38. package/src/lib/intake-model.js +1 -1
  39. package/src/lib/json-out.js +9 -1
  40. package/src/lib/metrics-model.js +23 -1
  41. package/src/lib/playbook.js +1 -1
  42. package/src/lib/shell.js +45 -0
  43. package/src/lib/skill-export.js +95 -0
  44. package/src/lib/snapshot.js +43 -2
  45. package/src/lib/task-card.js +195 -0
  46. package/src/lib/views.js +52 -3
  47. package/src/lib/work-model.js +66 -2
  48. package/src/lib/workspace-agents.js +306 -0
  49. package/src/lib/workspace-guard.js +65 -0
  50. package/src/lib/workspace-locate.js +141 -0
  51. package/src/lib/workspace-tools.js +160 -0
  52. package/src/lib/workspace-view.js +391 -0
  53. package/src/lib/workspace.js +323 -0
  54. package/templates/AGENTS.md.template +17 -14
  55. package/templates/README.md +11 -1
  56. package/templates/adapters/claude/.claude/settings.json.template +18 -0
  57. package/templates/adapters/cursor/.cursor/hooks.json.template +8 -0
  58. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +14 -9
  59. package/templates/adapters/cursor/.cursor/{commands/doctrina-context.md.template → skills/doctrina-context/SKILL.md.template} +6 -0
  60. package/templates/adapters/cursor/.cursor/{commands/doctrina-next.md.template → skills/doctrina-next/SKILL.md.template} +6 -0
  61. package/templates/adapters/cursor/.cursor/{commands/doctrina-status.md.template → skills/doctrina-status/SKILL.md.template} +6 -0
  62. package/templates/adapters/cursor/.cursor/{commands/doctrina-why.md.template → skills/doctrina-why/SKILL.md.template} +6 -0
  63. package/templates/adapters/cursor/.cursor/{commands/doctrina-work.md.template → skills/doctrina-work/SKILL.md.template} +6 -0
  64. package/templates/delivery.md.template +39 -0
  65. package/templates/playbooks/absorb.md.template +63 -0
  66. package/templates/playbooks/bootstrap.md.template +18 -6
  67. package/templates/playbooks/delivery.md.template +44 -0
  68. package/templates/playbooks/work.md.template +1 -1
package/src/index.js CHANGED
@@ -11,68 +11,73 @@ import { EXIT, exitCodeHelp } from "./lib/exit-codes.js";
11
11
  import { recordUsage, operationOf } from "./lib/usage.js";
12
12
  import { wantsJson, emitJson, captureOutput, stripAnsi, setDeprecation, deferJson, flushJson } from "./lib/json-out.js";
13
13
 
14
- import * as init from "./commands/init.js";
15
- import * as spec from "./commands/spec.js";
16
- import * as change from "./commands/change.js";
17
- import * as decision from "./commands/decision.js";
18
- import * as validate from "./commands/validate.js";
19
- import * as hooks from "./commands/hooks.js";
20
- import * as analyze from "./commands/analyze.js";
21
- import * as clarify from "./commands/clarify.js";
22
- import * as templates from "./commands/templates.js";
23
- import * as skill from "./commands/skill.js";
24
- import * as indexCmd from "./commands/index-rebuild.js";
25
- import * as next from "./commands/next.js";
26
- import * as metrics from "./commands/metrics.js";
27
- import * as context from "./commands/context.js";
28
- import * as search from "./commands/search.js";
29
- import * as intake from "./commands/intake.js";
30
- import * as work from "./commands/work.js";
31
- import * as coverage from "./commands/coverage.js";
32
- import * as verify from "./commands/verify.js";
33
- import * as contract from "./commands/contract.js";
34
- import * as trace from "./commands/trace.js";
35
- import * as status from "./commands/status.js";
36
- import * as close from "./commands/close.js";
37
- import * as review from "./commands/review.js";
38
- import * as watch from "./commands/watch.js";
39
- import * as why from "./commands/why.js";
40
- import * as prime from "./commands/prime.js";
41
- import * as handoff from "./commands/handoff.js";
42
- import * as show from "./commands/show.js";
43
- import * as doctor from "./commands/doctor.js";
44
- import * as report from "./commands/report.js";
45
- import * as completion from "./commands/completion.js";
46
- import * as intent from "./commands/intent.js";
47
- import * as upgrade from "./commands/upgrade.js";
48
- import * as adapter from "./commands/adapter.js";
49
- import * as triage from "./commands/triage.js";
50
- import * as ci from "./commands/ci.js";
51
14
 
15
+ // Each command is loaded when it is the one invoked (fifth audit, change
16
+ // 0236): the 39 static imports cost ~150 ms before any command ran, and
17
+ // `workspace guard` runs on EVERY MCP call an agent makes — a Playwright
18
+ // session's hundred clicks paid it a hundred times.
19
+ /** @type {Record<string, () => Promise<any>>} */
52
20
  const COMMANDS = {
53
- init, spec, change, decision, validate, hooks, analyze, clarify,
54
- templates, skill, index: indexCmd, next, metrics, context, search,
55
- intake, work, coverage, verify, contract, trace,
56
- status, close, review, watch, why,
57
- prime, handoff, show, doctor, report, completion,
58
- intent, upgrade, adapter, triage, ci,
21
+ init: () => import("./commands/init.js"),
22
+ spec: () => import("./commands/spec.js"),
23
+ change: () => import("./commands/change.js"),
24
+ decision: () => import("./commands/decision.js"),
25
+ validate: () => import("./commands/validate.js"),
26
+ hooks: () => import("./commands/hooks.js"),
27
+ analyze: () => import("./commands/analyze.js"),
28
+ clarify: () => import("./commands/clarify.js"),
29
+ templates: () => import("./commands/templates.js"),
30
+ skill: () => import("./commands/skill.js"),
31
+ index: () => import("./commands/index-rebuild.js"),
32
+ next: () => import("./commands/next.js"),
33
+ metrics: () => import("./commands/metrics.js"),
34
+ context: () => import("./commands/context.js"),
35
+ search: () => import("./commands/search.js"),
36
+ intake: () => import("./commands/intake.js"),
37
+ work: () => import("./commands/work.js"),
38
+ coverage: () => import("./commands/coverage.js"),
39
+ verify: () => import("./commands/verify.js"),
40
+ contract: () => import("./commands/contract.js"),
41
+ trace: () => import("./commands/trace.js"),
42
+ status: () => import("./commands/status.js"),
43
+ close: () => import("./commands/close.js"),
44
+ review: () => import("./commands/review.js"),
45
+ watch: () => import("./commands/watch.js"),
46
+ why: () => import("./commands/why.js"),
47
+ prime: () => import("./commands/prime.js"),
48
+ handoff: () => import("./commands/handoff.js"),
49
+ show: () => import("./commands/show.js"),
50
+ doctor: () => import("./commands/doctor.js"),
51
+ report: () => import("./commands/report.js"),
52
+ completion: () => import("./commands/completion.js"),
53
+ intent: () => import("./commands/intent.js"),
54
+ upgrade: () => import("./commands/upgrade.js"),
55
+ adapter: () => import("./commands/adapter.js"),
56
+ triage: () => import("./commands/triage.js"),
57
+ ci: () => import("./commands/ci.js"),
58
+ workspace: () => import("./commands/workspace.js"),
59
+ delivery: () => import("./commands/delivery.js"),
59
60
  };
60
61
 
61
- const TOP_HELP = `
62
- Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
62
+ const TOP_HELP = `Usage: doctrina <command> [options] (\`doctrina <command> --help\` for its flags)
63
63
 
64
64
  ${surfaceHelp()}
65
65
 
66
- Global flags:
67
- --help, -h Show this message (or per-command help if after a command)
68
- --version, -v Print the version
69
- --debug On an unexpected error, also print the stack trace
70
-
66
+ Global flags: --help/-h (this, or a command's own) · --version/-v · --debug (stack traces)
71
67
  Exit codes (a contract — an agent reads these to decide what to do next):
72
68
  ${exitCodeHelp()}
73
69
  `;
74
70
 
75
71
  async function main(argv) {
72
+ // The MCP guard's own door (fifth audit, change 0236): it answers before
73
+ // every tool call an agent makes, so it loads the guard and its registry
74
+ // reader only. `--help` and `--json` take the ordinary path.
75
+ if (argv[0] === "workspace" && argv[1] === "guard" && !argv.some((a) => /^(--help|-h|--json|--debug)$/.test(a))) {
76
+ const at = argv.indexOf("--hook");
77
+ const hook = at >= 0 ? argv[at + 1] : argv.find((a) => a.startsWith("--hook="))?.slice("--hook=".length);
78
+ const { runGuard } = await import("./lib/workspace-guard.js");
79
+ return runGuard(hook);
80
+ }
76
81
  // `--version` is only "print the CLI version" when NO command is given.
77
82
  // With a command it is that command's flag to consume — the old global
78
83
  // intercept made `doctrina spec set x --version 0.10.0` print the CLI
@@ -91,7 +96,8 @@ async function main(argv) {
91
96
  // Declarations now live with the command, so a new command cannot
92
97
  // reintroduce the gap by forgetting to edit this file.
93
98
  const bootstrap = parseArgs(argv, { boolean: [...GLOBAL_FLAGS.boolean] });
94
- const commandModule = COMMANDS[bootstrap.positional[0]];
99
+ const loader = Object.hasOwn(COMMANDS, bootstrap.positional[0] ?? "") ? COMMANDS[bootstrap.positional[0]] : null;
100
+ const commandModule = loader ? await loader() : null;
95
101
  const spec = commandModule?.flags;
96
102
  const { positional, flags } = parseArgs(argv, {
97
103
  boolean: [...GLOBAL_FLAGS.boolean, ...(spec?.boolean ?? [])],
@@ -109,7 +115,8 @@ async function main(argv) {
109
115
  }
110
116
 
111
117
  const commandName = positional[0];
112
- const command = COMMANDS[commandName];
118
+ const command = commandName === bootstrap.positional[0] ? commandModule
119
+ : (Object.hasOwn(COMMANDS, commandName) ? await COMMANDS[commandName]() : null);
113
120
  const removed = removalFor(positional);
114
121
  if (removed) {
115
122
  const name = removed === removalFor([commandName]) ? commandName : positional.slice(0, 2).join(" ");
@@ -209,12 +216,13 @@ async function main(argv) {
209
216
  ? command.jsonNative(positional.slice(1))
210
217
  : command.jsonNative === true;
211
218
  if (wantsJson(flags) && !jsonNative) {
212
- const { code, stdout, stderr } = await captureOutput(
219
+ const { code, stdout, stderr, error } = await captureOutput(
213
220
  () => command.run(positional.slice(1), flags),
214
221
  );
215
222
  emitJson(operationName(positional), {
216
223
  stdout: stdout.map(stripAnsi),
217
224
  stderr: stderr.map(stripAnsi),
225
+ ...(error ? { error: String(error.message ?? error) } : {}),
218
226
  }, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
219
227
  return code;
220
228
  }
@@ -224,27 +232,53 @@ async function main(argv) {
224
232
  if (wantsJson(flags)) {
225
233
  deferJson();
226
234
  let code = EXIT.OK;
235
+ let thrown = null;
227
236
  try {
228
237
  code = (await command.run(positional.slice(1), flags)) ?? EXIT.OK;
229
- } finally {
230
- flushJson(code);
238
+ } catch (err) {
239
+ thrown = err;
240
+ code = err.exitCode ?? EXIT.GATE;
241
+ }
242
+ // A native command that failed before building its payload still
243
+ // answers in JSON (third audit, change 0234): "not a Doctrina project"
244
+ // or an unknown delivery printed English only, and a loop parsing the
245
+ // payload read nothing at all.
246
+ if (!flushJson(code)) {
247
+ emitJson(operationName(positional), {
248
+ error: thrown ? String(thrown.message) : "the command stopped before it built its payload — stderr says why",
249
+ ...(thrown?.remedy ? { remedy: thrown.remedy } : {}),
250
+ }, { ok: code === EXIT.OK, exitCode: code, args: operationArgs(positional) });
231
251
  }
252
+ if (thrown) await reportError(thrown, flags);
232
253
  return code;
233
254
  }
234
255
  return await command.run(positional.slice(1), flags);
235
256
  } catch (err) {
236
- console.error(c.red("error:") + ` ${err.message}`);
237
- if (err.remedy) {
238
- console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
239
- }
240
- if (flags.get("debug") && err.stack) {
241
- console.error(c.gray(err.stack));
242
- }
243
- // A typed error carries its own class (precondition vs environment).
244
- // An untyped throw is an unexpected failure of the work itself, which
245
- // is the GATE class: something is wrong here, fix it and retry (C7).
246
- return err.exitCode ?? EXIT.GATE;
257
+ return await reportError(err, flags);
258
+ }
259
+ }
260
+
261
+ /** The human report of a thrown error, and the exit class it carries. */
262
+ async function reportError(err, flags) {
263
+ console.error(c.red("error:") + ` ${err.message}`);
264
+ // A clone of a shadowed repository is not a project to init (change 0234).
265
+ // Loaded only on this error path, like the commands (change 0236).
266
+ const view = /^not a Doctrina project/.test(String(err.message)) ? await import("./lib/workspace-view.js") : null;
267
+ const clone = view ? view.shadowedCloneAt(process.cwd()) : null;
268
+ if (clone) {
269
+ console.error(c.gray("hint: ") + (clone.same
270
+ ? `this repository is the workspace's shadowed "${clone.name}" and its links are missing; never \`doctrina init\` here — link them again: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`
271
+ : `this repository is the workspace's shadowed "${clone.name}" (registered at ${clone.path}); never \`doctrina init\` here — to work from this clone, link its context from the workspace: ${c.cyan(view?.attachCommand(clone, process.cwd()))}`));
272
+ } else if (err.remedy) {
273
+ console.error(c.gray("hint: ") + `run ${c.cyan(err.remedy)} first, then retry`);
274
+ }
275
+ if (flags.get("debug") && err.stack) {
276
+ console.error(c.gray(err.stack));
247
277
  }
278
+ // A typed error carries its own class (precondition vs environment).
279
+ // An untyped throw is an unexpected failure of the work itself, which
280
+ // is the GATE class: something is wrong here, fix it and retry (C7).
281
+ return err.exitCode ?? EXIT.GATE;
248
282
  }
249
283
 
250
284
  // The OPERATION an invocation names, and the arguments it carries — the two
@@ -4,10 +4,13 @@ import { readdirSync } from "node:fs";
4
4
  import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
5
  import * as idx from "./index-json.js";
6
6
  import { deriveIndex, indexesMatch, listHeader, specHeader } from "./scan.js";
7
+ import { checklistProgress } from "./doc-model.js";
7
8
  import { collectRuntimeFindings } from "./runtime.js";
8
9
  import { FIX_SHAPED } from "./lexicon.js";
9
10
  import { summarize as coverageSummary } from "./coverage-model.js";
10
11
  import { summarize as traceSummary } from "./trace-model.js";
12
+ import { projectWorkspaceLine } from "./workspace-view.js";
13
+ import { cdThen, qarg } from "./shell.js";
11
14
 
12
15
  // What comes next, as DATA (change 0032).
13
16
  //
@@ -151,7 +154,7 @@ export function computeActions(projectRoot, gates = {}) {
151
154
  severity: "blocking",
152
155
  why: "no capability is specced yet, so nothing states what this project must do",
153
156
  text: 'doctrina intake --text "<what this project is>" — no capability is specced yet; ' +
154
- "for an existing codebase, `doctrina work --from-diff` backfills from the code instead",
157
+ "for an existing codebase, `doctrina intake --from-code` takes its inventory as the intake (`doctrina work --from-diff` backfills one change from code already written)",
155
158
  }));
156
159
  }
157
160
 
@@ -214,9 +217,19 @@ export function computeActions(projectRoot, gates = {}) {
214
217
  continue;
215
218
  }
216
219
  const tasksPath = path.join(changesDir, id, "tasks.md");
217
- const unchecked = isFile(tasksPath)
218
- ? (read(tasksPath).match(/^-\s+\[ \]/gm) ?? []).length
219
- : 0;
220
+ const progress = isFile(tasksPath) ? checklistProgress(read(tasksPath)) : { total: 0, done: 0, open: [], placeholders: 0 };
221
+ const unchecked = progress.total - progress.done;
222
+ if (progress.placeholders > 0) {
223
+ // Scaffold boxes are not open work (third audit, change 0234): "complete 3
224
+ // open tasks" sent an agent to do work no one had written down.
225
+ actions.push(action({
226
+ id: "change-tasks-unwritten",
227
+ command: "work",
228
+ args: ["--resume", id],
229
+ why: `write the ${unchecked} open task${unchecked === 1 ? "" : "s"} in .doctrina/changes/${id}/tasks.md first — ${progress.placeholders === unchecked ? (unchecked === 1 ? "it is" : "all are") : `${progress.placeholders} are`} still the scaffold's placeholder${progress.placeholders === 1 ? "" : "s"}, and the delta with them`,
230
+ }));
231
+ continue;
232
+ }
220
233
  if (unchecked > 0) {
221
234
  // Deliberately NOT runnable, and deliberately without a command:
222
235
  // the only thing that clears this is doing the work. `change tick`
@@ -253,6 +266,33 @@ export function computeActions(projectRoot, gates = {}) {
253
266
  }
254
267
  }
255
268
 
269
+ // A delivery open on this repository (second audit, change 0233). `prime`
270
+ // named it, `next` did not: after the close of this repository's part,
271
+ // "Next: doctrina next" answered with this project's own chores, and the
272
+ // planner lost the delivery that was still going on at the workspace.
273
+ let inWorkspace = null;
274
+ try {
275
+ inWorkspace = projectWorkspaceLine(projectRoot);
276
+ } catch {
277
+ inWorkspace = null;
278
+ }
279
+ for (const d of inWorkspace?.deliveries ?? []) {
280
+ if (d.changes.length === 0) {
281
+ actions.push(action({
282
+ id: "delivery-change-missing",
283
+ why: `delivery ${d.id} is routed to this repository and has no change here yet`,
284
+ text: `doctrina work ${qarg(d.title)} --delivery ${d.id} — delivery ${d.id} is routed to this repository and has no change here yet`,
285
+ }));
286
+ } else if (d.open === 0) {
287
+ actions.push(action({
288
+ id: "delivery-continues-at-workspace",
289
+ severity: "advisory",
290
+ why: `this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
291
+ text: `${cdThen(inWorkspace?.dir ?? "", "doctrina next")} — this repository's part of delivery ${d.id} is closed; the delivery goes on at the workspace`,
292
+ }));
293
+ }
294
+ }
295
+
256
296
  // ADRs stuck in proposed need a human decision; accepted-but-bare ADRs are
257
297
  // the rot the review flagged — a decision with nothing behind it (no Evidence,
258
298
  // no Landed) is drift waiting to be superseded. Surface the proposed ones
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import { readdirSync } from "node:fs";
4
- import { exists, isDir, isFile, read, relPath, walk } from "./fs-ops.js";
4
+ import { exists, isDir, isFile, mkdirp, pruneEmptyDirs, read, relPath, remove, walk, write } from "./fs-ops.js";
5
5
  import { locateTemplatesDir, substitute } from "./templates.js";
6
6
 
7
7
  // Agent adapters: the thin per-agent files that point at AGENTS.md.
@@ -127,3 +127,145 @@ export function renderAdapterFile(file, tokens) {
127
127
  }
128
128
 
129
129
  export { exists };
130
+
131
+ // Files an adapter USED to write, and the file that replaced each (change
132
+ // 0238). Cursor's documentation no longer describes `.cursor/commands/`;
133
+ // Cursor 2.4 converts slash commands to skills (`/migrate-to-skills`), with
134
+ // `disable-model-invocation: true` to keep them explicit — which is what
135
+ // the cursor adapter writes now, under `.cursor/skills/`.
136
+ export const RETIRED_ADAPTER_FILES = Object.freeze({
137
+ cursor: ["context", "next", "status", "why", "work"].map((x) => ({
138
+ retired: `.cursor/commands/doctrina-${x}.md`,
139
+ replacedBy: `.cursor/skills/doctrina-${x}/SKILL.md`,
140
+ })),
141
+ });
142
+
143
+ /**
144
+ * Moving an installed adapter off its retired files: the replacement is
145
+ * written where it is missing; a retired file still exactly as the CLI
146
+ * wrote it is removed; one edited since is kept and named, never deleted.
147
+ * @param {string} projectRoot
148
+ * @param {Record<string, string>} tokens as `adapter add` renders with
149
+ * @param {string} [root] where the files live: the shadow folder of a shadowed repository
150
+ */
151
+ export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
152
+ /** @type {{ writes: Array<{ relativePath: string, content: string }>, removes: string[], kept: string[] }} */
153
+ const plan = { writes: [], removes: [], kept: [] };
154
+ const norm = (/** @type {string} */ s) => s.replace(/\r\n/g, "\n").trim();
155
+ for (const [name, list] of Object.entries(RETIRED_ADAPTER_FILES)) {
156
+ const spec = adapterFiles(projectRoot, name);
157
+ if (!spec) continue;
158
+ for (const { retired, replacedBy } of list) {
159
+ if (!isFile(path.join(root, retired))) continue;
160
+ const file = spec.files.find((f) => f.relativePath === replacedBy);
161
+ if (!file) continue;
162
+ const rendered = renderAdapterFile(file, tokens);
163
+ if (!isFile(path.join(root, replacedBy))) plan.writes.push({ relativePath: replacedBy, content: rendered });
164
+ const body = rendered.replace(/^---\n[\s\S]*?\n---\n+/, "");
165
+ (norm(read(path.join(root, retired))) === norm(body) ? plan.removes : plan.kept).push(retired);
166
+ }
167
+ }
168
+ return plan;
169
+ }
170
+
171
+ /**
172
+ * Apply a migration plan under `root` — the project, or the shadow folder
173
+ * a shadowed repository's files live in. Returns the removed files with
174
+ * what they held, for the caller that re-links a shadow.
175
+ * @returns {Array<{ rel: string, content: string }>}
176
+ */
177
+ export function applyAdapterMigration(root, plan) {
178
+ for (const w of plan.writes) {
179
+ mkdirp(path.dirname(path.join(root, w.relativePath)));
180
+ write(path.join(root, w.relativePath), w.content, { force: true });
181
+ }
182
+ const gone = [];
183
+ for (const rel of plan.removes) {
184
+ const at = path.join(root, rel);
185
+ if (!isFile(at)) continue;
186
+ gone.push({ rel, content: read(at) });
187
+ remove(at);
188
+ pruneEmptyDirs(path.dirname(at), root);
189
+ }
190
+ return gone;
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Configuration files an adapter SHARES with the user (change 0211).
195
+ //
196
+ // A hook lives in the agent's own settings — `.claude/settings.json`,
197
+ // `.cursor/hooks.json` — which the project may already own: permissions,
198
+ // other hooks, a model choice. Writing the file whole would be the data loss
199
+ // `adapter add` exists to avoid, and skipping it because it exists would
200
+ // leave the hooks out exactly where an agent is configured. So a `.json`
201
+ // adapter file is MERGED: objects recurse, arrays gain the entries they do
202
+ // not already hold, and a scalar the user set stays theirs. Removal takes
203
+ // back only what the template put in.
204
+
205
+ /** Whether an adapter file is merged into a shared configuration file. */
206
+ export function isMergedConfig(file) {
207
+ return file.relativePath.endsWith(".json");
208
+ }
209
+
210
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
211
+
212
+ /** `ours` merged into `theirs`; neither argument is modified. */
213
+ export function mergeJson(theirs, ours) {
214
+ if (Array.isArray(theirs) && Array.isArray(ours)) {
215
+ return [...theirs, ...ours.filter((o) => !theirs.some((t) => same(t, o)))];
216
+ }
217
+ if (isPlainObject(theirs) && isPlainObject(ours)) {
218
+ const out = { ...theirs };
219
+ for (const [k, v] of Object.entries(ours)) {
220
+ out[k] = k in theirs ? mergeJson(theirs[k], v) : v;
221
+ }
222
+ return out;
223
+ }
224
+ return theirs;
225
+ }
226
+
227
+ /**
228
+ * `theirs` with everything `ours` contributed taken out, and the containers
229
+ * that leaves empty pruned. A scalar is removed only when it equals ours AND
230
+ * nothing of the user's remains beside it. Returns null when nothing is left.
231
+ */
232
+ export function unmergeJson(theirs, ours) {
233
+ if (Array.isArray(theirs) && Array.isArray(ours)) {
234
+ const left = theirs.filter((t) => !ours.some((o) => same(t, o)));
235
+ return left.length > 0 ? left : null;
236
+ }
237
+ if (isPlainObject(theirs) && isPlainObject(ours)) {
238
+ const out = {};
239
+ for (const [k, v] of Object.entries(theirs)) {
240
+ if (!(k in ours) || isScalar(ours[k])) { out[k] = v; continue; }
241
+ const kept = unmergeJson(v, ours[k]);
242
+ if (kept !== null) out[k] = kept;
243
+ }
244
+ // Our scalars (a `"version": 1`) go only when they are all that is left.
245
+ const userKeys = Object.keys(out).filter((k) => !(k in ours && isScalar(ours[k]) && same(out[k], ours[k])));
246
+ return userKeys.length > 0 ? out : null;
247
+ }
248
+ return same(theirs, ours) ? null : theirs;
249
+ }
250
+
251
+ function isPlainObject(v) {
252
+ return v !== null && typeof v === "object" && !Array.isArray(v);
253
+ }
254
+
255
+ function isScalar(v) {
256
+ return v === null || typeof v !== "object";
257
+ }
258
+
259
+ /** Whether `theirs` holds at least one array entry `ours` contributes. */
260
+ export function holdsAnyOf(theirs, ours) {
261
+ if (Array.isArray(theirs) && Array.isArray(ours)) return ours.some((o) => theirs.some((t) => same(t, o)));
262
+ if (isPlainObject(theirs) && isPlainObject(ours)) {
263
+ return Object.keys(ours).some((k) => k in theirs && holdsAnyOf(theirs[k], ours[k]));
264
+ }
265
+ return false;
266
+ }
267
+
268
+ /** Whether every entry `ours` contributes is present in `theirs`. */
269
+ export function containsJson(theirs, ours) {
270
+ return same(mergeJson(theirs, ours), theirs);
271
+ }
package/src/lib/args.js CHANGED
@@ -8,12 +8,25 @@
8
8
  // -x=value or -x value -> string value
9
9
  // -- -> stop flag parsing; rest is positional
10
10
  //
11
- // Anything else becomes positional. Repeated flags overwrite the previous value.
11
+ // Anything else becomes positional. Repeated flags overwrite the previous value
12
+ // in `flags`; every value is still kept, in order, for `flagAll` — a command
13
+ // that takes a flag more than once (`change delta --op a --op b`) reads them
14
+ // all there instead of losing every one but the last (change 0228).
15
+
16
+ /** @type {WeakMap<Map<string, string|boolean>, Map<string, string[]>>} */
17
+ const EVERY_VALUE = new WeakMap();
12
18
 
13
19
  export function parseArgs(argv, opts = {}) {
14
20
  const booleanFlags = new Set(opts.boolean ?? []);
15
21
  const positional = [];
22
+ /** @type {Map<string, string|boolean>} */
16
23
  const flags = new Map();
24
+ /** @type {Map<string, string[]>} */
25
+ const every = new Map();
26
+ EVERY_VALUE.set(flags, every);
27
+ const keep = (/** @type {string} */ name, /** @type {string|boolean} */ value) => {
28
+ if (typeof value === "string") every.set(name, [...(every.get(name) ?? []), value]);
29
+ };
17
30
  let i = 0;
18
31
  let stopped = false;
19
32
 
@@ -37,12 +50,14 @@ export function parseArgs(argv, opts = {}) {
37
50
  const eq = body.indexOf("=");
38
51
  if (eq >= 0) {
39
52
  flags.set(body.slice(0, eq), body.slice(eq + 1));
53
+ keep(body.slice(0, eq), body.slice(eq + 1));
40
54
  i += 1;
41
55
  } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
42
56
  flags.set(body, true);
43
57
  i += 1;
44
58
  } else {
45
59
  flags.set(body, argv[i + 1] ?? true);
60
+ keep(body, argv[i + 1] ?? true);
46
61
  i += 2;
47
62
  }
48
63
  continue;
@@ -53,12 +68,14 @@ export function parseArgs(argv, opts = {}) {
53
68
  const eq = body.indexOf("=");
54
69
  if (eq >= 0) {
55
70
  flags.set(body.slice(0, eq), body.slice(eq + 1));
71
+ keep(body.slice(0, eq), body.slice(eq + 1));
56
72
  i += 1;
57
73
  } else if (booleanFlags.has(body) || isNextTokenFlag(argv[i + 1])) {
58
74
  flags.set(body, true);
59
75
  i += 1;
60
76
  } else {
61
77
  flags.set(body, argv[i + 1] ?? true);
78
+ keep(body, argv[i + 1] ?? true);
62
79
  i += 2;
63
80
  }
64
81
  continue;
@@ -91,6 +108,21 @@ export function parsePositiveInt(raw) {
91
108
  return Number.isSafeInteger(n) && n > 0 ? n : null;
92
109
  }
93
110
 
111
+ /**
112
+ * Every value a string flag was given, in the order given — `[]` when absent.
113
+ * A Map built by hand (tests, callers that never parsed argv) answers with
114
+ * its one value.
115
+ * @param {Map<string, string|boolean>} flags
116
+ * @param {string} name
117
+ * @returns {string[]}
118
+ */
119
+ export function flagAll(flags, name) {
120
+ const every = EVERY_VALUE.get(flags)?.get(name);
121
+ if (every) return every;
122
+ const one = flags.get(name);
123
+ return typeof one === "string" ? [one] : [];
124
+ }
125
+
94
126
  export function flagString(flags, name, fallback = undefined) {
95
127
  const v = flags.get(name);
96
128
  if (v === undefined || v === true) return fallback;