doctrina-cli 0.21.0 → 0.22.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 (75) hide show
  1. package/README.md +6 -6
  2. package/package.json +1 -1
  3. package/src/commands/change.js +111 -14
  4. package/src/commands/clarify.js +2 -2
  5. package/src/commands/close.js +1 -1
  6. package/src/commands/coverage.js +69 -6
  7. package/src/commands/doctor.js +62 -4
  8. package/src/commands/hooks.js +56 -11
  9. package/src/commands/index-rebuild.js +47 -1
  10. package/src/commands/init.js +51 -7
  11. package/src/commands/metrics.js +1 -1
  12. package/src/commands/onboard.js +113 -0
  13. package/src/commands/review.js +1 -1
  14. package/src/commands/search.js +2 -2
  15. package/src/commands/templates.js +2 -1
  16. package/src/commands/triage.js +16 -1
  17. package/src/commands/ui.js +512 -0
  18. package/src/commands/upgrade.js +46 -6
  19. package/src/commands/verify.js +3 -3
  20. package/src/commands/why.js +1 -1
  21. package/src/commands/work.js +53 -40
  22. package/src/commands/workspace.js +16 -3
  23. package/src/index.js +9 -0
  24. package/src/lib/actions.js +19 -0
  25. package/src/lib/adapters.js +23 -3
  26. package/src/lib/agent-bridge.js +400 -0
  27. package/src/lib/applied-deltas.js +82 -0
  28. package/src/lib/args.js +1 -1
  29. package/src/lib/change-numbers.js +115 -0
  30. package/src/lib/change-ops.js +2 -2
  31. package/src/lib/clarify-scan.js +3 -3
  32. package/src/lib/code-inventory.js +22 -5
  33. package/src/lib/commands.js +50 -4
  34. package/src/lib/config.js +27 -8
  35. package/src/lib/conflict-markers.js +61 -0
  36. package/src/lib/console-model.js +668 -0
  37. package/src/lib/console-prefs.js +67 -0
  38. package/src/lib/console-room.js +201 -0
  39. package/src/lib/console-state.js +150 -0
  40. package/src/lib/console-view.js +557 -0
  41. package/src/lib/coverage-model.js +29 -6
  42. package/src/lib/delivery.js +2 -2
  43. package/src/lib/docs-impact.js +1 -1
  44. package/src/lib/fs-ops.js +80 -12
  45. package/src/lib/gates.js +7 -1
  46. package/src/lib/git.js +18 -0
  47. package/src/lib/index-json.js +10 -1
  48. package/src/lib/index-merge.js +90 -0
  49. package/src/lib/install.js +80 -0
  50. package/src/lib/intake-model.js +4 -1
  51. package/src/lib/ledger.js +2 -3
  52. package/src/lib/lock.js +58 -0
  53. package/src/lib/merge-setup.js +55 -0
  54. package/src/lib/metrics-model.js +2 -2
  55. package/src/lib/onboard.js +197 -0
  56. package/src/lib/preset.js +177 -0
  57. package/src/lib/prompt.js +1 -1
  58. package/src/lib/properties.js +149 -0
  59. package/src/lib/runtime.js +19 -7
  60. package/src/lib/scan.js +13 -8
  61. package/src/lib/spec-ops.js +2 -2
  62. package/src/lib/task-card.js +1 -1
  63. package/src/lib/templates-model.js +3 -2
  64. package/src/lib/triage-model.js +75 -0
  65. package/src/lib/validation-model.js +23 -5
  66. package/src/lib/views.js +2 -1
  67. package/src/lib/workspace-agents.js +2 -2
  68. package/src/lib/workspace-tools.js +41 -1
  69. package/src/lib/workspace.js +1 -1
  70. package/templates/AGENTS.md.template +8 -4
  71. package/templates/README.md +2 -1
  72. package/templates/adapters/claude/.claude/commands/doctrina-onboard.md.template +15 -0
  73. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +2 -0
  74. package/templates/adapters/cursor/.cursor/skills/doctrina-onboard/SKILL.md.template +18 -0
  75. package/templates/adapters/kiro/README.md +22 -0
@@ -1,7 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
3
  import process from "node:process";
