@canonical/pragma-cli 0.35.0 → 0.36.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 (230) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +16 -15
  13. package/dist/pragma.conf.js +236 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  18. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  19. package/dist/src/capabilities/create/mount.js +4 -3
  20. package/dist/src/capabilities/create/mount.js.map +1 -1
  21. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  22. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  24. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  27. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  28. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  30. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  31. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  32. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  33. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  34. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  35. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  36. package/dist/src/capabilities/graph/query.render.js +2 -2
  37. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  38. package/dist/src/capabilities/info/collectInfo.js +8 -2
  39. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  40. package/dist/src/capabilities/info/info.render.js +6 -2
  41. package/dist/src/capabilities/info/info.render.js.map +1 -1
  42. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  43. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  44. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  45. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  46. package/dist/src/capabilities/setup/mode.js +2 -2
  47. package/dist/src/capabilities/setup/operations/index.js +4 -4
  48. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/setupGenerator.js +92 -45
  50. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupLsp.js +88 -36
  52. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupMcp.js +97 -51
  54. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupSkills.js +325 -47
  56. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  57. package/dist/src/capabilities/setup/plan.js.map +1 -1
  58. package/dist/src/capabilities/setup/plan.render.js +144 -66
  59. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  60. package/dist/src/capabilities/setup/setup.render.js +1 -1
  61. package/dist/src/capabilities/setup/setup.verb.js +86 -33
  62. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  63. package/dist/src/capabilities/setup/targets.js +110 -45
  64. package/dist/src/capabilities/setup/targets.js.map +1 -1
  65. package/dist/src/capabilities/shared/index.js +6 -5
  66. package/dist/src/capabilities/shared/index.js.map +1 -1
  67. package/dist/src/capabilities/shared/packageManager.js +300 -54
  68. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  69. package/dist/src/capabilities/skill/discover.js +97 -11
  70. package/dist/src/capabilities/skill/discover.js.map +1 -1
  71. package/dist/src/capabilities/skill/render.js +2 -2
  72. package/dist/src/capabilities/skill/render.js.map +1 -1
  73. package/dist/src/capabilities/sources/installSkills.js +145 -3
  74. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  75. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  76. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  77. package/dist/src/capabilities/sources/update.verb.js +3 -3
  78. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  79. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  80. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  81. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  82. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  83. package/dist/src/constants.js +6 -1
  84. package/dist/src/constants.js.map +1 -1
  85. package/dist/src/kernel/index.js +11 -2
  86. package/dist/src/kernel/index.js.map +1 -1
  87. package/dist/src/kernel/interactivity.js +68 -0
  88. package/dist/src/kernel/interactivity.js.map +1 -0
  89. package/dist/src/kernel/packs/renderPack.js +3 -3
  90. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  91. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  92. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  93. package/dist/src/kernel/packs/runBodies.js +57 -4
  94. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  95. package/dist/src/kernel/packs/schema.js +54 -2
  96. package/dist/src/kernel/packs/schema.js.map +1 -1
  97. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  98. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +306 -43
  100. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  101. package/dist/src/kernel/packs/types.js.map +1 -1
  102. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  103. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  104. package/dist/src/kernel/project/cli/dispatch.js +23 -23
  105. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  106. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  107. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  108. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  109. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  110. package/dist/src/kernel/project/cli/rootHelp.js +34 -4
  111. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  112. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  113. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  114. package/dist/src/kernel/render/renderers.js +1 -1
  115. package/dist/src/kernel/render/style.js +29 -9
  116. package/dist/src/kernel/render/style.js.map +1 -1
  117. package/dist/src/kernel/render/vocabulary.js +174 -0
  118. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  119. package/dist/src/kernel/spec/emitReference.js +1 -1
  120. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  121. package/package.json +16 -15
  122. package/pragma.conf.ts +241 -34
  123. package/src/bin.ts +8 -1
  124. package/src/capabilities/block.parity.test.ts +25 -11
  125. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  126. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  127. package/src/capabilities/colophon/colophon.render.ts +1 -2
  128. package/src/capabilities/colophon/colophon.test.ts +2 -2
  129. package/src/capabilities/create/createGrammar.test.ts +1 -1
  130. package/src/capabilities/create/mount.ts +3 -3
  131. package/src/capabilities/distribution.parity.test.ts +4 -0
  132. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  133. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  134. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  135. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  136. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  137. package/src/capabilities/doctor/checks/index.ts +6 -6
  138. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  139. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  140. package/src/capabilities/doctor/doctor.render.ts +56 -80
  141. package/src/capabilities/doctor/doctor.test.ts +247 -35
  142. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  143. package/src/capabilities/doctor/runChecks.ts +14 -11
  144. package/src/capabilities/doctor/types.ts +15 -24
  145. package/src/capabilities/emptyRecovery.test.ts +10 -2
  146. package/src/capabilities/graph/query.render.ts +2 -2
  147. package/src/capabilities/info/collectInfo.ts +8 -1
  148. package/src/capabilities/info/info.render.ts +6 -2
  149. package/src/capabilities/info/info.test.ts +37 -1
  150. package/src/capabilities/info/types.ts +10 -2
  151. package/src/capabilities/prompt/prompt.render.ts +2 -2
  152. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  153. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  154. package/src/capabilities/setup/buildPlan.ts +45 -41
  155. package/src/capabilities/setup/index.ts +1 -1
  156. package/src/capabilities/setup/mode.ts +2 -2
  157. package/src/capabilities/setup/operations/index.ts +6 -1
  158. package/src/capabilities/setup/operations/setupGenerator.ts +144 -70
  159. package/src/capabilities/setup/operations/setupLsp.ts +129 -37
  160. package/src/capabilities/setup/operations/setupMcp.ts +132 -61
  161. package/src/capabilities/setup/operations/setupSkills.ts +392 -58
  162. package/src/capabilities/setup/plan.render.ts +171 -76
  163. package/src/capabilities/setup/plan.ts +17 -12
  164. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  165. package/src/capabilities/setup/setup.render.test.ts +50 -36
  166. package/src/capabilities/setup/setup.render.ts +1 -1
  167. package/src/capabilities/setup/setup.test.ts +881 -50
  168. package/src/capabilities/setup/setup.verb.ts +91 -33
  169. package/src/capabilities/setup/targets.ts +120 -52
  170. package/src/capabilities/setup/types.ts +10 -18
  171. package/src/capabilities/shared/index.ts +17 -6
  172. package/src/capabilities/shared/packageManager.test.ts +421 -6
  173. package/src/capabilities/shared/packageManager.ts +394 -59
  174. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  175. package/src/capabilities/skill/discover.ts +100 -11
  176. package/src/capabilities/skill/render.ts +2 -2
  177. package/src/capabilities/skill/skill.test.ts +17 -4
  178. package/src/capabilities/sources/installSkills.ts +181 -3
  179. package/src/capabilities/sources/runUpdate.ts +78 -2
  180. package/src/capabilities/sources/sources.test.ts +228 -0
  181. package/src/capabilities/sources/update.verb.ts +3 -3
  182. package/src/capabilities/surface.test.ts +2 -2
  183. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  184. package/src/capabilities/upgrade/types.ts +10 -2
  185. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  186. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  187. package/src/constants.ts +6 -1
  188. package/src/identity.test.ts +18 -5
  189. package/src/kernel/completion/entitySource.test.ts +15 -2
  190. package/src/kernel/completion/safety.test.ts +16 -9
  191. package/src/kernel/config/defaults.test.ts +2 -1
  192. package/src/kernel/index.ts +11 -2
  193. package/src/kernel/interactivity.test.ts +76 -0
  194. package/src/kernel/interactivity.ts +69 -0
  195. package/src/kernel/packs/compile.test.ts +92 -0
  196. package/src/kernel/packs/renderPack.ts +3 -3
  197. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  198. package/src/kernel/packs/resolveEntity.ts +88 -24
  199. package/src/kernel/packs/runBodies.ts +84 -19
  200. package/src/kernel/packs/schema.ts +60 -4
  201. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  202. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  203. package/src/kernel/packs/sparql/buildLookupQuery.ts +323 -45
  204. package/src/kernel/packs/toolDescription.test.ts +35 -1
  205. package/src/kernel/packs/types.ts +125 -2
  206. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  207. package/src/kernel/project/cli/buildProgram.ts +8 -1
  208. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  209. package/src/kernel/project/cli/dispatch.ts +25 -24
  210. package/src/kernel/project/cli/globalFlags.ts +4 -1
  211. package/src/kernel/project/cli/helpFormat.ts +1 -2
  212. package/src/kernel/project/cli/rootHelp.ts +36 -3
  213. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  214. package/src/kernel/render/renderers.ts +1 -1
  215. package/src/kernel/render/style.ts +32 -9
  216. package/src/kernel/render/vocabulary.ts +276 -0
  217. package/src/kernel/spec/emitReference.ts +1 -1
  218. package/src/kernel/spec/types.ts +35 -6
  219. package/src/testing/PARITY_GAPS.ts +9 -3
  220. package/src/testing/behavioral/docExamples.test.ts +4 -2
  221. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  222. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  223. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  224. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  225. package/src/testing/eval/cases/readNouns.ts +245 -9
  226. package/src/testing/fixtures/blockGraph.ts +28 -13
  227. package/src/testing/fixtures/graph/canonical.ts +95 -12
  228. package/dist/src/capabilities/shared/bands.js +0 -16
  229. package/dist/src/capabilities/shared/bands.js.map +0 -1
  230. package/src/capabilities/shared/bands.ts +0 -16
