projectstore-claude 0.29.2 → 0.29.3

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 (57) hide show
  1. package/README.md +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -30,7 +30,9 @@
30
30
  // and a bare one refuses. There is no --yes flag. --surface narrows the plan
31
31
  // (by prefix, so `statusline` covers the launcher too); it confirms nothing —
32
32
  // except that naming the statusline surface is how a user opts into it
33
- // without the config flag.
33
+ // without the config flag. One change is never confirmed by a name: a channel
34
+ // switch planned with no channel answer (the converge story's rule 2) is asked
35
+ // at a terminal and, without one, answered `channel` — nothing is written.
34
36
  //
35
37
  // Surface handlers are keyed by the manifest's surfaces.<kind>.format, never
36
38
  // by a harness id: adding a harness is adding harnesses/<id>.json, and this
@@ -49,6 +51,25 @@
49
51
  // surface is then planned against the install path it produces — from npx,
50
52
  // the package's own root is a directory the package manager collects.
51
53
  //
54
+ // A SHELL-ROOTED harness (its registration conditioned on a distribution root:
55
+ // Codex) has its plugin root only in its distribution shell. install and
56
+ // upgrade fetch that shell at this root's own version before plan(), once per
57
+ // run (fetch-shell.mjs), and hand plan() the fetched root per harness id; plan
58
+ // and uninstall never fetch, and decide that registration from the host's
59
+ // facts and the version alone. plan() stays synchronous and pure either way;
60
+ // runVerb removes the run's scratch after apply() in a `finally`.
61
+ //
62
+ // Before that fetch, install and upgrade decide about the copies of ours this
63
+ // project already runs (convergence.mjs; the story "Setup and upgrade converge
64
+ // this project onto the running version…", rules 6 and 8): a copy newer than
65
+ // this root is never downgraded — the run stops and names the update when
66
+ // the registry's latest reaches it, and proceeds, saying why, when it does not
67
+ // — and a package root run in text says when a newer release is published.
68
+ // The registry is read there, at most once; plan() is handed the decision.
69
+ // Under the channel "git" (the keep, its rule 5) the kept copy's own plan is
70
+ // read there too, through its bin; plan() is handed it, and plans that
71
+ // harness as one host-style step that runs the copy's own upgrade.
72
+ //
52
73
  // Direction: installer → surfaces ← doctor; installer → provenance ← doctor.
53
74
  // Doctor never imports this file.
54
75
  //
@@ -64,12 +85,15 @@ import { homedir } from "node:os";
64
85
  import { randomUUID } from "node:crypto";
65
86
  import { fileURLToPath } from "node:url";
66
87
  import { spawnSync } from "node:child_process";
67
- import { caps as termCaps, painter, icon as termIcon, duration, stepReporter, wrap, askLine } from "./term.mjs";
68
- import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand } from "./harness.mjs";
88
+ import { caps as termCaps, painter, icon as termIcon, duration, stepReporter, liveLine, wrap, askApply, plain, badge as badgeText, heading, rowLines, kv, commandBlock, PLAIN } from "./term.mjs";
89
+ import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand, insideHostSession, registrationDeferred, bundlingShellHarnessId, cachePaths, commonBlockFiles, agentsBlockRow, configPath, childEnv } from "./harness.mjs";
69
90
  import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
70
- import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
91
+ import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, analyseHarnessState, isOurFile, readText, registrationRenderRoot, registrationPaths } from "./surfaces.mjs";
71
92
  import { payloadFiles, renderPortableCatalog } from "./portable-registration.mjs";
72
- import { pluginRoot, writeFileAtomic, writeExclusiveMetadata, ensureStateDir, ensureRuntimeDir, removeAgentsBlock, replaceAgentsBlock, readConfigAt, isPluginCacheRoot, isEphemeralRoot, statusLineIsOurWiring, claudeHome, packageDigest, writeOwnTree, removeOwnTree, cmpPrecedence, importsLine, whichOnPath as whichOnPathFromLib, moveStateDir, mergeEntryLog, movePath, removeInside, statusLineScriptPath, layoutPaths, stagePortableMarketplace, finishPortableMarketplace, rollbackPortableMarketplace, removeTreeUnder, projectDirRefusal } from "./lib.mjs";
93
+ import { rootVersion, rootName, fetchDecision, fetchArgv, npmRegistry, npmUserRegistry, REGISTRY_BUDGET_MS, fetchShell, fetchRefusal, fetchRunId, sweepFetchRuns } from "./fetch-shell.mjs";
94
+ import { HOST_BUDGET_MS, startFailure, runHost } from "./run-host.mjs";
95
+ import { runningCopies, newerCopies, lowestVersion, readLatest, decide, proceedCase, newerCopyDeferral, repairAdvice, advised, updateNotice, checksRoot, noticeRoot, updateCheckOff, channelChoices, switchConsequence, switchReason, noCopyToKeep, keepArgv, keepResult, readKeepPlan, ownUpgrade, npmRowBeside, DEFAULT_REGISTRY, NOTICE_BUDGET_MS, READ_TIMEOUT_MS } from "./convergence.mjs";
96
+ import { pluginRoot, channelMark, CHANNEL_SWITCH_KEY, writeFileAtomic, writeExclusiveMetadata, ensureStateDir, ensureRuntimeDir, removeAgentsBlock, replaceAgentsBlock, readConfigAt, isPluginCacheRoot, isEphemeralRoot, statusLineIsOurWiring, claudeHome, packageDigest, writeOwnTree, removeOwnTree, cmpPrecedence, importsLine, whichOnPath as whichOnPathFromLib, moveStateDir, mergeEntryLog, movePath, removeInside, statusLineScriptPath, layoutPaths, stagePortableMarketplace, finishPortableMarketplace, rollbackPortableMarketplace, removeTreeUnder, projectDirRefusal, registrationProvenance, envFlag, selectCopy, copyUpdateAdvice, takesVerbose } from "./lib.mjs";
73
97
 
74
98
  import { GENERATOR } from "./surfaces.mjs";
75
99
  export { GENERATOR };