4
- import { readdirSync } from "node:fs";
5
4
  import { eolOf, exists, isDir, isFile, mkdirp, read, relPath, withEol, write } from "../lib/fs-ops.js";
6
5
  import { flagString, flagBool } from "../lib/args.js";
7
6
  import * as idx from "../lib/index-json.js";
@@ -10,17 +9,18 @@ import { assessBrief } from "../lib/clarity.js";
10
9
  import { locateTemplatesDir, substitute } from "../lib/templates.js";
11
10
  import { findWorkspace, projectAt, readWorkspace } from "../lib/workspace.js";
12
11
  import { readDelivery, resolveDeliveryId, sectionText } from "../lib/delivery.js";
13
- import { parseLedger } from "../lib/ledger.js";
14
12
  import { EXIT, notADoctrinaProject } from "../lib/exit-codes.js";
15
13
  import { listHeader } from "../lib/scan.js";
16
14
  import { changeNew, reindexChange, addAffectedSpec } from "../lib/change-ops.js";
17
- import { classify } from "../lib/triage-model.js";
15
+ import { classify, directSignals, trackLines } from "../lib/triage-model.js";
18
16
  import { printPlaybookTemplate } from "../lib/playbook.js";
19
17
  import { specShape } from "../lib/spec-ops.js";
20
18
  import { changedFiles } from "../lib/git.js";
21
19
  import { slugFromPrompt, fold, CONFIDENT_MARGIN } from "../lib/lexicon.js";
22
20
  import { rankCapabilities, rankCapabilitiesByDiff, similarChanges, SIMILAR_CHANGE_DAYS } from "../lib/work-model.js";
23
21
  import { isChangeId, refuseChangeRef } from "../lib/project.js";
22
+ import { nextChangeNumber } from "../lib/change-numbers.js";
23
+ import { withProjectLock } from "../lib/lock.js";
24
24
  export { rankCapabilities, rankCapabilitiesByDiff } from "../lib/work-model.js";
25
25
 
26
26
  // `work` is the second half of the no-ceremony path (ADR 0005): a brief
@@ -176,6 +176,26 @@ export async function run(positional, flags) {
176
176
  }
177
177
  }
178
178
 
179
+ // THE DIRECT TRACK (change 0255). An edit that changes nothing a spec says
180
+ // opens nothing: the answer is the three tracks and the two commands that
181
+ // remain. Only the words that cannot mean behaviour decide it, and naming
182
+ // a capability, a delivery or an id says the author already chose.
183
+ if (prompt && verdict && !fromDiff && !chore && !forced &&
184
+ !flags.has("capability") && !flags.has("delivery") && !flags.has("id")) {
185
+ const hits = directSignals(prompt, verdict);
186
+ if (hits.length > 0) {
187
+ console.log(c.green("direct:") + ` no change to open — this reads as an edit that changes nothing a spec says ${c.gray(`(signals: ${[...new Set(hits)].slice(0, 6).join(", ")})`)}`);
188
+ console.log(` Edit it, run ${c.cyan("doctrina verify")}, and commit. No proposal, no delta, no close.`);
189
+ console.log("");
190
+ console.log("The tracks, by what the request changes:");
191
+ for (const line of trackLines()) console.log(line);
192
+ console.log("");
193
+ console.log(c.gray("If it does change behaviour, or deserves a record: ") +
194
+ c.cyan(`doctrina work --force "<request>"`) + c.gray(" · ") + c.cyan(`doctrina work --chore "<request>"`));
195
+ return EXIT.OK;
196
+ }
197
+ }
198
+
179
199
  const pinned = flagString(flags, "capability");
180
200
  if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
181
201
  console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