@@ -26,9 +26,9 @@ import {
26
26
  writeFileSync,
27
27
  } from "node:fs";
28
28
  import { tmpdir } from "node:os";
29
- import { join, relative } from "node:path";
29
+ import { join, relative, sep } from "node:path";
30
30
  import { execute } from "@canonical/summon-core";
31
- import { dryRun, type Effect, type Task } from "@canonical/task";
31
+ import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
32
32
  import { runTask, runUndo } from "@canonical/task/node";
33
33
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
34
  import { BIN_NAME } from "../../constants.js";
@@ -42,13 +42,16 @@ import { projectCli } from "../../testing/helpers/projectCli.js";
42
42
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
43
43
  import { capabilities } from "../index.js";
44
44
  import { detectCompletions } from "./operations/setupCompletions.js";
45
- import { buildSetupRun } from "./operations/setupGenerator.js";
45
+ import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
46
46
  import {
47
47
  composeSkills,
48
48
  composeSkillsRemoval,
49
49
  detectSkills,
50
50
  ownedSkillLinks,
51
+ skillsSkipReason,
52
+ skillsSkipRemedy,
51
53
  } from "./operations/setupSkills.js";
54
+ import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
52
55
  import { setupModule } from "./setup.verb.js";
53
56
  import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
54
57
 