@@ -107,9 +131,16 @@ const HANDLERS = {
107
131
  // line slot) decides whether the exclusive launcher is written at all.
108
132
  const KIND_ORDER = { registration: 0, shared: 1, exclusive: 2 };
109
133
 
134
+ // Planned once per RUN by plan(), never once per harness (the story "One run
135
+ // plans the agents block once, and a bare uninstall selects every harness the
136
+ // project uses", rule 1). `s` carries the run's common file list as `files`;
137
+ // `ctx.run` is every harness of the run that has a block, `ctx.harness` the
138
+ // first of them, `ctx.readers` set when the run names more than one, and
139
+ // `ctx.layout` the roster's layout name (rule 3).
110
140
  function planAgentsBlock(ctx, key, s) {
111
141
  const { projectDir, mode, root } = ctx;
112
- const a = analyseBlock(projectDir, s, { root });
142
+ const run = ctx.run || [ctx.harness];
143
+ const a = analyseBlock(projectDir, s, { root, layout: ctx.layout || null, readers: ctx.readers || null });
113
144
  const { withBlock, preferred, claude, importLine, PREFERRED, FALLBACK } = a;
114
145
  const items = [];
115
146
  if (a.refusal) return [{ surface: key, kind: "shared", path: a.files[0].path, entry: "projectstore:agents", state: "refused", action: "refuse", reason: a.refusal }];
@@ -118,8 +149,15 @@ function planAgentsBlock(ctx, key, s) {
118
149
  // A CLAUDE.md that is nothing but the import registration added is ours to
119
150
  // delete when the block goes (ADR-002 decision 4); anything else stays.
120
151
  const onlyImport = (text) => String(text ?? "").split("\n").every((l) => !l.trim() || l.trim() === importLine);
121
- const removal = (e, extra = {}) => ({ surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "remove", reason: null,
122
- before: e.text, after: removeAgentsBlock(e.text), deleteIfEmpty: e.file === FALLBACK, ...extra });
152
+ // A file the removal leaves empty is deleted when it held only the block
153
+ // (rule 4) — and the fallback file, as before. Keyed on the fallback alone,
154
+ // a run's common list (whose last entry is AGENTS.md) and Claude Code alone
155
+ // both left a 0-byte AGENTS.md (measured 2026-10-08).
156
+ const removal = (e, extra = {}) => {
157
+ const after = removeAgentsBlock(e.text);
158
+ return { surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "remove", reason: null,
159
+ before: e.text, after, deleteIfEmpty: e.file === FALLBACK || !after.trim(), ...extra };
160
+ };
123
161
 
124
162
  if (mode === "uninstall") {
125
163
  if (!withBlock.length) return [{ surface: key, kind: "shared", path: preferred.path, entry: "projectstore:agents", state: "ours-absent", action: "skip", reason: "no block to remove" }];
@@ -139,9 +177,14 @@ function planAgentsBlock(ctx, key, s) {
139
177
  // self-describing region a user can delete, where a deleted file another
140
178
  // harness reads is silent breakage. `--surface agents_block` still removes
141
179
  // it, which is the confirmation the gate asks for everywhere else.
142
- const alsoRead = (file) => [...loadHarnesses().values()]
143
- .filter((m) => m.id !== ctx.harness?.id)
144
- .some((m) => (m.surfaces?.agents_block?.files || []).includes(file));
180
+ //
181
+ // "Another" means a manifest OUTSIDE this run (rule 4): a run that names
182
+ // every harness reading the file is the last one, and removes the block
183
+ // once. Excluding only the planning harness kept the block for a harness
184
+ // that was in the same run.
185
+ const inRun = new Set(run.map((m) => m?.id));
186
+ const outside = [...loadHarnesses().values()].filter((m) => !inRun.has(m.id));
187
+ const alsoRead = (file) => outside.some((m) => (agentsBlockRow(m)?.[1].files || []).includes(file));
145
188
  for (const e of withBlock) {
146
189
  // Naming the surface removes it regardless: `--surface agents_block`
147
190
  // is the confirmation without a terminal, and a terminal is asked
@@ -180,7 +223,16 @@ function planAgentsBlock(ctx, key, s) {
180
223
  // the import need not be one this harness can read. Codex's list is a
181
224
  // single entry, which also made the old condition — block in PREFERRED and
182
225
  // not in FALLBACK — impossible to satisfy, so nothing was ever cleaned up.
226
+ //
227
+ // Except a BRIDGE (rule 4: a narrowed uninstall "leaves the block and the
228
+ // import in place"): the import in the file a harness outside this run
229
+ // reads by itself, pointing at a block this run keeps for that harness.
230
+ // It is the only way that harness sees the block; taking it out because it
231
+ // is ours kept the block and blinded its reader. The source harness's
232
+ // own uninstall still removes its own file's import, as before.
183
233
  const blockGone = new Set(items.filter((i) => i.action === "remove" && i.surface === key).map((i) => rel(projectDir, i.path)));
234
+ const blockKept = new Set(items.filter((i) => i.action === "skip" && i.surface === key).map((i) => rel(projectDir, i.path)));
235
+ const outsideNatives = new Set(outside.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
184
236
  const union = [...new Set([...loadHarnesses().values()].flatMap((m) => m.surfaces?.agents_block?.files || []))];
185
237
  for (const file of union) {
186
238
  if (blockGone.has(file)) continue;
@@ -188,7 +240,8 @@ function planAgentsBlock(ctx, key, s) {
188
240
  if (!e.present || e.text === null || !hasImport(e.text)) continue;
189
241
  const ours = onlyImport(e.text);
190
242
  const dangles = vanishing.has(importLine.slice(1));
191
- if (!ours && !dangles) continue;
243
+ const bridge = blockKept.has(importLine.slice(1)) && outsideNatives.has(file);
244
+ if ((!ours && !dangles) || bridge) continue;
192
245
  const after = e.text.split("\n").filter((l) => l.trim() !== importLine).join("\n").replace(/^\n+/, "");
193
246
  items.push({ surface: `${key}_import`, kind: "shared", path: join(projectDir, file), entry: importLine, state: "ours-current", action: "remove",
194
247
  reason: ours ? `${file} holds only the import registration added` : `${importLine} points at a file this uninstall removes`,
@@ -217,8 +270,10 @@ function planAgentsBlock(ctx, key, s) {
217
270
  // The distinction is what keeps a Claude-Code-only project from acquiring
218
271
  // an AGENTS.md it never asked for, while still moving the substance the
219
272
  // moment a harness that can only read AGENTS.md is installed.
273
+ // A run of several harnesses says which of them cannot read the file the
274
+ // block leaves (rule 1); one harness keeps its text, byte for byte.
220
275
  items.push(removal(current, { state: "ours-stale", reason: `migrating to ${preferred.file}` }));
221
- items.push({ surface: key, kind: "shared", path: preferred.path, entry, state: "ours-absent", action: preferred.present ? "add" : "create", reason: `migrated from ${current.file}`,
276
+ items.push({ surface: key, kind: "shared", path: preferred.path, entry, state: "ours-absent", action: preferred.present ? "add" : "create", reason: ctx.readers && !current.own ? a.reason : `migrated from ${current.file}`,
222
277
  before: preferred.present ? preferred.text : null, after: replaceAgentsBlock(preferred.present ? preferred.text : "", a.desired) });
223
278
  } else if (current.block.v === a.version && current.block.block === a.desired) {
224
279
  items.push({ surface: key, kind: "shared", path: current.path, entry, state: "ours-current", action: "skip", reason: null });
@@ -249,11 +304,16 @@ function planAgentsBlock(ctx, key, s) {
249
304
  // had an AGENTS.md took the block into it and created no bridge — so Claude
250
305
  // Code, the harness that ran the install, could not see what it had just
251
306
  // installed. `reads_natively` is the manifest's, so this is a file list, not
252
- // a harness name.
253
- const native = s.reads_natively;
254
- const nativeEntry = native ? a.files.find((e) => e.file === native) : null;
307
+ // a harness name — and it is every RUN harness's (rule 1): one run for two
308
+ // harnesses leaves both native files present, each holding the block or the
309
+ // import.
310
+ const natives = new Set(run.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
311
+ // The import item sits under the run's first harness, which need not be the
312
+ // one that reads the file: in a run of several, the reason names the reader.
313
+ // One harness is its own reader and keeps its text, byte for byte.
314
+ const reader = (file) => (ctx.readers && run.find((m) => agentsBlockRow(m)?.[1].reads_natively === file)?.display_name) || "this harness";
255
315
  for (const e of a.files) {
256
- const mustExist = nativeEntry && e.file === native;
316
+ const mustExist = natives.has(e.file);
257
317
  if (!blockFile || e.file === blockFile || (!e.present && !mustExist)) continue;
258
318
  const line = `@${blockFile}`;
259
319
  const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
@@ -263,7 +323,7 @@ function planAgentsBlock(ctx, key, s) {
263
323
  const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
264
324
  if (rewrite) { rewrite.after = after; rewrite.deleteIfEmpty = false; rewrite.reason += `; ${line} import added`; }
265
325
  else items.push({ surface: `${key}_import`, kind: "shared", path: e.path, entry: line, state: "ours-absent", action: e.present ? "add" : "create",
266
- reason: e.present ? `${blockFile} carries the block; ${e.file} must import it` : `${e.file} is what this harness reads by itself; it is created to import ${blockFile}`,
326
+ reason: e.present ? `${blockFile} carries the block; ${e.file} must import it` : `${e.file} is what ${reader(e.file)} reads by itself; it is created to import ${blockFile}`,
267
327
  before: e.present ? text : null, after });
268
328
  }
269
329
  return items;
@@ -310,9 +370,27 @@ function portableListFact(stdout, id) {
310
370
  };
311
371
  }
312
372
 
373
+ // A shell-rooted registration, in the order its rows are decided (the story
374
+ // "Install and upgrade fetch a shell-rooted harness at the bin's own version,
375
+ // once per run; plan and uninstall never fetch", rules 6–8):
376
+ // 1. a project uninstall never touches the global registration;
377
+ // 2. no payload and no version (a direct plan() caller), or a shell whose
378
+ // named root is not a plugin root: today's deferred rows, unchanged;
379
+ // 3. a run from the harness's own shell's core: deferred, it does not fetch;
380
+ // 4. foreign or conflicting: refused;
381
+ // 5. unavailable (the host CLI missing, nothing of ours): deferred;
382
+ // 6. global removal, from the host's facts — it stages nothing, so it needs
383
+ // no payload;
384
+ // 7. install and upgrade: from the fetched or named payload, or, with only
385
+ // the version, the dry run — every host fact decided, the payload's
386
+ // digest compared once a fetch brings it, and a write row's steps led by
387
+ // the fetch it would run.
388
+ // The analysis inputs are recorded privately on the row: apply()'s recheck
389
+ // and its read-back re-analyse with exactly those, never with the root's
390
+ // version or the variable as it stands by then.
313
391
  function planPortableRegistration(ctx, key, s) {
314
- const { projectDir, mode, root, home, env, harness, globalRemoval } = ctx;
315
- const a = analysePortableRegistration(projectDir, s, { root, home, harness, env });
392
+ const { projectDir, mode, root, home, env, harness, globalRemoval, version = null, fetched = null } = ctx;
393
+ const a = analysePortableRegistration(projectDir, s, { root, home, harness, env, version, fetched });
316
394
  const fill = { dir: a.paths.dir, marketplace: s.marketplace_name, id: a.id };
317
395
  const base = {
318
396
  surface: key,
@@ -336,39 +414,78 @@ function planPortableRegistration(ctx, key, s) {
336
414
  installed_digest: a.installed?.digest || null,
337
415
  enabled: a.enabled,
338
416
  },
417
+ analysed: { payloadRoot: a.payloadRoot || null, version: a.versionOnly ? version : null },
339
418
  };
340
- // The same fact the analyser computed: no portable payload in this run. A
341
- // shell that named a root which is not a plugin root is broken, and says so
342
- // through `incomplete`; the core run alone simply defers.
343
- if (!a.payloadRoot) {
344
- if (env.PROJECTSTORE_DISTRIBUTION_ROOT) ctx.incomplete = true;
345
- return [{ ...base, action: "skip", deferred: true, reason: env.PROJECTSTORE_DISTRIBUTION_ROOT
346
- ? `${env.PROJECTSTORE_DISTRIBUTION_ROOT} is not a portable plugin root; ${harness.display_name}'s registration runs only from its built distribution shell`
347
- : `the core package is not ${harness.display_name}'s plugin root; registration runs only from its built distribution shell` }];
348
- }
349
419
  if (mode === "uninstall" && !globalRemoval) {
350
420
  return [{ ...base, action: "skip", reason: "the plugin package and cache are global; project uninstall removes only project-owned surfaces. Use uninstall --global with an explicit harness to preview global removal" }];
351
421
  }
422
+ // No payload and no version: a direct plan() caller, which keeps today's
423
+ // deferral. A shell that named a root which is not a plugin root is broken,
424
+ // and says so through `incomplete`, as before — except for a removal,
425
+ // which needs no payload at all.
426
+ const namedRoot = env.PROJECTSTORE_DISTRIBUTION_ROOT || null;
427
+ if (!a.payloadRoot && (!a.versionOnly || (namedRoot && mode !== "uninstall"))) {
428
+ if (namedRoot) ctx.incomplete = true;
429
+ return [{ ...base, action: "skip", deferred: true, reason: namedRoot
430
+ ? `${namedRoot} is not a portable plugin root; ${harness.display_name}'s registration runs only from its built distribution shell`
431
+ : `the core package is not ${harness.display_name}'s plugin root; registration runs only from its built distribution shell` }];
432
+ }
433
+ // Rule 8: the core a shell bundles — the doctor skill's copy in the host's
434
+ // plugin cache, an npx copy or a global install of the shell, recognised by
435
+ // the shell's package name and never by a path — keeps today's deferral. A
436
+ // fetch there would change the host's home from inside a session (the
437
+ // front-door ADR's first open question; such a session is marked only by
438
+ // registration_defer_env, which defers the registration below, and only
439
+ // when the host's exec tool sets it), and needs the network inside its
440
+ // sandbox. Only this harness's own shell counts.
441
+ if (!a.payloadRoot && bundlingShellHarnessId({ core: root }) === harness.id) {
442
+ return [{ ...base, action: "skip", deferred: true, reason: "this run is from the plugin's own copy, which does not fetch" }];
443
+ }
352
444
  if (["foreign", "conflict"].includes(a.state)) return [{ ...base, action: "refuse", reason: a.refusal }];
353
445
  if (a.state === "unavailable") { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true }]; }
354
446
  if (mode === "uninstall") {
355
447
  if (a.state === "absent") return [{ ...base, action: "skip", reason: "no global registration of ours" }];
356
448
  if (!a.bin) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: `\`${s.cli.bin}\` is not on PATH; global registration is left untouched` }]; }
449
+ // The removal runs the same host CLI against the same home as an install,
450
+ // so it defers where an install would (the setup story, rule 15), with
451
+ // the host's checks above decided first, as the host plugin
452
+ // registration's removal decides them.
453
+ if (registrationDeferred(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: deferralReason(harness, env) }]; }
357
454
  const steps = [];
358
455
  if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `explicit global removal forgets ${a.id}`));
359
456
  if (a.market && s.cli.commands.marketplace_remove) steps.push(hostStep(a, s, "marketplace_remove", fill, `explicit global removal forgets marketplace ${s.marketplace_name}`));
360
457
  if (a.ownership) steps.push({ kind: "portable-remove", path: a.paths.dir, homeBase: a.paths.home, why: "the stable marketplace source is owned by this installer" });
361
458
  return [{ ...base, action: "remove", steps, reason: "explicit global removal; project overrides are preserved" }];
362
459
  }
363
- if (a.state === "current") return [{ ...base, action: "skip", reason: a.reason }];
460
+ // A registration newer than this run (the converge story's rule 6): the
461
+ // rows name both versions and, when the run proceeds, which case it was —
462
+ // the registry's latest below it, no answer, the check switched off, or for
463
+ // plan's dry run that install and upgrade read the registry first.
464
+ const newerCase = a.newer ? proceedCase(ctx.convergence, a.ownership.version) : null;
465
+ // An unchanged row carries no note by default (presentation spec contract
466
+ // 7); that its payload digest waits for a fetch is said under --verbose.
467
+ if (a.state === "current") return [{ ...base, action: "skip", reason: newerCase ? `${a.reason}; ${newerCase}` : a.reason, ...(a.versionOnly ? { digestDeferred: true } : {}) }];
468
+ // A newer registration that is damaged keeps its real reason and is never
469
+ // staged over with this run's older payload: it is repaired from its own
470
+ // version. Nothing of the host's runs, so the run is incomplete.
471
+ if (a.newer) {
472
+ ctx.incomplete = true;
473
+ return [{ ...base, action: "skip", deferred: true, ...advised(`${a.reason}${newerCase ? `; ${newerCase}` : ""}`, [repairAdvice(harness, a.ownership.version)]) }];
474
+ }
364
475
  if (!a.bin) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `\`${s.cli.bin}\` is not on PATH; no registration mutation was attempted` }]; }
476
+ // Decision 2 of the front-door ADR: the registration defers in the planned
477
+ // harness's own session, as the host-plugin registration does — by its
478
+ // session marker, or by its runtime.registration_defer_env (Codex's
479
+ // session_env is empty by design; the setup story, rule 15).
480
+ if (registrationDeferred(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${deferralReason(harness, env)}` }]; }
365
481
  const steps = [];
366
482
  const digest = a.desiredDigest;
367
483
  const mustWrite = !a.ownership || a.ownership.version !== a.desiredVersion || a.contentDiffers || a.state === "stale";
368
484
  if (mustWrite) {
369
- const files = payloadFiles(a.payloadRoot);
485
+ // Version-only, the payload is counted after the fetch: no listing, no root.
486
+ const files = a.versionOnly ? null : payloadFiles(a.payloadRoot);
370
487
  const ownership = { [s.provenance_key]: { grammar: GRAMMAR_VERSION, version: a.desiredVersion, generator: GENERATOR, digest } };
371
- steps.push({ kind: "portable-write", path: a.paths.dir, from: a.payloadRoot, files, subdir: s.plugin_subdir, catalogRel: s.manifest, catalog: renderPortableCatalog(s), ownershipRel: s.ownership_manifest, ownership, why: a.ownership ? `stage ${a.desiredVersion} over ${a.ownership.version}` : `stage ${a.desiredVersion}` });
488
+ steps.push({ kind: "portable-write", path: a.paths.dir, from: a.payloadRoot || null, files, subdir: s.plugin_subdir, catalogRel: s.manifest, catalog: renderPortableCatalog(s), ownershipRel: s.ownership_manifest, ownership, why: a.ownership ? `stage ${a.desiredVersion} over ${a.ownership.version}` : `stage ${a.desiredVersion}` });
372
489
  }
373
490
  if (!a.market) steps.push(hostStep(a, s, "marketplace_add", fill, "register the stable local marketplace source globally"));
374
491
  if (!a.installed || a.installedVersion !== a.desiredVersion || mustWrite) steps.push(hostStep(a, s, "install", fill, `materialise and enable ${a.id} from the staged source`));
@@ -382,6 +499,14 @@ function planPortableRegistration(ctx, key, s) {
382
499
  if (steps.some((x) => x.kind === "portable-write" || (x.kind === "host" && x.name !== "list"))) {
383
500
  steps.unshift({ ...hostStep(a, s, "list", fill, "check that the host can report what it installs, before anything changes"), name: "preflight" });
384
501
  }
502
+ // The dry run's first step is the fetch install or upgrade would run (rule
503
+ // 6), spelled by the run's own argv builder. plan spawns nothing, so the
504
+ // run id and the registry are literal placeholders, and the cache path is
505
+ // resolved but never made.
506
+ if (a.versionOnly && harness.install?.shell) {
507
+ const prefix = cachePaths({ env, home }).fetchRun("<run-id>") || "<cache>/fetch/<run-id>";
508
+ steps.unshift({ kind: "fetch", bin: "npm", argv: fetchArgv({ prefix, registry: "<registry>", shell: harness.install.shell, version }), why: `install and upgrade fetch ${harness.install.shell} at this version first; its payload is counted and its digest compared after the fetch` });
509
+ }
385
510
  return [{ ...base, action: a.state === "absent" ? "create" : "update", steps, verify: { version: a.desiredVersion, digest }, reason: a.reason }];
386
511
  }
387
512
 
@@ -400,14 +525,18 @@ export function registrationManifest(s, { pkg, projectDir, disabled = [], digest
400
525
 
401
526
  // Inside a live session of the host, its CLI and the session both rewrite the
402
527
  // same settings files on their own schedules; the registration is planned
403
- // only from a terminal outside one. The host marks its sessions in the
404
- // environment (manifest runtime.detect_env).
405
- function insideHostSession(env, harness) {
406
- // runtime.session_env, not detect_env: a Bash tool inside a session carries
407
- // the session marker, not the plugin-root variables a hook receives
408
- // (measured 2026-09-05; the critic's third pass caught the first draft
409
- // keying on detect_env, which never fired in a session).
410
- return (harness?.runtime?.session_env || []).some((k) => env && env[k]);
528
+ // only from a terminal outside one (insideHostSession, harness.mjs).
529
+ const inSessionReason = (harness) => `this runs inside a ${harness.display_name} session, whose exit rewrites the same settings files the host's CLI writes — run it from a terminal outside the session`;
530
+
531
+ // Why a registration defers (registrationDeferred, harness.mjs). The session's
532
+ // own marker keeps the measured reason above, byte for byte. A
533
+ // registration_defer_env variable alone names itself instead: what such a
534
+ // session does to the host's configuration is not measured, only that the
535
+ // session is there (the setup story, rule 15).
536
+ function deferralReason(harness, env) {
537
+ if (insideHostSession(env, harness)) return inSessionReason(harness);
538
+ const variable = (harness?.runtime?.registration_defer_env || []).find((k) => env && env[k]);
539
+ return `this runs inside a ${harness.display_name} session (${variable} is set), and the host's CLI would change the configuration that session runs on — run it from a terminal outside the session`;
411
540
  }
412
541
 
413
542
  function planRegistration(ctx, key, s) {
@@ -420,14 +549,17 @@ function planRegistration(ctx, key, s) {
420
549
  const fill = { dir: a.paths.dir, marketplace: s.marketplace_name, id, other: id };
421
550
  const notOnPath = `\`${binName}\` is not on PATH — the registration is left as it is; put the host's CLI on PATH and run install again`;
422
551
  const named = (ctx.surfaces || []).includes(key);
423
- const inSession = `this runs inside a ${harness.display_name} session, whose exit rewrites the same settings files the host's CLI writes — run it from a terminal outside the session`;
552
+ // The registration defers by the planned harness's session marker or its
553
+ // registration_defer_env (registrationDeferred; the setup story, rule 15).
554
+ const deferred = registrationDeferred(env, harness);
555
+ const inSession = deferred ? deferralReason(harness, env) : null;
424
556
  const other = (o) => ({ surface: `${key}_others`, kind: "registration", path: a.paths.projectSettings, entry: o.key, state: "enabled", home: base.home, scope: base.scope });
425
557
 
426
558
  if (mode === "uninstall") {
427
559
  if (a.state === "unavailable" || a.state === "absent") return [{ ...base, action: "skip", reason: a.state === "absent" ? a.reason : a.reason }];
428
560
  if (a.state === "foreign") return [{ ...base, action: "refuse", reason: a.refusal }];
429
561
  if (!bin) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: notOnPath }]; }
430
- if (insideHostSession(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: inSession }]; }
562
+ if (deferred) { ctx.incomplete = true; return [{ ...base, action: "skip", reason: inSession }]; }
431
563
  const steps = [];
432
564
  if (a.installed) steps.push(hostStep(a, s, "uninstall", fill, `the host forgets ${id} for this checkout (its row and enablement; other checkouts keep theirs)`));
433
565
  const last = a.otherProjects === 0;
@@ -441,6 +573,10 @@ function planRegistration(ctx, key, s) {
441
573
  // manifest says what install silenced somewhere; a copy the user disabled by
442
574
  // hand in another checkout is not ours to turn on (reviewer, 2026-09-05).
443
575
  for (const o of a.dir.disabled.filter((k) => a.disabledHere.includes(k))) steps.push(hostStep(a, s, "enable", { ...fill, other: o }, `${o} was silenced for this checkout by install; it is turned back on`));
576
+ // The mark goes with the registration (the converge story's rule 4): the
577
+ // binding stops recording that this checkout chose the npm install. After
578
+ // the host's steps, so a failed one leaves the record as it was.
579
+ if (ctx.binding?.hasKey) steps.push({ kind: "unmark", path: ctx.binding.path, key: CHANNEL_SWITCH_KEY, why: "the switch goes with the registration: this checkout's binding stops recording that it chose the npm install" });
444
580
  if (last && a.dir.present) steps.push({ kind: "remove", path: a.paths.dir, why: "our marketplace directory, removed whole (its manifest carries our provenance field); no other checkout is installed from it" });
445
581
  return [{ ...base, action: "remove", steps, reason: a.otherProjects ? `${a.otherProjects} other checkout(s) are installed from the shared directory; it and the host's marketplace entry stay` : a.reason }];
446
582
  }
@@ -457,10 +593,42 @@ function planRegistration(ctx, key, s) {
457
593
  const items = [];
458
594
  const needsHost = a.state !== "current" || a.others.length > 0;
459
595
  if (needsHost && !bin) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${notOnPath}` }]; }
460
- if (needsHost && insideHostSession(env, harness)) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${inSession}` }]; }
596
+ if (needsHost && deferred) { ctx.incomplete = true; return [{ ...base, action: "skip", deferred: true, reason: `${a.reason ? a.reason + "; " : ""}${inSession}` }]; }
597
+ // Our directory newer than this package and damaged (the converge story's
598
+ // rule 6): never rewritten from the older package — this checkout and every
599
+ // other would then update down to it. It is repaired from its own version;
600
+ // nothing of the host's runs, and the run is incomplete.
601
+ if (a.newer && a.dir.digestOk === false) {
602
+ ctx.incomplete = true;
603
+ const newerCase = proceedCase(ctx.convergence, a.dir.pkg);
604
+ return [{ ...base, action: "skip", deferred: true, ...advised(`the directory holds ${a.dir.pkg}, newer than this package (${a.pkg}), and its payload does not match its digest (edited by hand or half-written)${newerCase ? `; ${newerCase}` : ""}`, [repairAdvice(harness, a.dir.pkg)]) }];
605
+ }
606
+ // A copy from another marketplace newer than this package (rule 6), under
607
+ // no channel answer: switching it off would run the project on an older
608
+ // package, and the git channel serves main, often ahead of latest. The
609
+ // registration and every switch defer; no host command runs. The reason and
610
+ // its ways out print once, under the registration; each switch row points
611
+ // there.
612
+ // Newer than what the project would run with the copy off — the shared
613
+ // directory's version when another checkout wrote a newer one — not merely
614
+ // newer than this package: under that, turning it off is no downgrade.
615
+ const newerOther = a.others.filter((o) => typeof o.version === "string" && cmpPrecedence(o.version, a.targetPkg || a.pkg) > 0).sort((x, y) => cmpPrecedence(y.version, x.version))[0];
616
+ if (newerOther && (ctx.channel ?? null) === null) {
617
+ ctx.incomplete = true;
618
+ const deferral = newerCopyDeferral({ copy: newerOther, running: a.pkg, decision: ctx.convergence, harness, s });
619
+ return [{ ...base, action: "skip", deferred: true, ...deferral }, ...a.others.map((o) => ({ ...other(o), action: "skip", deferred: true, reason: `not switched off — see ${id} above` }))];
620
+ }
621
+ // What the mark (rule 2) is planned from, after the harness's rows: the
622
+ // switches this run plans, and contract 13's silenced set — the copies this
623
+ // registration turned off that this checkout still holds off — for a
624
+ // registration this checkout holds.
625
+ ctx.channelFacts = { switches: a.others.map((o) => o.key), silenced: ["current", "stale"].includes(a.state) ? a.dir.disabled.filter((k) => a.disabledHere.includes(k)) : [] };
461
626
 
462
627
  if (a.state === "current") {
463
- items.push({ ...base, action: "skip" });
628
+ // A directory and row newer than this package: rung 12, not downgraded,
629
+ // and — when the run proceeds — which case it was (rule 6).
630
+ const newerCase = a.newer ? proceedCase(ctx.convergence, a.dir.pkg) : null;
631
+ items.push({ ...base, action: "skip", ...(newerCase ? { reason: `${a.reason}; ${newerCase}` } : {}) });
464
632
  } else {
465
633
  const steps = [];
466
634
  // The directory is rewritten when it is missing, older than this package or
@@ -489,15 +657,193 @@ function planRegistration(ctx, key, s) {
489
657
  // A competing enabled registration of the same plugin is silenced for THIS
490
658
  // checkout only, as a precaution the preview names (whether two enabled copies
491
659
  // load twice is the live test's row). Our manifest records it for uninstall.
660
+ //
661
+ // That is the channel switch (the converge story's rule 2): its row says
662
+ // what it means, as the consequence line — a `note` step after the host
663
+ // step, so the JSON item keeps its keys, drawn first beneath the row
664
+ // (presentation spec contract 7). Under no channel answer it is asked, never
665
+ // silent: without a terminal the gate answers `channel` (confirm()). Under
666
+ // the channel "npm" the row also names a newer copy's versions and case
667
+ // (rule 6), and that an unbound project's switch is recorded once it is
668
+ // bound — words only that channel adds, so the null row keeps its shape.
492
669
  for (const o of a.others) {
493
670
  const steps = [];
494
671
  if (a.state === "current" && !a.dir.disabled.includes(o.key)) steps.push({ kind: "write", path: a.paths.dir, files: 0, manifestOnly: true, manifest: { ...a.dir.manifest, [s.provenance_key]: { ...a.dir.prov, disabled: [...a.dir.disabled, o.key] } }, why: "our manifest records what install silences, so uninstall can turn it back on" });
495
672
  steps.push(hostStep(a, s, "disable", { ...fill, other: o.key }, `${o.key} (${o.version}) is enabled for this checkout too; two enabled copies of one plugin would load twice — silenced in this checkout's local settings only, never globally`));
496
- items.push({ ...other(o), action: "disable", steps });
673
+ steps.push(consequenceStep(switchConsequence(harness)));
674
+ const reason = ctx.channel === "npm" ? switchReason({ copy: o, running: a.pkg, runs: a.targetPkg, decision: ctx.convergence, unbound: !ctx.binding?.bound }) : null;
675
+ items.push({ ...other(o), action: "disable", ...(reason ? { reason } : {}), steps });
497
676
  }
498
677
  return items;
499
678
  }
500
679
 
680
+ // A row's consequence (presentation spec contract 7) carried as a `note` step:
681
+ // the envelope reads `{ kind: "note", why }`, as any note; PLAN draws it first
682
+ // beneath its row, in the explanation role, by the mark kept off the envelope.
683
+ function consequenceStep(why) {
684
+ const st = { kind: "note", why };
685
+ Object.defineProperty(st, "consequence", { value: true, enumerable: false });
686
+ return st;
687
+ }
688
+ const isConsequence = (st) => st?.kind === "note" && st.consequence === true;
689
+ // A channel switch: a copy from another marketplace turned off for this checkout.
690
+ const isSwitch = (i) => String(i?.surface || "").endsWith("_others") && i.action === "disable";
691
+
692
+ // The channel "git" (the converge story's rules 1 and 5), for the first
693
+ // harness of the run with a host plugin registration — the channel concerns
694
+ // that registration alone; Codex has none. The copy kept is contract 12's
695
+ // selection (lib's selectCopy), limited to copies from another marketplace —
696
+ // the git marketplace, a fork's — so the npm registration's copy is never the
697
+ // kept one. None enabled for this project is a refusal: there is nothing to
698
+ // keep. A copy of ours this checkout's local settings hold off — a hand
699
+ // removal of the npm row leaves the git copy off — is named with the way to
700
+ // turn it back on. Read-only; plan() and the prepare step both call it, so the
701
+ // refusal is decided before any shell fetch.
702
+ // → null: no harness of the run has a host plugin registration
703
+ // → { harness, key, s, copy: null, refusal }: nothing to keep
704
+ // → { harness, key, s, copy }: the copy, with selectCopy's capability read
705
+ function keepTarget(projectDir, ids, { root, home, env }) {
706
+ for (const id of ids) {
707
+ const harness = loadHarness(id);
708
+ const row = Object.entries(harness?.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s?.format === "host-plugin-registration");
709
+ if (!row) continue;
710
+ const [key, s] = row;
711
+ const copy = selectCopy(home, projectDir, { harness, only: "marketplace" });
712
+ if (copy) return { harness, key, s, copy };
713
+ const a = analyseRegistration(projectDir, s, { root, home, harness, env });
714
+ const off = (a.disabledHere || []).filter((k) => k !== a.id && k.split("@")[0] === s.plugin_name);
715
+ return { harness, key, s, copy: null, refusal: noCopyToKeep({ harness, surface: key, off }) };
716
+ }
717
+ return null;
718
+ }
719
+
720
+ // The environment the kept copy's bin runs in, for its plan's read and for
721
+ // the keep step: the run's own, through childEnv for who called, then the
722
+ // KEPT harness's own runtime names from its manifest — never the caller's
723
+ // active harness's, which from a Codex terminal would name Codex's: the
724
+ // copy's root as its plugin root, so the child renders from itself; the
725
+ // project; and its home pinned, as every host command's is, so the child reads
726
+ // the home this run read and never another. Every other manifest's plugin-root
727
+ // variable is dropped: the child is that one harness's copy.
728
+ function keepEnv(env, { harness, projectDir, copyRoot, harnessHome }) {
729
+ const out = childEnv(env);
730
+ for (const m of loadHarnesses().values()) if (m.runtime?.plugin_root_env) delete out[m.runtime.plugin_root_env];
731
+ const r = harness.runtime || {};
732
+ if (r.plugin_root_env) out[r.plugin_root_env] = copyRoot;
733
+ if (r.project_dir_env) out[r.project_dir_env] = projectDir;
734
+ if (r.home_env) out[r.home_env] = harnessHome;
735
+ return out;
736
+ }
737
+
738
+ // The surface key the keep's one item carries (kind `channel`).
739
+ const KEEP_SURFACE = "channel";
740
+
741
+ // The keep (the converge story's rule 5): this project goes on loading the
742
+ // git-marketplace copy, and that copy renders its own surfaces here. One item
743
+ // of kind `channel` under the harness, in place of every row of its own —
744
+ // registration, competing copies, status line, launcher, host-managed report.
745
+ // Its one step is host-style: the copy's bin in the layout remedy's form
746
+ // (keepArgv), its touches the writes the copy's own plan listed (`read`, made
747
+ // by the prepare step) less the agents block's rows, which this run's one
748
+ // block item writes before it (the story "One run plans the agents block
749
+ // once…", rule 1). No write left: no item. The other rows it can be:
750
+ // - a copy that cannot run the form (lib's capability read): skip, with the
751
+ // update in its channel — in the row's reason, since DONE never prints when
752
+ // nothing else writes, and as DONE's next line;
753
+ // - a plan that printed no result: skip, naming the copy's own upgrade, which
754
+ // the person runs with its own preview — never run unpreviewed;
755
+ // - a plan that refused: a refuse row, and each refusal on the plan's, led by
756
+ // the copy's key and version;
757
+ // - inside the harness's own session: skip, deferred, as the registration is;
758
+ // - a copy plan that left work for later and lists no write (its layout move,
759
+ // deferred inside a session): skip, deferred, naming what it deferred — the
760
+ // run never reads as converged while the copy has work pending;
761
+ // - a run narrowed by `--surface`: skip — the copy's upgrade takes no
762
+ // --surface (naming the status line would opt it in), so a narrowed run
763
+ // leaves this harness's surfaces to it.
764
+ // This checkout's npm row enabled beside the copy stays, and is named with its
765
+ // uninstall (rule 4), in the row and in DONE.
766
+ function planKeep(ctx, target, read) {
767
+ const { projectDir, env, home, root, harness, surfaces } = ctx;
768
+ const { key, s, copy } = target;
769
+ const a = analyseRegistration(projectDir, s, { root, home, harness, env });
770
+ const beside = a.installed?.present && a.enabled ? npmRowBeside({ harness, id: a.id, surface: key }) : null;
771
+ const besides = beside ? [beside] : [];
772
+ const named = `${copy.key} ${copy.version || "(no version recorded)"}`;
773
+ // Built whole, never spread into place: `next` (what DONE says) and `kept`
774
+ // (DONE's fact and its tip: the copy, and whether its CLI takes --verbose)
775
+ // stay off the envelope as properties that are not enumerable.
776
+ const item = (fields, next = []) => {
777
+ const it = { harness: harness.id, surface: KEEP_SURFACE, kind: "channel", path: copy.path, entry: copy.key, state: "kept", home: claudeHome(home), scope: copy.scope || null, ...fields };
778
+ const all = [...next, ...besides];
779
+ if (all.length) Object.defineProperty(it, "next", { value: all, enumerable: false });
780
+ Object.defineProperty(it, "kept", { value: { key: copy.key, version: copy.version || null, bin: copy.missing ? null : copy.bin, verbose: !copy.missing && takesVerbose(copy.path) }, enumerable: false });
781
+ return it;
782
+ };
783
+ const kept = `this project keeps ${named} from the git marketplace, and that copy writes its own ${harness.display_name} surfaces here — no registration changes`;
784
+ if (surfaces && surfaces.length) {
785
+ return [item({ action: "skip", ...advised(`${kept}; ${named}'s own upgrade takes no --surface, so a run narrowed by --surface leaves these surfaces to it — run without --surface to converge them`, besides) })];
786
+ }
787
+ if (copy.missing) {
788
+ ctx.incomplete = true;
789
+ const advice = copyUpdateAdvice(copy, { cannot: copy.missing === "bin" ? "predates the move and cannot write this project's surfaces itself" : "predates --no-register and cannot write this project's surfaces without registering", then: "then run this again" });
790
+ return [item({ action: "skip", deferred: true, ...advised(advice, besides) }, [{ lead: advice }])];
791
+ }
792
+ if (!read) {
793
+ ctx.incomplete = true;
794
+ return [item({ action: "skip", deferred: true, ...advised(`${kept}; install and upgrade read that copy's own plan first, and this plan did not`, besides) })];
795
+ }
796
+ if (!read.envelope) {
797
+ ctx.incomplete = true;
798
+ const how = read.error || (read.status !== null && read.status !== undefined ? `it exited ${read.status}` : "it printed nothing");
799
+ const said = read.stderr ? `: ${read.stderr.split("\n")[0]}` : "";
800
+ const own = ownUpgrade(copy, harness);
801
+ return [item({ action: "skip", deferred: true, ...advised(`${named}'s own plan printed no result to read (${how}${said}), so nothing is planned for it.`, [own, ...besides]) }, [own])];
802
+ }
803
+ if (!read.ok) {
804
+ const quoted = read.refusals.length ? read.refusals : ["its plan refused and named no reason"];
805
+ ctx.refusals.push(...quoted.map((r) => `${named}: ${r}`));
806
+ return [item({ state: "refused", action: "refuse", reason: `the kept copy's own plan refused, so nothing is written: ${quoted.join("; ")}`, advice: { text: `${named}'s own plan refused (below), so nothing is written`, blocks: [] } })];
807
+ }
808
+ const block = agentsBlockRow(harness);
809
+ const blockRows = block ? [block[0], `${block[0]}_import`] : [];
810
+ const writes = read.writes.filter((w) => !blockRows.includes(w.surface));
811
+ if (!writes.length && read.incomplete) {
812
+ ctx.incomplete = true;
813
+ const why = (read.deferred || []).filter((d) => !blockRows.includes(d.surface)).map((d) => (d.reason ? `${d.surface}: ${d.reason}` : d.surface));
814
+ return [item({ action: "skip", deferred: true, ...advised(`${named}'s own plan left work for later${why.length ? ` — ${why.join("; ")}` : ", and names none of it"}`, besides) })];
815
+ }
816
+ if (!writes.length) return beside ? [item({ action: "skip", ...advised(`${named} has nothing to change here`, besides) })] : [];
817
+ if (insideHostSession(env, harness)) {
818
+ ctx.incomplete = true;
819
+ return [item({ action: "skip", deferred: true, ...advised(`${kept}; ${inSessionReason(harness)}`, besides) })];
820
+ }
821
+ const step = { kind: "host", name: "keep", argv: keepArgv(copy, { verb: "upgrade", harness, projectDir }), why: `the kept copy's own upgrade, from its own bin without registering (the layout remedy's form), read back as JSON; it re-plans from the files as they stand then, after the agents block above`, touches: writes };
822
+ return [item({ action: "update", ...advised(kept, besides), steps: [step] })];
823
+ }
824
+
825
+ // The mark (the converge story's rule 2), for the channel "npm" only: one
826
+ // item of kind `binding`, last in its harness's group, adding `channel_switch:
827
+ // { by: "setup", at: <the time of apply> }` to the binding and keeping every
828
+ // other key. Planned when this run switches a copy off, bound or not; or, to
829
+ // adopt an earlier switch, when the registration this checkout holds already
830
+ // keeps a copy off and a binding exists to record it in. Never over a mark
831
+ // already there: a re-run after a switch is zero-change. The file is the one
832
+ // configPath resolves at apply time, after the layout item moved it; the
833
+ // preview names the new path when the plan moves the binding.
834
+ function planMark(ctx, facts) {
835
+ const b = ctx.binding;
836
+ if (!b || b.hasMark || !facts) return null;
837
+ const switching = facts.switches.length > 0;
838
+ const adopting = !switching && facts.silenced.length > 0 && b.bound;
839
+ if (!switching && !adopting) return null;
840
+ const reason = !b.bound ? "the project is not bound yet: the switch is recorded only if a binding exists by the time of apply"
841
+ : adopting ? `this checkout already holds ${facts.silenced.join(", ")} off for the npm registration; the switch is recorded`
842
+ : null;
843
+ return { surface: CHANNEL_SWITCH_KEY, kind: "binding", path: b.path, entry: CHANNEL_SWITCH_KEY, state: b.bound ? "absent" : "unbound", action: "add", reason,
844
+ steps: [{ kind: "mark", path: b.path, key: CHANNEL_SWITCH_KEY, why: "this checkout chose the npm install; doctor stops naming the copy it turned off, and the registration's uninstall removes the record with it" }] };
845
+ }
846
+
501
847
  function planJsonEntry(ctx, key, s) {
502
848
  const { projectDir, mode, root, home } = ctx;
503
849
  if (s.supported === false) {
@@ -587,18 +933,60 @@ function planStampedFile(ctx, key, s) {
587
933
 
588
934
  // ─── plan ──────────────────────────────────────────────────────────────
589
935
 
590
- export function plan(projectDir, { harnesses = [], mode = "install", env = process.env, home = homedir(), root = pluginRoot(), surfaces = null, globalRemoval = false, register = true } = {}) {
936
+ // The surface rows a run leaves out: a --surface that does not name the key
937
+ // (by prefix, so `statusline` covers the launcher), or --no-register's
938
+ // registration. One predicate for plan() and the shell fetch's decision.
939
+ function surfaceExcluded(key, s, { surfaces = null, register = true } = {}) {
940
+ return Boolean(surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) || (register === false && s.kind === "registration");
941
+ }
942
+
943
+ // `state` selects by the state directory too (upgrade's set, the shell-fetch
944
+ // story's rule 5); `version` is the running root's, for a shell-rooted
945
+ // registration planned without its payload; `payloadRoots` holds a fetched
946
+ // root per harness id — never PROJECTSTORE_DISTRIBUTION_ROOT, which names one
947
+ // root for every harness and is inherited by every child the run spawns.
948
+ // `layout` is a layout NAME: the agents block's roster is rendered from it,
949
+ // the layout the run leaves; null keeps the binding's (the story "One run
950
+ // plans the agents block once…", rule 3). `convergence` is the prepare step's
951
+ // decision about the copies this project runs (convergence.mjs, the converge
952
+ // story's rule 6): a stop is rendered as the plan's refusal, and the proceed
953
+ // cases as the newer rows' words. plan() reads no registry and decides
954
+ // nothing about it; without a decision (plan's dry run) a newer row says
955
+ // that install and upgrade read the registry first — from a root that checks
956
+ // at all. `fetchedFrom` names the project's registry a shell was fetched
957
+ // from, when it is not the user's ({ registry, user, harnesses }).
958
+ // `channel` is the answer to setup's channel question (the converge story's
959
+ // rule 1): "npm" switches a copy from another marketplace off and records the
960
+ // switch in the binding; "git" keeps it; null — what install and upgrade pass
961
+ // — plans the switch as before, and the gate asks it at a terminal and refuses
962
+ // it without one. `keep` is the prepare step's read of the kept copy's own
963
+ // plan under the channel "git" ({ copy, read }, rule 5); without it the keep's
964
+ // row says that copy's plan was not read.
965
+ export function plan(projectDir, { harnesses = [], mode = "install", env = process.env, home = homedir(), root = pluginRoot(), surfaces = null, globalRemoval = false, register = true, state = false, version = null, payloadRoots = null, layout = null, convergence = null, fetchedFrom = null, channel = null, keep = null } = {}) {
591
966
  projectDir = resolve(projectDir);
592
- const detected = detectHarnesses(projectDir);
967
+ const detected = detectHarnesses(projectDir, { state });
593
968
  const named = harnesses.filter(Boolean);
594
969
  const ids = named.length ? named : detected.map((d) => d.id);
595
970
  const out = { projectDir, mode, named: named.length > 0, detected, harnesses: [], reports: [], items: [], refusals: [], ok: true, incomplete: false, root, plannedAgainst: {} };
971
+ // Not enumerable, like hostManaged: plan --json keeps the shape it had.
972
+ Object.defineProperty(out, "version", { value: version, enumerable: false });
973
+ Object.defineProperty(out, "payloadRoots", { value: { ...(payloadRoots || {}) }, enumerable: false });
974
+ Object.defineProperty(out, "convergence", { value: convergence, enumerable: false });
975
+ Object.defineProperty(out, "fetchedFrom", { value: fetchedFrom, enumerable: false });
976
+ // The channel answer, for the gate (confirm()): a switch planned under null
977
+ // is asked at a terminal and refused without one.
978
+ Object.defineProperty(out, "channel", { value: channel, enumerable: false });
596
979
  const unknown = named.filter((id) => !harnessIds().includes(id));
597
980
  if (unknown.length) {
598
981
  out.refusals.push(`unknown harness: ${unknown.join(", ")} — known: ${harnessIds().join(", ")}`);
599
982
  out.ok = false;
600
983
  return out;
601
984
  }
985
+ if (![null, "npm", "git"].includes(channel)) {
986
+ out.refusals.push(`unknown channel: ${channel} — known: npm, git`);
987
+ out.ok = false;
988
+ return out;
989
+ }
602
990
  // A project that does not exist is refused before contract 8's detection
603
991
  // could stand in for it ("no harness detected"), and before anything is
604
992
  // written, staged, locked or spawned (contract 19).
@@ -610,10 +998,40 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
610
998
  return out;
611
999
  }
612
1000
  if (!ids.length) {
613
- out.refusals.push(harnessRefusal(projectDir));
1001
+ out.refusals.push(harnessRefusal(projectDir, { state }));
1002
+ out.ok = false;
1003
+ return out;
1004
+ }
1005
+ // The stop (rule 6), decided in the prepare step: this project runs a newer
1006
+ // copy and the registry's latest reaches it. Nothing is planned; the
1007
+ // refusal carries the whole text, and its two commands — each printed on a
1008
+ // line of its own (presentation spec contract 6) — are kept off the
1009
+ // envelope, as hostManaged is.
1010
+ if (convergence?.stop) {
1011
+ out.harnesses = ids;
1012
+ out.refusals.push(convergence.stop.text);
614
1013
  out.ok = false;
1014
+ Object.defineProperty(out, "stop", { value: { lead: convergence.stop.lead, commands: [...convergence.stop.commands], between: convergence.stop.between }, enumerable: false });
615
1015
  return out;
616
1016
  }
1017
+ // The channel "git" (rule 1): with no copy from another marketplace enabled
1018
+ // for this project there is nothing to keep, and nothing is planned. The
1019
+ // refusal carries the whole text; its commands, each printed on a line of
1020
+ // its own, are kept off the envelope, as the stop's are. Otherwise the
1021
+ // harness the copy is kept for is planned by the keep alone (rule 5).
1022
+ const keeping = channel === "git" && mode !== "uninstall" ? keepTarget(projectDir, ids, { root, home, env }) : null;
1023
+ if (keeping && !keeping.copy) {
1024
+ out.harnesses = ids;
1025
+ out.refusals.push(keeping.refusal.reason);
1026
+ out.ok = false;
1027
+ if (keeping.refusal.advice) Object.defineProperty(out, "refusalAdvice", { value: keeping.refusal.advice, enumerable: false });
1028
+ return out;
1029
+ }
1030
+ // The prepare step's read, when it read the copy this plan keeps.
1031
+ const keepRead = keeping && keep?.copy?.path === keeping.copy.path ? keep.read ?? null : null;
1032
+ // What the newer rows say: the prepare step's decision; for a dry run, the
1033
+ // note that install and upgrade read first — only from a root that checks.
1034
+ const decision = convergence ?? (mode === "install" && !checksRoot(root, { home, env }) ? { checks: false } : null);
617
1035
  const cfg = readConfigAt(projectDir);
618
1036
  const optIn = new Set(surfaces || []);
619
1037
  if (cfg?.statusline?.enabled === true) optIn.add("statusline");
@@ -622,13 +1040,83 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
622
1040
  // planned against the new paths; its cleanup is planned last (below).
623
1041
  const layoutHarness = loadHarness(ids.find((id) => { const p = layoutPaths(projectDir, { harnessDir: loadHarness(id).runtime?.harness_dir || null }); return existsSync(p.legacy.binding) || existsSync(p.legacy.runtime); }) || ids[0]);
624
1042
  const layoutCtx = { projectDir, mode, env, home, root, harness: layoutHarness, incomplete: false, surfaces };
625
- const layout = planLayout(layoutCtx);
626
- for (const item of layout.first) out.items.push({ harness: layoutHarness.id, ...item });
1043
+ // Under the keep, the layout move and its cleanup are the kept copy's own
1044
+ // run when their harness is the one kept: moved under a pre-move copy, the
1045
+ // project would read as unbound to it (rule 5).
1046
+ const moved = keeping && layoutHarness.id === keeping.harness.id ? { first: [], last: [] } : planLayout(layoutCtx);
1047
+ for (const item of moved.first) out.items.push({ harness: layoutHarness.id, ...item });
627
1048
  if (layoutCtx.incomplete) out.incomplete = true;
1049
+ // The binding a channel switch is recorded in (the converge story's rules 2
1050
+ // and 4): the file configPath resolves now, whether it exists, and whether it
1051
+ // carries the mark. Apply resolves it again, after the layout item; when the
1052
+ // plan moves the binding, the preview names where the move puts it.
1053
+ const binding = (() => {
1054
+ const at = configPath(projectDir, env);
1055
+ const bound = existsSync(at);
1056
+ let cfg = null;
1057
+ if (bound) { try { cfg = JSON.parse(readFileSync(at, "utf8")); } catch { cfg = null; } }
1058
+ const movesIt = moved.first.some((i) => i.action === "migrate");
1059
+ return { path: movesIt ? layoutPaths(projectDir).binding : at, bound, hasMark: Boolean(channelMark(cfg)), hasKey: Boolean(cfg && typeof cfg === "object" && Object.hasOwn(cfg, CHANNEL_SWITCH_KEY)) };
1060
+ })();
1061
+ const marks = [];
1062
+ // The agents block is the PROJECT's, not a harness's (the story "One run
1063
+ // plans the agents block once, and a bare uninstall selects every harness
1064
+ // the project uses", rule 1): planned once for the run, before the loop and
1065
+ // from the files as they stand before it, by the first run harness that has
1066
+ // one, over the files every run harness names, with an import in every run
1067
+ // harness's native file. Planned per harness, each read the same snapshot,
1068
+ // so two harnesses wrote two blocks and an uninstall of both kept it for
1069
+ // "another harness" in the same run. Rendered from `root`, never from a
1070
+ // registration's install path or a fetched shell: every copy renders the
1071
+ // same block. --surface narrows it as it narrowed each harness's row.
1072
+ const blockRun = ids.map((id) => loadHarness(id)).filter((m) => agentsBlockRow(m));
1073
+ const owner = blockRun[0] || null;
1074
+ let blockItems = [];
1075
+ let blockKey = null;
1076
+ if (owner) {
1077
+ const [key, s] = agentsBlockRow(owner);
1078
+ if (!surfaceExcluded(key, s, { surfaces, register })) {
1079
+ const readers = blockRun.length > 1 ? blockRun.map((m) => ({ display: m.display_name, files: agentsBlockRow(m)[1].files || [] })) : null;
1080
+ blockItems = planAgentsBlock({ projectDir, mode, root, harness: owner, run: blockRun, readers, layout, surfaces: surfaces || [] }, key, { ...s, files: commonBlockFiles(blockRun) });
1081
+ blockKey = key;
1082
+ }
1083
+ }
1084
+ // Placed once, at the owner's own row, so one harness's plan keeps its order;
1085
+ // after the loop if that row never came. The seam for the convergence
1086
+ // story's keep step: it calls placeBlock() before it pushes itself, so the
1087
+ // block precedes the keep and covers the whole run, Claude Code included.
1088
+ let placed = false;
1089
+ const placeBlock = () => {
1090
+ if (placed) return;
1091
+ placed = true;
1092
+ for (const item of blockItems) out.items.push({ harness: owner.id, ...item });
1093
+ };
1094
+ // The other run harnesses plan no block item; the preview names the owner's
1095
+ // at their position (presentation spec contract 7) — when there IS a block,
1096
+ // on disk or planned: the one written or kept, else the one removed. Never
1097
+ // under "no block to remove" or a refusal, where "the same block" names
1098
+ // nothing. Not enumerable, like hostManaged: plan --json keeps the shape it
1099
+ // had.
1100
+ const blockOf = (i) => i.surface === blockKey;
1101
+ const shown = blockItems.find((i) => blockOf(i) && (["add", "create", "replace-entry"].includes(i.action) || (i.action === "skip" && i.state !== "ours-absent")))
1102
+ || blockItems.find((i) => blockOf(i) && i.action === "remove");
1103
+ Object.defineProperty(out, "sharedBlock", { value: shown ? { harness: owner.id, display: owner.display_name, file: rel(projectDir, shown.path), others: [] } : null, enumerable: false });
628
1104
  for (const id of ids) {
629
1105
  const harness = loadHarness(id);
630
1106
  out.harnesses.push(id);
631
- const ctx = { projectDir, mode, env, home, root, harness, optIn, slotForeign: new Set(), incomplete: false, renderRoot: root, surfaces: surfaces || [], globalRemoval };
1107
+ const ctx = { projectDir, mode, env, home, root, harness, optIn, slotForeign: new Set(), incomplete: false, renderRoot: root, surfaces: surfaces || [], globalRemoval, version, fetched: payloadRoots?.[id] || null, convergence: decision, channel, binding, channelFacts: null, refusals: [] };
1108
+ // The keep (rule 5): the run's one block first — over the whole run set,
1109
+ // this harness included — then the keep's one item, and none of this
1110
+ // harness's own rows. Placed here when this harness owns the block; named
1111
+ // as the same block, above, when an earlier harness placed it.
1112
+ if (keeping && id === keeping.harness.id) {
1113
+ if (id !== owner?.id && out.sharedBlock && agentsBlockRow(harness)) out.sharedBlock.others.push({ harness: id, display: harness.display_name, at: out.items.length });
1114
+ placeBlock();
1115
+ for (const item of planKeep(ctx, keeping, keepRead)) out.items.push(item);
1116
+ out.refusals.push(...ctx.refusals);
1117
+ if (ctx.incomplete) out.incomplete = true;
1118
+ continue;
1119
+ }
632
1120
  const hostRows = [];
633
1121
  const unsupportedHost = [];
634
1122
  let registration = null;
@@ -638,7 +1126,7 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
638
1126
  // --no-register: this run changes the project's files and nothing of the
639
1127
  // host's (the layout move run from an installed copy; the layout spec,
640
1128
  // contract 12 as amended 2026-10-03).
641
- const excluded = (surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) || (register === false && s.kind === "registration");
1129
+ const excluded = surfaceExcluded(key, s, { surfaces, register });
642
1130
  if (excluded) {
643
1131
  // A registration this run leaves out still decides the render root,
644
1132
  // read-only — HERE, before the surfaces that render against it (the
@@ -649,8 +1137,9 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
649
1137
  // fixes, S1).
650
1138
  if (s.kind === "registration" && !isPluginCacheRoot(root, home)) {
651
1139
  const analyser = s.format === "portable-plugin-registration" ? analysePortableRegistration : analyseRegistration;
652
- const a = analyser(projectDir, s, { root, home, harness, env });
653
- if (a.installed && (a.installed.present ?? true) && a.enabled) ctx.renderRoot = a.installPath;
1140
+ const a = analyser(projectDir, s, { root, home, harness, env, version, fetched: ctx.fetched });
1141
+ const installed = registrationRenderRoot(a);
1142
+ if (installed) ctx.renderRoot = installed;
654
1143
  }
655
1144
  continue;
656
1145
  }
@@ -662,6 +1151,13 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
662
1151
  // waiting for it somewhere. An unsupported surface is named below by its
663
1152
  // own row, with the reason the manifest gives.
664
1153
  if (s.kind === "host") { if (s.supported !== false) hostRows.push(key); else unsupportedHost.push([key, s]); continue; }
1154
+ // The run's block, planned above: the owner places it here, every other
1155
+ // harness only records where the preview names it.
1156
+ if (s.format === "markdown-block") {
1157
+ if (id === owner?.id) placeBlock();
1158
+ else if (out.sharedBlock) out.sharedBlock.others.push({ harness: id, display: harness.display_name, at: out.items.length });
1159
+ continue;
1160
+ }
665
1161
  const handler = HANDLERS[s.format];
666
1162
  if (!handler) { out.refusals.push(`${id}: surface ${key} has format ${s.format}, which this installer cannot handle`); continue; }
667
1163
  const items = handler(ctx, key, s);
@@ -671,7 +1167,11 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
671
1167
  // Phase two (contract 4′): the rest is planned against the root the
672
1168
  // registration produces — when it produces one on this run or has.
673
1169
  const own = items.find((i) => i.surface === key);
674
- registration = own || null;
1170
+ // The host-managed report names where a host plugin registration is
1171
+ // loaded in place: the plugin's root in our directory, from the same
1172
+ // registrationPaths the analysis used. Kept off the item, so plan
1173
+ // --json keeps the shape it had.
1174
+ registration = own ? { ...own, payload: s.format === "host-plugin-registration" ? registrationPaths(s, { home, projectDir, harness }).payload : null } : null;
675
1175
  if (own && mode !== "uninstall" && ["create", "update", "skip"].includes(own.action) && own.root && !own.deferred) ctx.renderRoot = own.root;
676
1176
  }
677
1177
  }
@@ -696,8 +1196,24 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
696
1196
  for (const [key, s] of unsupportedHost) {
697
1197
  out.items.push({ harness: id, surface: key, kind: "host", path: null, entry: null, state: "unsupported", action: "skip", reason: s.why_unsupported || "not supported for this harness yet" });
698
1198
  }
1199
+ // The mark, for the channel "npm" only: placed last of all (below), so it
1200
+ // is the last row of this harness's group and applies after the layout
1201
+ // item has moved the binding it is recorded in.
1202
+ if (mode !== "uninstall" && channel === "npm") {
1203
+ const mark = planMark(ctx, ctx.channelFacts);
1204
+ if (mark) marks.push({ harness: id, ...mark });
1205
+ }
1206
+ // Last for this harness: its state directory, which a project uninstall
1207
+ // empties of the welcome marker and removes once empty. A narrowed
1208
+ // uninstall plans no such step, as it plans no layout cleanup.
1209
+ if (mode === "uninstall" && !surfaces) {
1210
+ const item = planHarnessState(projectDir, harness, out.items.filter((i) => i.harness === id));
1211
+ if (item) out.items.push({ harness: id, ...item });
1212
+ }
699
1213
  }
700
- for (const item of layout.last) out.items.push({ harness: layoutHarness.id, ...item });
1214
+ placeBlock();
1215
+ for (const item of moved.last) out.items.push({ harness: layoutHarness.id, ...item });
1216
+ out.items.push(...marks);
701
1217
  if (out.items.some((i) => i.action === "refuse")) out.ok = false;
702
1218
  if (out.refusals.length) out.ok = false;
703
1219
  return out;
@@ -732,7 +1248,7 @@ function planLayout(ctx) {
732
1248
  if (!a.legacy.runtime || !a.legacy.runtimeOurs) return none;
733
1249
  return { first: [], last: [{ ...base, surface: "layout_cleanup", path: P.legacy.runtime, state: "legacy", action: "remove", reason: "the legacy runtime directory is ours (its .gitignore header) and goes with the state", steps: [
734
1250
  { kind: "remove-legacy-runtime", path: P.legacy.runtime, why: "the pre-0.28 state directory, removed whole" },
735
- { kind: "note", why: ".projectstore/state/ (sessions, the entry log, the welcome marker) and the binding stay: the hooks' records and bind's file, not install's — delete .projectstore/ by hand to disown fully" },
1251
+ { kind: "note", why: ".projectstore/state/ (sessions, the entry log) and the binding stay: the hooks' records and bind's file, not install's — delete .projectstore/ by hand to disown fully" },
736
1252
  ] }] };
737
1253
  }
738
1254
  if (a.twoConfigs && !a.resumable) return { first: [{ ...base, action: "refuse", reason: `two bindings: ${P.legacy.binding} (legacy) and ${P.binding} — keep one and delete the other (usually the legacy one), then run install again; nothing is written while both exist` }], last: [] };
@@ -758,6 +1274,29 @@ function planLayout(ctx) {
758
1274
  return { first, last };
759
1275
  }
760
1276
 
1277
+ // A project uninstall's last item per harness (the shell-fetch story's rule
1278
+ // 5; install spec contract 13 as amended): .projectstore/state/<id>/ is
1279
+ // emptied of the welcome marker — recognised by analyseHarnessState — and
1280
+ // removed once empty, so the next bare upgrade stops selecting the harness
1281
+ // it disowned. Planned for a directory holding the marker, or nothing; a
1282
+ // directory holding only what is not ours is left without a row. It never
1283
+ // touches a launcher: that is its surface item's to remove, when it is ours.
1284
+ // What stays — a launcher this plan does not remove, a file of the user's —
1285
+ // is named in the preview, and keeps the directory. Not a guarantee: a
1286
+ // harness whose plugin stays installed for the machine fires its hooks here
1287
+ // again, its welcome comes back, and so does the signal — accurately.
1288
+ function planHarnessState(projectDir, harness, planned) {
1289
+ const a = analyseHarnessState(projectDir, harness.id);
1290
+ if (!a.present || (!a.marker && a.names.length)) return null;
1291
+ const removing = new Set(planned.filter((i) => i.kind === "exclusive" && i.action === "remove" && dirname(i.path) === a.dir).map((i) => basename(i.path)));
1292
+ const left = a.others.filter((n) => !removing.has(n));
1293
+ const steps = [];
1294
+ if (a.marker) steps.push({ kind: "delete", path: a.marker, why: "the welcome marker the SessionStart hook wrote; a bare upgrade selects the harness by it" });
1295
+ steps.push({ kind: "rmdir-state", path: a.dir, left, why: left.length ? `${left.join(", ")} ${left.length === 1 ? "is" : "are"} not this step's to remove, and keep${left.length === 1 ? "s" : ""} the directory` : "removed once empty" });
1296
+ return { surface: "harness_state", kind: "layout", path: a.dir, entry: null, state: a.marker ? "ours" : "empty", action: "remove",
1297
+ reason: a.marker ? `${harness.display_name}'s welcome marker — the state directory selects ${harness.display_name} for a bare upgrade` : `an empty state directory of ${harness.display_name}`, steps };
1298
+ }
1299
+
761
1300
  function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
762
1301
  const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
763
1302
  const within = p.projectDir;
@@ -818,6 +1357,15 @@ function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
818
1357
  out.steps.push({ kind: st.kind, ok: true, removed: true });
819
1358
  }
820
1359
  else if (st.kind === "remove-legacy-runtime") { removeInside(st.path, within, { recursive: true }); out.steps.push({ kind: st.kind, ok: true, removed: true }); }
1360
+ else if (st.kind === "rmdir-state") {
1361
+ // rmdirSync on an empty directory only: whatever is left — read now,
1362
+ // after the items before it ran — keeps it, and is named.
1363
+ let left = [];
1364
+ try { left = readdirSync(st.path).sort(); } catch { out.steps.push({ kind: st.kind, ok: true, removed: false }); continue; }
1365
+ if (left.length) { out.steps.push({ kind: st.kind, ok: true, removed: false, reason: `${left.join(", ")} remain` }); continue; }
1366
+ rmdirSync(st.path);
1367
+ out.steps.push({ kind: st.kind, ok: true, removed: true });
1368
+ }
821
1369
  } catch (e) { return fail(st.kind, e && e.message ? e.message : String(e)); }
822
1370
  finally { if (onStep) onStep(st, "end", stepResult(out, seen)); }
823
1371
  }
@@ -883,7 +1431,14 @@ function hostManagedReport(m, rows, registration = null) {
883
1431
  const lines = [`${m.display_name}: ${rows.join(", ")} are installed by ${inst.mechanism || "the host"} — nothing to write.`];
884
1432
  if (registration && registration.entry) {
885
1433
  const how = registration.action === "skip" && registration.state === "current" ? "registered here" : registration.action === "refuse" ? "refused, see below" : registration.action === "skip" ? "not registered on this run, see below" : `registered by this run (${registration.action})`;
886
- lines.push(` They come from the registration ${registration.entry}, ${how}${registration.root ? ` — the host loads them from ${registration.root}` : ""}.`);
1434
+ // A host plugin registration is a marketplace added from a local path, and
1435
+ // the host loads its plugin in place, from the plugin's root inside that
1436
+ // directory (`payload`, set by plan()); the install path is the registry's
1437
+ // copy, which the launcher renders (the story "The SessionStart hook and
1438
+ // install agree on the status line…", measured on host 2.1.293 and
1439
+ // 2.1.296). A portable registration's host loads the copy.
1440
+ const where = !registration.root ? "" : registration.payload ? ` — install path ${registration.root}; the host loads them in place from ${registration.payload}` : ` — the host loads them from ${registration.root}`;
1441
+ lines.push(` They come from the registration ${registration.entry}, ${how}${where}.`);
887
1442
  }
888
1443
  if (inst.why_not_scripted) lines.push(` ${inst.why_not_scripted}`);
889
1444
  for (const s of inst.steps || []) lines.push(` ${s}`);
@@ -896,119 +1451,286 @@ const isWrite = (i) => !["skip", "refuse"].includes(i.action);
896
1451
 
897
1452
  // ─── preview ───────────────────────────────────────────────────────────
898
1453
  //
899
- // Contract 18: a header, then PLAN — one line per item (what, where, the state
900
- // transition) with its steps beneath. Every path written and every host argv
901
- // with the files it touches stays in the default view: that is contract 9's
902
- // consent content, and a reader that is an agent needs it as much as a person.
903
- // The explanations — the host-managed report, per-row reasons, each step's why,
904
- // the planned-against note — are folded behind `--verbose`. Colour comes from
905
- // the caller (term.mjs decides); the default is plain text.
906
-
907
- // Every action a plan item can carry. A write never wears the skip glyph:
908
- // `add` and `replace-entry` are changes as much as `create` and `update`.
909
- const ACTION_ICON = { create: "create", add: "create", update: "update", "replace-entry": "update", migrate: "migrate", disable: "update", cleanup: "cleanup", remove: "remove", prune: "remove", skip: "skip", refuse: "refuse" };
910
- const ACTION_COLOR = { create: "green", add: "green", update: "cyan", "replace-entry": "cyan", migrate: "cyan", disable: "cyan", cleanup: "yellow", remove: "yellow", prune: "yellow", skip: "gray", refuse: "red" };
1454
+ // PLAN (presentation spec contract 7, which revises the layout clauses of
1455
+ // install spec contract 18): the badge, the project path, a summary line of
1456
+ // counts, then the rows grouped under each run harness's display name, in run
1457
+ // order. A row is one aligned line — glyph, action word, target, note — and
1458
+ // beneath it what the reader consents to (install spec contract 9: every path
1459
+ // written and every host argv, with the files it touches), the reason when
1460
+ // there is one, and a consequence the reader must know. Internal words —
1461
+ // kinds, provenance states, entry markers, roadmap ids — and each step's why
1462
+ // are behind `--verbose`. Groups and counts are computed here, never added to
1463
+ // the plan: plan --json keeps its keys. Colour and glyphs are the caller's
1464
+ // stream's (`caps`, term.mjs); the default is plain text in Unicode.
1465
+ const unicodeGlyph = (name) => termIcon({ ascii: false }, name);
1466
+
1467
+ // Every action a plan item can carry, as its glyph. A write never wears the
1468
+ // skip glyph: `add` and `replace-entry` are changes as much as `create` and
1469
+ // `update`.
1470
+ // A copy from another marketplace turned off for this checkout is a channel
1471
+ // switch, `disable` (the converge story; contract 7's `switch` word, ⇄).
1472
+ const ACTION_ICON = { create: "create", add: "create", update: "update", "replace-entry": "update", migrate: "migrate", disable: "switch", cleanup: "cleanup", remove: "remove", prune: "remove", skip: "skip", refuse: "refuse" };
1473
+ // …and as a person's word (contract 7's table). A registration's write takes
1474
+ // `register`; a skip reads `unchanged` when the item is current and carries
1475
+ // no reason, and `skipped` otherwise — deferred, opted out, unsupported, or
1476
+ // left in place: a current row with a reason is one the run did not act on
1477
+ // for that reason (uninstall keeping a user-global registration, a host CLI
1478
+ // not on PATH), and the reason beneath it is what the reader acts on.
1479
+ const ACTION_WORD = { create: "add", add: "add", update: "update", "replace-entry": "update", migrate: "update", disable: "switch", cleanup: "remove", remove: "remove", prune: "remove", refuse: "refused" };
1480
+ // Each word's colour role (contract 3): a refusal's ▲, a channel switch and a
1481
+ // removal are attention; red is for what failed.
1482
+ const WORD_ROLE = { add: "done", register: "done", update: "action", switch: "attention", remove: "attention", refused: "attention", unchanged: "explanation", skipped: "explanation" };
1483
+ // APPLY says a word while its step runs and once it has (contract 8).
1484
+ const WORD_TENSES = { add: ["adding", "added"], register: ["registering", "registered"], update: ["updating", "updated"], switch: ["switching", "switched"], remove: ["removing", "removed"] };
1485
+
1486
+ const CURRENT_STATES = new Set(["current", "ours-current", "ours"]);
1487
+ const isUnchanged = (i) => i.action === "skip" && !i.deferred && !i.reason && CURRENT_STATES.has(i.state);
1488
+ // A surface this harness does not have: folded into one line per harness.
1489
+ const isUnsupported = (i) => i.action === "skip" && i.state === "unsupported";
1490
+ // A row of something another project wrote last (install spec contract 12):
1491
+ // never collapsed, and its note says whose it is.
1492
+ const writtenElsewhere = (i) => i.state === "current" && Boolean(i.writtenBy) && !i.sameProject;
1493
+ // A row that writes nothing and asks nothing of the reader collapses into
1494
+ // one line per group naming it (contract 7).
1495
+ const isQuiet = (i) => isUnchanged(i) && !writtenElsewhere(i) && !(i.steps || []).length;
1496
+ // The registration row a fetched shell's registry lines sit beneath, when the
1497
+ // run fetched one from a registry that is not the user's.
1498
+ const fetchedFor = (p, i) => (p.fetchedFrom && i.kind === "registration" && !String(i.surface).endsWith("_others") && p.fetchedFrom.harnesses.includes(i.harness) ? p.fetchedFrom : null);
1499
+
1500
+ function actionWord(i) {
1501
+ if (i.action === "skip") return isUnchanged(i) ? "unchanged" : "skipped";
1502
+ if (i.kind === "registration" && ["create", "add", "update"].includes(i.action)) return "register";
1503
+ return ACTION_WORD[i.action] || "update";
1504
+ }
1505
+ const glyphName = (i) => ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip");
1506
+ // The surface key in words: `agents_block` reads "agents block".
1507
+ const surfaceWords = (key) => String(key).replace(/_/g, " ");
1508
+ // What a row names: a registration by its plugin id — its paths are the
1509
+ // consent lines beneath it — anything else by its path, project-relative.
1510
+ function rowTarget(p, i) {
1511
+ // …and the keep (kind `channel`) by the key of the copy it keeps.
1512
+ if ((i.kind === "registration" || i.kind === "channel") && i.entry) return i.entry;
1513
+ if (i.path === null) return `${i.surface} [${i.harness}, no filesystem path]`;
1514
+ return rel(p.projectDir, i.path);
1515
+ }
1516
+ function rowNote(i, { verbose, glyph }) {
1517
+ const words = writtenElsewhere(i) ? `last written by ${i.writtenBy}` : surfaceWords(i.surface);
1518
+ if (!verbose) return words;
1519
+ return `${words} ${glyph("dot")} ${i.kind}${i.entry && i.kind !== "registration" ? ` [${i.entry}]` : ""}`;
1520
+ }
1521
+ // --verbose's line beneath a row: the provenance state, the reason and the
1522
+ // plan's own action, as install spec contract 18 wrote them.
1523
+ function transitionText(i, glyph) {
1524
+ let state = writtenElsewhere(i) ? `current, last written by ${i.writtenBy}` : i.state;
1525
+ // A reason with commands gives its prose here; the commands follow, alone.
1526
+ const reason = i.advice ? i.advice.text : i.reason;
1527
+ if (reason && i.action !== "refuse") state += ` (${reason})`;
1528
+ // A dry run's unchanged registration: right by every host fact, its
1529
+ // payload digest still to compare. Said only where the reasoning is.
1530
+ if (i.digestDeferred) state += " (the payload digest is compared when install or upgrade fetches the shell)";
1531
+ return `${state} ${glyph("transition")} ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
1532
+ }
911
1533
 
912
1534
  // One step of an item as the lines it shows: always the action and its target,
913
1535
  // then — under --verbose — why it runs.
914
- function stepLines(p, st, { verbose, paint, width = 0 }) {
1536
+ function stepLines(p, st, { verbose, paint, width = 0, glyph = unicodeGlyph }) {
915
1537
  const r = (x) => rel(p.projectDir, x);
1538
+ const to = ` ${glyph("transition")} `;
916
1539
  const lead = " ";
917
1540
  const sub = " ";
918
- const why = verbose && st.why ? wrap(st.why, width, sub).split("\n").map((l, k) => (k ? "" : sub) + paint("gray", l)) : [];
1541
+ const why = verbose && st.why ? wrap(st.why, width, sub).split("\n").map((l, k) => (k ? "" : sub) + paint("explanation", l)) : [];
1542
+ // A consent line (install spec contract 9): the step's verb and what it
1543
+ // touches, in the explanation role (presentation spec contracts 3 and 7).
1544
+ const said = (text) => `${lead}${paint("explanation", text)}`;
919
1545
  switch (st.kind) {
920
1546
  case "host": {
921
- const out = [`${lead}${paint("cyan", "$")} ${[st.bin, ...st.argv].join(" ")}`];
922
- if (st.touches.length) out.push(`${sub}${paint("gray", `touches ${st.touches.map(r).join(", ")}`)}`);
1547
+ // The keep step's argv is whole (its bin is the Node that runs us), and
1548
+ // its touches are the kept copy's planned writes, each with its action.
1549
+ const out = [said(`$ ${(st.bin ? [st.bin, ...st.argv] : st.argv).join(" ")}`)];
1550
+ if (st.touches.length) out.push(`${sub}${paint("explanation", `touches ${st.touches.map((t) => (typeof t === "string" ? r(t) : `${t.path ? r(t.path) : t.surface} (${t.action})`)).join(", ")}`)}`);
923
1551
  return [...out, ...why];
924
1552
  }
925
- case "note": return wrap(`note: ${st.why}`, width, sub).split("\n").map((l, k) => (k ? l : lead + paint("yellow", "note:") + l.slice(5)));
926
- case "write": return [`${lead}write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`, ...why];
927
- case "portable-write": return [`${lead}stage ${st.path} (${st.files.length} payload files + catalogue + ownership)`, ...why];
1553
+ // The fetch a dry run would run first (the shell-fetch story's rule 6),
1554
+ // with the fetch glyph (presentation spec contract 4).
1555
+ case "fetch": return [`${lead}${paint("action", glyph("fetch"))} ${paint("explanation", [st.bin, ...st.argv].join(" "))}`, ...why];
1556
+ case "note": return wrap(`note: ${st.why}`, width, sub).split("\n").map((l, k) => (k ? l : lead + paint("attention", "note:") + l.slice(5)));
1557
+ case "write": return [said(`write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`), ...why];
1558
+ // A dry run names no file count: the payload is counted after the fetch.
1559
+ case "portable-write": return [said(`stage ${st.path} (${Array.isArray(st.files) ? `${st.files.length} payload files` : "the fetched payload"} + catalogue + ownership)`), ...why];
1560
+ case "rmdir-state": return [said(`remove ${r(st.path)}/ once empty${st.left?.length ? ` ${glyph("dash")} ${st.left.join(", ")} stay${st.left.length === 1 ? "s" : ""} and keep${st.left.length === 1 ? "s" : ""} it` : ""}`), ...why];
928
1561
  case "portable-remove":
929
- case "remove": return [`${lead}remove ${st.path}`, ...why];
930
- case "unregister": return [`${lead}edit ${r(st.path)} [${st.pointer}.${st.name}] → removed`, ...why];
931
- case "ensure": return [`${lead}ensure ${r(st.path)}/`, ...why];
932
- case "move-state": return [`${lead}move ${r(st.from)}/ → ${r(st.to)}/ (${st.files} entries)`, ...why];
933
- case "merge-log": return [`${lead}merge ${r(st.from)} → ${r(st.to)}`, ...why];
934
- case "delete": return [`${lead}delete ${r(st.path)}`, ...why];
935
- case "move-marker": return [`${lead}move ${r(st.from)} → ${r(st.to)}`, ...why];
936
- case "move-binding": return [`${lead}move ${r(st.from)} → ${r(st.to)} (agents → ${r(st.overlay)})`, ...why];
1562
+ case "remove": return [said(`remove ${st.path}`), ...why];
1563
+ case "unregister": return [said(`edit ${r(st.path)} [${st.pointer}.${st.name}]${to}removed`), ...why];
1564
+ // The channel switch's record in the binding (the converge story's rules
1565
+ // 2 and 4): its time is the apply's, unknown to a preview.
1566
+ case "mark": return [said(`edit ${r(st.path)} [${st.key}]${to}by "setup", at the time of apply`), ...why];
1567
+ case "unmark": return [said(`edit ${r(st.path)} [${st.key}]${to}removed`), ...why];
1568
+ case "ensure": return [said(`ensure ${r(st.path)}/`), ...why];
1569
+ case "move-state": return [said(`move ${r(st.from)}/${to}${r(st.to)}/ (${st.files} entries)`), ...why];
1570
+ case "merge-log": return [said(`merge ${r(st.from)}${to}${r(st.to)}`), ...why];
1571
+ case "delete": return [said(`delete ${r(st.path)}`), ...why];
1572
+ case "move-marker": return [said(`move ${r(st.from)}${to}${r(st.to)}`), ...why];
1573
+ case "move-binding": return [said(`move ${r(st.from)}${to}${r(st.to)} (agents${to}${r(st.overlay)})`), ...why];
937
1574
  case "remove-legacy-launcher":
938
1575
  case "rmdir-legacy":
939
- case "remove-legacy-runtime": return [`${lead}remove ${r(st.path)}`, ...why];
1576
+ case "remove-legacy-runtime": return [said(`remove ${r(st.path)}`), ...why];
940
1577
  default: return [];
941
1578
  }
942
1579
  }
943
1580
 
944
- // The harnesses a plan names, by their display names — for the header.
1581
+ const displayName = (id) => loadHarness(id)?.display_name || id;
1582
+ // The harnesses a plan names, by their display names — for the badge.
945
1583
  function displayNames(p) {
946
- return p.harnesses.map((id) => loadHarness(id)?.display_name || id).join(", ") || "(no harness)";
1584
+ return p.harnesses.map(displayName).join(", ") || "(no harness)";
947
1585
  }
948
1586
 
949
- const tilde = (path) => {
950
- const h = homedir();
951
- return path === h || path.startsWith(h + "/") ? "~" + path.slice(h.length) : path;
952
- };
1587
+ const tilde = (path, home = homedir()) => (path === home || path.startsWith(home + "/") ? "~" + path.slice(home.length) : path);
1588
+
1589
+ // The badge (presentation spec contract 7): `projectstore <verb> · <display
1590
+ // names>`, the first line of a verb's output. A run that fetches prints it
1591
+ // before the fetch line, and its preview then leaves it out (`badge: false`),
1592
+ // so it is printed once.
1593
+ export function badgeLine(p, { verb = null, caps: c = PLAIN } = {}) {
1594
+ return badgeText(c, `projectstore ${verb || p.mode}`, [displayNames(p)]);
1595
+ }
1596
+
1597
+ // The update notice in the badge's slot (the converge story's rule 8;
1598
+ // presentation spec contracts 6 and 7): the note glyph, the text in the
1599
+ // explanation role, then the command alone on its line after a blank one, in
1600
+ // the action role, and a blank line before the preview.
1601
+ export function noticeText(n, c = PLAIN) {
1602
+ const paint = painter(c);
1603
+ return [` ${paint("attention", termIcon(c, "note"))} ${paint("explanation", n.text)}`, ...commandBlock(c, [n.command], { indent: 4 }), ""].join("\n") + "\n";
1604
+ }
1605
+
1606
+ // The summary line: the counts by kind of change, each in its word's role.
1607
+ // Unsupported rows are not counted: they are the harness's, not the run's.
1608
+ const COUNTED = [["add", (n) => `${n} to add`], ["register", (n) => `${n} to register`], ["update", (n) => `${n} to update`], ["switch", (n) => `${n} to switch`], ["remove", (n) => `${n} to remove`], ["unchanged", (n) => `${n} unchanged`], ["skipped", (n) => `${n} skipped`]];
1609
+ function planSummary(p, c) {
1610
+ if (!p.ok) return heading(c, "Plan", [["refused", "attention"]]);
1611
+ const n = {};
1612
+ for (const i of p.items) if (!isUnsupported(i)) n[actionWord(i)] = (n[actionWord(i)] || 0) + 1;
1613
+ const counts = COUNTED.filter(([w]) => n[w]).map(([w, say]) => [say(n[w]), WORD_ROLE[w]]);
1614
+ return heading(c, "Plan", counts.length ? counts : [["nothing to change", "explanation"]]);
1615
+ }
953
1616
 
954
- export function renderPreview(p, { verbose = false, verb = null, paint = (_style, text) => String(text), icon = null, width = 0 } = {}) {
955
- const glyph = icon || ((name) => ({ create: "+", update: "↻", migrate: "↻", cleanup: "✕", remove: "✕", skip: "·", refuse: "!" }[name] || "·"));
1617
+ // `home` shortens the project path to ~ (the caller's home; a test's fixed
1618
+ // one); everything else the screen needs is in `p`.
1619
+ export function renderPreview(p, { verbose = false, verb = null, caps: c = PLAIN, badge = true, home = homedir() } = {}) {
1620
+ const paint = painter(c);
1621
+ const glyph = (name) => termIcon(c, name);
1622
+ const width = c.live ? c.width : 0;
956
1623
  const writes = p.items.filter(isWrite);
1624
+ // Prose, wrapped to the terminal with every line indented; a path or a
1625
+ // command inside it is one word and never broken (term.mjs wrap).
1626
+ const prose = (indent, text, role = "explanation") => wrap(text, width, indent).split("\n").map((l, k) => indent + paint(role, k ? l.slice(indent.length) : l));
957
1627
  const lines = [
958
- `${paint("bold", "projectstore")} · ${verb || p.mode} · ${displayNames(p)}`,
959
- ` ${paint("gray", tilde(p.projectDir))}`,
1628
+ ...(badge ? [badgeLine(p, { verb, caps: c })] : []),
1629
+ ` ${paint("explanation", tilde(p.projectDir, home))}`,
960
1630
  "",
961
1631
  ];
962
- // Prose, wrapped to the terminal with every line indented; a path or a
963
- // command inside it is one word and never broken (term.mjs wrap).
964
- const prose = (indent, text, style = "gray") => wrap(text, width, indent).split("\n").map((l, k) => (k ? "" : indent) + paint(style, l));
965
1632
  if (verbose) {
966
1633
  for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(...prose(" ", `${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`), "");
967
1634
  for (const r of p.reports) lines.push(...r.split("\n").flatMap((l) => (l.trim() ? prose(" ", l) : [""])), "");
968
1635
  }
969
- const count = writes.length === 1 ? "1 change" : `${writes.length} changes`;
970
- lines.push(`${paint("bold", "PLAN")} — ${p.ok ? count : "refused"}`);
971
- // Unsupported host rows (no path, nothing this harness has) fold into one
972
- // line per harness by default; --verbose lists each with the manifest's reason.
973
- const folded = new Map();
974
- for (const i of p.items) {
975
- if (!verbose && i.path === null && i.state === "unsupported" && i.action === "skip") {
976
- const k = loadHarness(i.harness)?.display_name || i.harness;
977
- if (!folded.has(k)) folded.set(k, []);
978
- folded.get(k).push(i.surface);
979
- continue;
1636
+ lines.push(planSummary(p, c));
1637
+ // An emptied state directory is pruned with the launcher removed from it;
1638
+ // said once, beneath the first such row.
1639
+ let pruneSaid = false;
1640
+ const beneath = (i) => {
1641
+ const out = [];
1642
+ // A user-scope registration reaches past this project: contract 7's
1643
+ // consequence line, on the line beneath its row, before anything else.
1644
+ if (isWrite(i) && i.kind === "registration" && i.scope === "user") out.push(...prose(" ", `user-wide: the plugin is ${i.action === "remove" ? "removed" : "registered"} for every project ${displayName(i.harness)} opens, not only this one`));
1645
+ // …and a channel switch's (the converge story's rule 2), carried as its
1646
+ // note step: first, before the reason and the consent lines.
1647
+ for (const st of (i.steps || []).filter(isConsequence)) out.push(...prose(" ", st.why));
1648
+ if (verbose) out.push(...prose(" ", transitionText(i, glyph)));
1649
+ else if (i.reason && !isUnsupported(i)) out.push(...prose(" ", i.advice ? i.advice.text : i.reason, i.action === "refuse" ? "attention" : "explanation"));
1650
+ // The reason's ways out (the converge story): each lead, then its command
1651
+ // alone on its line after a blank one (contract 6), and a blank line after.
1652
+ if (i.advice?.blocks?.length) {
1653
+ for (const b of i.advice.blocks) out.push(...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 10 }), "");
980
1654
  }
981
- const target = i.path === null ? `${i.surface} [${i.harness}, no filesystem path]` : rel(p.projectDir, i.path);
982
- const where = target + (i.entry ? ` [${i.entry}]` : "");
983
- let state = i.state;
984
- if (i.state === "current" && i.writtenBy && !i.sameProject) state = `current, last written by ${i.writtenBy}`;
985
- // A row's reason is shown whenever it has one: a row of something
986
- // already right carries none, and every other reason — a change, a skip
987
- // that needs a terminal or a PATH, a block kept for another harness —
988
- // is something the reader acts on (the reviewer's pass, 2026-10-05).
989
- if (i.reason && i.action !== "refuse") state += ` (${i.reason})`;
990
- const color = ACTION_COLOR[i.action] || (isWrite(i) ? "cyan" : "gray");
991
- const mark = paint(color, glyph(ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip")));
992
- const transition = `${state} → ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
993
- lines.push(` ${mark} ${paint("bold", i.kind.padEnd(12))} ${where}`);
994
- lines.push(...prose(" ", transition));
995
- for (const st of i.steps || []) lines.push(...stepLines(p, st, { verbose, paint, width }));
996
- if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` ${paint("gray", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
997
- if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` ${paint("gray", "(the file would hold nothing else and is removed)")}`);
998
- }
999
- const hostLine = (text) => { const [first, ...rest] = wrap(text, width, " ".repeat(17)).split("\n"); return [` ${paint("gray", glyph("skip"))} ${paint("bold", "host".padEnd(12))} ${first}`, ...rest]; };
1000
- for (const h of p.hostManaged || []) {
1655
+ // A shell this run fetched from a registry that is not the user's (the
1656
+ // converge story's rule 8, as amended 2026-10-09): both named. Neutral
1657
+ // words: the fetch's URL can come from the project's .npmrc or from the
1658
+ // run's own environment (`npx --registry`), and either reads the same.
1659
+ const from = fetchedFor(p, i);
1660
+ if (from) out.push(...prose(" ", `fetched from ${from.registry}`), ...prose(" ", `your npm's own registry is ${from.user}`));
1661
+ for (const st of (i.steps || []).filter((x) => !isConsequence(x))) out.push(...stepLines(p, st, { verbose, paint, width, glyph }));
1662
+ if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) out.push(` ${paint("explanation", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
1663
+ if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) out.push(` ${paint("explanation", "(the file would hold nothing else and is removed)")}`);
1664
+ if (!pruneSaid && i.action === "remove" && i.kind === "exclusive") { pruneSaid = true; out.push(` ${paint("explanation", `(an emptied ${rel(p.projectDir, dirname(i.path))}/ is pruned)`)}`); }
1665
+ return out;
1666
+ };
1667
+ // The run's one agents block, named again where each other harness's row
1668
+ // would have been (contract 7; the story "One run plans the agents block
1669
+ // once…", rule 1): in that harness's group, before its first item at or
1670
+ // after the recorded index, else at the group's end. Not a step and not an
1671
+ // item: nothing is planned there.
1672
+ const sameBlock = () => ` ${paint("explanation", glyph("skip"))} ${p.sharedBlock.file} ${glyph("dash")} the same block as ${p.sharedBlock.display}, above`;
1673
+ const hostLines = (h) => {
1001
1674
  const from = h.entry ? `from the registration ${h.entry}` : "from the host's own plugin system";
1002
- lines.push(...hostLine(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`));
1003
- }
1004
- for (const [display, rows] of folded) lines.push(...hostLine(`not on ${display}: ${rows.join(", ")} — unsupported → skip ${paint("gray", "(--verbose says why)")}`));
1005
- const exclusiveRemoval = p.items.find((i) => i.action === "remove" && i.kind === "exclusive");
1006
- if (exclusiveRemoval) lines.push(` ${paint("gray", `(an emptied ${rel(p.projectDir, dirname(exclusiveRemoval.path))}/ is pruned)`)}`);
1007
- for (const r of p.refusals) { const [first, ...rest] = wrap(r, width, " ".repeat(17)).split("\n"); lines.push(` ${paint("red", glyph("refuse"))} ${paint("bold", "refused".padEnd(12))} ${first}`, ...rest); }
1008
- lines.push("", ` ${paint("gray", "Nothing outside a marked entry is read, rewritten or removed.")}`);
1009
- if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push(` ${paint("gray", "Each $ line runs the host's own CLI, which writes the host-owned files named after it.")}`);
1010
- if (!p.ok) lines.push("", ` ${paint("red", "Nothing will be written: resolve the refusals above first.")}`);
1011
- else if (!writes.length) lines.push("", " Nothing to change." + (p.incomplete ? " One surface could not be planned (see above)." : ""));
1675
+ return wrap(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`, width, " ").split("\n").map((l, k) => (k ? ` ${paint("explanation", l.slice(4))}` : ` ${paint("explanation", glyph("skip"))} ${paint("explanation", l)}`));
1676
+ };
1677
+ const ids = [...p.harnesses, ...new Set(p.items.map((i) => i.harness).filter((h) => !p.harnesses.includes(h)))];
1678
+ for (const id of ids) {
1679
+ const entries = p.items.map((i, at) => ({ i, at })).filter((e) => e.i.harness === id);
1680
+ const others = (p.sharedBlock?.others || []).filter((o) => o.harness === id).sort((a, b) => a.at - b.at);
1681
+ const host = (p.hostManaged || []).filter((h) => h.harness === id);
1682
+ if (!entries.length && !others.length && !host.length) continue;
1683
+ const folded = verbose ? [] : entries.filter((e) => isUnsupported(e.i));
1684
+ // A registration this run fetched from another registry keeps its row, so
1685
+ // the registry lines beneath it show on every run, not only on a change.
1686
+ const quiet = verbose ? [] : entries.filter((e) => isQuiet(e.i) && !fetchedFor(p, e.i));
1687
+ const shown = entries.filter((e) => !folded.includes(e) && !quiet.includes(e));
1688
+ const specs = shown.map(({ i }) => ({ glyph: glyphName(i), role: WORD_ROLE[actionWord(i)], action: actionWord(i), target: rowTarget(p, i), note: rowNote(i, { verbose, glyph }) }));
1689
+ if (quiet.length) specs.push({ glyph: "unchanged", role: "explanation", action: "unchanged", target: paint("explanation", quiet.map((e) => surfaceWords(e.i.surface)).join(", ")) });
1690
+ // Unsupported rows keep one line per harness (contract 18), named in
1691
+ // words as the unchanged line names its rows; --verbose lists each with
1692
+ // the manifest's reason.
1693
+ if (folded.length) specs.push({ glyph: "unchanged", role: "explanation", action: "skipped", target: paint("explanation", `not on ${displayName(id)}: ${folded.map((e) => surfaceWords(e.i.surface)).join(", ")}`), note: "--verbose says why" });
1694
+ const drawn = rowLines(c, specs);
1695
+ lines.push("", paint("emphasis", displayName(id)));
1696
+ const pending = [...others];
1697
+ shown.forEach(({ i, at }, k) => {
1698
+ while (pending.length && pending[0].at <= at) { pending.shift(); lines.push(sameBlock()); }
1699
+ lines.push(...drawn[k], ...beneath(i));
1700
+ });
1701
+ for (const _ of pending) lines.push(sameBlock());
1702
+ for (const extra of drawn.slice(shown.length)) lines.push(...extra);
1703
+ for (const h of host) lines.push(...hostLines(h));
1704
+ }
1705
+ if (p.refusals.length) lines.push("");
1706
+ // A plan-level refusal: its message in the attention role, as an item's
1707
+ // refusal reason is (presentation spec contract 3 as amended 2026-10-11).
1708
+ p.refusals.forEach((r, k) => {
1709
+ const lead = ` ${paint("attention", glyph("refuse"))} ${paint("attention", "refused")} `;
1710
+ const pad = " ".repeat(plain(lead).length);
1711
+ // The stop (the converge story's rule 6) carries its commands apart from
1712
+ // its words: each is printed alone on its line after a blank one, with
1713
+ // nothing after it (contract 6), the way a person copies it.
1714
+ const stop = k === 0 && p.stop ? p.stop : null;
1715
+ // The same for a refusal that names its ways out (the channel "git" with
1716
+ // nothing to keep): its prose, then each lead and its command alone.
1717
+ const advice = k === 0 && !stop && p.refusalAdvice ? p.refusalAdvice : null;
1718
+ const [first, ...rest] = wrap(stop ? stop.lead : advice ? advice.text : r, width, pad).split("\n");
1719
+ lines.push(lead + paint("attention", first), ...rest.map((l) => pad + paint("attention", l.slice(pad.length))));
1720
+ if (stop) lines.push(...commandBlock(c, [stop.commands[0]], { indent: 4 }), "", ...prose(" ", stop.between), ...commandBlock(c, stop.commands.slice(1), { indent: 4 }));
1721
+ if (advice) for (const b of advice.blocks) lines.push("", ...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 4 }));
1722
+ });
1723
+ // The closing lines, in a person's words: only what is listed is written,
1724
+ // and a $ line is the host's own CLI at work.
1725
+ const closing = [];
1726
+ if (p.ok && writes.length) closing.push(...prose(" ", `Only the entries listed above are written ${glyph("dash")} nothing else in those files is read, rewritten or removed.`));
1727
+ if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host" && s.name !== "keep"))) closing.push(...prose(" ", "Each $ line runs the host's own CLI, which writes the host-owned files named after it."));
1728
+ if (p.items.some((i) => isWrite(i) && (i.steps || []).some((s) => s.kind === "host" && s.name === "keep"))) closing.push(...prose(" ", "The kept copy's $ line runs that copy's own projectstore, which writes the files named after it."));
1729
+ if (closing.length) lines.push("", ...closing);
1730
+ if (!p.ok) lines.push("", ` ${paint("attention", "Nothing will be written: resolve the refusals above first.")}`);
1731
+ // A plan that wrote nothing because part of it could not be planned never
1732
+ // reads as converged (the converge story's keep, a copy with work pending).
1733
+ else if (!writes.length) lines.push("", p.incomplete ? " Nothing will be written: one surface could not be planned (see above)." : " Nothing to change.");
1012
1734
  else if (p.incomplete) lines.push("", " One surface could not be planned (see above); the rest proceeds.");
1013
1735
  return lines.join("\n") + "\n";
1014
1736
  }
@@ -1023,7 +1745,7 @@ export function renderPreview(p, { verbose = false, verb = null, paint = (_style
1023
1745
  export function isInteractive({ stdin = null, stdout = null, env = process.env, json = false } = {}) {
1024
1746
  if (json) return false;
1025
1747
  if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return false;
1026
- if (env.CI && !["0", "false"].includes(String(env.CI).toLowerCase())) return false;
1748
+ if (envFlag(env, "CI")) return false;
1027
1749
  // A host session's own tool may run us in a pseudo-terminal; the manifests'
1028
1750
  // session markers say when that is happening — every manifest's, not only
1029
1751
  // the planned harness's: a Claude Code agent running the Codex shell is
@@ -1033,19 +1755,36 @@ export function isInteractive({ stdin = null, stdout = null, env = process.env,
1033
1755
  return true;
1034
1756
  }
1035
1757
 
1758
+ // Will this run ask? The gate's own test, shared with the shell fetch's
1759
+ // decision so a run is fetched for exactly when it can be asked: passing
1760
+ // `ask` means "this is a terminal" (the tests' TTY double).
1761
+ export function asksAtTerminal({ stdin = null, stdout = null, ask = null, env = process.env, json = false } = {}) {
1762
+ return !json && (ask ? true : isInteractive({ stdin, stdout, env, json }));
1763
+ }
1764
+
1036
1765
  // Streams and `ask` are parameters so the terminal branch is testable without
1037
1766
  // a pseudo-terminal; passing `ask` means "this is a terminal". Without streams
1038
- // the library never asks — the bin and main() pass theirs.
1039
- export async function confirm(p, { stdin = null, stdout = null, ask = null, env = process.env, json = false, paint = (_style, text) => String(text) } = {}) {
1767
+ // the library never asks — the bin and main() pass theirs. `caps` is the
1768
+ // asking stream's (colour and glyphs), read from `stdout` when not given;
1769
+ // with neither, the question is unpainted, in Unicode.
1770
+ //
1771
+ // One answer more (the converge story's rule 2): a channel switch planned
1772
+ // under no channel answer is the ADR's choice made at a terminal — asked
1773
+ // there, previewed, never silent. Without one, naming the harness does not
1774
+ // confirm it, a bare run is refused for it too, and nothing is written:
1775
+ // `channel`, ahead of the whole non-interactive branch. A plan built without
1776
+ // plan() (no `channel` on it) reads as no answer.
1777
+ export const switchUnanswered = (p) => (p.channel ?? null) === null && p.items.some(isSwitch);
1778
+ export async function confirm(p, { stdin = null, stdout = null, ask = null, env = process.env, json = false, caps = stdout ? termCaps(stdout, env) : null } = {}) {
1040
1779
  if (!p.ok) return { confirmed: false, why: "refused" };
1041
1780
  const writes = p.items.filter(isWrite);
1042
1781
  if (!writes.length) return { confirmed: false, why: "nothing-to-do" };
1043
- const interactive = !json && (ask ? true : isInteractive({ stdin, stdout, env, json }));
1782
+ const interactive = asksAtTerminal({ stdin, stdout, ask, env, json });
1783
+ if (!interactive && switchUnanswered(p)) return { confirmed: false, why: "channel" };
1044
1784
  if (!interactive) return p.named ? { confirmed: true, why: "named" } : { confirmed: false, why: "non-tty" };
1045
- const question = `${paint("bold", `Apply ${writes.length === 1 ? "1 change" : `${writes.length} changes`}?`)} ${paint("gray", "[Y/n]")} `;
1046
- const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
1047
- if (answer === null || answer === undefined) return { confirmed: false, why: "declined" };
1048
- return /^(y(es)?)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
1785
+ // The question's bytes and its answer rule are term.mjs's, shared with
1786
+ // `scaffold --write`; the refused / nothing / named branches above stay here.
1787
+ return (await askApply(writes.length, { stdin, stdout, ask, ...(caps ? { caps } : {}) })) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
1049
1788
  }
1050
1789
 
1051
1790
  // ─── apply ─────────────────────────────────────────────────────────────
@@ -1055,6 +1794,10 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
1055
1794
  const done = [];
1056
1795
  let registrationFailed = false;
1057
1796
  let layoutFailed = false;
1797
+ // Per harness: whether its registration applied without failure, and each
1798
+ // switch's outcome — what the order rule and the mark read.
1799
+ const registered = new Map();
1800
+ const switched = new Map();
1058
1801
  // One item's writes, returning the record apply reports for it. The order
1059
1802
  // and the failure rules are the loop's; onItem only watches (contract 18).
1060
1803
  const one = (i) => {
@@ -1063,12 +1806,44 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
1063
1806
  if (r.failed) { done.failed = r.failed; layoutFailed = true; }
1064
1807
  return r;
1065
1808
  }
1809
+ if (i.kind === "registration" && isSwitch(i)) {
1810
+ // The order (the converge story's rule 2): a switch applies only when
1811
+ // this run's registration for the same harness is current, or applied
1812
+ // without failure. Turning the copy off after a failed registration left
1813
+ // the checkout loading no copy at all (measured on 0.29.2).
1814
+ const reg = p.items.find((x) => x.harness === i.harness && x.kind === "registration" && !String(x.surface).endsWith("_others"));
1815
+ const ready = Boolean(reg) && (isWrite(reg) ? registered.get(i.harness) === true : reg.state === "current");
1816
+ if (!ready) {
1817
+ if (!switched.has(i.harness)) switched.set(i.harness, []);
1818
+ switched.get(i.harness).push(false);
1819
+ return { path: i.path, action: "skipped", surface: i.surface, reason: `the npm registration did not complete; ${i.entry} stays on for this checkout` };
1820
+ }
1821
+ const r = applyRegistration(p, i, { env, spawn, home, onStep });
1822
+ if (r.failed) { done.failed = r.failed; registrationFailed = true; }
1823
+ if (!switched.has(i.harness)) switched.set(i.harness, []);
1824
+ switched.get(i.harness).push(!r.failed);
1825
+ return r;
1826
+ }
1066
1827
  if (i.kind === "registration") {
1067
1828
  const r = applyRegistration(p, i, { env, spawn, home, onStep });
1068
1829
  // A registration that did not complete leaves the surfaces planned against
1069
1830
  // its install path unwritten: a launcher pointing at nothing is worse than
1070
1831
  // none. Surfaces rendered from the package root (the block) still apply.
1071
1832
  if (r.failed) { done.failed = r.failed; registrationFailed = true; }
1833
+ registered.set(i.harness, !r.failed);
1834
+ return r;
1835
+ }
1836
+ if (i.kind === "binding") {
1837
+ const r = applyMark(p, i, { env, onStep, switched: switched.get(i.harness) || [] });
1838
+ if (r.failed) done.failed = r.failed;
1839
+ return r;
1840
+ }
1841
+ if (i.kind === "channel") {
1842
+ // The keep (the converge story's rule 5): the kept copy's own upgrade.
1843
+ // A run it left incomplete makes this one incomplete; it is not a stop.
1844
+ const r = applyKeep(p, i, { env, spawn, home, onStep });
1845
+ if (r.failed) done.failed = r.failed;
1846
+ if (r.incomplete) done.incomplete = true;
1072
1847
  return r;
1073
1848
  }
1074
1849
  if (registrationFailed && i.plannedAgainst) return { path: i.path, action: "skipped", surface: i.surface, reason: "the registration did not complete; this surface was planned against its install path" };
@@ -1110,6 +1885,94 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
1110
1885
  return done;
1111
1886
  }
1112
1887
 
1888
+ // The mark (the converge story's rule 2), at apply: the binding is the one
1889
+ // configPath resolves now — after the layout item moved it, or a bind wrote it
1890
+ // — and must be the one the preview named; a mark is never written to a file
1891
+ // the preview did not name, so a legacy binding is never re-created. Skipped,
1892
+ // with the reason, when no binding exists, when a switch it records was
1893
+ // skipped or failed in this run, or when a mark is already there. Every other
1894
+ // key of the binding is kept.
1895
+ function applyMark(p, i, { env, onStep = null, switched = [] }) {
1896
+ const out = { path: i.path, action: i.action, surface: i.surface };
1897
+ const skipped = (reason) => ({ ...out, action: "skipped", reason });
1898
+ if (switched.some((ok) => !ok)) return skipped("the switch did not complete; nothing is recorded");
1899
+ const at = configPath(p.projectDir, env);
1900
+ if (!existsSync(at)) return skipped("the project is not bound; the switch is recorded once it is");
1901
+ if (resolve(at) !== resolve(i.path)) return skipped(`the binding is at ${at}, not where the plan named it; nothing is recorded`);
1902
+ let cfg;
1903
+ try { cfg = JSON.parse(readFileSync(at, "utf8")); } catch { cfg = null; }
1904
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) return skipped(`${at} is not a JSON object; the switch is not recorded`);
1905
+ if (channelMark(cfg)) return { ...out, action: "skip", reason: "the switch is already recorded" };
1906
+ const st = (i.steps || []).find((x) => x.kind === "mark") || { kind: "mark", path: at, key: CHANNEL_SWITCH_KEY };
1907
+ if (onStep) onStep(st, "start");
1908
+ try {
1909
+ writeFileAtomic(at, JSON.stringify({ ...cfg, [CHANNEL_SWITCH_KEY]: { by: "setup", at: new Date().toISOString() } }, null, 2) + "\n", { sweep: false });
1910
+ } catch (e) {
1911
+ if (onStep) onStep(st, "end", { ok: false });
1912
+ return { ...out, failed: { step: "mark", status: null, stderr: e && e.message ? e.message : String(e) } };
1913
+ }
1914
+ if (onStep) onStep(st, "end", { ok: true });
1915
+ return { ...out, steps: [{ kind: "mark", path: at, ok: true }] };
1916
+ }
1917
+
1918
+ // The keep step (the converge story's rule 5), at apply: the kept copy's own
1919
+ // upgrade, spawned as a host command is — the project as its working
1920
+ // directory, the run's environment with the copy's root as the plugin root and
1921
+ // the harness home pinned, 120 s — through the caller's spawn, so APPLY prints
1922
+ // its $ line. Its envelope is read: one APPLY line beneath per item the copy
1923
+ // applied, the ones the preview did not list marked "(not in the preview)" —
1924
+ // the copy plans afresh, and a file that changed after the question can add a
1925
+ // row. STOPPED (contract 19's cause form, the argv, the status and stderr) is
1926
+ // the copy's own failure, an `ok` it reports false, a gate of its that
1927
+ // confirmed nothing, or no envelope to read: what it wrote is then not known.
1928
+ // The copy's run left incomplete leaves this one incomplete.
1929
+ function applyKeep(p, i, { env, spawn, home, onStep = null }) {
1930
+ const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
1931
+ const st = (i.steps || []).find((x) => x.kind === "host");
1932
+ const harness = loadHarness(i.harness);
1933
+ const [bin, ...argv] = st.argv;
1934
+ const r = spawn(bin, argv, { env: keepEnv(env, { harness, projectDir: p.projectDir, copyRoot: i.path, harnessHome: i.home || claudeHome(home) }), cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS, maxBuffer: 1 << 26 });
1935
+ const status = r.status ?? null;
1936
+ const stderr = String(r.stderr || "").trim();
1937
+ const result = keepResult(r.stdout);
1938
+ const stop = (said) => {
1939
+ out.steps.push({ kind: "host", argv: st.argv, status, ok: false, stderr: said });
1940
+ out.failed = { step: "keep", status, stderr: said, argv: st.argv };
1941
+ // How it ended, for STOPPED, as a host command's (not enumerable).
1942
+ Object.defineProperty(out.failed, "couldNotStart", { value: Boolean(r.error) && !r.signal && status === null, enumerable: false });
1943
+ Object.defineProperty(out.failed, "signal", { value: r.signal || null, enumerable: false });
1944
+ return out;
1945
+ };
1946
+ if (r.error && !result) return stop(startFailure(r.error, { bin, resolved: bin, cwd: p.projectDir }));
1947
+ if (!result) return stop([stderr, `${i.entry} printed no result to read, so what it wrote is not known; its own plan starts from what is on disk now`].filter(Boolean).join("\n"));
1948
+ // What the copy applied, beneath its $ line — even when it then stopped.
1949
+ const listed = new Set((st.touches || []).map((t) => `${t.surface}\n${t.path}`));
1950
+ const applied = Array.isArray(result.applied) ? result.applied.filter((x) => x && typeof x === "object" && !["skip", "skipped"].includes(x.action)) : [];
1951
+ const kept = [];
1952
+ for (const rec of applied) {
1953
+ const sub = { kind: "kept", surface: rec.surface ?? null, action: rec.action ?? null, path: rec.path ?? null, ...(listed.has(`${rec.surface}\n${rec.path}`) ? {} : { unplanned: true }) };
1954
+ if (onStep) { onStep(sub, "start"); onStep(sub, "end", { ok: !rec.failed }); }
1955
+ kept.push({ ...sub, ok: !rec.failed });
1956
+ }
1957
+ const gate = result.gate && typeof result.gate === "object" ? result.gate : null;
1958
+ const said = [];
1959
+ if (result.failed && typeof result.failed === "object") {
1960
+ const f = result.failed;
1961
+ said.push(`${i.entry} stopped at ${f.step || "a step"}${f.status !== null && f.status !== undefined ? ` (exit ${f.status})` : ""}${Array.isArray(f.argv) ? `: $ ${f.argv.join(" ")}` : ""}`, ...String(f.stderr || "").split("\n").filter(Boolean));
1962
+ } else if (result.ok === false) said.push(`${i.entry} reported that its run did not succeed`);
1963
+ else if (gate && gate.confirmed !== true && gate.why !== "nothing-to-do") said.push(`${i.entry} wrote nothing: its own gate answered ${gate.why || "no"}`);
1964
+ if (said.length) {
1965
+ for (const x of (Array.isArray(result.refusals) ? result.refusals : [])) said.push(String(x));
1966
+ const back = stop([stderr, ...said].filter(Boolean).join("\n"));
1967
+ back.steps.push(...kept);
1968
+ return back;
1969
+ }
1970
+ out.steps.push({ kind: "host", argv: st.argv, status, ok: true }, ...kept);
1971
+ if (!applied.length) { out.action = "skip"; out.reason = `${i.entry} found nothing to change`; }
1972
+ if (result.incomplete === true) out.incomplete = true;
1973
+ return out;
1974
+ }
1975
+
1113
1976
  // The registration's steps, in order, each leaving a state plan() can read.
1114
1977
  // The host binary runs with the harness home pinned in its environment — the
1115
1978
  // same home the plan was read from — and with the project as its cwd, which is
@@ -1131,10 +1994,14 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1131
1994
  if (lock !== null) { try { closeSync(lock); } catch {} lock = null; }
1132
1995
  if (lockPath) { try { unlinkSync(lockPath); } catch {} lockPath = null; }
1133
1996
  };
1997
+ // What the plan analysed with (planPortableRegistration's private record):
1998
+ // the recheck and the read-back below re-analyse with exactly these — the
1999
+ // fetched payload's root, or the version a version-only row was read at.
2000
+ const analysed = { root: p.root, payloadRoot: i.analysed?.payloadRoot || null, version: i.analysed?.version || null, home, harness, env: childEnv, ignoreJournal: true };
1134
2001
  const readHostList = () => {
1135
2002
  const argv = s.cli.commands.list || [];
1136
2003
  const bin = whichOnPathFromLib(s.cli.bin, env);
1137
- const r = spawn(bin || s.cli.bin, argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
2004
+ const r = spawn(bin || s.cli.bin, argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS });
1138
2005
  const rows = !r.error && r.status === 0 ? portableListFacts(r.stdout) : null;
1139
2006
  out.steps.push({ kind: "portable-recovery-list", argv: [s.cli.bin, ...argv], status: r.status ?? null, ok: Boolean(rows) });
1140
2007
  return { rows, stderr: r.error ? startFailure(r.error, { bin: s.cli.bin, resolved: bin, cwd: p.projectDir }) : String(r.stderr || "").trim() };
@@ -1207,6 +2074,12 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1207
2074
  : `${why}; ${journalPath} was retained and blocks automatic mutation until a later run proves the previous state` };
1208
2075
  return out;
1209
2076
  };
2077
+ // A staging step with no payload is a dry run's row (a fetch the run never
2078
+ // made); it is refused before the lock, with nothing staged or spawned.
2079
+ if (portable && (i.steps || []).some((st) => st.kind === "portable-write" && !st.from)) {
2080
+ out.failed = { step: "portable-write", status: null, stderr: `${i.entry}: this row was planned without ${harness.display_name}'s payload — its shell was not fetched — so there is nothing to stage; nothing is written. Run install or upgrade from a terminal, where the shell is fetched` };
2081
+ return out;
2082
+ }
1210
2083
  if (portable) {
1211
2084
  const lockDir = join(i.home, "projectstore");
1212
2085
  mkdirSync(lockDir, { recursive: true });
@@ -1288,7 +2161,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1288
2161
  return out;
1289
2162
  }
1290
2163
  }
1291
- const current = analysePortableRegistration(p.projectDir, s, { root: p.root, home, harness, env: childEnv, ignoreJournal: true });
2164
+ const current = analysePortableRegistration(p.projectDir, s, analysed);
1292
2165
  const desiredCurrent = current.state === "current"
1293
2166
  && current.desiredVersion === i.verify?.version
1294
2167
  && current.desiredDigest?.sha256 === i.verify?.digest?.sha256
@@ -1318,7 +2191,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1318
2191
  return out;
1319
2192
  }
1320
2193
  }
1321
- const fail = (step, status, stderr, argv = null) => {
2194
+ const fail = (step, status, stderr, argv = null, ended = null) => {
1322
2195
  if (staged) {
1323
2196
  // A first install can fail after Codex has accepted the marketplace but
1324
2197
  // before verification. Best-effort compensation happens while the staged
@@ -1331,7 +2204,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1331
2204
  if (!Array.isArray(template)) continue;
1332
2205
  const args = template.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => fill[k] ?? `{${k}}`));
1333
2206
  const bin = whichOnPathFromLib(s.cli.bin, env);
1334
- const r = spawn(bin || s.cli.bin, args, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
2207
+ const r = spawn(bin || s.cli.bin, args, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS });
1335
2208
  const ok = !r.error && r.status === 0;
1336
2209
  out.steps.push({ kind: "portable-compensate", name, argv: [s.cli.bin, ...args], status: r.status ?? null, ok, ...(ok ? {} : { stderr: r.error ? startFailure(r.error, { bin: s.cli.bin, resolved: bin, cwd: p.projectDir }) : String((r.stderr || "") + (r.stdout || "")).trim() }) });
1337
2210
  }
@@ -1351,6 +2224,13 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1351
2224
  staged = null;
1352
2225
  }
1353
2226
  out.failed = { step, status, stderr, ...(argv ? { argv } : {}) };
2227
+ // How a host command ended, for STOPPED (presentation spec contract 8):
2228
+ // whether it could start at all, and the signal that stopped one that
2229
+ // did. Not enumerable, so the --json envelope keeps the record's shape.
2230
+ if (ended) {
2231
+ Object.defineProperty(out.failed, "couldNotStart", { value: Boolean(ended.couldNotStart), enumerable: false });
2232
+ Object.defineProperty(out.failed, "signal", { value: ended.signal || null, enumerable: false });
2233
+ }
1354
2234
  releaseLock();
1355
2235
  return out;
1356
2236
  };
@@ -1384,7 +2264,8 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1384
2264
  // Re-check at apply time what plan() proved: the directory is absent or ours.
1385
2265
  if (existsSync(st.path)) {
1386
2266
  let ours = false;
1387
- try { ours = Boolean(JSON.parse(readFileSync(manifestPath, "utf8"))[s.provenance_key]); } catch {}
2267
+ // The one reader of the provenance field (contract 2, amended 2026-10-10).
2268
+ try { ours = Boolean(registrationProvenance(JSON.parse(readFileSync(manifestPath, "utf8")), s)); } catch {}
1388
2269
  if (!ours) { out.steps.push({ kind: "write", ok: false }); return fail("write", null, `${st.path} changed under the plan: its manifest is no longer ours; nothing is written`); }
1389
2270
  }
1390
2271
  if (st.manifestOnly) writeFileAtomic(manifestPath, JSON.stringify(st.manifest, null, 2) + "\n", { sweep: false });
@@ -1397,12 +2278,25 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1397
2278
  if (!settings || typeof settings !== "object" || Array.isArray(settings)) { out.steps.push({ kind: "unregister", ok: false }); return fail("unregister", null, `${st.path} is not a JSON object; the marketplace entry was not removed`); }
1398
2279
  if (settings[st.pointer] && typeof settings[st.pointer] === "object") { delete settings[st.pointer][st.name]; writeFileAtomic(st.path, JSON.stringify(settings, null, 2) + "\n", { sweep: false }); }
1399
2280
  out.steps.push({ kind: "unregister", path: st.path, ok: true });
2281
+ } else if (st.kind === "unmark") {
2282
+ // The switch's record goes with the registration (the converge story's
2283
+ // rule 4): the one key, from the binding configPath resolves now; every
2284
+ // other key stays. A binding already without it is left as it is.
2285
+ const at = configPath(p.projectDir, env);
2286
+ let cfg = null;
2287
+ if (existsSync(at)) {
2288
+ try { cfg = JSON.parse(readFileSync(at, "utf8")); } catch { cfg = null; }
2289
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) { out.steps.push({ kind: "unmark", ok: false }); return fail("unmark", null, `${at} is not a JSON object; ${st.key} was not removed`); }
2290
+ }
2291
+ const has = Boolean(cfg) && Object.hasOwn(cfg, st.key);
2292
+ if (has) { delete cfg[st.key]; writeFileAtomic(at, JSON.stringify(cfg, null, 2) + "\n", { sweep: false }); }
2293
+ out.steps.push({ kind: "unmark", path: at, ok: true, removed: has });
1400
2294
  } else if (st.kind === "remove") {
1401
2295
  removeOwnTree(st.path, home);
1402
2296
  out.steps.push({ kind: "remove", path: st.path, ok: true });
1403
2297
  } else if (st.kind === "host") {
1404
2298
  const bin = whichOnPathFromLib(st.bin, env);
1405
- const r = spawn(bin || st.bin, st.argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
2299
+ const r = spawn(bin || st.bin, st.argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: HOST_BUDGET_MS });
1406
2300
  const ran = !r.error && r.status === 0;
1407
2301
  // A preflight that ran must also answer in the shape verification reads:
1408
2302
  // a host that exits 0 with text would pass on its status alone.
@@ -1422,8 +2316,12 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1422
2316
  : said;
1423
2317
  out.steps.push({ kind: "host", argv: [st.bin, ...st.argv], status: r.status ?? null, ok, ...(ok ? {} : { stderr: told }) });
1424
2318
  // An unreadable answer exited 0; STOPPED names it by what it said, as
1425
- // the final list's own failure does, not by an "exited 0".
1426
- if (!ok) return fail(st.name, unreadable ? null : r.status ?? null, told, [st.bin, ...st.argv]);
2319
+ // the final list's own failure does, not by an "exited 0". A command
2320
+ // that could not start has an error and neither a status nor a signal
2321
+ // (ENOENT, EACCES); a timeout's error comes with the signal that
2322
+ // stopped it, so that command ran.
2323
+ const couldNotStart = Boolean(r.error) && !r.signal && (r.status === null || r.status === undefined);
2324
+ if (!ok) return fail(st.name, unreadable ? null : r.status ?? null, told, [st.bin, ...st.argv], { couldNotStart, signal: r.signal || null });
1427
2325
  if (portable && st.name === "list") {
1428
2326
  hostList = portableListFact(r.stdout, i.entry);
1429
2327
  if (!hostList) return fail("list", null, `${st.bin} ${st.argv.join(" ")} did not report ${i.entry} in JSON output`, [st.bin, ...st.argv]);
@@ -1440,7 +2338,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1440
2338
  // was rendered against must be the one the host recorded for this checkout.
1441
2339
  if (i.verify) {
1442
2340
  const a = portable
1443
- ? analysePortableRegistration(p.projectDir, s, { root: p.root, home, harness, env: childEnv, ignoreJournal: true })
2341
+ ? analysePortableRegistration(p.projectDir, s, analysed)
1444
2342
  : analyseRegistration(p.projectDir, s, { root: p.root, home, harness, env });
1445
2343
  if (portable) {
1446
2344
  const sourceDigestOk = a.sourceDigest?.sha256 === i.verify.digest?.sha256 && a.sourceDigest?.count === i.verify.digest?.count;
@@ -1472,18 +2370,8 @@ function homeEnvName(harnessId) {
1472
2370
  try { return loadHarness(harnessId).runtime.home_env; } catch { return sourceHarness().runtime.home_env; }
1473
2371
  }
1474
2372
 
1475
- // What Node cannot say: spawnSync reports a working directory that does not
1476
- // exist and a binary not on PATH with the same `spawnSync <bin> ENOENT`
1477
- // (measured 2026-10-05). The filesystem tells them apart, in this order (the
1478
- // install spec, contract 19); any other error passes through as Node wrote it.
1479
- function startFailure(err, { bin, resolved, cwd }) {
1480
- let dir = false;
1481
- try { dir = statSync(cwd).isDirectory(); } catch {}
1482
- if (!dir) return `${bin} could not start: the working directory ${JSON.stringify(String(cwd))} is not an existing directory`;
1483
- if (!resolved) return `${bin} could not start: it was not found on PATH`;
1484
- if (["ENOENT", "EACCES", "ENOEXEC"].includes(err?.code)) return `${bin} could not start: ${resolved} exists but could not be run (${err.code})`;
1485
- return String(err?.message || err);
1486
- }
2373
+ // startFailure — what Node cannot say about a child that did not start —
2374
+ // lives in run-host.mjs, shared with the shell fetch (contract 19).
1487
2375
 
1488
2376
  // rmdirSync refuses a non-empty directory — that refusal IS the guarantee
1489
2377
  // (contract 13): we prune only a directory we emptied. The runtime dir's own
@@ -1500,70 +2388,309 @@ function pruneEmptyDir(dir, projectDir) {
1500
2388
 
1501
2389
  // ─── verbs ─────────────────────────────────────────────────────────────
1502
2390
 
2391
+ // What `plan` is given by both entry points (main() and the bin's
2392
+ // runInstallVerb), so the two cannot drift: the root, and its version for a
2393
+ // shell-rooted registration planned without its payload — the dry run.
2394
+ export function planOptions(opts = {}) {
2395
+ const root = opts.root || pluginRoot();
2396
+ return { ...opts, root, version: opts.version ?? rootVersion(root) };
2397
+ }
2398
+
2399
+ // Before plan(): the shell fetch (the shell-fetch story's rules 1–4 and 9).
2400
+ // For each harness in the run's set whose registration is shell-rooted, rule
2401
+ // 1 decides from the manifest, the environment, the run's root and a
2402
+ // version-only analysis; a harness that passes is fetched once, verified and
2403
+ // handed to plan() as payloadRoots[id]. The selection refusals (an unknown
2404
+ // harness, a missing project, no harness) are plan()'s and come first: none
2405
+ // of them fetches. A failed fetch is a refusal before the plan: a stub with
2406
+ // plan()'s early-return keys, so every caller reads it as it reads a refused
2407
+ // plan. `cleanup` removes the run's scratch — the caller calls it in a
2408
+ // `finally`, after apply(), since staging copies from it; best effort, the
2409
+ // next fetching run's sweep converges on whatever it leaves. Ctrl+C here is
2410
+ // the terminal's SIGINT and leaves the scratch to that sweep: no listener is
2411
+ // held before the plan.
2412
+ // opts.onFetch(label, ids) → a live line ({ end, abort }) or null
2413
+ // opts.fetchSpawn, opts.fetchBudget → the tests' npm and clock
2414
+ //
2415
+ // Before the fetch, the converge story's rules 6 and 8, in this order (its
2416
+ // Technical Notes): the copies this project runs (read-only); the registry
2417
+ // read — awaited when a copy is newer than this root, else started for the
2418
+ // update notice and awaited after the fetch, before PLAN; the stop, which
2419
+ // returns here with nothing fetched, written, locked or spawned but the
2420
+ // registry resolver; the keep read, for the channel "git" (rule 5): the kept
2421
+ // copy's own plan, read through its bin, and no fetch when plan() will refuse;
2422
+ // then the fetch, which skips a newer registration. The user's registry
2423
+ // is resolved at most once: for a read, and by every run that fetches a shell
2424
+ // — which also resolves the project's, and names it in PLAN when the two
2425
+ // differ (the 2026-10-09 amendment). A run that neither fetches nor reads
2426
+ // resolves neither. On the notice-only path the resolver and the request
2427
+ // share one 2 s bound.
2428
+ // opts.notice → the caller prints text (runVerb's `out`): the notice may show
2429
+ // opts.fetch, opts.now, opts.timers → the tests' registry read and clock
2430
+ async function prepareRun(verb, projectDir, opts = {}) {
2431
+ const env = opts.env || process.env;
2432
+ const home = opts.home || homedir();
2433
+ const root = opts.root || pluginRoot();
2434
+ const version = rootVersion(root);
2435
+ const none = { version, payloadRoots: {}, fetched: [], refused: null, convergence: null, notice: null, cleanup: () => {} };
2436
+ if (!["install", "upgrade"].includes(verb)) return none;
2437
+ projectDir = resolve(projectDir);
2438
+ const state = verb === "upgrade";
2439
+ const named = (opts.harnesses || []).filter(Boolean);
2440
+ if (named.some((id) => !harnessIds().includes(id)) || projectDirRefusal(projectDir)) return none;
2441
+ const detected = detectHarnesses(projectDir, { state });
2442
+ const ids = named.length ? named : detected.map((d) => d.id);
2443
+ if (!ids.length) return none;
2444
+ // One version-only analysis per shell-rooted registration, shared by the
2445
+ // running copies and the fetch's decision.
2446
+ const analyses = new Map();
2447
+ const analyse = (harness, s) => {
2448
+ if (!analyses.has(harness.id)) analyses.set(harness.id, analysePortableRegistration(projectDir, s, { root, home, harness, env, version }));
2449
+ return analyses.get(harness.id);
2450
+ };
2451
+ // 1. The user's registry: resolved once, when first needed (the 2026-10-09
2452
+ // amendment); null when npm could not say.
2453
+ let userRegistry = null;
2454
+ const userRegistryOf = (budget) => (userRegistry ||= npmUserRegistry({ home, env, spawn: opts.fetchSpawn, budget }).catch(() => null));
2455
+ // The read. `shared`: the resolver and the request fit in one bound — the
2456
+ // notice-only path's 2 s; otherwise the request has its own 2 s after a
2457
+ // resolver allowed rule 6's 10 s.
2458
+ const read = (budget, { atLeast = null, shared = false } = {}) => {
2459
+ const t0 = Date.now();
2460
+ return userRegistryOf(budget)
2461
+ .then((registry) => {
2462
+ const timeoutMs = shared ? budget - (Date.now() - t0) : READ_TIMEOUT_MS;
2463
+ if (timeoutMs < 1) return { latest: null, from: null, error: `no answer within ${budget / 1000} s` };
2464
+ return readLatest({ registry: registry || DEFAULT_REGISTRY, cachePath: cachePaths({ env, home }).registry, fetch: opts.fetch, now: opts.now, timers: opts.timers, atLeast, timeoutMs });
2465
+ })
2466
+ .catch((e) => ({ latest: null, from: null, error: String(e?.message || e) }));
2467
+ };
2468
+ // 2. The copies this project runs: from a package root or a checkout only;
2469
+ // a host's copy is updated through its own channel and never stopped.
2470
+ const checks = Boolean(version) && checksRoot(root, { home, env });
2471
+ // Under the channel "git" no host plugin copy counts: the run renders
2472
+ // nothing for that harness (rule 6). "npm" and null count them alike.
2473
+ const copies = checks ? runningCopies(projectDir, { harnesses: ids, root, home, env, version, analyse, channel: opts.channel ?? null }) : [];
2474
+ const newer = newerCopies(copies, version);
2475
+ const off = updateCheckOff(env);
2476
+ // The notice: text mode, from a package root, never under CI or the switch.
2477
+ const wantNotice = Boolean(opts.notice) && !opts.json && Boolean(version) && noticeRoot(root, { home, env }) && !envFlag(env, "CI") && !off;
2478
+ // 3. The read: one request per run at most, shared by rule 6 and the notice.
2479
+ // --json and CI do not switch rule 6's read off: it decides what is written.
2480
+ let answer = null, pending = null;
2481
+ if (newer.length && !off) answer = await read(REGISTRY_BUDGET_MS, { atLeast: lowestVersion(newer) });
2482
+ else if (wantNotice) pending = read(NOTICE_BUDGET_MS, { shared: true });
2483
+ // 4. The stop: nothing after it runs.
2484
+ const convergence = checks
2485
+ ? decide({ copies, version, read: answer, skipped: off && newer.length > 0, verb, args: { harnesses: named, surfaces: opts.surfaces || null, register: opts.register !== false } })
2486
+ : { checks: false };
2487
+ if (convergence.stop) return { ...none, convergence };
2488
+ // 5. The keep (rule 5), for the channel "git": the copy kept, and its own
2489
+ // plan read through its bin — before the fetch, so a run plan() will refuse
2490
+ // fetches nothing: no copy to keep (rule 1), or the copy's plan refused. A
2491
+ // copy that cannot run the form, and a run narrowed by --surface, read
2492
+ // nothing; plan() says why. Inside a session the plan is read all the same.
2493
+ let keep = null, refuses = false;
2494
+ if ((opts.channel ?? null) === "git") {
2495
+ const target = keepTarget(projectDir, ids, { root, home, env });
2496
+ if (target && !target.copy) refuses = true;
2497
+ else if (target && !target.copy.missing && !(opts.surfaces && opts.surfaces.length)) {
2498
+ const childEnvironment = keepEnv(env, { harness: target.harness, projectDir, copyRoot: target.copy.path, harnessHome: claudeHome(home) });
2499
+ const read = await readKeepPlan(target.copy, { harness: target.harness, projectDir, env: childEnvironment, run: runHost, budget: HOST_BUDGET_MS });
2500
+ keep = { copy: target.copy, read };
2501
+ if (read.envelope && !read.ok) refuses = true;
2502
+ }
2503
+ }
2504
+ // 6. The fetch.
2505
+ const fetchedPart = refuses ? { ...none, fetchedFrom: null } : await fetchShells(verb, projectDir, { ...opts, env, home, root, version, named, detected, ids, analyse, userRegistryOf });
2506
+ const latest = pending ? (await pending).latest : answer?.latest;
2507
+ const notice = wantNotice ? updateNotice({ latest, version, root, verb }) : null;
2508
+ return { ...fetchedPart, convergence, notice, keep };
2509
+ }
2510
+
2511
+ async function fetchShells(verb, projectDir, { env, home, root, version, named, detected, ids, analyse, userRegistryOf, ...opts }) {
2512
+ const none = { version, payloadRoots: {}, fetched: [], refused: null, fetchedFrom: null, cleanup: () => {} };
2513
+ const interactive = asksAtTerminal({ ...opts, env });
2514
+ const ownShell = bundlingShellHarnessId({ core: root });
2515
+ const jobs = [];
2516
+ for (const id of ids) {
2517
+ const harness = loadHarness(id);
2518
+ for (const [key, s] of Object.entries(harness.surfaces || {})) {
2519
+ if (key.startsWith("_") || s.format !== "portable-plugin-registration") continue;
2520
+ const excluded = surfaceExcluded(key, s, { surfaces: opts.surfaces || null, register: opts.register });
2521
+ const analysis = excluded ? null : analyse(harness, s);
2522
+ const decision = fetchDecision({ verb, harness, s, excluded, named: named.length > 0, interactive, analysis, ownShell: ownShell === id, env });
2523
+ if (decision.fetch) jobs.push({ harness, s });
2524
+ }
2525
+ }
2526
+ if (!jobs.length) return none;
2527
+ const refuse = (message, cleanup = () => {}) => ({ ...none, cleanup, refused: { projectDir, mode: "install", named: named.length > 0, detected, harnesses: ids, reports: [], items: [], refusals: [message], ok: false, incomplete: false, root, plannedAgainst: {} } });
2528
+ const cache = cachePaths({ env, home });
2529
+ if (!cache.root) return refuse(fetchRefusal("ENOCACHE", { variable: cache.variable }));
2530
+ sweepFetchRuns(cache.fetch);
2531
+ const runDir = cache.fetchRun(fetchRunId());
2532
+ try { mkdirSync(runDir, { recursive: true }); }
2533
+ // The root is named, not the run's own directory: that is where the user acts.
2534
+ catch (e) { return refuse(fetchRefusal("ECACHE", { path: cache.root, errno: e?.code, from: cache.from, variable: cache.variable })); }
2535
+ const cleanup = () => { try { removeTreeUnder(runDir, cache.root, { maxRetries: 3 }); } catch {} };
2536
+ const coreName = rootName(root);
2537
+ const budget = opts.fetchBudget || HOST_BUDGET_MS;
2538
+ let line = null;
2539
+ try {
2540
+ // Two resolvers, side by side: the project's registry, which the fetch
2541
+ // uses (its .npmrc counts), and the user's, which PLAN names beside it
2542
+ // when they differ — a shell from a registry the user did not choose is
2543
+ // said, never silent (the 2026-10-09 amendment).
2544
+ const [registry, user] = await Promise.all([
2545
+ npmRegistry(projectDir, { env, spawn: opts.fetchSpawn, budget: Math.min(budget, REGISTRY_BUDGET_MS) }),
2546
+ userRegistryOf(Math.min(budget, REGISTRY_BUDGET_MS)),
2547
+ ]);
2548
+ // A user read that failed means npm's default (the amendment), so a
2549
+ // project registry is still named when the user's could not be read.
2550
+ const bare = (u) => String(u || "").replace(/\/+$/, "");
2551
+ const mine = user || DEFAULT_REGISTRY;
2552
+ const differs = Boolean(registry) && bare(registry) !== bare(mine);
2553
+ const payloadRoots = {}, fetched = [];
2554
+ for (const job of jobs) {
2555
+ const { harness } = job;
2556
+ const pkg = `${harness.install.shell}@${version}`;
2557
+ // One prefix per shell: npm prunes what a prefix holds that its own
2558
+ // install did not ask for, so a second shell would take the first away.
2559
+ const dir = jobs.length === 1 ? runDir : join(runDir, harness.id);
2560
+ if (dir !== runDir) mkdirSync(dir, { recursive: true });
2561
+ line = opts.onFetch ? opts.onFetch(`fetching ${pkg} for ${harness.display_name}`, ids) : null;
2562
+ const r = await fetchShell({ ...job, version, coreName }, { dir, registry, env, spawn: opts.fetchSpawn, budget });
2563
+ if (!r.ok) {
2564
+ if (line) line.end(false, `fetch failed ${pkg}`, r.code);
2565
+ return refuse(r.refusal, cleanup);
2566
+ }
2567
+ if (line) line.end(true, `fetched ${pkg} for ${harness.display_name}`);
2568
+ line = null;
2569
+ payloadRoots[harness.id] = r.root;
2570
+ fetched.push({ harness: harness.id, package: harness.install.shell, version, ms: r.ms });
2571
+ }
2572
+ return { version, payloadRoots, fetched, refused: null, fetchedFrom: differs ? { registry, user: mine, harnesses: fetched.map((f) => f.harness) } : null, cleanup };
2573
+ } catch (e) {
2574
+ // The caller's `finally` is not reached from here: the scratch goes now.
2575
+ if (line) line.abort();
2576
+ cleanup();
2577
+ throw e;
2578
+ }
2579
+ }
2580
+
1503
2581
  export async function runVerb(verb, projectDir, opts = {}) {
1504
2582
  const mode = verb === "uninstall" ? "uninstall" : "install"; // upgrade is install re-run (contract 14)
1505
- const p = plan(projectDir, { ...opts, mode });
1506
2583
  const env = opts.env || process.env;
2584
+ const home = opts.home || homedir();
1507
2585
  // `out` is the text-mode caller's stdout. With it, this prints: the plan
1508
- // first, then the question, then each step as it runs, then DONE.
2586
+ // first, then the question, then each step as it runs, then DONE. A run
2587
+ // that fetches prints its badge first, then the fetch line beneath it, then
2588
+ // the preview without the badge (presentation spec contract 7's slot).
1509
2589
  const out = opts.out || null;
1510
2590
  const c = out ? termCaps(out, env) : null;
1511
- const paint = c ? painter(c) : (_style, text) => String(text);
1512
- const glyph = c ? (name) => termIcon(c, name) : null;
1513
- const preview = renderPreview(p, { verbose: Boolean(opts.verbose), verb, paint, icon: glyph, width: c && c.live ? c.width : 0 });
1514
- if (out) out.write(preview + "\n");
1515
- const gate = await confirm(p, { ...opts, env, paint });
1516
- const result = { verb, plan: p, preview, gate, applied: [], failed: null, elapsed: 0 };
1517
- if (gate.confirmed) {
1518
- const t0 = Date.now();
1519
- const reporter = out ? applyReporter(out, c, p) : null;
1520
- const spawn = opts.spawn || spawnSync;
1521
- result.applied = apply(p, { env, spawn: reporter ? reporter.spawn(spawn) : spawn, home: opts.home || homedir(), onItem: reporter ? reporter.onItem : null, onStep: reporter ? reporter.onStep : null });
1522
- result.failed = result.applied.failed || null;
1523
- result.elapsed = Date.now() - t0;
1524
- if (out) out.write(renderDone(result, { paint, glyph: glyph || undefined, verbose: Boolean(opts.verbose) }));
1525
- }
1526
- return result;
2591
+ let badged = false;
2592
+ const onFetch = out ? (label, ids) => {
2593
+ if (!badged) { out.write(badgeLine({ harnesses: ids, mode }, { verb, caps: c }) + "\n"); badged = true; }
2594
+ return liveLine(out, c, label, { env });
2595
+ } : null;
2596
+ const prep = await prepareRun(verb, projectDir, { ...opts, env, onFetch, notice: Boolean(out) });
2597
+ try {
2598
+ // upgrade selects by the state directory too (rule 5), and so does a
2599
+ // project uninstall, setup's mirror: it leaves the project for every
2600
+ // harness that used it (the story "One run plans the agents block once…",
2601
+ // rule 5). `uninstall --global` keeps contract 8 — a state directory is a
2602
+ // project fact. install and plan keep contract 8, so a bare plan and a
2603
+ // bare install still agree.
2604
+ const p = prep.refused || plan(projectDir, { ...opts, mode, state: verb === "upgrade" || (verb === "uninstall" && !opts.globalRemoval), version: prep.version, payloadRoots: prep.payloadRoots, convergence: prep.convergence, fetchedFrom: prep.fetchedFrom || null, keep: prep.keep || null });
2605
+ const view = { verbose: Boolean(opts.verbose), verb, caps: c || PLAIN, home };
2606
+ const preview = renderPreview(p, view);
2607
+ // The update notice (the converge story's rule 8): in the header slot,
2608
+ // under the badge and after the fetch line, before PLAN. Never beside the
2609
+ // stop, whose refusal names the update itself.
2610
+ if (out && prep.notice) {
2611
+ if (!badged) { out.write(badgeLine(p, { verb, caps: c }) + "\n"); badged = true; }
2612
+ out.write(noticeText(prep.notice, c));
2613
+ }
2614
+ if (out) out.write((badged ? renderPreview(p, { ...view, badge: false }) : preview) + "\n");
2615
+ const gate = await confirm(p, { ...opts, env, ...(c ? { caps: c } : {}) });
2616
+ const result = { verb, plan: p, preview, gate, applied: [], failed: null, elapsed: 0, fetched: prep.fetched };
2617
+ if (gate.confirmed) {
2618
+ // DONE's time leaves the fetch out: it wrote nothing of the project's.
2619
+ const t0 = Date.now();
2620
+ const reporter = out ? applyReporter(out, c, p, { home }) : null;
2621
+ const spawn = opts.spawn || spawnSync;
2622
+ result.applied = apply(p, { env, spawn: reporter ? reporter.spawn(spawn) : spawn, home, onItem: reporter ? reporter.onItem : null, onStep: reporter ? reporter.onStep : null });
2623
+ result.failed = result.applied.failed || null;
2624
+ // A kept copy's run left incomplete (the converge story's rule 5) leaves
2625
+ // this one incomplete: both entries read the plan's flag for the exit.
2626
+ if (result.applied.incomplete) p.incomplete = true;
2627
+ result.elapsed = Date.now() - t0;
2628
+ if (out) out.write(renderDone(result, { caps: c, verbose: Boolean(opts.verbose), cwd: opts.cwd || process.cwd() }));
2629
+ }
2630
+ return result;
2631
+ } finally {
2632
+ prep.cleanup();
2633
+ }
1527
2634
  }
1528
2635
 
1529
- // APPLY, one line per step (contract 18). An item with steps — a
1530
- // registration, the layout move — prints its name, then one line per step
1531
- // beneath it: each host command, staging write, write and move. Any other
1532
- // item is one line.
1533
- function applyReporter(out, c, p) {
2636
+ // APPLY, one line per step as it finishes (presentation spec contract 8):
2637
+ // the glyph, what was done in the past tense — "added CLAUDE.md", having read
2638
+ // "adding CLAUDE.md" while it ran on a terminal — and the duration. An item
2639
+ // with steps — a registration, the layout move — prints its own line as it
2640
+ // starts ("registering …"), one line per step beneath it — each host
2641
+ // command, staging write, write and move — and, once all of them have run,
2642
+ // its finished line in the past tense ("registered …") with the item's whole
2643
+ // time, so a transcript never ends on a line that reads as still running.
2644
+ // No heading: the plan's last line and the question are above it. `now` is
2645
+ // the test's clock; `home` shortens paths to ~.
2646
+ export function applyReporter(out, c, p, { now = Date.now, home = homedir() } = {}) {
1534
2647
  const paint = painter(c);
1535
- const item = stepReporter(out, c, { indent: 2 });
1536
- const step = stepReporter(out, c, { indent: 6 });
1537
- let opened = false;
1538
- const open = () => { if (!opened) { out.write(`${paint("bold", "APPLY")}\n`); opened = true; } };
1539
- const label = (i) => {
1540
- const target = i.path === null ? i.surface : rel(p.projectDir, i.path);
1541
- return `${i.kind} ${target}${i.entry ? ` [${i.entry}]` : ""}`;
2648
+ const glyph = (name) => termIcon(c, name);
2649
+ const item = stepReporter(out, c, { indent: 2, now });
2650
+ const step = stepReporter(out, c, { indent: 6, now });
2651
+ const said = (i) => {
2652
+ const word = actionWord(i);
2653
+ const [running, done] = WORD_TENSES[word] || [word, word];
2654
+ const target = rowTarget(p, i);
2655
+ return { word, running: `${running} ${target}`, done: `${done} ${target}` };
1542
2656
  };
1543
- const stepped = (i) => i.kind === "registration" || i.kind === "layout";
2657
+ // The mark too: a skip prints its reason (the project not bound, the switch
2658
+ // not done) beneath the line it started.
2659
+ // …and the keep, whose $ line and the kept copy's own items print beneath it.
2660
+ const stepped = (i) => i.kind === "registration" || i.kind === "layout" || i.kind === "binding" || i.kind === "channel";
2661
+ const began = new Map();
1544
2662
  return {
1545
2663
  onItem(i, phase, r) {
1546
- open();
2664
+ const s = said(i);
1547
2665
  if (stepped(i)) {
1548
- if (phase === "start") out.write(` ${paint(ACTION_COLOR[i.action] || "cyan", termIcon(c, ACTION_ICON[i.action] || "update"))} ${label(i)}\n`);
1549
- else if (phase === "abort") step.abort();
1550
- else if (r && (r.action === "skipped" || r.action === "skip")) out.write(` ${paint("gray", termIcon(c, "skip"))} ${paint("gray", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`);
1551
- else if (r && r.failed) out.write(` ${paint("red", termIcon(c, "fail"))} ${paint("red", "stopped")}\n`);
2666
+ if (phase === "start") { began.set(i, now()); out.write(` ${paint(WORD_ROLE[s.word], glyph(glyphName(i)))} ${s.running}\n`); return; }
2667
+ const t0 = began.get(i) ?? now();
2668
+ began.delete(i);
2669
+ if (phase === "abort") step.abort();
2670
+ else if (r && (r.action === "skipped" || r.action === "skip")) out.write(` ${paint("explanation", glyph("skip"))} ${paint("explanation", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`);
2671
+ else if (r && r.failed) out.write(` ${paint("error", glyph("fail"))} ${paint("error", "stopped")}\n`);
2672
+ else item.finish(s.done, t0);
1552
2673
  return;
1553
2674
  }
1554
- if (phase === "start") item.start(label(i));
2675
+ if (phase === "start") item.start(s.running);
1555
2676
  else if (phase === "abort") item.abort();
1556
- else item.end(!(r && (r.failed || r.action === "skipped")), r && r.action === "skipped" ? "skipped" : "");
2677
+ else {
2678
+ // An item that failed or was skipped keeps the words it ran under.
2679
+ const failed = Boolean(r && (r.failed || r.action === "skipped"));
2680
+ item.end(!failed, r && r.action === "skipped" ? "skipped" : "", failed ? {} : { label: s.done });
2681
+ }
1557
2682
  },
1558
2683
  onStep(st, phase, r) {
1559
- const text = stepLabel(p, st);
1560
- if (!text) return;
1561
- if (phase === "start") step.start(text);
1562
- else step.end(r ? r.ok : true, r && r.kept ? "kept" : "", { kept: Boolean(r && r.kept) });
2684
+ const l = stepLabel(p, st, { home, glyph });
2685
+ if (!l) return;
2686
+ if (phase === "start") { step.start(l.running); return; }
2687
+ const ok = r ? r.ok : true;
2688
+ const kept = Boolean(r && r.kept);
2689
+ // A step that left its target in place did not do its verb.
2690
+ step.end(ok, kept ? "kept" : "", ok ? { kept, label: kept ? l.base : l.done } : {});
1563
2691
  },
1564
2692
  spawn(inner) {
1565
2693
  return (bin, argv, o) => {
1566
- open();
1567
2694
  step.start(`$ ${basename(String(bin))} ${argv.join(" ")}`);
1568
2695
  let r;
1569
2696
  try { r = inner(bin, argv, o); }
@@ -1575,33 +2702,60 @@ function applyReporter(out, c, p) {
1575
2702
  };
1576
2703
  }
1577
2704
 
1578
- // A step as its APPLY line names it: the verb and the target, short. The
1579
- // full form — file counts, entry pointers — was in PLAN.
1580
- function stepLabel(p, st) {
1581
- const r = (x) => tilde(rel(p.projectDir, x));
2705
+ // A step as its APPLY line names it: the verb and the target, short — the
2706
+ // full form, file counts and entry pointers, was in PLAN. `base` is the plain
2707
+ // verb, for a step that left its target in place.
2708
+ const STEP_TENSES = { stage: ["staging", "staged"], lift: ["lifting", "lifted"], write: ["writing", "wrote"], edit: ["editing", "edited"], ensure: ["ensuring", "ensured"], move: ["moving", "moved"], merge: ["merging", "merged"], remove: ["removing", "removed"] };
2709
+ function stepLabel(p, st, { home = homedir(), glyph = unicodeGlyph } = {}) {
2710
+ const r = (x) => tilde(rel(p.projectDir, x), home);
2711
+ const to = ` ${glyph("transition")} `;
2712
+ const said = (verb, rest) => ({ base: `${verb} ${rest}`, running: `${STEP_TENSES[verb][0]} ${rest}`, done: `${STEP_TENSES[verb][1]} ${rest}` });
1582
2713
  switch (st.kind) {
1583
- case "portable-write": return `stage ${r(st.path)}`;
1584
- case "recover": return `lift ${r(st.path)} — the previous state proved${st.lifted ? `; it held: ${String(st.lifted).split("\n")[0]}` : ""}`;
1585
- case "write": return `write ${r(st.path)}`;
1586
- case "unregister": return `edit ${r(st.path)} [${st.pointer}.${st.name}]`;
1587
- case "ensure": return `ensure ${r(st.path)}/`;
1588
- case "move-state": return `move ${r(st.from)}/ → ${r(st.to)}/`;
1589
- case "merge-log": return `merge ${r(st.from)} → ${r(st.to)}`;
2714
+ case "portable-write": return said("stage", r(st.path));
2715
+ case "recover": return said("lift", `${r(st.path)} ${glyph("dash")} the previous state proved${st.lifted ? `; it held: ${String(st.lifted).split("\n")[0]}` : ""}`);
2716
+ case "write": return said("write", r(st.path));
2717
+ case "unregister": return said("edit", `${r(st.path)} [${st.pointer}.${st.name}]`);
2718
+ case "mark":
2719
+ case "unmark": return said("edit", `${r(st.path)} [${st.key}]`);
2720
+ case "ensure": return said("ensure", `${r(st.path)}/`);
2721
+ case "move-state": return said("move", `${r(st.from)}/${to}${r(st.to)}/`);
2722
+ case "merge-log": return said("merge", `${r(st.from)}${to}${r(st.to)}`);
1590
2723
  case "move-marker":
1591
- case "move-binding": return `move ${r(st.from)} → ${r(st.to)}`;
2724
+ case "move-binding": return said("move", `${r(st.from)}${to}${r(st.to)}`);
1592
2725
  case "delete":
1593
2726
  case "portable-remove":
1594
2727
  case "remove":
1595
2728
  case "remove-legacy-launcher":
1596
2729
  case "rmdir-legacy":
1597
- case "remove-legacy-runtime": return `remove ${r(st.path)}`;
2730
+ case "remove-legacy-runtime": return said("remove", r(st.path));
2731
+ case "rmdir-state": return said("remove", `${r(st.path)}/`);
2732
+ // An item the kept copy applied (the converge story's rule 5), in this
2733
+ // run's words for its action; one the preview did not list says so.
2734
+ case "kept": {
2735
+ const word = ACTION_WORD[st.action] || "update";
2736
+ const [running, done] = WORD_TENSES[word] || [word, word];
2737
+ const target = `${st.path ? r(st.path) : st.surface}${st.unplanned ? " (not in the preview)" : ""}`;
2738
+ return { base: `${word} ${target}`, running: `${running} ${target}`, done: `${done} ${target}` };
2739
+ }
1598
2740
  default: return null;
1599
2741
  }
1600
2742
  }
1601
2743
 
1602
- // DONE: what happened, how long it took, what to do next — from the manifests,
1603
- // never from a harness-id branch — and at most two tips.
1604
- export function renderDone(r, { paint = (_style, text) => String(text), glyph = (n) => termIcon({ ascii: false }, n), verbose = false } = {}) {
2744
+ // DONE and STOPPED (presentation spec contract 8). DONE: the count and the
2745
+ // time; Next, one line per harness — its display name in a column, its
2746
+ // manifest's next steps beside it, deduplicated; the facts the run settled
2747
+ // (the version, for now); then the tip — the shells' `plan` commands with
2748
+ // --verbose unless it was given, each on its own line (contract 6). Never
2749
+ // from a harness-id branch: every word comes from the manifests. STOPPED:
2750
+ // what applied, the step that failed by its argv and exit status or signal —
2751
+ // or, for a command that could not start, its cause in their place, once —
2752
+ // or by its item, the output beneath, and what to do. `cwd` decides whether
2753
+ // the tip's --project is "$PWD".
2754
+ export function renderDone(r, { caps: c = PLAIN, verbose = false, cwd = process.cwd() } = {}) {
2755
+ const paint = painter(c);
2756
+ const glyph = (name) => termIcon(c, name);
2757
+ const width = c.live ? c.width : 0;
2758
+ const dash = glyph("dash");
1605
2759
  const p = r.plan;
1606
2760
  // What applied: a record that neither failed nor was skipped — a recheck
1607
2761
  // that found the work already done is not a change.
@@ -1613,34 +2767,106 @@ export function renderDone(r, { paint = (_style, text) => String(text), glyph =
1613
2767
  const record = r.applied.find((a) => a.failed);
1614
2768
  const item = record ? p.items.find((i) => i.surface === record.surface && i.path === record.path) : null;
1615
2769
  const what = f.argv ? "a host command failed" : `${f.step} failed`;
1616
- lines.push(`${paint("bold", "STOPPED")} — ${changes} applied, then ${what}`);
1617
- if (f.argv) lines.push(` ${paint("red", glyph("fail"))} $ ${f.argv.join(" ")}${f.status === null || f.status === undefined ? "" : ` exited ${f.status}`}`);
1618
- else lines.push(` ${paint("red", glyph("fail"))} ${f.step}${item ? ` (${item.kind} ${item.path === null ? item.surface : rel(p.projectDir, item.path)})` : ""}`);
1619
- if (f.stderr) for (const l of String(f.stderr).split("\n")) lines.push(` ${l}`);
1620
- if (item && item.kind === "registration") lines.push(" The surfaces planned against its install path were not written; run the verb again once it succeeds.");
1621
- else lines.push(" Run the verb again once the cause is fixed: its plan starts from what is on disk now.");
2770
+ lines.push(`${paint("emphasis", "Stopped")} ${dash} ${changes} applied, then ${what}`);
2771
+ const said = f.stderr ? String(f.stderr).split("\n") : [];
2772
+ const mark = ` ${paint("error", glyph("fail"))} `;
2773
+ if (f.argv) {
2774
+ // How the command ended: its exit status, the signal that stopped it,
2775
+ // or — only for one that could not start — its cause in their place,
2776
+ // said once (install spec contract 19). A command that ran and failed
2777
+ // otherwise (an answer in text where JSON is read) keeps its whole
2778
+ // message beneath.
2779
+ const ended = f.status !== null && f.status !== undefined ? ` exited ${f.status}`
2780
+ : f.signal ? ` killed by ${f.signal}`
2781
+ : f.couldNotStart && said.length ? ` ${dash} ${said.shift()}`
2782
+ : "";
2783
+ lines.push(`${mark}$ ${f.argv.join(" ")}${ended}`);
2784
+ } else lines.push(`${mark}${f.step}${item ? ` (${surfaceWords(item.surface)} ${rowTarget(p, item)})` : ""}`);
2785
+ for (const l of said) lines.push(` ${paint("explanation", l)}`);
2786
+ const closing = item && item.kind === "registration"
2787
+ ? "The surfaces planned against its install path were not written; run the verb again once it succeeds."
2788
+ : "Run the verb again once the cause is fixed: its plan starts from what is on disk now.";
2789
+ lines.push(...wrap(closing, width, " ").split("\n").map((l, k) => (k ? l : ` ${l}`)));
1622
2790
  return lines.join("\n") + "\n";
1623
2791
  }
1624
- lines.push(`${paint("bold", "DONE")} — ${changes} in ${duration(r.elapsed || 0)}`);
1625
- const next = [], tips = [];
1626
- const here = (() => { try { return realpathSync(p.projectDir) === realpathSync(process.cwd()); } catch { return p.projectDir === process.cwd(); } })();
2792
+ lines.push(`${paint("emphasis", "Done")} ${dash} ${changes} in ${duration(r.elapsed || 0)}`);
2793
+ const next = [], seen = new Set();
1627
2794
  for (const id of p.harnesses) {
1628
2795
  const m = loadHarness(id);
1629
2796
  if (!m) continue;
1630
- const steps = r.verb === "uninstall" ? [`restart ${m.display_name}`] : (m.install?.next || []);
1631
- for (const s of steps) if (!next.includes(s)) next.push(s);
1632
- // `plan` previews an install: after an uninstall it would preview the
1633
- // opposite of what just ran.
1634
- if (r.verb === "uninstall") continue;
1635
- const preview = packageCommand(m, "plan", { args: `--project ${here ? '"$PWD"' : `"${p.projectDir}"`}` });
1636
- if (!tips.some((t) => t.includes(preview))) tips.push(`preview without writing: ${preview}`);
1637
- }
1638
- if (!verbose) tips.push("every row's reasoning: add --verbose");
1639
- for (const s of next) lines.push(` ${paint("cyan", "next")} ${s}`);
1640
- for (const t of tips.slice(0, 2)) lines.push(` ${paint("gray", "tip")} ${paint("gray", t)}`);
2797
+ const steps = (r.verb === "uninstall" ? [`restart ${m.display_name}`] : (m.install?.next || [])).filter((s) => !seen.has(s));
2798
+ for (const s of steps) seen.add(s);
2799
+ if (steps.length) next.push([m.display_name, steps.join("; ")]);
2800
+ }
2801
+ // The keep's own next lines (the converge story's rule 5): the update a copy
2802
+ // that could not run the keep needs, its own upgrade when its plan printed
2803
+ // no result, the npm row's uninstall — each lead, then its command alone on
2804
+ // its line (contract 6).
2805
+ const kept = p.items.filter((i) => i.kind === "channel" && Array.isArray(i.next)).flatMap((i) => i.next);
2806
+ if (next.length || kept.length) lines.push("", paint("emphasis", "Next"));
2807
+ if (next.length) {
2808
+ const kw = Math.max(...next.map(([name]) => name.length));
2809
+ const col = " ".repeat(2 + kw + 2);
2810
+ for (const [name, text] of next) {
2811
+ const [first, ...rest] = wrap(text, width, col).split("\n");
2812
+ lines.push(` ${paint("explanation", name)}${" ".repeat(kw - name.length + 2)}${first}`, ...rest);
2813
+ }
2814
+ }
2815
+ kept.forEach((b, k) => {
2816
+ if (k || next.length) lines.push("");
2817
+ lines.push(...wrap(b.lead, width, " ").split("\n").map((l, n) => (n ? l : ` ${l}`)));
2818
+ if (b.command) lines.push(...commandBlock(c, [b.command], { indent: 4 }));
2819
+ });
2820
+ // The facts: the version, and under the keep (the converge story's rule 5)
2821
+ // the copy each kept harness runs — this package's version is not what that
2822
+ // harness's surfaces were written by.
2823
+ const keeps = p.items.filter((i) => i.kind === "channel" && i.kept);
2824
+ const facts = [...(p.version ? [["version", p.version]] : []), ...keeps.map((i) => ["kept", `${displayName(i.harness)}: ${i.kept.key}${i.kept.version ? ` ${i.kept.version}` : ""}`])];
2825
+ if (facts.length) lines.push("", ...kv(c, facts));
2826
+ // The tip: each shell's `plan` command, carrying --verbose unless it was
2827
+ // given, so the --verbose tip is this one and DONE has one tip, never
2828
+ // more than contract 18's two. Each command stands alone on its line, the
2829
+ // flag last (contract 6). `plan` previews an install: after an uninstall
2830
+ // it would preview the opposite of what just ran, so there is no tip.
2831
+ // A kept harness's tip is the kept copy's own plan, from its bin with
2832
+ // --no-register — this package's plan previews the other channel — and
2833
+ // none when that copy cannot run it; --verbose only where its CLI takes it.
2834
+ const here = (() => { try { return realpathSync(p.projectDir) === realpathSync(cwd); } catch { return p.projectDir === cwd; } })();
2835
+ const previews = [];
2836
+ let reasoned = true;
2837
+ if (r.verb !== "uninstall") {
2838
+ for (const id of p.harnesses) {
2839
+ const m = loadHarness(id);
2840
+ const k = keeps.find((i) => i.harness === id);
2841
+ if (k && k.kept.bin && !verbose && !k.kept.verbose) reasoned = false;
2842
+ const project = `--project ${here ? '"$PWD"' : `"${p.projectDir}"`}`;
2843
+ const cmd = k ? (k.kept.bin ? `node "${k.kept.bin}" plan --harness ${id} --no-register ${project}${verbose || !k.kept.verbose ? "" : " --verbose"}` : null)
2844
+ : m ? packageCommand(m, "plan", { args: `${project}${verbose ? "" : " --verbose"}` }) : null;
2845
+ if (cmd && !previews.includes(cmd)) previews.push(cmd);
2846
+ }
2847
+ }
2848
+ const lead = verbose || !reasoned ? "To preview without writing:" : "To preview without writing, with every row's reasoning:";
2849
+ if (previews.length) lines.push("", ` ${paint("explanation", lead)}`, ...commandBlock(c, previews));
1641
2850
  return lines.join("\n") + "\n";
1642
2851
  }
1643
2852
 
2853
+ // What a text run that wrote nothing says after its plan: one helper for
2854
+ // main() and the bin's runInstallVerb, so the two entries cannot drift (the
2855
+ // converge story's rule 2). `harnesses` and `surfaces` are the run's own
2856
+ // arguments, which the channel gate's command repeats — never --project. Its
2857
+ // commands are rule 2's helper's (channelChoices), each alone on its line
2858
+ // after a blank one (presentation spec contract 6).
2859
+ export function gateText(r, { verb, harnesses = [], surfaces = null, caps: c = PLAIN } = {}) {
2860
+ const why = r.gate?.why;
2861
+ if (why === "non-tty") return `Nothing written: without a terminal, a bare ${verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`;
2862
+ if (why === "declined") return "Nothing written.\n";
2863
+ if (why === "channel") {
2864
+ const keys = [...new Set(r.plan.items.filter(isSwitch).map((i) => i.entry))];
2865
+ return channelChoices({ gate: { verb, args: { harnesses, surfaces }, keys } }).map((b) => [b.lead, ...commandBlock(c, [b.command], { indent: 4 })].join("\n")).join("\n\n") + "\n";
2866
+ }
2867
+ return "";
2868
+ }
2869
+
1644
2870
  // ─── main ──────────────────────────────────────────────────────────────
1645
2871
 
1646
2872
  function usage() {
@@ -1668,7 +2894,11 @@ function publicStep({ manifest, ...s }) {
1668
2894
  if (s.kind === "portable-write" && Array.isArray(s.files)) s.files = s.files.length;
1669
2895
  return s;
1670
2896
  }
1671
- export const publicItem = ({ before, after, steps, observed, ...rest }) => steps
2897
+ // `analysed` (the recheck's inputs) and `digestDeferred` (a --verbose note)
2898
+ // are the plan's own, never the envelope's.
2899
+ // `advice` is the reason's commands, kept apart for PLAN to draw alone on
2900
+ // their lines; the envelope has them in `reason`, one part per line.
2901
+ export const publicItem = ({ before, after, steps, observed, analysed, digestDeferred, advice, ...rest }) => steps
1672
2902
  ? { ...rest, steps: steps.map(publicStep) }
1673
2903
  : rest;
1674
2904
 
@@ -1694,20 +2924,21 @@ async function main() {
1694
2924
  projectDir = resolve(projectDir || (src && process.env[src.runtime?.project_dir_env]) || process.cwd());
1695
2925
  const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register, json, verbose, stdin: process.stdin, stdout: process.stdout };
1696
2926
  if (verb === "plan") {
1697
- const p = plan(projectDir, opts);
2927
+ const p = plan(projectDir, planOptions(opts));
1698
2928
  const c = termCaps(process.stdout, process.env);
1699
- process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p, { verbose, verb, paint: painter(c), icon: (n) => termIcon(c, n), width: c.live ? c.width : 0 }));
2929
+ process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p, { verbose, verb, caps: c }));
1700
2930
  process.exit(p.ok && !p.incomplete ? 0 : 1);
1701
2931
  }
1702
2932
  const r = await runVerb(verb, projectDir, json ? opts : { ...opts, out: process.stdout });
2933
+ // The bin's form (scripts/cli.mjs): a gate that confirmed nothing — a
2934
+ // channel switch without a terminal among them — is not ok through either entry.
2935
+ const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
1703
2936
  if (json) {
1704
- process.stdout.write(JSON.stringify({ verb, ok: r.plan.ok && !r.plan.incomplete && !r.failed, gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, items: r.plan.items.map(publicItem), refusals: r.plan.refusals, reports: r.plan.reports }, null, 2) + "\n");
1705
- } else if (r.gate.why === "non-tty") {
1706
- process.stdout.write(`Nothing written: without a terminal, a bare ${verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
1707
- } else if (r.gate.why === "declined") {
1708
- process.stdout.write("Nothing written.\n");
2937
+ process.stdout.write(JSON.stringify({ verb, ok, gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, items: r.plan.items.map(publicItem), refusals: r.plan.refusals, reports: r.plan.reports, fetched: r.fetched }, null, 2) + "\n");
2938
+ } else {
2939
+ process.stdout.write(gateText(r, { verb, harnesses, surfaces: opts.surfaces, caps: termCaps(process.stdout, process.env) }));
1709
2940
  }
1710
- process.exit(r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do") ? 0 : 1);
2941
+ process.exit(ok ? 0 : 1);
1711
2942
  }
1712
2943
 
1713
2944
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();