@@ -196,14 +216,19 @@ export async function run(positional, flags) {
196
216
  const slug = flags.has("title")
197
217
  ? slugify(title)
198
218
  : (prompt ? slugFromPrompt(prompt) : "backfill");
199
- const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
200
- if (!isChangeId(id)) {
219
+ // The number is read and the folder created under one lock (change 0250):
220
+ // read apart, eight parallel `work` opened three changes numbered 0003.
221
+ const opened = withProjectLock(projectRoot, () => {
222
+ const candidate = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slug}`;
223
+ if (!isChangeId(candidate)) return { id: candidate, code: null };
224
+ return { id: candidate, code: changeNew([candidate, title], flags) };
225
+ });
226
+ const id = opened.id;
227
+ if (opened.code === null) {
201
228
  console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
202
229
  return 2;
203
230
  }
204
-
205
- const code = changeNew([id, title], flags);
206
- if (code !== 0) return code;
231
+ if (opened.code !== 0) return opened.code;
207
232
 
208
233
  // The CLI reduces a prompt to an id deterministically; it cannot write a
209
234
  // good short name, and ADR 0005 says it must not try. So it says how to get
@@ -330,7 +355,7 @@ export async function run(positional, flags) {
330
355
  // see beats a missing file nothing reports.
331
356
  //
332
357
  // Returns what was written, or null when the file already exists.
333
- function scaffoldDelta(projectRoot, { id, capability, guess = null, runnerUp = null }) {
358
+ function scaffoldDelta(projectRoot, { id, capability, guess = /** @type {any} */ (null), runnerUp = /** @type {any} */ (null) }) {
334
359
  const deltaPath = path.join(projectRoot, ".doctrina", "changes", id, "specs", capability, "delta.md");
335
360
  if (exists(deltaPath)) return null;
336
361
  const tpl = read(path.join(locateTemplatesDir(), "change", "spec-delta.md.template"));
@@ -415,8 +440,8 @@ function writeLane(projectRoot, id, verdict, opts) {
415
440
  const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
416
441
  if (!isFile(proposalPath)) return;
417
442
  const text = read(proposalPath);
418
- // CRLF-safe: the scaffolded proposals are CRLF, and a `.*$` pattern
419
- // silently matches nothing against them — the `patch-doctrina-files-as-crlf`
443
+ // CRLF-safe: a scaffolded proposal may be CRLF, and a `.*$` pattern
444
+ // silently matches nothing against one — the `check-the-line-ending-before-patching`
420
445
  // skill exists for exactly this mistake.
421
446
  const updated = text.replace(/^(-[ \t]+\*\*Lane:\*\*)[^\r\n]*/m, `$1 ${line}`);
422
447
  if (updated !== text) write(proposalPath, updated, { force: true });
@@ -424,34 +449,6 @@ function writeLane(projectRoot, id, verdict, opts) {
424
449
 
425
450
  // Next sequential NNNN across open changes and the archive, so work-driven
426
451
  // ids stay ordered no matter how earlier changes were named.
427
- function nextChangeNumber(projectRoot) {
428
- const changesDir = path.join(projectRoot, ".doctrina", "changes");
429
- let max = 0;
430
- const harvest = (name) => {
431
- const m = name.match(/^(\d{1,4})-/) ?? name.match(/^(\d{1,4})$/);
432
- if (m) max = Math.max(max, Number(m[1]));
433
- };
434
- if (isDir(changesDir)) {
435
- for (const entry of readdirSync(changesDir)) {
436
- if (entry === "archive" || entry.startsWith(".")) continue;
437
- if (isDir(path.join(changesDir, entry))) harvest(entry);
438
- }
439
- }
440
- const archiveDir = path.join(changesDir, "archive");
441
- if (isDir(archiveDir)) {
442
- for (const entry of readdirSync(archiveDir)) {
443
- if (!isDir(path.join(archiveDir, entry))) continue;
444
- harvest(entry.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
445
- }
446
- }
447
- // An abandoned change leaves no folder, only its ledger line (third audit,
448
- // change 0234): its number was handed out again, and the ledger then named
449
- // two different changes by one id — the abandoned part of a delivery among them.
450
- const ledger = path.join(archiveDir, "LEDGER.md");
451
- if (isFile(ledger)) for (const e of parseLedger(read(ledger)).entries) harvest(e.id);
452
- return String(max + 1).padStart(4, "0");
453
- }
454
-
455
452
  // Open (non-archived) changes from the index — the set `work --resume` and
456
453
  // the thin-prompt guard offer to continue.
457
454
  function openChanges(projectRoot) {
@@ -591,7 +588,7 @@ function printChorePlaybook(projectRoot, { id, prompt }) {
591
588
  printPlaybookTemplate(projectRoot, "chore", { CHANGE_ID: id, PROMPT: prompt });
592
589
  }
593
590
 
594
- function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, scaffolded = null, fromDiff = false, diffMatches = [] }) {
591
+ function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability, clarity, scaffolded = /** @type {any} */ (null), fromDiff = false, diffMatches = /** @type {any[]} */ ([]) }) {
595
592
  printPlaybookTemplate(projectRoot, "work", {
596
593
  TITLE: fromDiff ? "Backfill playbook" : "Work playbook",
597
594
  CHANGE_ID: id,
@@ -720,6 +717,22 @@ A request that mostly repeats a change already open, or archived in the
720
717
  last 14 days, is HELD the same way (exit 3), naming it: the same work is
721
718
  resumed, or reported as done where it is archived; --force opens it anyway.
722
719
 
720
+ TRACKS. How much ceremony a request earns is decided by what it changes,
721
+ not by the size of the diff:
722
+
723
+ direct nothing a spec says — a typo, a comment, formatting, a fix that
724
+ makes the code do what the spec already says
725
+ edit · doctrina verify · commit (no change is opened)
726
+ chore the build, the tooling or the docs, worth a record — no spec
727
+ changes: doctrina work --chore "<request>"
728
+ change behaviour — a sentence in a spec becomes false, or one is
729
+ missing: doctrina work "<request>"
730
+
731
+ A prompt whose words can only mean a direct edit (a typo, whitespace, a
732
+ stale comment, dead code) opens nothing: work prints the tracks and exits
733
+ 0. --force opens the change anyway, --chore records it, and naming
734
+ --capability, --delivery or --id skips the reading.
735
+
723
736
  A prompt that reads as a RUNTIME problem — a workflow, an env value, a run
724
737
  that executed nothing — is HELD before anything is scaffolded, and pointed
725
738
  at \`doctrina triage\` instead (exit 3: a precondition, not a bad prompt).
@@ -5,7 +5,7 @@ import { spawnSync } from "node:child_process";
5
5
  import { readFileSync } from "node:fs";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { exists, isDir, mkdirp, relPath, write } from "../lib/fs-ops.js";
8
- import { flagString } from "../lib/args.js";
8
+ import { flagString, flagGivenWithoutValue } from "../lib/args.js";
9
9
  import { c } from "../lib/colors.js";
10
10
  import { suggest } from "../lib/suggest.js";
11
11
  import { EXIT, PreconditionError } from "../lib/exit-codes.js";
@@ -29,7 +29,7 @@ const CLI_ENTRY = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..
29
29
 
30
30
  export const flags = {
31
31
  boolean: ["json", "no-git", "remove"],
32
- string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "hook"],
32
+ string: ["name", "mode", "role", "about", "depends-on", "agent", "path", "server", "access", "for", "purpose", "only", "hook"],
33
33
  };
34
34
 
35
35
  export async function run(positional, flags) {
@@ -290,11 +290,18 @@ function workspaceTool(wsDir, name, flags) {
290
290
  console.log(c.green("removed") + ` tool ${name}`);
291
291
  return EXIT.OK;
292
292
  }
293
+ // `--only` with nothing after it is not "no list": taking it for one
294
+ // removed the list a strict tool had, with exit 0 (change 0250).
295
+ if (flagGivenWithoutValue(flags, "only")) {
296
+ console.error(c.red("error:") + " --only needs the calls it allows, e.g. --only \"getJiraIssue,search*\" — or --only none to take the list away");
297
+ return EXIT.USAGE;
298
+ }
293
299
  let entry;
294
300
  try {
295
301
  entry = toolEntry(name, {
296
302
  server: flagString(flags, "server"), access: flagString(flags, "access"),
297
303
  for: flagString(flags, "for"), purpose: flagString(flags, "purpose"),
304
+ only: flags.has("only") ? flagString(flags, "only") ?? "" : undefined,
298
305
  }, at >= 0 ? ws.tools[at] : null);
299
306
  } catch (err) {
300
307
  console.error(c.red("error:") + ` ${err.message}`);
@@ -304,6 +311,7 @@ function workspaceTool(wsDir, name, flags) {
304
311
  else ws.tools.push(entry);
305
312
  writeWorkspace(wsDir, ws);
306
313
  console.log(c.green(at >= 0 ? "updated" : "declared") + ` ${entry.name} → MCP server "${entry.server}", ${entry.access === "write" ? "read/write (each write asks the human)" : "read-only"}, for ${(entry.for ?? []).join(", ") || "the planner"}`);
314
+ if (entry.only) console.log(c.gray(`only these calls pass through "${entry.server}": ${entry.only.join(", ")} — every other call is denied`));
307
315
  console.log(c.gray(`the server itself is configured in your agent (Cursor: .cursor/mcp.json; Claude Code: .mcp.json) under the name "${entry.server}"`));
308
316
  // Said now, not only at the next validate (change 0235): a key that matches
309
317
  // no configuration is a guard that never fires.
@@ -350,6 +358,7 @@ function workspaceList(wsDir) {
350
358
  console.log(c.bold("Tools"));
351
359
  for (const t of ws.tools) {
352
360
  console.log(` ${c.cyan(t.name.padEnd(12))} ${t.server.padEnd(12)} ${t.access.padEnd(6)} ${(t.for ?? []).join(", ").padEnd(16)} ${c.gray(t.purpose ?? "")}`);
361
+ if (t.only?.length) console.log(c.gray(` ${"".padEnd(12)} only: ${t.only.join(", ")}`));
353
362
  }
354
363
  }
355
364
  // The orchestrator files carry what changes without a workspace write — a
@@ -394,12 +403,16 @@ artifacts of repositories that cannot carry .doctrina/ themselves.
394
403
  files when a project's state or workspace.json changed since.
395
404
 
396
405
  tool <name> [--server <mcp-name>] [--access read|write] [--for <roles>]
397
- [--purpose "<what for>"] [--remove]
406
+ [--purpose "<what for>"] [--only "<calls>"] [--remove]
398
407
  Declare (or update, or --remove) a tool the work uses. Presets:
399
408
  jira, confluence, figma, playwright, datadog, databricks, sql.
400
409
  Roles: planner, executor, qa, observer, reviewer. Access defaults
401
410
  to read: the guard refuses a write through a read-only tool and
402
411
  makes a declared write wait for the human.
412
+ --only "getJiraIssue,search*" lists the calls the tool may make,
413
+ by MCP tool name (\`*\` for any run of characters): every other
414
+ call through its server is denied, instead of being judged by the
415
+ verb in its name. --only none takes the list away.
403
416
 
404
417
  guard --hook cursor|claude
405
418
  The pre-call hook the workspace installs (Cursor beforeMCPExecution,
package/src/index.js CHANGED
@@ -34,6 +34,8 @@ const COMMANDS = {
34
34
  context: () => import("./commands/context.js"),
35
35
  search: () => import("./commands/search.js"),
36
36
  intake: () => import("./commands/intake.js"),
37
+ onboard: () => import("./commands/onboard.js"),
38
+ ui: () => import("./commands/ui.js"),
37
39
  work: () => import("./commands/work.js"),
38
40
  coverage: () => import("./commands/coverage.js"),
39
41
  verify: () => import("./commands/verify.js"),
@@ -110,6 +112,13 @@ async function main(argv) {
110
112
  }
111
113
 
112
114
  if (positional.length === 0) {
115
+ // In a terminal, `doctrina` alone opens the console (change 0257): a
116
+ // person who typed the name and nothing else is asking where to start.
117
+ // A pipe, a script or an agent still gets the help it always got.
118
+ if (argv.length === 0 && process.stdin.isTTY && process.stdout.isTTY) {
119
+ const ui = await COMMANDS.ui();
120
+ return ui.run([], new Map());
121
+ }
113
122
  process.stdout.write(TOP_HELP);
114
123
  return 0;
115
124
  }
@@ -1,5 +1,6 @@
1
1
  // @ts-check
2
2
  import path from "node:path";
3
+ import { conflictedArtifacts } from "./conflict-markers.js";
3
4
  import { readdirSync } from "node:fs";
4
5
  import { isDir, isFile, read, relPath, walk } from "./fs-ops.js";
5
6
  import * as idx from "./index-json.js";
@@ -98,6 +99,24 @@ export function computeActions(projectRoot, gates = {}) {
98
99
  /** @type {Action[]} */
99
100
  const actions = [];
100
101
 
102
+ // A MERGE THAT STOPPED INSIDE AN ARTIFACT comes before everything (change
103
+ // 0250): every action below is computed from files that hold both halves
104
+ // of a conflict, and the next thing an agent does with a spec is read it.
105
+ try {
106
+ const conflicted = conflictedArtifacts(projectRoot);
107
+ if (conflicted.length > 0) {
108
+ const first = path.relative(projectRoot, conflicted[0].file).split(path.sep).join("/");
109
+ actions.push(action({
110
+ id: "merge-conflict",
111
+ command: "validate",
112
+ gate: "structure",
113
+ runnable: false,
114
+ why: `${conflicted.length} artifact${conflicted.length === 1 ? " carries" : "s carry"} git's merge conflict markers (first: ${first}:${conflicted[0].line})`,
115
+ text: `resolve the merge conflict in ${first} (line ${conflicted[0].line})${conflicted.length > 1 ? ` and ${conflicted.length - 1} more` : ""} — keep what both branches meant and delete the marker lines; \`doctrina validate\` names each file`,
116
+ }));
117
+ }
118
+ } catch { /* an unreadable tree is reported by the checks below */ }
119
+
101
120
  // RUNTIME FIRST (change 0029). A declared wiring that does not hold is