@@ -204,7 +207,7 @@ describe("setup completions", () => {
204
207
  // The dry-run renders the PLAN through the verb's formatPlan seam, not the
205
208
  // kernel's raw effect dump: one row, the shell it detected, and the script
206
209
  // path rendered against the header's roots instead of repeated absolutely.
207
- expect(outcome.stdout).toContain("Setup plan — global band");
210
+ expect(outcome.stdout).toContain("Setup plan — global");
208
211
  expect(outcome.stdout).toMatch(
209
212
  new RegExp(`completions\\s+install\\s+${SHELL} →`),
210
213
  );
@@ -249,7 +252,7 @@ describe("setup mcp — recap gate", () => {
249
252
  mkdirSync(join(cwd, ".cursor"), { recursive: true }); // makes Cursor detected
250
253
  const configPath = join(cwd, ".cursor", "mcp.json");
251
254
 
252
- // `--local`: the project band is opt-in now, so a bare `setup mcp` would
255
+ // `--local`: the project scope is opt-in now, so a bare `setup mcp` would
253
256
  // write the HOME config and leave this repository untouched.
254
257
  const outcome = await executeVerb(
255
258
  verbOf("mcp"),
@@ -405,7 +408,7 @@ describe("setup mcp — scope & dedup", () => {
405
408
  // A per-user server must not be pinned to whatever directory `setup mcp
406
409
  // --global` happened to run from (a registration made from ~/Downloads
407
410
  // used to serve ~/Downloads forever, and re-running from repo B flipped
408
- // the machine band to repo B).
411
+ // the machine scope to repo B).
409
412
  const home = process.env.HOME ?? "";
410
413
  const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
411
414
  const register = async (cwd: string): Promise<string> => {
@@ -461,9 +464,9 @@ describe("setup mcp — scope & dedup", () => {
461
464
  expect(
462
465
  JSON.parse(readFileSync(configPath, "utf-8")).mcpServers.pragma,
463
466
  ).toEqual({ command: "pragma", args: ["mcp", "serve"] });
464
- // ...and it stays `configured` afterwards (no churn on every run).
467
+ // ...and it stays `registered` afterwards (no churn on every run).
465
468
  const after = await detectMcp(bootRuntime(FLAGS, cwd), "global");
466
- expect(mcpGroupState(after, configPath)).toBe("configured");
469
+ expect(mcpGroupState(after, configPath)).toBe("registered");
467
470
  });
468
471
 
469
472
  it("a PROJECT entry still records the project root as cwd", async () => {
@@ -482,25 +485,32 @@ describe("setup mcp — scope & dedup", () => {
482
485
  });
483
486
  });
484
487
 
485
- describe("setup mcp — customize opt-in gate (Item 6)", () => {
486
- it("gates the per-file multiselect behind an explicit customize=true", async () => {
488
+ describe("setup mcp — the per-file question, asked directly", () => {
489
+ it("asks the per-file multiselect with NO `customize` meta-question in front", async () => {
490
+ // `answers.customize` was read in exactly one place in the repo — this
491
+ // prompt's `when` — and by nothing else: not `generate`, not `applied`, not
492
+ // any plan, recap, renderer or JSON projection. Answering no just meant the
493
+ // child prompt went unasked, its key was absent, and the compose bodies
494
+ // read that as "all" — so everything detected was configured anyway.
487
495
  const cwd = tmp("pragma-setup-proj-");
488
- mkdirSync(join(cwd, ".cursor"), { recursive: true }); // ⇒ one MCP target group
496
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
497
+ mkdirSync(join(cwd, ".gemini"), { recursive: true }); // 2 children ⇒ narrowable
489
498
  const { generator } = await buildSetupRun(
490
499
  bootRuntime(FLAGS, cwd),
491
500
  "mcp",
492
- "both",
501
+ "project",
493
502
  );
494
- const customize = generator.prompts.find((p) => p.name === "customize");
503
+ expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
495
504
  const targets = generator.prompts.find((p) => p.name === "mcpTargets");
496
- // The gate is an opt-in confirm that defaults to NOT customizing.
497
- expect(customize?.type).toBe("confirm");
498
- expect(customize?.default).toBe(false);
499
- // The per-file multiselect only surfaces after an explicit yes — so the
500
- // "all" default configures every deduped file without an extra question.
501
- expect(targets?.when?.({})).toBe(false);
502
- expect(targets?.when?.({ customize: false })).toBe(false);
503
- expect(targets?.when?.({ customize: true })).toBe(true);
505
+ expect(targets).toBeDefined();
506
+ // It is asked whenever its row is in the run — the row's own selection is
507
+ // the only gate, and the choices carry the detected state per file.
508
+ expect(targets?.when?.({})).toBe(true);
509
+ expect(
510
+ (targets?.choices as { label: string }[]).every((c) =>
511
+ /— (add|update|unchanged)$/.test(c.label),
512
+ ),
513
+ ).toBe(true);
504
514
  });
505
515
 
506
516
  it("recaps the files the run KEPT, not the ones it offered", async () => {
@@ -517,7 +527,6 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
517
527
 
518
528
  const applied = run.applied({
519
529
  targets: ["project:mcp"],
520
- customize: true,
521
530
  mcpTargets: [kept],
522
531
  });
523
532
  expect(applied.rows.find((r) => r.target === "mcp")?.outcome?.note).toBe(
@@ -526,6 +535,67 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
526
535
  });
527
536
  });
528
537
 
538
+ describe("setup — live row events (the wizard's step feed)", () => {
539
+ it("brackets each row with start/done, in the recap's own sentence", async () => {
540
+ // The wizard's live progress renders the PLAN's rows, not the effect
541
+ // transcript — one `start` as a row's body is entered, one `done` carrying
542
+ // the same `target detail — note` sentence the recap will print, so the
543
+ // watcher and the recap reader see one dialect.
544
+ const cwd = tmp("pragma-setup-proj-");
545
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
546
+ const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
547
+ const events: RowEvent[] = [];
548
+ run.setRowListener((event) => events.push(event));
549
+ await runTask(run.generator.generate({}), { onLog: () => {} });
550
+ expect(events.map((event) => event.status)).toEqual(["start", "done"]);
551
+ expect(events[0]?.key).toBe("project:mcp");
552
+ // The start label is the row without an outcome; the done label appends
553
+ // the SAME note `applied()` puts on the recap row.
554
+ const applied = run.applied({});
555
+ const note = applied.rows.find((r) => r.target === "mcp")?.outcome?.note;
556
+ expect(note).toBe("1 added");
557
+ expect(events[1]?.label).toBe(`${events[0]?.label} — ${note}`);
558
+ });
559
+
560
+ it("no listener registered costs nothing — the run is unchanged", async () => {
561
+ // `--yes`, `--dry-run` and MCP never register one; the bracket emits into
562
+ // the void and the composed effects are identical.
563
+ const cwd = tmp("pragma-setup-proj-");
564
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
565
+ const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
566
+ await runTask(run.generator.generate({}), { onLog: () => {} });
567
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
568
+ });
569
+
570
+ it("a failing row reports `failed` with its cause, and its siblings still bracket", async () => {
571
+ // Same arrangement as the S1-1 isolation test: an editor on PATH with no
572
+ // `bun`, so the LSP row's compose fails. The event stream must name the
573
+ // failure — the wizard paints that row ✗ live — while the run continues.
574
+ const prevPath = process.env.PATH;
575
+ const prevData = process.env.XDG_DATA_HOME;
576
+ process.env.XDG_DATA_HOME = tmp("pragma-rowevents-data-");
577
+ const dir = stubPath();
578
+ writeFileSync(join(dir, "code"), "");
579
+ process.env.PATH = dir;
580
+ try {
581
+ const run = await buildSetupRun(
582
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
583
+ "lsp",
584
+ "global",
585
+ );
586
+ const events: RowEvent[] = [];
587
+ run.setRowListener((event) => events.push(event));
588
+ await runTask(run.generator.generate({}), { onLog: () => {} });
589
+ expect(events.map((event) => event.status)).toEqual(["start", "failed"]);
590
+ expect(events[1]?.label).toContain("bun");
591
+ } finally {
592
+ process.env.PATH = prevPath;
593
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
594
+ else process.env.XDG_DATA_HOME = prevData;
595
+ }
596
+ });
597
+ });
598
+
529
599
  describe("setup lsp — per-editor multiselect (child rows)", () => {
530
600
  let prevPath: string | undefined;
531
601
  let stubDir = "";
@@ -573,20 +643,18 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
573
643
  expect(children?.find((c) => c.key === "codium")?.action).toBe("add");
574
644
  });
575
645
 
576
- it("gates the per-editor multiselect behind the same customize confirm", async () => {
646
+ it("asks the per-editor multiselect directly, with both forks pre-selected", async () => {
577
647
  const { generator } = await buildSetupRun(
578
648
  bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
579
649
  "lsp",
580
650
  "global",
581
651
  );
582
- const customize = generator.prompts.find((p) => p.name === "customize");
652
+ expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
583
653
  const editors = generator.prompts.find((p) => p.name === "lspEditors");
584
- expect(customize?.default).toBe(false);
585
- // Both pending editors are pre-selected, so the default path installs into
586
- // every fork without an extra question.
654
+ // Both pending editors are pre-selected, so pressing enter installs into
655
+ // every fork — the same default the meta-question used to stand for.
587
656
  expect(editors?.default).toEqual(["code", "codium"]);
588
- expect(editors?.when?.({ customize: false })).toBe(false);
589
- expect(editors?.when?.({ customize: true })).toBe(true);
657
+ expect(editors?.when?.({})).toBe(true);
590
658
  });
591
659
 
592
660
  it("sideloads only into the editors the user kept", async () => {
@@ -608,6 +676,202 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
608
676
  const { effects } = dryRun(composeLsp(detected, []));
609
677
  expect(effects.some((e) => e._tag === "Exec")).toBe(false);
610
678
  });
679
+
680
+ it("the removal is `none` when no editor carries the extension", async () => {
681
+ // Both forks are on PATH, neither has a copy — there is nothing this
682
+ // command owns, so a removal has honestly nothing to do.
683
+ const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
684
+ "./operations/setupLsp.js"
685
+ );
686
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
687
+ expect(ownedLspEditors(detected)).toEqual([]);
688
+ expect(collectUndos(composeLspRemoval(detected))).toEqual([]);
689
+
690
+ const { plan } = await buildSetupRun(
691
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
692
+ "lsp",
693
+ "global",
694
+ true,
695
+ );
696
+ const row = plan.rows.find((r) => r.target === "lsp");
697
+ expect(row?.action).toBe("none");
698
+ expect(row?.detail).toBe("no editor carries the extension");
699
+ });
700
+ });
701
+
702
+ describe("setup lsp — the removal contract the other four rows implement", () => {
703
+ let prevPath: string | undefined;
704
+ let stubDir = "";
705
+ beforeEach(() => {
706
+ prevPath = process.env.PATH;
707
+ stubDir = stubPath();
708
+ writeFileSync(join(stubDir, "code"), "");
709
+ writeFileSync(join(stubDir, "codium"), "");
710
+ process.env.PATH = stubDir;
711
+ });
712
+ afterEach(() => {
713
+ process.env.PATH = prevPath;
714
+ });
715
+
716
+ /** Seed a versioned extension copy in an editor's extensions dir. */
717
+ const seedExtension = (dir: string, version: string): void => {
718
+ mkdirSync(
719
+ join(
720
+ process.env.HOME ?? "",
721
+ dir,
722
+ "extensions",
723
+ `canonical.terrazzo-lsp-extension-${version}`,
724
+ ),
725
+ { recursive: true },
726
+ );
727
+ };
728
+
729
+ it("carries one `--uninstall-extension` exec per OWNED editor as its undo", async () => {
730
+ // The row hardcoded a skip behind a docblock claiming "an `exec` carries no
731
+ // reversal" — factually wrong: `Exec` has an `undo` slot, and the sibling
732
+ // `composeMcpRemoval` already uses this exact carrier shape.
733
+ seedExtension(".vscode", "1.2.3");
734
+ const { detectLsp, composeLspRemoval } = await import(
735
+ "./operations/setupLsp.js"
736
+ );
737
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
738
+
739
+ // The FORWARD side must be walkable with every effect mocked: it is a
740
+ // `mkdir` of the editor's own extensions dir, which reads nothing.
741
+ const forward = dryRun(composeLspRemoval(detected));
742
+ expect(forward.effects.map((e) => e._tag)).toEqual(["MakeDir"]);
743
+ expect(forward.effects.some((e) => e._tag === "Exec")).toBe(false);
744
+
745
+ const undos = collectUndos(composeLspRemoval(detected));
746
+ expect(undos).toHaveLength(1);
747
+ const execs = undos
748
+ .flatMap((u) => dryRun(u).effects)
749
+ .filter((e) => e._tag === "Exec") as (Effect & {
750
+ _tag: "Exec";
751
+ command: string;
752
+ args: string[];
753
+ })[];
754
+ expect(execs.map((e) => e.command)).toEqual(["code"]);
755
+ expect(execs[0].args).toEqual([
756
+ "--uninstall-extension",
757
+ "canonical.terrazzo-lsp-extension",
758
+ ]);
759
+ });
760
+
761
+ it("REGRESSION: a pre-0.8.3 copy still plans `install` forward but `remove` back", async () => {
762
+ // The conflation that would have caused a silent wrong-skip.
763
+ // `extensionInstalled` was version-gated, so an editor carrying a dead
764
+ // pre-0.8.3 copy — one this command itself installed — reported
765
+ // `installed: false`, and a removal keyed on it would have walked past the
766
+ // dead extension while reporting success.
767
+ seedExtension(".vscode", "0.8.1");
768
+ const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
769
+ "./operations/setupLsp.js"
770
+ );
771
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
772
+ const code = detected.editors.find((e) => e.editor.cli === "code");
773
+ // Present, but not current — the two facts stay separate.
774
+ expect(code?.present).toBe(true);
775
+ expect(code?.installed).toBe(false);
776
+
777
+ // Forward is UNCHANGED by this fix: the dead copy is still "not installed".
778
+ const forward = await buildSetupRun(
779
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
780
+ "lsp",
781
+ "global",
782
+ );
783
+ expect(forward.plan.rows.find((r) => r.target === "lsp")?.action).toBe(
784
+ "install",
785
+ );
786
+
787
+ // Removal keys on ownership, so the dead copy IS removed.
788
+ expect(ownedLspEditors(detected).map((e) => e.editor.cli)).toEqual([
789
+ "code",
790
+ ]);
791
+ const { plan } = await buildSetupRun(
792
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
793
+ "lsp",
794
+ "global",
795
+ true,
796
+ );
797
+ const row = plan.rows.find((r) => r.target === "lsp");
798
+ expect(row?.action).toBe("remove");
799
+ expect(row?.children?.map((c) => c.key)).toEqual(["code"]);
800
+ expect(
801
+ collectUndos(composeLspRemoval(detected))
802
+ .flatMap((u) => dryRun(u).effects)
803
+ .filter((e) => e._tag === "Exec"),
804
+ ).toHaveLength(1);
805
+ });
806
+
807
+ it("the remedy names an OWNING editor, not the first one on PATH", async () => {
808
+ // With Cursor (or here: `code`) installed and the extension only in
809
+ // VSCodium, the printed remedy named the wrong editor — a command that
810
+ // would report success having uninstalled nothing.
811
+ seedExtension(".vscode-oss", "1.2.3"); // codium's extensions dir
812
+ const { detectLsp, lspUninstallRemedy } = await import(
813
+ "./operations/setupLsp.js"
814
+ );
815
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
816
+ expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
817
+ expect(lspUninstallRemedy(detected)).toBe(
818
+ "codium --uninstall-extension canonical.terrazzo-lsp-extension",
819
+ );
820
+ });
821
+
822
+ it("still SKIPS with the named reason when no editor CLI is on PATH", async () => {
823
+ process.env.PATH = stubPath(); // no editor CLI at all
824
+ const { plan } = await buildSetupRun(
825
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
826
+ "lsp",
827
+ "global",
828
+ true,
829
+ );
830
+ const row = plan.rows.find((r) => r.target === "lsp");
831
+ expect(row?.action).toBe("skip");
832
+ expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
833
+ });
834
+
835
+ it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
836
+ // The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
837
+ // marker-writing script — never a real editor: an undo test must not be
838
+ // able to mutate the machine running it.
839
+ const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
840
+ const dir = stubPath();
841
+ writeFileSync(
842
+ join(dir, "code"),
843
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\n`,
844
+ { mode: 0o755 },
845
+ );
846
+ process.env.PATH = dir;
847
+ seedExtension(".vscode", "1.2.3");
848
+
849
+ const outcome = await executeVerb(
850
+ verbOf("lsp"),
851
+ {},
852
+ UNDO,
853
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
854
+ );
855
+ expect(outcome.exitCode).toBe(0);
856
+ expect(outcome.stdout).toBe("Undid 1 step(s).\n");
857
+ expect(readFileSync(marker, "utf-8")).toBe(
858
+ "--uninstall-extension\ncanonical.terrazzo-lsp-extension\n",
859
+ );
860
+ // NON-GOAL guard: the staging dir is a cache, not this removal's business.
861
+ // Nothing here deletes it, and nothing here touches the VSIX.
862
+ const { detectLsp, composeLspRemoval } = await import(
863
+ "./operations/setupLsp.js"
864
+ );
865
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
866
+ const all = [
867
+ ...dryRun(composeLspRemoval(detected)).effects,
868
+ ...collectUndos(composeLspRemoval(detected)).flatMap(
869
+ (u) => dryRun(u).effects,
870
+ ),
871
+ ];
872
+ expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
873
+ expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
874
+ });
611
875
  });
612
876
 
613
877
  describe("setup (run-all wizard) — scope threading", () => {
@@ -630,7 +894,7 @@ describe("setup (run-all wizard) — scope threading", () => {
630
894
  writeFileSync(join(editorPath, "code"), "");
631
895
  };
632
896
 
633
- /** Seed a discoverable skill so the project-band skills step is offerable. */
897
+ /** Seed a discoverable skill so the project-scope skills step is offerable. */
634
898
  const seedSkill = (cwd: string): void => {
635
899
  const dir = join(cwd, ".pragma", "skills", "s");
636
900
  mkdirSync(dir, { recursive: true });
@@ -640,10 +904,10 @@ describe("setup (run-all wizard) — scope threading", () => {
640
904
  );
641
905
  };
642
906
 
643
- it("--local omits the global-band completions + lsp steps, keeping project steps", async () => {
907
+ it("--local omits the global-scope completions + lsp steps, keeping project steps", async () => {
644
908
  const cwd = tmp("pragma-setup-proj-");
645
- mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-band MCP target
646
- seedSkill(cwd); // project-band skills step
909
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-scope MCP target
910
+ seedSkill(cwd); // project-scope skills step
647
911
  const outcome = await executeVerb(
648
912
  setupSelfVerb,
649
913
  { local: true },
@@ -652,17 +916,19 @@ describe("setup (run-all wizard) — scope threading", () => {
652
916
  );
653
917
  expect(outcome.exitCode).toBe(0);
654
918
  const plan = outcome.stdout ?? "";
655
- // A band FILTERS the run-all: the out-of-band targets are still named, as
656
- // rows saying why they are not in this run, so nothing is silently absent.
657
- expect(plan).toContain("completions skip");
658
- expect(plan).toContain("user-level only — it has no project band");
659
- // The project-band steps remain: MCP into .cursor, skills into .agents/skills.
919
+ // A scope FILTERS the run-all: the out-of-scope targets are still named, as
920
+ // rows saying why they are not in this run, so nothing is silently absent —
921
+ // and the reason names the flag that WOULD reach them, so the row is not a
922
+ // dead end either.
923
+ expect(plan).toContain("completions nothing to do");
924
+ expect(plan).toContain("this one is global only — run it with `--global`");
925
+ // The per-project steps remain: MCP into .cursor, skills into .agents/skills.
660
926
  expect(plan).toContain("mcp.json");
661
927
  expect(plan).toContain(".agents/skills");
662
928
  });
663
929
 
664
930
  withShell(
665
- "--global omits the project-band skills step, keeping global steps",
931
+ "--global omits the project-scope skills step, keeping global steps",
666
932
  async () => {
667
933
  const cwd = tmp("pragma-setup-proj-");
668
934
  seedSkill(cwd); // WOULD be offered under the default `both`
@@ -675,13 +941,20 @@ describe("setup (run-all wizard) — scope threading", () => {
675
941
  );
676
942
  expect(outcome.exitCode).toBe(0);
677
943
  const plan = outcome.stdout ?? "";
678
- // The global-band steps are present under --global. Paths render against
944
+ // The global-scope steps are present under --global. Paths render against
679
945
  // the header's roots, so the row shows `~/.zfunc/_pragma`, not an absolute
680
946
  // prefix repeated on every line.
681
947
  expect(plan).toMatch(new RegExp(`completions\\s+install\\s+${SHELL} →`));
682
948
  expect(plan).toContain("lsp");
683
- // The project-band skills step is gone (the bug: it used to run under --global).
684
- expect(plan).not.toContain(".agents/skills");
949
+ // The project-scope skills step is gone (the bug: it used to run under
950
+ // --global). `shortenPath` renders a project path as `./…` and a global
951
+ // one as `~/…`, and the two scopes' cross-client directories differ only
952
+ // by that marker — so the covenant is asserted on the PROJECT spelling.
953
+ // A bare "not .agents/skills" no longer separates them: the global scope
954
+ // legitimately links its own `~/.agents/skills` now that the bundled
955
+ // snapshot gives it skills on a machine that has run nothing.
956
+ expect(plan).not.toContain(`.${sep}.agents${sep}skills`);
957
+ expect(plan).toContain(`~${sep}.agents${sep}skills`);
685
958
  },
686
959
  );
687
960
  });
@@ -701,7 +974,101 @@ describe("setup skills", () => {
701
974
  );
702
975
  expect(outcome.exitCode).toBe(0);
703
976
  expect(outcome.stdout).toContain("skills");
704
- expect(outcome.stdout).toContain("no project skills");
977
+ expect(outcome.stdout).toContain("this project holds no skills");
978
+ // The skip is no longer a dead end: it names the action that settles it on
979
+ // THIS machine. The reason does NOT invert — the row still skips.
980
+ expect(outcome.stdout).toContain("SKILL.md, then run this again");
981
+ });
982
+
983
+ it("an EXISTING but empty project skills root is not reported as absent", async () => {
984
+ // `available` is false for an absent root and for an empty one alike, so
985
+ // the shared reason line said "is absent" about a directory the user is
986
+ // looking at. `rootExists` is the flag that already separates those two
987
+ // states everywhere else in the module; the wording now honours it.
988
+ const cwd = tmp("pragma-setup-proj-");
989
+ mkdirSync(join(cwd, ".pragma", "skills"), { recursive: true });
990
+
991
+ const { plan } = await buildSetupRun(
992
+ bootRuntime(FLAGS, cwd),
993
+ "skills",
994
+ "project",
995
+ );
996
+ const row = plan.rows.find((r) => r.target === "skills");
997
+ expect(row?.action).toBe("skip"); // still nothing to do...
998
+ expect(row?.reason).toContain("is empty"); // ...for the honest reason
999
+ expect(row?.reason).not.toContain("does not exist");
1000
+ });
1001
+
1002
+ it("a genuinely MISSING project skills root still reports absent", async () => {
1003
+ // The other half of the distinction: the empty-root wording must not have
1004
+ // been bought by making every skip vague.
1005
+ const { plan } = await buildSetupRun(
1006
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1007
+ "skills",
1008
+ "project",
1009
+ );
1010
+ const row = plan.rows.find((r) => r.target === "skills");
1011
+ expect(row?.action).toBe("skip");
1012
+ expect(row?.reason).toContain("does not exist");
1013
+ });
1014
+
1015
+ it("the GLOBAL scope OFFERS skills on a machine that has run nothing", async () => {
1016
+ // THE BEHAVIOUR CHANGE, asserted at the scope that used to be empty. The
1017
+ // global scope's only source was the installed root, so an empty
1018
+ // `XDG_DATA_HOME` — a fresh install — planned `skip` and told the user to
1019
+ // go and run `sources update` first. The bundled snapshot is the scope's
1020
+ // second root, so the row is actionable with no network and no prior
1021
+ // command. The empty-root WORDING this replaces is still asserted, on the
1022
+ // pure reason/remedy pair, in the cell below.
1023
+ const prevData = process.env.XDG_DATA_HOME;
1024
+ process.env.XDG_DATA_HOME = tmp("pragma-skills-data-");
1025
+ try {
1026
+ const { plan } = await buildSetupRun(
1027
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1028
+ "skills",
1029
+ "global",
1030
+ );
1031
+ const row = plan.rows.find((r) => r.target === "skills");
1032
+ expect(row?.action).toBe("link");
1033
+ expect(row?.detail).toMatch(/\d+ skills? →/);
1034
+
1035
+ // End to end, inside the same jail (an ambient data root that happened to
1036
+ // hold a skill would stop this exercising the fresh-machine condition).
1037
+ const outcome = await executeVerb(
1038
+ verbOf("skills"),
1039
+ {},
1040
+ YES,
1041
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1042
+ );
1043
+ expect(outcome.exitCode).toBe(0);
1044
+ expect(outcome.stdout).not.toContain("no skills installed");
1045
+ } finally {
1046
+ // `process.env.X = undefined` STORES THE STRING "undefined"; it does not
1047
+ // unset. The variable is always set here (`setupXdgIsolation.ts` gives
1048
+ // every test file its own XDG root), so this restores rather than
1049
+ // deletes in practice — but a test must not leave a poisoned path behind
1050
+ // for the files that run after it if that ever stops being true.
1051
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
1052
+ else process.env.XDG_DATA_HOME = prevData;
1053
+ }
1054
+ });
1055
+
1056
+ it("the GLOBAL skip still names `sources update` when it does happen", async () => {
1057
+ // The remedy the cell above used to assert end-to-end. That state is no
1058
+ // longer reachable with a snapshot in the package — it takes a build whose
1059
+ // `bundled-skills/` is missing AND an empty installed root — so it is
1060
+ // asserted where it actually lives: on the pure pair that both the setup row
1061
+ // and the doctor row read, which is what stops the two surfaces wording one
1062
+ // finding differently. A skip with no next step is the defect.
1063
+ expect(
1064
+ skillsSkipReason("~/.local/share/pragma/skills", "global", true),
1065
+ ).toBe(
1066
+ "nothing to link — no skills installed yet; they arrive with the packs " +
1067
+ `\`${BIN_NAME} sources update\` builds`,
1068
+ );
1069
+ expect(
1070
+ skillsSkipRemedy("~/.local/share/pragma/skills", "global"),
1071
+ ).toContain(`${BIN_NAME} sources update`);
705
1072
  });
706
1073
 
707
1074
  it("detects a created action and composes a symlink carrying an undo", async () => {
@@ -727,6 +1094,77 @@ describe("setup skills", () => {
727
1094
  expect(existsSync(join(cwd, ".agents", "skills", "my-skill"))).toBe(false);
728
1095
  });
729
1096
 
1097
+ it("NEVER deletes a user's own symlink that collides with a shipped skill (REGRESSION)", async () => {
1098
+ // Bundled skills ship on every install, so a folder-name collision with a
1099
+ // user's own link is reachable with NO user action — where previously an
1100
+ // empty skill set meant no action was ever composed for that path.
1101
+ // `composeSkills` composes every non-skipped row, so classifying a
1102
+ // resolving foreign link as `replaced` deletes the user's link.
1103
+ const cwd = tmp("pragma-setup-proj-");
1104
+ const skillDir = join(cwd, ".pragma", "skills", "my-skill");
1105
+ mkdirSync(skillDir, { recursive: true });
1106
+ writeFileSync(
1107
+ join(skillDir, "SKILL.md"),
1108
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1109
+ );
1110
+
1111
+ // Somewhere entirely outside every pragma root, and it RESOLVES.
1112
+ const mine = join(cwd, "my-own-work", "my-skill");
1113
+ mkdirSync(mine, { recursive: true });
1114
+ writeFileSync(join(mine, "SKILL.md"), "---\nname: mine\n---\n");
1115
+
1116
+ const linkDir = join(cwd, ".agents", "skills");
1117
+ mkdirSync(linkDir, { recursive: true });
1118
+ const linkPath = join(linkDir, "my-skill");
1119
+ symlinkSync(mine, linkPath);
1120
+
1121
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1122
+ const action = detected.actions.find((a) => a.linkPath === linkPath);
1123
+ expect(action?.action).toBe("skipped");
1124
+ expect(action?.owned).toBe(false);
1125
+
1126
+ // Neither the preview nor the real run may touch it.
1127
+ const { effects } = dryRun(composeSkills(detected));
1128
+ expect(
1129
+ effects.filter(
1130
+ (e) =>
1131
+ e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
1132
+ ),
1133
+ ).toHaveLength(0);
1134
+
1135
+ await runTask(composeSkills(detected));
1136
+ expect(readlinkSync(linkPath)).toBe(mine);
1137
+ expect(existsSync(join(linkPath, "SKILL.md"))).toBe(true);
1138
+ });
1139
+
1140
+ it("sweeps a link into a REMOVED package directory once its skill is gone (REGRESSION)", async () => {
1141
+ // The upgrade shape under a version-stamped layout (pnpm, npx, volta): the
1142
+ // link points into `…@0.34.0/bundled-skills/<name>`, a directory the
1143
+ // upgrade replaced. Ownership by residence in a CURRENT root cannot see it,
1144
+ // and once the release drops that skill no per-skill action covers the path
1145
+ // either — so the dangling link would survive every reconcile, invisible to
1146
+ // setup and to doctor alike.
1147
+ const cwd = tmp("pragma-setup-proj-");
1148
+ const skillDir = join(cwd, ".pragma", "skills", "still-here");
1149
+ mkdirSync(skillDir, { recursive: true });
1150
+ writeFileSync(
1151
+ join(skillDir, "SKILL.md"),
1152
+ "---\nname: still-here\ndescription: A test skill.\n---\n",
1153
+ );
1154
+
1155
+ const linkDir = join(cwd, ".agents", "skills");
1156
+ mkdirSync(linkDir, { recursive: true });
1157
+ // Never created: this is the point — the old package directory is gone.
1158
+ const retired = join(cwd, "pragma-cli@0.34.0", "bundled-skills", "retired");
1159
+ const linkPath = join(linkDir, "retired");
1160
+ symlinkSync(retired, linkPath);
1161
+
1162
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1163
+ const orphan = detected.orphans.find((o) => o.linkPath === linkPath);
1164
+ expect(orphan).toBeDefined();
1165
+ expect(orphan?.owned).toBe(true);
1166
+ });
1167
+
730
1168
  it("classifies a DANGLING symlink as replaced and repairs it (S1-2)", async () => {
731
1169
  // `existsSync` FOLLOWS a symlink, so a dangling link used to read as
732
1170
  // "absent" → plan `created` → the real symlink() crashed EEXIST labeled
@@ -877,17 +1315,168 @@ describe("setup skills", () => {
877
1315
  const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
878
1316
  const action = detected.actions.find((a) => a.linkPath === linkPath);
879
1317
  expect(action?.owned).toBe(true);
1318
+ // A relative link that RESOLVES to the skill is already correct. Comparing
1319
+ // the raw `readlink` string to an absolute path classified it `replaced`,
1320
+ // so it churned on every run and doctor called it "points elsewhere".
1321
+ expect(action?.action).toBe("skipped");
1322
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
880
1323
 
881
1324
  await runUndo(composeSkillsRemoval(detected));
882
1325
  expect(existsSync(linkPath)).toBe(false);
883
1326
  });
884
1327
  });
885
1328
 
1329
+ describe("setup skills — the forward pass is a RECONCILE", () => {
1330
+ /** Seed one project skill and return its source dir. */
1331
+ const seed = (cwd: string, name: string): string => {
1332
+ const dir = join(cwd, ".pragma", "skills", name);
1333
+ mkdirSync(dir, { recursive: true });
1334
+ writeFileSync(
1335
+ join(dir, "SKILL.md"),
1336
+ `---\nname: ${name}\ndescription: A skill.\n---\n`,
1337
+ );
1338
+ return dir;
1339
+ };
1340
+
1341
+ it("removes the link a dropped skill left behind, without --undo", async () => {
1342
+ // The reported bug. `d.orphans` was computed and read ONLY by `--undo`,
1343
+ // which removes every owned link rather than the stale ones — so no command
1344
+ // reconciled, and a skill dropped upstream kept its link forever.
1345
+ const cwd = tmp("pragma-setup-proj-");
1346
+ const kept = seed(cwd, "kept");
1347
+ const dropped = seed(cwd, "dropped");
1348
+ const linkDir = join(cwd, ".agents", "skills");
1349
+ mkdirSync(linkDir, { recursive: true });
1350
+ symlinkSync(kept, join(linkDir, "kept"));
1351
+ symlinkSync(dropped, join(linkDir, "dropped"));
1352
+ rmSync(dropped, { recursive: true, force: true }); // retired upstream
1353
+
1354
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1355
+ expect(detected.orphans.map((o) => o.skillName)).toEqual(["dropped"]);
1356
+
1357
+ await runTask(composeSkills(detected));
1358
+ // `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
1359
+ expect(() => lstatSync(join(linkDir, "dropped"))).toThrow();
1360
+ expect(readlinkSync(join(linkDir, "kept"))).toBe(kept);
1361
+ });
1362
+
1363
+ it("plans the sweep as an ACTIONABLE row, not a deselected `none`", async () => {
1364
+ // An orphan-only tree planned `none` → `defaultSelected(none)` is false →
1365
+ // the row was never composed, so even the fixed reconcile would not run.
1366
+ const cwd = tmp("pragma-setup-proj-");
1367
+ const dropped = seed(cwd, "dropped");
1368
+ const linkDir = join(cwd, ".agents", "skills");
1369
+ mkdirSync(linkDir, { recursive: true });
1370
+ symlinkSync(dropped, join(linkDir, "dropped"));
1371
+ rmSync(dropped, { recursive: true, force: true });
1372
+
1373
+ const { plan } = await buildSetupRun(
1374
+ bootRuntime(FLAGS, cwd),
1375
+ "skills",
1376
+ "project",
1377
+ );
1378
+ const row = plan.rows.find((r) => r.target === "skills");
1379
+ expect(row?.action).toBe("update");
1380
+ expect(row?.selected).toBe(true);
1381
+ expect(row?.detail).toContain("1 stale link to remove");
1382
+ });
1383
+
1384
+ it("the sweep is reversible — undo puts the stale link back", async () => {
1385
+ const cwd = tmp("pragma-setup-proj-");
1386
+ const dropped = seed(cwd, "dropped");
1387
+ const linkDir = join(cwd, ".agents", "skills");
1388
+ mkdirSync(linkDir, { recursive: true });
1389
+ const linkPath = join(linkDir, "dropped");
1390
+ symlinkSync(dropped, linkPath);
1391
+ rmSync(dropped, { recursive: true, force: true });
1392
+
1393
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1394
+ const { undoCount } = await runUndo(composeSkills(detected));
1395
+ expect(undoCount).toBeGreaterThan(0);
1396
+ expect(readlinkSync(linkPath)).toBe(dropped);
1397
+ });
1398
+
1399
+ it("is idempotent — a second pass composes zero effects", async () => {
1400
+ const cwd = tmp("pragma-setup-proj-");
1401
+ const dropped = seed(cwd, "dropped");
1402
+ const linkDir = join(cwd, ".agents", "skills");
1403
+ mkdirSync(linkDir, { recursive: true });
1404
+ symlinkSync(dropped, join(linkDir, "dropped"));
1405
+ rmSync(dropped, { recursive: true, force: true });
1406
+
1407
+ await runTask(
1408
+ composeSkills(await detectSkills(bootRuntime(FLAGS, cwd), "project")),
1409
+ );
1410
+ const second = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1411
+ expect(dryRun(composeSkills(second)).effects).toEqual([]);
1412
+ });
1413
+
1414
+ it("SAFETY: an ABSENT source root means no opinion — nothing is swept", async () => {
1415
+ // `available` conflated "the root is empty" with "the root does not exist".
1416
+ // Sweeping on that alone would wipe every pragma link out of the harness
1417
+ // dirs on any machine where XDG_DATA_HOME moved or sources update never ran.
1418
+ const cwd = tmp("pragma-setup-proj-");
1419
+ const skillDir = seed(cwd, "gone");
1420
+ const linkDir = join(cwd, ".agents", "skills");
1421
+ mkdirSync(linkDir, { recursive: true });
1422
+ const linkPath = join(linkDir, "gone");
1423
+ symlinkSync(skillDir, linkPath);
1424
+ // The whole source ROOT disappears, not just one skill.
1425
+ rmSync(join(cwd, ".pragma", "skills"), { recursive: true, force: true });
1426
+
1427
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1428
+ expect(detected.rootExists).toBe(false);
1429
+ expect(detected.available).toBe(false);
1430
+ expect(detected.orphans).toEqual([]);
1431
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
1432
+
1433
+ await runTask(composeSkills(detected));
1434
+ expect(readlinkSync(linkPath)).toBe(skillDir);
1435
+ });
1436
+
1437
+ it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
1438
+ // The three shapes the sweep must never touch, in one tree: a hand-placed
1439
+ // directory, a link into someone's own checkout, and a link into a SIBLING
1440
+ // whose name merely EXTENDS the root's (the old string-prefix bug).
1441
+ const cwd = tmp("pragma-setup-proj-");
1442
+ const root = join(cwd, ".pragma", "skills");
1443
+ mkdirSync(root, { recursive: true }); // exists, but holds no skill
1444
+ const backup = join(cwd, ".pragma", "skills-backup", "sibling");
1445
+ mkdirSync(backup, { recursive: true });
1446
+ writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
1447
+ const elsewhere = join(cwd, "elsewhere", "mine");
1448
+ mkdirSync(elsewhere, { recursive: true });
1449
+
1450
+ const linkDir = join(cwd, ".agents", "skills");
1451
+ mkdirSync(linkDir, { recursive: true });
1452
+ const realDir = join(linkDir, "hand-placed");
1453
+ mkdirSync(realDir, { recursive: true });
1454
+ writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
1455
+ symlinkSync(elsewhere, join(linkDir, "mine"));
1456
+ symlinkSync(backup, join(linkDir, "sibling"));
1457
+
1458
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1459
+ expect(detected.rootExists).toBe(true); // the sweep IS entitled to act
1460
+ expect(detected.orphans).toEqual([]); // ...and finds nothing of its own
1461
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
1462
+
1463
+ await runTask(composeSkills(detected));
1464
+ expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
1465
+ "hand-placed\n",
1466
+ );
1467
+ expect(readlinkSync(join(linkDir, "mine"))).toBe(elsewhere);
1468
+ expect(readlinkSync(join(linkDir, "sibling"))).toBe(backup);
1469
+ expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
1470
+ "the user's own copy\n",
1471
+ );
1472
+ });
1473
+ });
1474
+
886
1475
  describe("setup — detections settle independently", () => {
887
1476
  it("gives a rejecting detection its own failed row, leaving the rest planned", async () => {
888
1477
  // The targets are independent, and one `Promise.all` made them share a
889
1478
  // fate: a single rejecting detection prevented a run being built at all,
890
- // so NONE of the other targets ran and doctor rendered no banded rows.
1479
+ // so NONE of the other targets ran and doctor rendered no scoped rows.
891
1480
  const { TARGETS } = await import("./targets.js");
892
1481
  const lsp = TARGETS.find((t) => t.id === "lsp") as (typeof TARGETS)[number];
893
1482
  const spy = vi
@@ -1364,8 +1953,8 @@ describe("setup (run-all wizard)", () => {
1364
1953
  let thrown: unknown;
1365
1954
  try {
1366
1955
  // `--scope both`: the failing target (lsp) is global-only, and the two
1367
- // that must survive it write the project band, so the run has to cover
1368
- // both. A bare run-all is the GLOBAL band now and would not touch this
1956
+ // that must survive it write the project scope, so the run has to cover
1957
+ // both. A bare run-all is the GLOBAL scope now and would not touch this
1369
1958
  // repository at all.
1370
1959
  await executeVerb(
1371
1960
  setupSelfVerb,
@@ -1392,11 +1981,17 @@ describe("setup (run-all wizard)", () => {
1392
1981
  });
1393
1982
 
1394
1983
  it("omits skills gracefully when none are discovered (no mid-wizard EMPTY_RESULTS)", async () => {
1395
- // A run-all in a project with no skills must NOT throw — it just doesn't
1396
- // offer the skills step. Reaching a clean plan proves the graceful degrade.
1984
+ // A run-all in a scope with no skills must NOT throw — it just doesn't offer
1985
+ // the skills step. Reaching a clean plan proves the graceful degrade.
1986
+ //
1987
+ // POINTED AT THE PROJECT BAND, which is where an empty skills root is still
1988
+ // an ordinary condition: the global scope now always has the bundled
1989
+ // snapshot behind it, so a run-all there can no longer produce the empty
1990
+ // row this cell exists to survive. The condition is the same one; only the
1991
+ // scope that can still reach it has changed.
1397
1992
  const outcome = await executeVerb(
1398
1993
  setupSelfVerb,
1399
- {},
1994
+ { local: true },
1400
1995
  DRY,
1401
1996
  bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1402
1997
  );
@@ -1404,9 +1999,245 @@ describe("setup (run-all wizard)", () => {
1404
1999
  // The skills row is PRESENT and says why it cannot act, rather than being
1405
2000
  // dropped from a plan that then claims completeness.
1406
2001
  expect(outcome.stdout).toContain("skills");
1407
- expect(outcome.stdout).toContain("no skills installed");
2002
+ expect(outcome.stdout).toContain("this project holds no skills");
1408
2003
  expect(outcome.stdout).toContain("lsp");
1409
2004
  });
2005
+
2006
+ it("the GLOBAL run-all offers the skills row on a fresh machine", async () => {
2007
+ // The other side of the same graceful-degrade question, and the one a fresh
2008
+ // install actually meets: a bare run-all is the global scope, whose skills
2009
+ // row is now offerable from the bundled snapshot rather than skipped with a
2010
+ // remedy the user has to go away and run.
2011
+ const prevData = process.env.XDG_DATA_HOME;
2012
+ process.env.XDG_DATA_HOME = tmp("pragma-runall-data-");
2013
+ try {
2014
+ const outcome = await executeVerb(
2015
+ setupSelfVerb,
2016
+ {},
2017
+ DRY,
2018
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2019
+ );
2020
+ expect(outcome.exitCode).toBe(0);
2021
+ expect(outcome.stdout).toContain("skills");
2022
+ expect(outcome.stdout).not.toContain("no skills installed");
2023
+ } finally {
2024
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
2025
+ else process.env.XDG_DATA_HOME = prevData;
2026
+ }
2027
+ });
2028
+ });
2029
+
2030
+ describe("setup — a converged machine is reported, never asked", () => {
2031
+ // A converged run composes ZERO effects, and the confirm gate then mounted
2032
+ // Ink to render summon-core's "No operations planned." above "Proceed?".
2033
+ // On a sub-verb, whose prompt list is empty, that contentless gate was the
2034
+ // first and only thing the user ever saw.
2035
+ let prevPath: string | undefined;
2036
+ beforeEach(() => {
2037
+ prevPath = process.env.PATH;
2038
+ process.env.PATH = stubPath(); // no editor CLI ⇒ lsp skips
2039
+ });
2040
+ afterEach(() => {
2041
+ process.env.PATH = prevPath;
2042
+ });
2043
+
2044
+ const NO = { dryRun: false, undo: false, yes: false };
2045
+
2046
+ it("non-interactive + no --yes: the recap, NOT the `run again with --yes` hint", async () => {
2047
+ // The compounding defect: a converged plan printed "Nothing was applied.
2048
+ // Run again with --yes to apply." over a machine where there was nothing
2049
+ // to apply in the first place.
2050
+ const outcome = await executeVerb(
2051
+ verbOf("lsp"),
2052
+ {},
2053
+ NO,
2054
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2055
+ );
2056
+ expect(outcome.exitCode).toBe(0);
2057
+ expect(outcome.stdout).not.toContain("Run again with --yes");
2058
+ expect(outcome.stdout).not.toContain("Setup plan");
2059
+ // It says what was detected and what happened to it — doctor's house shape.
2060
+ expect(outcome.stdout).toContain("skipped");
2061
+ expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
2062
+ });
2063
+
2064
+ it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
2065
+ // The acceptance case. Faking a terminal on both streams is what makes
2066
+ // `cliIsTTY()` true, which is the ONLY gate between this run and the wizard.
2067
+ const prevIn = process.stdin.isTTY;
2068
+ const prevErr = process.stderr.isTTY;
2069
+ (process.stdin as { isTTY?: boolean }).isTTY = true;
2070
+ (process.stderr as { isTTY?: boolean }).isTTY = true;
2071
+ let outcome: Awaited<ReturnType<typeof executeVerb>>;
2072
+ try {
2073
+ outcome = await executeVerb(
2074
+ verbOf("lsp"),
2075
+ {},
2076
+ NO,
2077
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2078
+ );
2079
+ } finally {
2080
+ (process.stdin as { isTTY?: boolean }).isTTY = prevIn;
2081
+ (process.stderr as { isTTY?: boolean }).isTTY = prevErr;
2082
+ }
2083
+ expect(outcome.exitCode).toBe(0);
2084
+ expect(outcome.stdout).not.toContain("Proceed");
2085
+ expect(outcome.stdout).toContain("skipped");
2086
+ // The same lazy-React probe the PROTECTED guard below uses: had the
2087
+ // contentless gate mounted, Ink would be in the module cache.
2088
+ const isReactPkg = (k: string) =>
2089
+ /[\\/](react|react-dom|ink)@\d/.test(k) ||
2090
+ /[\\/]node_modules[\\/](react|react-dom|ink)[\\/]/.test(k);
2091
+ expect(Object.keys(require.cache ?? {}).filter(isReactPkg)).toEqual([]);
2092
+ });
2093
+
2094
+ it("--dry-run is untouched — it still ends with the dry-run hint", async () => {
2095
+ // The guard sits AFTER the `previewing` computation precisely so a preview
2096
+ // keeps saying it was a preview.
2097
+ const outcome = await executeVerb(
2098
+ verbOf("lsp"),
2099
+ {},
2100
+ DRY,
2101
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2102
+ );
2103
+ expect(outcome.exitCode).toBe(0);
2104
+ expect(outcome.stdout).toContain("Setup plan");
2105
+ expect(outcome.stdout).toContain(DRY_RUN_HINT);
2106
+ });
2107
+
2108
+ it("a PARTIALLY converged plan is NOT short-circuited", async () => {
2109
+ // One actionable row is enough: the run goes through `execute` as before,
2110
+ // so the wizard is still reachable on a machine with something to do.
2111
+ const cwd = tmp("pragma-setup-proj-");
2112
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2113
+ const outcome = await executeVerb(
2114
+ verbOf("mcp"),
2115
+ { local: true },
2116
+ NO,
2117
+ bootRuntime(FLAGS, cwd),
2118
+ );
2119
+ expect(outcome.exitCode).toBe(0);
2120
+ // Non-interactive without --yes ⇒ still the preview, hint and all.
2121
+ expect(outcome.stdout).toContain("Setup plan");
2122
+ expect(outcome.stdout).toContain(PREVIEW_HINT);
2123
+ });
2124
+
2125
+ it("the run-all on a fully converged machine recaps every row at exit 0", async () => {
2126
+ const cwd = tmp("pragma-setup-proj-");
2127
+ // First pass configures whatever this machine can hold...
2128
+ await executeVerb(setupSelfVerb, {}, YES, bootRuntime(FLAGS, cwd));
2129
+ // ...the second has nothing left to do.
2130
+ const outcome = await executeVerb(
2131
+ setupSelfVerb,
2132
+ {},
2133
+ NO,
2134
+ bootRuntime(FLAGS, cwd),
2135
+ );
2136
+ expect(outcome.exitCode).toBe(0);
2137
+ expect(outcome.stdout).not.toContain("Run again with --yes");
2138
+ expect(outcome.stdout).toContain("targets configured");
2139
+ });
2140
+ });
2141
+
2142
+ describe("setup — the detection summary", () => {
2143
+ let prevPath: string | undefined;
2144
+ beforeEach(() => {
2145
+ prevPath = process.env.PATH;
2146
+ process.env.PATH = stubPath();
2147
+ });
2148
+ afterEach(() => {
2149
+ process.env.PATH = prevPath;
2150
+ });
2151
+
2152
+ /**
2153
+ * Drive a run, capturing STDERR — which is where the report seam writes, and
2154
+ * the same stream the Ink frame would use. The dispatcher builds its own
2155
+ * runtime for a mutation, so an `rt.report` set on the boot runtime is
2156
+ * replaced; capturing the stream is what actually pins the behaviour.
2157
+ */
2158
+ const onStderr = async (
2159
+ verb: VerbSpec,
2160
+ params: Record<string, unknown>,
2161
+ flags: GlobalFlags,
2162
+ cwd: string,
2163
+ mutation = YES,
2164
+ ): Promise<string[]> => {
2165
+ const chunks: string[] = [];
2166
+ const spy = vi
2167
+ .spyOn(process.stderr, "write")
2168
+ .mockImplementation((chunk: unknown): boolean => {
2169
+ chunks.push(String(chunk));
2170
+ return true;
2171
+ });
2172
+ try {
2173
+ await executeVerb(verb, params, mutation, bootRuntime(flags, cwd));
2174
+ } finally {
2175
+ spy.mockRestore();
2176
+ }
2177
+ return chunks;
2178
+ };
2179
+
2180
+ /** The one write the report seam made for the summary block, if any. */
2181
+ const summaryOf = (chunks: readonly string[]): string =>
2182
+ chunks.find((c) => c.startsWith("Detected —")) ?? "";
2183
+
2184
+ it("names what was detected BEFORE anything is asked, detected rows only", async () => {
2185
+ const cwd = tmp("pragma-setup-proj-");
2186
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2187
+ const chunks = await onStderr(setupSelfVerb, { local: true }, FLAGS, cwd);
2188
+ // The report seam writes the block as ONE write, before the generator is
2189
+ // driven — so it is also the first thing on the stream.
2190
+ expect(chunks[0]?.startsWith("Detected —")).toBe(true);
2191
+ const summary = summaryOf(chunks);
2192
+ // FIRST — the Ink frame and this line both go to stderr, so only a line
2193
+ // written before `execute` lands above the frame in scrollback.
2194
+ expect(summary).toContain("Detected —");
2195
+ expect(summary).toContain("mcp");
2196
+ // A skip is "nothing detected here", so it is out of the default view...
2197
+ expect(summary).not.toContain("user-level only");
2198
+ expect(summary).toContain("--verbose lists them");
2199
+ });
2200
+
2201
+ it("--verbose widens it to every row, with no `not detected` footer", async () => {
2202
+ const cwd = tmp("pragma-setup-proj-");
2203
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2204
+ const summary = summaryOf(
2205
+ await onStderr(
2206
+ setupSelfVerb,
2207
+ { local: true },
2208
+ { ...FLAGS, verbose: true },
2209
+ cwd,
2210
+ ),
2211
+ );
2212
+ // ...and back in under `--verbose`, which consumes the EXISTING global flag
2213
+ // rather than adding a signal.
2214
+ expect(summary).toContain("this one is global only");
2215
+ expect(summary).not.toContain("--verbose lists them");
2216
+ });
2217
+
2218
+ it("says nothing on a --dry-run — the plan table IS that run's output", async () => {
2219
+ const cwd = tmp("pragma-setup-proj-");
2220
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2221
+ const chunks = await onStderr(
2222
+ setupSelfVerb,
2223
+ { local: true },
2224
+ FLAGS,
2225
+ cwd,
2226
+ DRY,
2227
+ );
2228
+ expect(summaryOf(chunks)).toBe("");
2229
+ });
2230
+
2231
+ it("is silent over MCP — `rt.report` is a no-op there, so no Ink and no noise", async () => {
2232
+ // The seam is the point: a plain-string report, not an Ink mount, is what
2233
+ // keeps this off the React graph AND out of the MCP data stream.
2234
+ const cwd = tmp("pragma-setup-proj-");
2235
+ const mcp = await projectMcp([setupModule], cwd);
2236
+ const result = await mcp.callTool("setup", { confirm: true });
2237
+ await mcp.cleanup();
2238
+ expect(result.ok).toBe(true);
2239
+ expect(JSON.stringify(result.data)).not.toContain("Detected —");
2240
+ });
1410
2241
  });
1411
2242
 
1412
2243
  describe("lazy-React discipline (PROTECTED)", () => {