102
121
  // not a queue item — it is the reason the last run lied. After a job goes
103
122
  // green having executed nothing, "open a change on the observability
@@ -148,9 +148,12 @@ export const RETIRED_ADAPTER_FILES = Object.freeze({
148
148
  * @param {Record<string, string>} tokens as `adapter add` renders with
149
149
  * @param {string} [root] where the files live: the shadow folder of a shadowed repository
150
150
  */
151
+ // The core loop an adapter installs as slash commands or skills.
152
+ const CORE_LOOP_FILE = /(^|\/)doctrina-[a-z-]+(\.md|\/SKILL\.md)$/;
153
+
151
154
  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: [] };
155
+ /** @type {{ writes: Array<{ relativePath: string, content: string }>, removes: string[], kept: string[], added: Array<{ relativePath: string, content: string }> }} */
156
+ const plan = { writes: [], removes: [], kept: [], added: [] };
154
157
  const norm = (/** @type {string} */ s) => s.replace(/\r\n/g, "\n").trim();
155
158
  for (const [name, list] of Object.entries(RETIRED_ADAPTER_FILES)) {
156
159
  const spec = adapterFiles(projectRoot, name);
@@ -165,6 +168,23 @@ export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
165
168
  (norm(read(path.join(root, retired))) === norm(body) ? plan.removes : plan.kept).push(retired);
166
169
  }
167
170
  }
171
+ // A family the adapter installed gains the member a newer CLI ships
172
+ // (change 0256). `/doctrina-onboard` reached only the projects that ran
173
+ // `adapter add` again: the upgrade compared nothing but retired files. An
174
+ // adapter is read as installed by its own core-loop files, so one the
175
+ // project never added is never written. Not in a shadow, where every
176
+ // file is also a link the workspace owns.
177
+ if (root === projectRoot) {
178
+ for (const name of listAdapterNames(projectRoot)) {
179
+ const family = (adapterFiles(projectRoot, name)?.files ?? [])
180
+ .filter((f) => CORE_LOOP_FILE.test(f.relativePath));
181
+ if (!family.some((f) => isFile(path.join(root, f.relativePath)))) continue;
182
+ for (const f of family) {
183
+ if (isFile(path.join(root, f.relativePath)) || plan.writes.some((w) => w.relativePath === f.relativePath)) continue;
184
+ plan.added.push({ relativePath: f.relativePath, content: renderAdapterFile(f, tokens) });
185
+ }
186
+ }
187
+ }
168
188
  return plan;
169
189
  }
170
190
 
@@ -175,7 +195,7 @@ export function planAdapterMigration(projectRoot, tokens, root = projectRoot) {
175
195
  * @returns {Array<{ rel: string, content: string }>}
176
196
  */
177
197
  export function applyAdapterMigration(root, plan) {
178
- for (const w of plan.writes) {
198
+ for (const w of [...plan.writes, ...(plan.added ?? [])]) {
179
199
  mkdirp(path.dirname(path.join(root, w.relativePath)));
180
200
  write(path.join(root, w.relativePath), w.content, { force: true });
181
201
  }