projectstore-claude 0.30.4 → 0.30.6

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 (50) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
  3. package/node_modules/projectstore/.mcp.json +1 -2
  4. package/node_modules/projectstore/README.md +65 -16
  5. package/node_modules/projectstore/agents/archaeologist.md +1 -1
  6. package/node_modules/projectstore/agents/librarian.md +1 -1
  7. package/node_modules/projectstore/agents/planner.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +14 -11
  9. package/node_modules/projectstore/commands/reconcile.md +1 -1
  10. package/node_modules/projectstore/commands/review.md +2 -2
  11. package/node_modules/projectstore/commands/story.md +1 -1
  12. package/node_modules/projectstore/docs/getting-started.md +3 -4
  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/docs/images/icon.png +0 -0
  16. package/node_modules/projectstore/harnesses/claude-code.json +61 -11
  17. package/node_modules/projectstore/harnesses/codex.json +45 -15
  18. package/node_modules/projectstore/hooks/band.tsx +19 -8
  19. package/node_modules/projectstore/hooks/hooks.json +6 -6
  20. package/node_modules/projectstore/hooks/session-start.mjs +55 -24
  21. package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
  22. package/node_modules/projectstore/package.json +11 -12
  23. package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
  24. package/node_modules/projectstore/scripts/band.mjs +20 -2
  25. package/node_modules/projectstore/scripts/binding.mjs +1 -1
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
  27. package/node_modules/projectstore/scripts/cli.mjs +74 -1373
  28. package/node_modules/projectstore/scripts/convergence.mjs +59 -16
  29. package/node_modules/projectstore/scripts/doctor.mjs +199 -36
  30. package/node_modules/projectstore/scripts/harness.mjs +88 -6
  31. package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
  32. package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
  33. package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
  34. package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
  35. package/node_modules/projectstore/scripts/lib.mjs +387 -107
  36. package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
  37. package/node_modules/projectstore/scripts/mcp.mjs +60 -60
  38. package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
  39. package/node_modules/projectstore/scripts/models.mjs +6 -10
  40. package/node_modules/projectstore/scripts/query.mjs +7 -6
  41. package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
  42. package/node_modules/projectstore/scripts/setup.mjs +62 -12
  43. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
  44. package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
  45. package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
  46. package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
  47. package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
  48. package/package.json +3 -3
  49. package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
  50. package/node_modules/projectstore/docs/images/team-light.svg +0 -79
@@ -79,6 +79,12 @@
79
79
  // status-line flag, the keep's record and the mark — each a set of keys
80
80
  // merged into the binding at apply time, after the layout item.
81
81
  //
82
+ // What never reads the registry, fetches a shell or runs a host command is in
83
+ // three leaf modules, moved as it was: install-handlers.mjs (the format-keyed
84
+ // planners, setup's items, the plan helpers), install-layout.mjs (the layout
85
+ // migration) and install-preview.mjs (PLAN and the APPLY lines). None imports
86
+ // this file, and every name this file exported, it still exports.
87
+ //
82
88
  // Direction: installer → surfaces ← doctor; installer → provenance ← doctor.
83
89
  // Doctor never imports this file.
84
90
  //
@@ -97,10 +103,9 @@ import {
97
103
  readFileSync,
98
104
  openSync,
99
105
  closeSync,
100
- statSync,
101
106
  realpathSync,
102
107
  } from "node:fs";
103
- import { join, resolve, dirname, relative, isAbsolute, basename } from "node:path";
108
+ import { join, resolve, dirname } from "node:path";
104
109
  import { homedir } from "node:os";
105
110
  import { randomUUID } from "node:crypto";
106
111
  import { fileURLToPath } from "node:url";
@@ -110,14 +115,9 @@ import {
110
115
  painter,
111
116
  icon as termIcon,
112
117
  duration,
113
- stepReporter,
114
118
  liveLine,
115
119
  wrap,
116
120
  askApply,
117
- plain,
118
- badge as badgeText,
119
- heading,
120
- rowLines,
121
121
  kv,
122
122
  commandBlock,
123
123
  PLAIN,
@@ -139,17 +139,10 @@ import {
139
139
  configPath,
140
140
  childEnv,
141
141
  } from "./harness.mjs";
142
- import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
142
+ import { GRAMMAR_VERSION } from "./provenance.mjs";
143
143
  import {
144
- analyseBlock,
145
- analyseJsonEntry,
146
- analyseStampedFile,
147
144
  analyseRegistration,
148
145
  analysePortableRegistration,
149
- analyseLayout,
150
- analyseHarnessState,
151
- isOurFile,
152
- readText,
153
146
  registrationRenderRoot,
154
147
  registrationPaths,
155
148
  } from "./surfaces.mjs";
@@ -184,6 +177,8 @@ import {
184
177
  updateCheckOff,
185
178
  channelChoices,
186
179
  switchConsequence,
180
+ directoryConsequence,
181
+ copyOrigin,
187
182
  switchReason,
188
183
  noCopyToKeep,
189
184
  keepArgv,
@@ -201,7 +196,7 @@ import {
201
196
  READ_TIMEOUT_MS,
202
197
  MIN_READ_MS,
203
198
  } from "./convergence.mjs";
204
- import { applyBind, bindPlanSteps, recheckBind, bindResult } from "./binding.mjs";
199
+ import { applyBind, recheckBind, bindResult } from "./binding.mjs";
205
200
  import {
206
201
  pluginRoot,
207
202
  channelMark,
@@ -212,25 +207,15 @@ import {
212
207
  writeExclusiveMetadata,
213
208
  ensureStateDir,
214
209
  ensureStateRoot,
215
- ensureRuntimeDir,
216
- removeAgentsBlock,
217
- replaceAgentsBlock,
218
210
  readConfigAt,
219
- isPluginCacheRoot,
211
+ isHostInstallRoot,
220
212
  isEphemeralRoot,
221
- statusLineIsOurWiring,
222
213
  claudeHome,
223
214
  packageDigest,
224
215
  writeOwnTree,
225
216
  removeOwnTree,
226
217
  cmpPrecedence,
227
- importsLine,
228
218
  whichOnPath as whichOnPathFromLib,
229
- moveStateDir,
230
- mergeEntryLog,
231
- movePath,
232
- removeInside,
233
- statusLineScriptPath,
234
219
  layoutPaths,
235
220
  stagePortableMarketplace,
236
221
  finishPortableMarketplace,
@@ -238,30 +223,52 @@ import {
238
223
  removeTreeUnder,
239
224
  projectDirRefusal,
240
225
  fromTerminalIn,
241
- newerBlockWords,
242
226
  registrationProvenance,
243
227
  envFlag,
244
228
  selectCopy,
245
229
  copyUpdateAdvice,
246
230
  takesVerbose,
247
231
  } from "./lib.mjs";
232
+ import {
233
+ rel,
234
+ inside,
235
+ planAgentsBlock,
236
+ consequenceStep,
237
+ planStatuslineFlag,
238
+ planVault,
239
+ planStateDir,
240
+ STAMP,
241
+ stamped,
242
+ bindingItem,
243
+ planJsonEntry,
244
+ planStampedFile,
245
+ surfaceExcluded,
246
+ oneAddPerFile,
247
+ hostManagedReport,
248
+ isWrite,
249
+ } from "./install-handlers.mjs";
250
+ import { planLayout, planHarnessState, applyLayout, stepResult } from "./install-layout.mjs";
251
+ import {
252
+ surfaceWords,
253
+ rowTarget,
254
+ VAULT_GROUP,
255
+ displayName,
256
+ badgeLine,
257
+ noticeText,
258
+ renderPreview,
259
+ applyReporter,
260
+ } from "./install-preview.mjs";
248
261
 
249
262
  import { GENERATOR } from "./surfaces.mjs";
250
263
  export { GENERATOR };
251
264
 
252
- function rel(projectDir, p) {
253
- const r = relative(projectDir, p);
254
- return r && !r.startsWith("..") && !isAbsolute(r) ? r : p;
255
- }
256
-
257
265
  // isEphemeralRoot lives in lib.mjs beside the channel classifier that uses it;
258
266
  // re-exported here under the name the installer's callers already import.
259
267
  export { isEphemeralRoot };
260
268
 
261
- function inside(dir, parent) {
262
- const r = relative(parent, dir);
263
- return r !== "" && !r.startsWith("..") && !isAbsolute(r);
264
- }
269
+ // PLAN, the badge, the notice and the APPLY lines live in install-preview.mjs;
270
+ // re-exported here under the names the installer's callers already import.
271
+ export { renderPreview, badgeLine, noticeText, applyReporter };
265
272
 
266
273
  // ─── Surface handlers, keyed by format ─────────────────────────────────
267
274
  //
@@ -282,386 +289,6 @@ const HANDLERS = {
282
289
  // line slot) decides whether the exclusive launcher is written at all.
283
290
  const KIND_ORDER = { registration: 0, shared: 1, exclusive: 2 };
284
291
 
285
- // Planned once per RUN by plan(), never once per harness (the story "One run
286
- // plans the agents block once, and a bare uninstall selects every harness the
287
- // project uses", rule 1). `s` carries the run's common file list as `files`;
288
- // `ctx.run` is every harness of the run that has a block, `ctx.harness` the
289
- // first of them, `ctx.readers` set when the run names more than one, and
290
- // `ctx.layout` the roster's layout name (rule 3).
291
- function planAgentsBlock(ctx, key, s) {
292
- const { projectDir, mode, root } = ctx;
293
- const run = ctx.run || [ctx.harness];
294
- const a = analyseBlock(projectDir, s, { root, layout: ctx.layout || null, readers: ctx.readers || null });
295
- const { withBlock, preferred, importLine, FALLBACK } = a;
296
- const items = [];
297
- if (a.refusal)
298
- return [
299
- {
300
- surface: key,
301
- kind: "shared",
302
- path: a.files[0].path,
303
- entry: "projectstore:agents",
304
- state: "refused",
305
- action: "refuse",
306
- reason: a.refusal,
307
- },
308
- ];
309
-
310
- const hasImport = (text) => importsLine(text, importLine);
311
- // A CLAUDE.md that is nothing but the import registration added is ours to
312
- // delete when the block goes (ADR-002 decision 4); anything else stays.
313
- const onlyImport = (text) =>
314
- String(text ?? "")
315
- .split("\n")
316
- .every((l) => !l.trim() || l.trim() === importLine);
317
- // A file the removal leaves empty is deleted when it held only the block
318
- // (rule 4) — and the fallback file, as before. Keyed on the fallback alone,
319
- // a run's common list (whose last entry is AGENTS.md) and Claude Code alone
320
- // both left a 0-byte AGENTS.md (measured 2026-10-08).
321
- const removal = (e, extra = {}) => {
322
- const after = removeAgentsBlock(e.text);
323
- return {
324
- surface: key,
325
- kind: "shared",
326
- path: e.path,
327
- entry: `projectstore:agents v${e.block.v}`,
328
- state: "ours-current",
329
- action: "remove",
330
- reason: null,
331
- before: e.text,
332
- after,
333
- deleteIfEmpty: e.file === FALLBACK || !after.trim(),
334
- ...extra,
335
- };
336
- };
337
-
338
- if (mode === "uninstall") {
339
- if (!withBlock.length)
340
- return [
341
- {
342
- surface: key,
343
- kind: "shared",
344
- path: preferred.path,
345
- entry: "projectstore:agents",
346
- state: "ours-absent",
347
- action: "skip",
348
- reason: "no block to remove",
349
- },
350
- ];
351
- // The agents block is the one surface the PROJECT owns rather than a
352
- // harness: several harnesses read the same file. So disowning one harness
353
- // may not remove it, and measurably did — `uninstall --harness <other>`
354
- // deleted AGENTS.md while the source harness was still reading it through
355
- // an @AGENTS.md import, and the reverse stripped the block Codex reads
356
- // (both measured 2026-09-09). Either way, disowning one harness silently
357
- // disabled the other.
358
- //
359
- // The test is static manifest data, not detection: is the file the block
360
- // lives in one that ANOTHER manifest also names? No project state is read,
361
- // so there is no circularity — and the single-harness case is untouched,
362
- // because a file only one manifest names is that harness's to clear.
363
- // Over-conservative on purpose: a block left behind is a marked,
364
- // self-describing region a user can delete, where a deleted file another
365
- // harness reads is silent breakage. `--surface agents_block` still removes
366
- // it, which is the confirmation the gate asks for everywhere else.
367
- //
368
- // "Another" means a manifest OUTSIDE this run (rule 4): a run that names
369
- // every harness reading the file is the last one, and removes the block
370
- // once. Excluding only the planning harness kept the block for a harness
371
- // that was in the same run.
372
- const inRun = new Set(run.map((m) => m?.id));
373
- const outside = [...loadHarnesses().values()].filter((m) => !inRun.has(m.id));
374
- const alsoRead = (file) => outside.some((m) => (agentsBlockRow(m)?.[1].files || []).includes(file));
375
- for (const e of withBlock) {
376
- // Naming the surface removes it regardless: `--surface agents_block`
377
- // is the confirmation without a terminal, and a terminal is asked
378
- // (contract 9 as amended 2026-10-04).
379
- if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
380
- items.push({
381
- surface: key,
382
- kind: "shared",
383
- path: e.path,
384
- entry: `projectstore:agents v${e.block.v}`,
385
- state: "ours-current",
386
- action: "skip",
387
- reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}`,
388
- });
389
- continue;
390
- }
391
- items.push(removal(e));
392
- }
393
- // An import that points at a file whose block has just gone is a pointer to
394
- // nothing. Look across every file any manifest names, not just this one's:
395
- // with a single-entry list PREFERRED and FALLBACK are the same file, which
396
- // made the old condition — block in PREFERRED and not in FALLBACK —
397
- // impossible to satisfy, so the import was never cleaned up at all.
398
- // An import is dangling only when the file it names will be GONE — not
399
- // merely when the block left it. A user's own AGENTS.md keeps its prose and
400
- // survives, and importing it stays meaningful (install contract 13 /
401
- // ADR-002 decision 4). The condition is therefore apply's own: marked for
402
- // deletion and left holding nothing (`install-harness.mjs:760`).
403
- const vanishing = new Set(
404
- items
405
- .filter((i) => i.action === "remove" && i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim())
406
- .map((i) => rel(projectDir, i.path)),
407
- );
408
- // Two independent reasons to take an import out, and the first version of
409
- // this replaced one with the other:
410
- // (1) it is OURS and nothing else is there — the registration we added to
411
- // a file that held nothing else goes with the block (ADR-002
412
- // decision 4), whether or not its target survives;
413
- // (2) it DANGLES — the file it names will be gone, so it points at
414
- // nothing even though the reader's own prose keeps the file alive.
415
- // A user's AGENTS.md that keeps its prose satisfies neither, and its import
416
- // stays: that is install contract 13, and it is what caught the mistake.
417
- //
418
- // Over the UNION of every manifest's list, not this one's: the file holding
419
- // the import need not be one this harness can read. Codex's list is a
420
- // single entry, which also made the old condition — block in PREFERRED and
421
- // not in FALLBACK — impossible to satisfy, so nothing was ever cleaned up.
422
- //
423
- // Except a BRIDGE (rule 4: a narrowed uninstall "leaves the block and the
424
- // import in place"): the import in the file a harness outside this run
425
- // reads by itself, pointing at a block this run keeps for that harness.
426
- // It is the only way that harness sees the block; taking it out because it
427
- // is ours kept the block and blinded its reader. The source harness's
428
- // own uninstall still removes its own file's import, as before.
429
- const blockGone = new Set(
430
- items.filter((i) => i.action === "remove" && i.surface === key).map((i) => rel(projectDir, i.path)),
431
- );
432
- const blockKept = new Set(
433
- items.filter((i) => i.action === "skip" && i.surface === key).map((i) => rel(projectDir, i.path)),
434
- );
435
- const outsideNatives = new Set(outside.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
436
- const union = [...new Set([...loadHarnesses().values()].flatMap((m) => m.surfaces?.agents_block?.files || []))];
437
- for (const file of union) {
438
- if (blockGone.has(file)) continue;
439
- const e = readText(join(projectDir, file));
440
- if (!e.present || e.text === null || !hasImport(e.text)) continue;
441
- const ours = onlyImport(e.text);
442
- const dangles = vanishing.has(importLine.slice(1));
443
- const bridge = blockKept.has(importLine.slice(1)) && outsideNatives.has(file);
444
- if ((!ours && !dangles) || bridge) continue;
445
- const after = e.text
446
- .split("\n")
447
- .filter((l) => l.trim() !== importLine)
448
- .join("\n")
449
- .replace(/^\n+/, "");
450
- items.push({
451
- surface: `${key}_import`,
452
- kind: "shared",
453
- path: join(projectDir, file),
454
- entry: importLine,
455
- state: "ours-current",
456
- action: "remove",
457
- reason: ours
458
- ? `${file} holds only the import registration added`
459
- : `${importLine} points at a file this uninstall removes`,
460
- before: e.text,
461
- after,
462
- deleteIfEmpty: ours,
463
- });
464
- }
465
- return items;
466
- }
467
-
468
- const current = a.current;
469
- // A block marked above this copy's template is never rendered down (the
470
- // install spec, contracts 4, 6 and 9, as amended 2026-10-14): a move, a
471
- // migration or a dedupe carries its own bytes, stamped with its own marker.
472
- const newer = a.newer || null;
473
- const words = newer ? newerBlockWords(newer.block.v, a.version) : null;
474
- const body = newer ? newer.block.block : a.desired;
475
- const entry = `projectstore:agents v${newer ? newer.block.v : a.version}`;
476
- // Two files, one block each: resolve in favour of the preferred file —
477
- // register migrates, never duplicates (ADR-002 decision 3).
478
- for (const e of a.duplicates || [])
479
- items.push(
480
- removal(e, {
481
- state: "ours-stale",
482
- reason:
483
- newer === e
484
- ? `carries v${e.block.v}, newer than this copy's v${a.version}; its text replaces the block in ${current.file}`
485
- : `duplicate of the block in ${current.file}`,
486
- }),
487
- );
488
- let newerInPlace = null;
489
-
490
- if (!current) {
491
- const target = preferred;
492
- items.push({
493
- surface: key,
494
- kind: "shared",
495
- path: target.path,
496
- entry,
497
- state: "ours-absent",
498
- action: target.present ? "add" : "create",
499
- reason: null,
500
- before: target.present ? target.text : null,
501
- after: replaceAgentsBlock(target.present ? target.text : "", a.desired),
502
- });
503
- } else if (!current.own || (current.file !== preferred.file && preferred.present)) {
504
- // Two reasons to move, and they are not the same reason:
505
- // - the block sits in a file this harness cannot read (`!own`). It must
506
- // move, and its target is CREATED if it does not exist — this is a
507
- // second harness arriving in a project that had one.
508
- // - it sits in a readable but non-preferred file while the preferred one
509
- // already exists. A preference, satisfied because the file is there.
510
- // The distinction is what keeps a Claude-Code-only project from acquiring
511
- // an AGENTS.md it never asked for, while still moving the substance the
512
- // moment a harness that can only read AGENTS.md is installed.
513
- // A run of several harnesses says which of them cannot read the file the
514
- // block leaves (rule 1); one harness keeps its text, byte for byte.
515
- items.push(removal(current, { state: "ours-stale", reason: `migrating to ${preferred.file}` }));
516
- items.push({
517
- surface: key,
518
- kind: "shared",
519
- path: preferred.path,
520
- entry,
521
- state: "ours-absent",
522
- action: preferred.present ? "add" : "create",
523
- reason:
524
- ctx.readers && !current.own
525
- ? a.reason
526
- : `migrated from ${current.file}${newer ? ` with its own text (${words})` : ""}`,
527
- before: preferred.present ? preferred.text : null,
528
- after: replaceAgentsBlock(preferred.present ? preferred.text : "", body),
529
- });
530
- } else if (newer && newer !== current) {
531
- // The duplicate carries the higher marker: its text replaces the stayer's.
532
- items.push({
533
- surface: key,
534
- kind: "shared",
535
- path: current.path,
536
- entry,
537
- state: "ours-stale",
538
- action: "replace-entry",
539
- reason: `its text is replaced by ${newer.file}'s, which carries v${newer.block.v}, newer than this copy's v${a.version}`,
540
- before: current.text,
541
- after: replaceAgentsBlock(current.text, body),
542
- });
543
- } else if (newer) {
544
- // It stays as it is; a named run with nothing else to place overwrites it,
545
- // decided once the imports are known (below).
546
- newerInPlace = {
547
- surface: key,
548
- kind: "shared",
549
- path: current.path,
550
- entry,
551
- state: "ours-newer",
552
- action: "skip",
553
- reason: `${words}: left as it is — ${
554
- ctx.takesSurface === false
555
- ? `to write this copy's v${a.version} over it: ${coreCommand("install", { args: `--harness ${ctx.harness.id} --surface ${key}` })}`
556
- : `name --surface ${key} to write this copy's v${a.version} over it`
557
- }`,
558
- };
559
- items.push(newerInPlace);
560
- } else if (current.block.v === a.version && current.block.block === a.desired) {
561
- items.push({
562
- surface: key,
563
- kind: "shared",
564
- path: current.path,
565
- entry,
566
- state: "ours-current",
567
- action: "skip",
568
- reason: null,
569
- });
570
- } else {
571
- items.push({
572
- surface: key,
573
- kind: "shared",
574
- path: current.path,
575
- entry,
576
- state: "ours-stale",
577
- action: "replace-entry",
578
- reason:
579
- current.block.v !== a.version
580
- ? `v${current.block.v} → v${a.version}`
581
- : "content differs from the current source",
582
- before: current.text,
583
- after: replaceAgentsBlock(current.text, a.desired),
584
- });
585
- }
586
-
587
- // The @AGENTS.md import in CLAUDE.md (ADR-002 decision 3), only when the
588
- // block lives in AGENTS.md and CLAUDE.md exists without it. A removal
589
- // already rewriting CLAUDE.md in this plan takes the import onto its own
590
- // text, or the two items would race.
591
- const written = items.find((i) => ["add", "create", "replace-entry", "skip"].includes(i.action) && i.surface === key);
592
- const blockFile = written ? rel(projectDir, written.path) : null;
593
- // Every OTHER file any manifest names, and only if it already exists: a
594
- // reader whose file no longer holds the substance is pointed at the one that
595
- // does. Never a file we would have to create — a project with no CLAUDE.md
596
- // does not acquire one because Codex was installed.
597
- //
598
- // It used to key on `a.files.length > 1`, which is a property of the
599
- // INSTALLING harness's list: Codex's has one entry, so installing Codex left
600
- // a CLAUDE.md that still exists, still reads as authoritative, and no longer
601
- // holds anything. The block's file is what decides, not the list's length.
602
- // Installing FOR a harness means the file that harness reads by itself ends
603
- // up present. It holds the block when the block lands there; it holds the
604
- // import when the block lands elsewhere. Without this, a project that already
605
- // had an AGENTS.md took the block into it and created no bridge — so Claude
606
- // Code, the harness that ran the install, could not see what it had just
607
- // installed. `reads_natively` is the manifest's, so this is a file list, not
608
- // a harness name — and it is every RUN harness's (rule 1): one run for two
609
- // harnesses leaves both native files present, each holding the block or the
610
- // import.
611
- const natives = new Set(run.map((m) => agentsBlockRow(m)?.[1].reads_natively).filter(Boolean));
612
- // The import item sits under the run's first harness, which need not be the
613
- // one that reads the file: in a run of several, the reason names the reader.
614
- // One harness is its own reader and keeps its text, byte for byte.
615
- const reader = (file) =>
616
- (ctx.readers && run.find((m) => agentsBlockRow(m)?.[1].reads_natively === file)?.display_name) || "this harness";
617
- for (const e of a.files) {
618
- const mustExist = natives.has(e.file);
619
- if (!blockFile || e.file === blockFile || (!e.present && !mustExist)) continue;
620
- const line = `@${blockFile}`;
621
- const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
622
- // An absent native file is empty text, not a reason to skip: it is created.
623
- const text = rewrite ? rewrite.after : e.present ? e.text : "";
624
- if (typeof text !== "string" || importsLine(text, line)) continue;
625
- const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
626
- if (rewrite) {
627
- rewrite.after = after;
628
- rewrite.deleteIfEmpty = false;
629
- rewrite.reason += `; ${line} import added`;
630
- } else
631
- items.push({
632
- surface: `${key}_import`,
633
- kind: "shared",
634
- path: e.path,
635
- entry: line,
636
- state: "ours-absent",
637
- action: e.present ? "add" : "create",
638
- reason: e.present
639
- ? `${blockFile} carries the block; ${e.file} must import it`
640
- : `${e.file} is what ${reader(e.file)} reads by itself; it is created to import ${blockFile}`,
641
- before: e.present ? text : null,
642
- after,
643
- });
644
- }
645
- // Naming the surface confirms the overwrite only when the run has nothing
646
- // else to do for the block: it is the only block and every import it needs
647
- // is in place. Doctor's placement remedies name the surface too, and they
648
- // must never downgrade (contract 9 as amended 2026-10-14).
649
- if (
650
- newerInPlace &&
651
- (ctx.surfaces || []).includes(key) &&
652
- !(a.duplicates || []).length &&
653
- !items.some((i) => i.surface === `${key}_import`)
654
- )
655
- Object.assign(newerInPlace, {
656
- entry: `projectstore:agents v${a.version}`,
657
- action: "replace-entry",
658
- reason: `v${current.block.v} → v${a.version}: ${words}, and --surface ${key} names it`,
659
- before: current.text,
660
- after: replaceAgentsBlock(current.text, a.desired),
661
- });
662
- return items;
663
- }
664
-
665
292
  // ─── host-plugin-registration ──────────────────────────────────────────
666
293
 
667
294
  // One host command as a plan step: the verbatim argv (the manifest's
@@ -1134,16 +761,29 @@ function planRegistration(ctx, key, s) {
1134
761
  path: a.paths.dir,
1135
762
  why: "our marketplace directory, removed whole (its manifest carries our provenance field); no other checkout is installed from it",
1136
763
  });
1137
- return [
1138
- {
1139
- ...base,
1140
- action: "remove",
1141
- steps,
1142
- reason: a.otherProjects
1143
- ? `${a.otherProjects} other checkout(s) are installed from the shared directory; it and the host's marketplace entry stay`
1144
- : a.reason,
1145
- },
1146
- ];
764
+ const removed = {
765
+ ...base,
766
+ action: "remove",
767
+ steps,
768
+ reason: a.otherProjects
769
+ ? `${a.otherProjects} other checkout(s) are installed from the shared directory; it and the host's marketplace entry stay`
770
+ : a.reason,
771
+ };
772
+ // The way back from the npm answer over a directory copy (PS-DIRECTORY,
773
+ // decision 5): when this checkout's npm copy was the one loading and no
774
+ // other copy is left on, the synced copy is the one the host loads again.
775
+ // DONE says so (off the envelope).
776
+ const reenabled = a.dir.disabled.some((k) => a.disabledHere.includes(k));
777
+ if (a.synced && a.installed?.present && a.enabled && !a.others.length && !reenabled)
778
+ Object.defineProperty(removed, "next", {
779
+ value: [
780
+ {
781
+ lead: `${a.synced.key}, from ${copyOrigin(a.synced)}, loads in this project again at the next start of ${harness.display_name}.`,
782
+ },
783
+ ],
784
+ enumerable: false,
785
+ });
786
+ return [removed];
1147
787
  }
1148
788
 
1149
789
  // install / upgrade
@@ -1238,7 +878,8 @@ function planRegistration(ctx, key, s) {
1238
878
  // Newer than what the project would run with the copy off — the shared
1239
879
  // directory's version when another checkout wrote a newer one — not merely
1240
880
  // newer than this package: under that, turning it off is no downgrade.
1241
- const newerOther = a.others
881
+ // A directory copy this registration would load over counts too (PS-DIRECTORY).
882
+ const newerOther = [...a.others, ...(a.directory ? [a.directory] : [])]
1242
883
  .filter((o) => typeof o.version === "string" && cmpPrecedence(o.version, a.targetPkg || a.pkg) > 0)
1243
884
  .sort((x, y) => cmpPrecedence(y.version, x.version))[0];
1244
885
  if (newerOther && (ctx.channel ?? null) === null) {
@@ -1264,8 +905,11 @@ function planRegistration(ctx, key, s) {
1264
905
  // switches this run plans, and contract 13's silenced set — the copies this
1265
906
  // registration turned off that this checkout still holds off — for a
1266
907
  // registration this checkout holds.
908
+ // A directory copy the registration loads over is that switch too, never
909
+ // turned off (PS-DIRECTORY, decision 4): a skip row of its own, below.
910
+ const shadows = a.directory && a.state !== "current" ? a.directory : null;
1267
911
  ctx.channelFacts = {
1268
- switches: a.others.map((o) => o.key),
912
+ switches: [...a.others.map((o) => o.key), ...(shadows ? [shadows.key] : [])],
1269
913
  silenced: ["current", "stale"].includes(a.state) ? a.dir.disabled.filter((k) => a.disabledHere.includes(k)) : [],
1270
914
  };
1271
915
 
@@ -1435,27 +1079,44 @@ function planRegistration(ctx, key, s) {
1435
1079
  : null;
1436
1080
  items.push({ ...other(o), action: "disable", ...(reason ? { reason } : {}), steps });
1437
1081
  }
1082
+ // The directory copy (PS-DIRECTORY, "Setup and install keep a directory
1083
+ // copy…", decision 4): the registration above loads over it by Claude
1084
+ // Code's own precedence, so nothing is turned off — no `disable`, no ⇄
1085
+ // switch, no entry in our manifest's disabled list. A skip whose
1086
+ // consequence line says what changes; asked like a switch (the gate).
1087
+ if (shadows) {
1088
+ const extra =
1089
+ ctx.channel === "npm"
1090
+ ? switchReason({
1091
+ copy: shadows,
1092
+ running: a.pkg,
1093
+ runs: a.targetPkg,
1094
+ decision: ctx.convergence,
1095
+ unbound: !ctx.binding?.bound,
1096
+ })
1097
+ : null;
1098
+ items.push({
1099
+ ...other(shadows),
1100
+ state: "shadowed",
1101
+ action: "skip",
1102
+ reason: `${shadows.key} (${shadows.version || "no version recorded"}), from ${copyOrigin(shadows)}, stays enabled: nothing turns it off${extra ? `; ${extra}` : ""}`,
1103
+ steps: [consequenceStep(directoryConsequence(harness.display_name))],
1104
+ });
1105
+ }
1438
1106
  return items;
1439
1107
  }
1440
-
1441
- // A row's consequence (presentation spec contract 7) carried as a `note` step:
1442
- // the envelope reads `{ kind: "note", why }`, as any note; PLAN draws it first
1443
- // beneath its row, in the explanation role, by the mark kept off the envelope.
1444
- function consequenceStep(why) {
1445
- const st = { kind: "note", why };
1446
- Object.defineProperty(st, "consequence", { value: true, enumerable: false });
1447
- return st;
1448
- }
1449
- const isConsequence = (st) => st?.kind === "note" && st.consequence === true;
1450
1108
  // A channel switch: a copy from another marketplace turned off for this checkout.
1451
1109
  const isSwitch = (i) => String(i?.surface || "").endsWith("_others") && i.action === "disable";
1110
+ // …and a directory copy an npm registration loads over, never turned off.
1111
+ const isShadow = (i) => String(i?.surface || "").endsWith("_others") && i.state === "shadowed" && i.action === "skip";
1452
1112
 
1453
1113
  // The channel "git" (the converge story's rules 1 and 5), for the first
1454
1114
  // harness of the run with a host plugin registration — the channel concerns
1455
1115
  // that registration alone; Codex has none. The copy kept is contract 12's
1456
- // selection (lib's selectCopy), limited to copies from another marketplace —
1457
- // the git marketplace, a fork's — so the npm registration's copy is never the
1458
- // kept one. None enabled for this project is a refusal: there is nothing to
1116
+ // selection (lib's selectCopy), limited to copies from another channel — the
1117
+ // git marketplace, a fork's, or Anthropic's directory (after every registry
1118
+ // row, so an enabled git copy wins) — so the npm registration's copy is never
1119
+ // the kept one. None enabled for this project is a refusal: there is nothing to
1459
1120
  // keep. A copy of ours this checkout's local settings hold off — a hand
1460
1121
  // removal of the npm row leaves the git copy off — is named with the way to
1461
1122
  // turn it back on. Read-only; plan() and the prepare step both call it, so the
@@ -1471,7 +1132,7 @@ function keepTarget(projectDir, ids, { root, home, env }) {
1471
1132
  );
1472
1133
  if (!row) continue;
1473
1134
  const [key, s] = row;
1474
- const copy = selectCopy(home, projectDir, { harness, only: "marketplace" });
1135
+ const copy = selectCopy(home, projectDir, { harness, only: ["marketplace", "synced"] });
1475
1136
  if (copy) return { harness, key, s, copy };
1476
1137
  const a = analyseRegistration(projectDir, s, { root, home, harness, env });
1477
1138
  const off = (a.disabledHere || []).filter((k) => k !== a.id && k.split("@")[0] === s.plugin_name);
@@ -1537,7 +1198,7 @@ function planKeep(ctx, target, read) {
1537
1198
  const { projectDir, env, home, root, harness, surfaces } = ctx;
1538
1199
  const { key, s, copy } = target;
1539
1200
  const a = analyseRegistration(projectDir, s, { root, home, harness, env });
1540
- const beside = a.installed?.present && a.enabled ? npmRowBeside({ harness, id: a.id, surface: key }) : null;
1201
+ const beside = a.installed?.present && a.enabled ? npmRowBeside({ harness, id: a.id, surface: key, copy }) : null;
1541
1202
  const besides = beside ? [beside] : [];
1542
1203
  const named = `${copy.key} ${copy.version || "(no version recorded)"}`;
1543
1204
  // Built whole, never spread into place: `next` (what DONE says) and `kept`
@@ -1568,7 +1229,7 @@ function planKeep(ctx, target, read) {
1568
1229
  });
1569
1230
  return it;
1570
1231
  };
1571
- const kept = `this project keeps ${named} from the git marketplace, and that copy writes its own ${harness.display_name} surfaces here — no registration changes`;
1232
+ const kept = `this project keeps ${named} from ${copyOrigin(copy)}, and that copy writes its own ${harness.display_name} surfaces here — no registration changes`;
1572
1233
  if (surfaces && surfaces.length) {
1573
1234
  return [
1574
1235
  item({
@@ -1768,7 +1429,7 @@ function planMark(ctx, facts) {
1768
1429
  // answer wins, as for the mark: a `channel_switch` this checkout recorded
1769
1430
  // earlier, of any shape, goes in the same item — alone, when the keep is
1770
1431
  // already recorded.
1771
- function planKeepRecord(ctx) {
1432
+ function planKeepRecord(ctx, copy = null) {
1772
1433
  const b = ctx.binding;
1773
1434
  if (!b || !b.bound) return null;
1774
1435
  const record = !b.hasKeepRecord;
@@ -1789,8 +1450,7 @@ function planKeepRecord(ctx) {
1789
1450
  set: record ? { [CHANNEL_KEEP_KEY]: STAMP } : {},
1790
1451
  unset: b.hasKey ? [CHANNEL_SWITCH_KEY] : [],
1791
1452
  why: {
1792
- [CHANNEL_KEEP_KEY]:
1793
- "this checkout chose to keep the git marketplace: setup's channel question defaults to keep from now on, and a run without a terminal reads it as --channel git",
1453
+ [CHANNEL_KEEP_KEY]: `this checkout chose to keep ${copy?.synced ? `the copy from ${copyOrigin(copy)}` : "the git marketplace"}: setup's channel question defaults to keep from now on, and a run without a terminal reads it as --channel git`,
1794
1454
  [CHANNEL_SWITCH_KEY]:
1795
1455
  "the switch this checkout recorded earlier goes: the keep is its later answer, and doctor names a copy held off here again",
1796
1456
  },
@@ -1802,428 +1462,8 @@ function planKeepRecord(ctx) {
1802
1462
  });
1803
1463
  }
1804
1464
 
1805
- // The status line's flag (the setup story, rule 9; the configure spec,
1806
- // contract 6): `statusline.enabled`, from setup's answer or from a run that
1807
- // names the surface — install records on, uninstall off. This item is the
1808
- // flag's one writer; every other key of a `statusline` object is kept.
1809
- // Planned for the run's harness that has the status-line surface, when the
1810
- // binding the run leaves does not already say the same; only when there is a
1811
- // binding to record it in.
1812
- const STATUSLINE_FLAG = "statusline.enabled";
1813
- function planStatuslineFlag(ctx, value, mode = "install") {
1814
- const b = ctx.binding;
1815
- if (!b || !b.bound || typeof value !== "boolean" || b.statusline === value) return null;
1816
- const off =
1817
- mode === "uninstall"
1818
- ? "the status line is removed in this run, and the SessionStart hook keeps it off"
1819
- : "the status line stays off, and setup does not ask again";
1820
- return bindingItem({
1821
- harness: ctx.harness.id,
1822
- surface: "statusline_flag",
1823
- entry: STATUSLINE_FLAG,
1824
- path: b.path,
1825
- bound: true,
1826
- set: { [STATUSLINE_FLAG]: value },
1827
- why: {
1828
- [STATUSLINE_FLAG]: value
1829
- ? "the status line is wired in this run, and the SessionStart hook keeps it current"
1830
- : off,
1831
- },
1832
- said: {
1833
- unbound: "the project is not bound; the status line's flag is recorded once it is",
1834
- done: "the status line's flag is already recorded",
1835
- object: "the status line's flag is not recorded",
1836
- },
1837
- });
1838
- }
1839
-
1840
- // Setup's vault item (the story's rule 10; the front-door ADR's decision 7):
1841
- // one item of kind `vault`, first in the plan and under no harness — PLAN's
1842
- // VAULT group. Its steps are the binding plan's own (binding.mjs
1843
- // bindPlanSteps): the mkdir, the binding, `git init`, the scaffold. `create`
1844
- // for a vault this run makes (missing, or there and empty); `add` for a
1845
- // binding of one that exists, scaffolded or not, and for a worktree's
1846
- // inherited binding. The binding plan rides along off the envelope, for
1847
- // apply; with nothing to write there is no item.
1848
- function planVault(bp) {
1849
- const steps = bindPlanSteps(bp);
1850
- if (!steps.length) return null;
1851
- const reason = bp.inherit
1852
- ? `the vault of ${bp.inherit.from}, with its settings`
1853
- : bp.buildsVault || !bp.vaultExists
1854
- ? null
1855
- : bp.scaffoldsExisting
1856
- ? "it already exists; it is bound as it is, and the layout's missing folders and indexes are added"
1857
- : "it already exists; it is bound as it is";
1858
- const item = {
1859
- harness: null,
1860
- surface: "vault",
1861
- kind: "vault",
1862
- path: bp.vault,
1863
- entry: null,
1864
- state: !bp.vaultExists ? "absent" : bp.buildsVault ? "empty" : "present",
1865
- action: bp.buildsVault ? "create" : "add",
1866
- reason,
1867
- steps,
1868
- };
1869
- Object.defineProperty(item, "bind", { value: bp, enumerable: false });
1870
- return item;
1871
- }
1872
-
1873
- // Setup's state directory (the story's rule 10): an empty
1874
- // .projectstore/state/<id>/, the signal detection reads (detectHarnesses'
1875
- // `state`), so a harness setup installed with no directory of its own is used
1876
- // here before its first session. Never the welcome marker: that file means
1877
- // the welcome was shown, and the first session writes it into this
1878
- // directory. The first such item names state/ and its .gitignore when they
1879
- // are still to be made.
1880
- function planStateDir(projectDir, harness, { first = false } = {}) {
1881
- const P = layoutPaths(projectDir);
1882
- const path = P.harnessState(harness.id);
1883
- if (existsSync(path)) return null;
1884
- const steps =
1885
- first && !existsSync(P.stateGitignore)
1886
- ? [
1887
- {
1888
- kind: "ensure",
1889
- path: P.state,
1890
- why: "state/ with its .gitignore, which keeps everything under it out of git",
1891
- },
1892
- ]
1893
- : [];
1894
- return {
1895
- harness: harness.id,
1896
- surface: "harness_state",
1897
- kind: "state",
1898
- path,
1899
- entry: null,
1900
- state: "absent",
1901
- action: "create",
1902
- reason: null,
1903
- ...(steps.length ? { steps } : {}),
1904
- };
1905
- }
1906
-
1907
- // A key a `binding` item stamps at apply: `{ by: "setup", at: <the time of
1908
- // apply> }`, the mark's shape and the keep's.
1909
- const STAMP = Object.freeze({ stamp: "setup" });
1910
- const stamped = (v) =>
1911
- Boolean(v) && typeof v === "object" && !Array.isArray(v) && typeof v.by === "string" && typeof v.at === "string";
1912
-
1913
- // A `binding` item (the converge story's kind, made generic by the setup
1914
- // story's rule 10): keys merged into the binding at apply time, after the
1915
- // layout item, every other key kept. `set` maps a key — dotted for a nested
1916
- // one — to its value, or to STAMP; `unset` lists the keys it removes. Both
1917
- // are apply's data, kept off the envelope, as `said` is: the item's own
1918
- // words for a binding that is not there, not an object, or already says it.
1919
- // The steps are what PLAN shows and the envelope carries, in that order:
1920
- // `mark` for a stamped key, `set` for a value, `unmark` for a removal. An
1921
- // item that only removes a key is an `update` of a binding whose key is
1922
- // `present`.
1923
- function bindingItem({
1924
- harness,
1925
- surface,
1926
- entry,
1927
- path,
1928
- bound,
1929
- reason = null,
1930
- set = {},
1931
- unset = [],
1932
- why = {},
1933
- said = {},
1934
- action = "add",
1935
- state = "absent",
1936
- }) {
1937
- const step = (fields, key) => (why[key] ? { ...fields, why: why[key] } : fields);
1938
- const steps = [
1939
- ...Object.entries(set).map(([key, value]) =>
1940
- step(value === STAMP ? { kind: "mark", path, key } : { kind: "set", path, key, value }, key),
1941
- ),
1942
- ...unset.map((key) => step({ kind: "unmark", path, key }, key)),
1943
- ];
1944
- const item = {
1945
- harness,
1946
- surface,
1947
- kind: "binding",
1948
- path,
1949
- entry,
1950
- state: bound ? state : "unbound",
1951
- action,
1952
- reason,
1953
- steps,
1954
- };
1955
- for (const [k, v] of Object.entries({ set, unset, said }))
1956
- Object.defineProperty(item, k, { value: v, enumerable: false });
1957
- return item;
1958
- }
1959
-
1960
- function planJsonEntry(ctx, key, s) {
1961
- const { projectDir, mode, root, home } = ctx;
1962
- if (s.supported === false) {
1963
- return [
1964
- {
1965
- surface: key,
1966
- kind: "shared",
1967
- path: join(projectDir, s.file),
1968
- entry: s.marker?.pointer || null,
1969
- state: "unsupported",
1970
- action: "skip",
1971
- reason: s.why_unsupported || "not supported for this harness yet",
1972
- },
1973
- ];
1974
- }
1975
- const path = join(projectDir, s.file);
1976
- const entryKey = (s.marker?.pointer || "statusLine.command").split(".")[0];
1977
- // The status line is opt-in (projectstore.json → statusline.enabled), as
1978
- // the SessionStart refresh already honours; naming the surface explicitly
1979
- // is the other way to opt in. A verb with no --surface (setup, its rule 9)
1980
- // names install's command, which takes it, in the core form.
1981
- if (mode === "install" && !ctx.optIn.has(key)) {
1982
- const anyway =
1983
- ctx.takesSurface === false
1984
- ? `to wire it anyway: ${coreCommand("install", { args: `--harness ${ctx.harness.id} --surface ${key}` })}`
1985
- : "name --surface statusline to wire it anyway";
1986
- return [
1987
- {
1988
- surface: key,
1989
- kind: "shared",
1990
- path,
1991
- entry: entryKey,
1992
- state: "opt-out",
1993
- action: "skip",
1994
- reason: `statusline.enabled is not true in projectstore.json — ${anyway}`,
1995
- },
1996
- ];
1997
- }
1998
- const renderRoot = ctx.renderRoot || root;
1999
- // A root the package manager will collect (npx's cache, a node_modules) is
2000
- // never wired directly: the entry would name a path that disappears. It is
2001
- // wired against a registration's install path — or not at all (contract 4′).
2002
- if (mode === "install" && !isPluginCacheRoot(renderRoot, home) && isEphemeralRoot(renderRoot)) {
2003
- return [
2004
- {
2005
- surface: key,
2006
- kind: "shared",
2007
- path,
2008
- entry: entryKey,
2009
- state: "absent-or-present",
2010
- action: "skip",
2011
- reason:
2012
- "this package root is a package-manager cache; the status line is wired only against a registered install (see the registration above)",
2013
- },
2014
- ];
2015
- }
2016
- const a = analyseJsonEntry(projectDir, s, { root, home, renderRoot, harness: ctx.harness });
2017
- if (a.state === "unparseable")
2018
- return [
2019
- { surface: key, kind: "shared", path, entry: entryKey, state: "unparseable", action: "refuse", reason: a.reason },
2020
- ];
2021
-
2022
- if (mode === "uninstall") {
2023
- if (!a.curEntry)
2024
- return [
2025
- { surface: key, kind: "shared", path, entry: entryKey, state: "ours-absent", action: "skip", reason: null },
2026
- ];
2027
- if (!a.ours)
2028
- return [
2029
- {
2030
- surface: key,
2031
- kind: "shared",
2032
- path,
2033
- entry: entryKey,
2034
- state: "theirs",
2035
- action: "skip",
2036
- reason: "the entry is not ours — left in place",
2037
- },
2038
- ];
2039
- const after = { ...a.settings };
2040
- delete after[entryKey];
2041
- return [
2042
- {
2043
- surface: key,
2044
- kind: "shared",
2045
- path,
2046
- entry: entryKey,
2047
- state: "ours-current",
2048
- action: "remove",
2049
- reason: null,
2050
- before: a.settings,
2051
- after,
2052
- },
2053
- ];
2054
- }
2055
- if (a.state === "theirs") {
2056
- ctx.slotForeign.add(key);
2057
- return [
2058
- {
2059
- surface: key,
2060
- kind: "shared",
2061
- path,
2062
- entry: entryKey,
2063
- state: "theirs",
2064
- action: "skip",
2065
- reason: "a status line we did not write owns the slot — left to its owner, and nothing else is wired for it",
2066
- },
2067
- ];
2068
- }
2069
- if (a.state === "ours-current")
2070
- return [
2071
- { surface: key, kind: "shared", path, entry: entryKey, state: "ours-current", action: "skip", reason: null },
2072
- ];
2073
- const after = {
2074
- ...a.settings,
2075
- [entryKey]: {
2076
- ...(a.curEntry && typeof a.curEntry === "object" ? a.curEntry : {}),
2077
- type: "command",
2078
- command: a.desired,
2079
- },
2080
- };
2081
- return [
2082
- {
2083
- surface: key,
2084
- kind: "shared",
2085
- path,
2086
- entry: entryKey,
2087
- state: a.state,
2088
- action: a.curEntry ? "replace-entry" : a.cur.present ? "add" : "create",
2089
- reason: a.curEntry ? a.reason : null,
2090
- before: a.cur.present ? a.settings : null,
2091
- after,
2092
- },
2093
- ];
2094
- }
2095
-
2096
- function planStampedFile(ctx, key, s) {
2097
- const { projectDir, mode, root, home, harness } = ctx;
2098
- const renderRoot = ctx.renderRoot || root;
2099
- const path = join(projectDir, s.file);
2100
- const produced = !(s.condition === "plugin_cache_install" && !isPluginCacheRoot(renderRoot, home));
2101
- // The policy early-outs come before the render-and-hash, which they make
2102
- // unnecessary.
2103
- if (produced && mode === "install" && !ctx.optIn.has(s.condition ? "statusline" : key) && !ctx.optIn.has(key)) {
2104
- return [
2105
- {
2106
- surface: key,
2107
- kind: "exclusive",
2108
- path,
2109
- entry: null,
2110
- state: "opt-out",
2111
- action: "skip",
2112
- reason: "statusline.enabled is not true in projectstore.json",
2113
- },
2114
- ];
2115
- }
2116
- if (produced && mode === "install" && ctx.slotForeign.size) {
2117
- return [
2118
- {
2119
- surface: key,
2120
- kind: "exclusive",
2121
- path,
2122
- entry: null,
2123
- state: "absent-or-present",
2124
- action: "skip",
2125
- reason: "the status line slot is foreign; a launcher nothing points at is not written",
2126
- },
2127
- ];
2128
- }
2129
- const a = analyseStampedFile(projectDir, s, { root, home, harness, renderRoot });
2130
- // Not produced for this installation (a dev checkout is wired directly):
2131
- // a root that cannot produce a file has, by construction, never written it,
2132
- // so install and upgrade REPORT it and leave it (contract 13's wording;
2133
- // contract 7 as amended 2026-09-05 — a dev checkout's plan used to prune a
2134
- // cache install's launcher, the maintainer's habitual loop). Only uninstall
2135
- // removes it: the user asked to disown, and the file is recognisably ours.
2136
- // `prune` stays an action for the day a surface leaves the roster.
2137
- // A file found at its legacy path (the layout ADR): classified from there,
2138
- // removed from there, created at the new path — the legacy copy is then
2139
- // the layout cleanup's to delete once nothing names it.
2140
- const at = a.legacyPath || path;
2141
- if (!a.produced) {
2142
- if (!a.file.present) return [];
2143
- if (!a.ours)
2144
- return [
2145
- {
2146
- surface: key,
2147
- kind: "exclusive",
2148
- path: at,
2149
- entry: null,
2150
- state: "foreign",
2151
- action: "skip",
2152
- reason: "not produced for a dev checkout, and not ours — left in place",
2153
- },
2154
- ];
2155
- return [
2156
- {
2157
- surface: key,
2158
- kind: "exclusive",
2159
- path: at,
2160
- entry: null,
2161
- state: "stale",
2162
- action: mode === "uninstall" ? "remove" : "skip",
2163
- reason: a.reason,
2164
- },
2165
- ];
2166
- }
2167
- if (a.refusal)
2168
- return [{ surface: key, kind: "exclusive", path: at, state: "refused", action: "refuse", reason: a.refusal }];
2169
- const base = {
2170
- surface: key,
2171
- kind: "exclusive",
2172
- path: at,
2173
- entry: null,
2174
- state: a.state,
2175
- reason: a.reason,
2176
- writtenBy: a.writtenBy,
2177
- sameProject: a.sameProject,
2178
- };
2179
- if (mode === "uninstall") {
2180
- if (a.state === "absent") return [{ ...base, action: "skip" }];
2181
- if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
2182
- return [{ ...base, action: "remove" }];
2183
- }
2184
- if (a.state === "foreign") return [{ ...base, action: "refuse", reason: FOREIGN_TEXT }];
2185
- // Current, but written for another project (a copied or moved checkout):
2186
- // the file lives inside THIS project and its render names its project since
2187
- // 2026-09-06, so it is re-rendered here — doctor still reports who wrote it
2188
- // (contract 12); only the installer acts on it.
2189
- if (a.state === "current" && !a.legacyPath && a.writtenBy && !a.sameProject)
2190
- return [
2191
- {
2192
- ...base,
2193
- path,
2194
- action: "update",
2195
- reason: `current, written for ${a.writtenBy} — re-rendered for this project`,
2196
- after: a.stamped.text,
2197
- },
2198
- ];
2199
- if (a.state === "current" && !a.legacyPath) return [{ ...base, action: "skip" }];
2200
- // Written at the NEW path; a legacy file's state is why (stale, or current-but-moving).
2201
- return [
2202
- {
2203
- ...base,
2204
- path,
2205
- action: a.state === "absent" || a.legacyPath ? "create" : "update",
2206
- reason: a.legacyPath
2207
- ? `${a.reason ? a.reason + "; " : ""}moving from ${rel(projectDir, a.legacyPath)} (the layout ADR)`
2208
- : a.reason,
2209
- after: a.stamped.text,
2210
- legacyPath: a.legacyPath,
2211
- },
2212
- ];
2213
- }
2214
-
2215
1465
  // ─── plan ──────────────────────────────────────────────────────────────
2216
1466
 
2217
- // The surface rows a run leaves out: a --surface that does not name the key
2218
- // (by prefix, so `statusline` covers the launcher), or --no-register's
2219
- // registration. One predicate for plan() and the shell fetch's decision.
2220
- function surfaceExcluded(key, s, { surfaces = null, register = true } = {}) {
2221
- return (
2222
- Boolean(surfaces && !surfaces.some((x) => key === x || key.startsWith(x + "_"))) ||
2223
- (register === false && s.kind === "registration")
2224
- );
2225
- }
2226
-
2227
1467
  // `state` selects by the state directory too (upgrade's set, the shell-fetch
2228
1468
  // story's rule 5); `version` is the running root's, for a shell-rooted
2229
1469
  // registration planned without its payload; `payloadRoots` holds a fetched
@@ -2492,7 +1732,7 @@ export function plan(
2492
1732
  }
2493
1733
  if (mode === "uninstall") return;
2494
1734
  if (recordKeep && keeping && ctx.harness.id === keeping.harness.id) {
2495
- const kept = planKeepRecord(ctx);
1735
+ const kept = planKeepRecord(ctx, keeping.copy);
2496
1736
  if (kept) marks.push(kept);
2497
1737
  }
2498
1738
  };
@@ -2641,7 +1881,7 @@ export function plan(
2641
1881
  // never an item: a --no-register run from a checkout re-pointed the
2642
1882
  // status line at the checkout (the second review of the 2026-10-03
2643
1883
  // fixes, S1).
2644
- if (s.kind === "registration" && !isPluginCacheRoot(root, home)) {
1884
+ if (s.kind === "registration" && !isHostInstallRoot(root, home)) {
2645
1885
  const analyser =
2646
1886
  s.format === "portable-plugin-registration" ? analysePortableRegistration : analyseRegistration;
2647
1887
  const a = analyser(projectDir, s, { root, home, harness, env, version, fetched: ctx.fetched });
@@ -2678,8 +1918,12 @@ export function plan(
2678
1918
  const items = handler(ctx, key, s);
2679
1919
  const dependent =
2680
1920
  s.kind !== "registration" && ctx.renderRoot !== root && ["json-entry", "mjs"].includes(s.format);
2681
- for (const item of items)
2682
- out.items.push({ harness: id, ...item, ...(dependent ? { plannedAgainst: ctx.renderRoot } : {}) });
1921
+ for (const item of items) {
1922
+ const row = { harness: id, ...item, ...(dependent ? { plannedAgainst: ctx.renderRoot } : {}) };
1923
+ // DONE's next lines stay off the envelope, through the spread too.
1924
+ if (item.next) Object.defineProperty(row, "next", { value: item.next, enumerable: false });
1925
+ out.items.push(row);
1926
+ }
2683
1927
  if (s.kind === "registration") {
2684
1928
  // Phase two (contract 4′): the rest is planned against the root the
2685
1929
  // registration produces — when it produces one on this run or has.
@@ -2774,1028 +2018,6 @@ export function plan(
2774
2018
  return out;
2775
2019
  }
2776
2020
 
2777
- // One add per file (the presentation spec, contract 7 as amended 2026-10-14):
2778
- // within one plan, a file an earlier item creates is added once. A later
2779
- // `create` or `add` on that path changes the file the run has just made, so it
2780
- // reads `update`; its state, steps and detail stay. Each item is planned
2781
- // against the tree before the run, so the binding the vault item writes was
2782
- // "absent" to the status-line flag and the channel mark, and all three read
2783
- // "add". A creator is an earlier `create` or `add` item that is neither a
2784
- // registration (its host-owned files are the host CLI's) nor a binding row (it
2785
- // never makes the binding: apply skips it on an unbound project, and the vault
2786
- // item or the layout move makes the file), counted for the paths absent at
2787
- // plan time: its own and each step's `path` — never a move step's `to`, so a
2788
- // migrating project's binding rows keep `add`. An entry row on a file that was
2789
- // there keeps `add`.
2790
- function oneAddPerFile(items) {
2791
- const created = new Set();
2792
- const writes = (i) => i.action === "create" || i.action === "add";
2793
- for (const item of items) {
2794
- if (!writes(item) || item.kind === "registration") continue;
2795
- if (item.path && created.has(resolve(item.path))) {
2796
- item.action = "update";
2797
- continue;
2798
- }
2799
- if (item.kind === "binding") continue;
2800
- for (const p of [item.path, ...(item.steps || []).map((s) => s.path)])
2801
- if (p && !existsSync(p)) created.add(resolve(p));
2802
- }
2803
- }
2804
-
2805
- // ─── the layout migration (the layout ADR; layout spec contract 6) ──────
2806
- //
2807
- // Two items, kind `layout`. The FIRST moves what only we read — the legacy
2808
- // state directory (per-file collision policy), the entry log (merged), the
2809
- // two legacy markers — and, last of its steps, the binding, whose `agents`
2810
- // block becomes the harness's overlay. It never touches the launcher: the
2811
- // settings entry names it, and the entry is re-pointed by the statusline item
2812
- // only after the launcher item has written the new one. The LAST item, planned
2813
- // after every surface, removes the legacy launcher once nothing names it and
2814
- // prunes the emptied legacy runtime directory. Two config files is the one
2815
- // refusal (a merge is the user's); uninstall is never blocked by it.
2816
- function planLayout(ctx) {
2817
- const { projectDir, mode, env, harness } = ctx;
2818
- const a = analyseLayout(projectDir, { harness });
2819
- const P = a.paths;
2820
- const none = { first: [], last: [] };
2821
- if (!a.pending) return none;
2822
- const base = { surface: "layout", kind: "layout", path: P.legacy.binding, entry: null, state: a.state, reason: null };
2823
- if (mode === "uninstall") {
2824
- // Disowning: the legacy runtime directory goes with the new one when it is
2825
- // ours (its header); the legacy binding is bind's, never uninstall's. A
2826
- // narrowed uninstall leaves it alone: the way back from an npm switch is
2827
- // `uninstall --surface plugin`, and it deleted a not-yet-moved project's
2828
- // sessions, log and the launcher its status line still ran (the critic's
2829
- // fourth pass, 2026-10-04). `surfaces` is null when nothing was named.
2830
- if (ctx.surfaces) return none;
2831
- if (!a.legacy.runtime || !a.legacy.runtimeOurs) return none;
2832
- return {
2833
- first: [],
2834
- last: [
2835
- {
2836
- ...base,
2837
- surface: "layout_cleanup",
2838
- path: P.legacy.runtime,
2839
- state: "legacy",
2840
- action: "remove",
2841
- reason: "the legacy runtime directory is ours (its .gitignore header) and goes with the state",
2842
- steps: [
2843
- {
2844
- kind: "remove-legacy-runtime",
2845
- path: P.legacy.runtime,
2846
- why: "the pre-0.28 state directory, removed whole",
2847
- },
2848
- {
2849
- kind: "note",
2850
- 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",
2851
- },
2852
- ],
2853
- },
2854
- ],
2855
- };
2856
- }
2857
- if (a.twoConfigs && !a.resumable)
2858
- return {
2859
- first: [
2860
- {
2861
- ...base,
2862
- action: "refuse",
2863
- 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`,
2864
- },
2865
- ],
2866
- last: [],
2867
- };
2868
- if (insideHostSession(env, harness)) {
2869
- ctx.incomplete = true;
2870
- return {
2871
- first: [
2872
- {
2873
- ...base,
2874
- action: "skip",
2875
- deferred: true,
2876
- reason: `the layout migration moves files this ${harness.display_name} session reads and writes — run it from a terminal outside the session`,
2877
- },
2878
- ],
2879
- last: [],
2880
- };
2881
- }
2882
- const steps = [
2883
- {
2884
- kind: "note",
2885
- why: `close other ${harness.display_name} sessions in this project first and restart afterwards: the migration moves files a running session reads and writes, and the status line does not reload mid-session`,
2886
- },
2887
- { kind: "ensure", path: P.root, why: ".projectstore/ with its .gitignore (projectstore.json, state/)" },
2888
- ];
2889
- if (a.legacy.state)
2890
- steps.push({
2891
- kind: "move-state",
2892
- from: P.legacy.state,
2893
- to: P.sessions,
2894
- files: a.legacy.stateFiles.length,
2895
- why: "session files move into state/sessions/; one present on both sides keeps the newer, a per-session directory keeps the new side",
2896
- });
2897
- if (a.legacy.entryLog)
2898
- steps.push({
2899
- kind: "merge-log",
2900
- from: P.legacy.entryLog,
2901
- to: P.entryLog,
2902
- why: "the legacy entry log's lines go before the new log's",
2903
- });
2904
- if (a.legacy.welcomed)
2905
- steps.push({
2906
- kind: "move-marker",
2907
- from: P.legacy.welcomed,
2908
- to: P.welcomed(harness.id),
2909
- why: "the welcome marker moves under state/<harness>/ — a migrated project is not welcomed twice",
2910
- });
2911
- if (a.legacy.sessionId) steps.push({ kind: "delete", path: P.legacy.sessionId, why: "the 0.6-era session-id file" });
2912
- if (a.legacy.binding && a.resumable)
2913
- steps.push({
2914
- kind: "delete",
2915
- path: P.legacy.binding,
2916
- why: "the binding was already moved (an interrupted run left the legacy copy, byte-equal but for its agents block)",
2917
- });
2918
- else if (a.legacy.binding)
2919
- steps.push({
2920
- kind: "move-binding",
2921
- from: P.legacy.binding,
2922
- to: P.binding,
2923
- overlay: P.overlay(harness.id),
2924
- why: `the binding moves; its agents block becomes harness/${harness.id}.json`,
2925
- });
2926
- const first = [
2927
- {
2928
- ...base,
2929
- action: "migrate",
2930
- steps,
2931
- reason: `${a.legacy.binding ? "binding" : "state"} in the pre-0.28 layout under ${a.legacy.dir}/`,
2932
- },
2933
- ];
2934
- const last = [];
2935
- if (a.legacy.launcher || a.legacy.runtime) {
2936
- const cleanup = [];
2937
- if (a.legacy.launcher)
2938
- cleanup.push({
2939
- kind: "remove-legacy-launcher",
2940
- path: P.legacy.launcher,
2941
- why: "removed once the new launcher is written and the settings entry names it — or at once when the status-line slot is not ours (foreign, or empty because the status line is off); kept while a settings file the host reads (this project's local or committed settings, or the user's) still runs it as the status line — a script that calls it indirectly is not seen",
2942
- });
2943
- if (a.legacy.runtime)
2944
- cleanup.push({ kind: "rmdir-legacy", path: P.legacy.runtime, why: "the emptied pre-0.28 runtime directory" });
2945
- last.push({
2946
- ...base,
2947
- surface: "layout_cleanup",
2948
- path: P.legacy.runtime,
2949
- state: "legacy",
2950
- action: "cleanup",
2951
- reason: null,
2952
- steps: cleanup,
2953
- });
2954
- }
2955
- return { first, last };
2956
- }
2957
-
2958
- // A project uninstall's last item per harness (the shell-fetch story's rule
2959
- // 5; install spec contract 13 as amended): .projectstore/state/<id>/ is
2960
- // emptied of the welcome marker — recognised by analyseHarnessState — and
2961
- // removed once empty, so the next bare upgrade stops selecting the harness
2962
- // it disowned. Planned for a directory holding the marker, or nothing; a
2963
- // directory holding only what is not ours is left without a row. It never
2964
- // touches a launcher: that is its surface item's to remove, when it is ours.
2965
- // What stays — a launcher this plan does not remove, a file of the user's —
2966
- // is named in the preview, and keeps the directory. Not a guarantee: a
2967
- // harness whose plugin stays installed for the machine fires its hooks here
2968
- // again, its welcome comes back, and so does the signal — accurately.
2969
- function planHarnessState(projectDir, harness, planned) {
2970
- const a = analyseHarnessState(projectDir, harness.id);
2971
- if (!a.present || (!a.marker && a.names.length)) return null;
2972
- const removing = new Set(
2973
- planned
2974
- .filter((i) => i.kind === "exclusive" && i.action === "remove" && dirname(i.path) === a.dir)
2975
- .map((i) => basename(i.path)),
2976
- );
2977
- const left = a.others.filter((n) => !removing.has(n));
2978
- const steps = [];
2979
- if (a.marker)
2980
- steps.push({
2981
- kind: "delete",
2982
- path: a.marker,
2983
- why: "the welcome marker the SessionStart hook wrote; a bare upgrade selects the harness by it",
2984
- });
2985
- steps.push({
2986
- kind: "rmdir-state",
2987
- path: a.dir,
2988
- left,
2989
- why: left.length
2990
- ? `${left.join(", ")} ${left.length === 1 ? "is" : "are"} not this step's to remove, and keep${left.length === 1 ? "s" : ""} the directory`
2991
- : "removed once empty",
2992
- });
2993
- return {
2994
- surface: "harness_state",
2995
- kind: "layout",
2996
- path: a.dir,
2997
- entry: null,
2998
- state: a.marker ? "ours" : "empty",
2999
- action: "remove",
3000
- reason: a.marker
3001
- ? `${harness.display_name}'s welcome marker — the state directory selects ${harness.display_name} for a bare upgrade`
3002
- : `an empty state directory of ${harness.display_name}`,
3003
- steps,
3004
- };
3005
- }
3006
-
3007
- function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
3008
- const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
3009
- const within = p.projectDir;
3010
- if (i.action === "cleanup" && failed) {
3011
- out.action = "skipped";
3012
- out.reason = "an earlier item failed; the legacy files stay until the next run";
3013
- return out;
3014
- }
3015
- const fail = (step, message) => {
3016
- out.failed = { step, status: null, stderr: message };
3017
- return out;
3018
- };
3019
- for (const st of i.steps || []) {
3020
- const seen = out.steps.length;
3021
- if (onStep) onStep(st, "start");
3022
- try {
3023
- if (st.kind === "ensure") {
3024
- ensureRuntimeDir(within);
3025
- out.steps.push({ kind: st.kind, ok: true });
3026
- } else if (st.kind === "move-state") {
3027
- const r = moveStateDir(st.from, st.to, within);
3028
- ensureStateDir(within);
3029
- out.steps.push({ kind: st.kind, ok: true, ...r });
3030
- } else if (st.kind === "merge-log") {
3031
- const r = mergeEntryLog(st.from, st.to, within);
3032
- out.steps.push({ kind: st.kind, ok: true, result: r });
3033
- } else if (st.kind === "delete") {
3034
- out.steps.push({ kind: st.kind, path: st.path, ok: true, removed: removeInside(st.path, within) });
3035
- } else if (st.kind === "move-marker") {
3036
- const r = movePath(st.from, st.to, within);
3037
- if (r === "target-exists") removeInside(st.from, within);
3038
- out.steps.push({ kind: st.kind, ok: true, result: r });
3039
- } else if (st.kind === "move-binding") {
3040
- let cfg;
3041
- try {
3042
- cfg = JSON.parse(readFileSync(st.from, "utf8"));
3043
- } catch {
3044
- return fail(st.kind, `${st.from} is not valid JSON; the binding was not moved`);
3045
- }
3046
- if (existsSync(st.to)) return fail(st.kind, `${st.to} appeared under the plan; two bindings are a refusal`);
3047
- const { agents, ...binding } = cfg && typeof cfg === "object" ? cfg : {};
3048
- if (agents && typeof agents === "object") {
3049
- let overlay = {};
3050
- try {
3051
- overlay = JSON.parse(readFileSync(st.overlay, "utf8"));
3052
- } catch {}
3053
- mkdirSync(dirname(st.overlay), { recursive: true });
3054
- writeFileAtomic(st.overlay, JSON.stringify({ ...overlay, agents }, null, 2) + "\n", { sweep: false });
3055
- }
3056
- mkdirSync(dirname(st.to), { recursive: true });
3057
- writeFileAtomic(st.to, JSON.stringify(binding, null, 2) + "\n", { sweep: false });
3058
- removeInside(st.from, within);
3059
- out.steps.push({ kind: st.kind, ok: true, overlay: Boolean(agents) });
3060
- } else if (st.kind === "remove-legacy-launcher") {
3061
- // Only ours, and only when no settings entry names it any more.
3062
- let text = null;
3063
- try {
3064
- text = readFileSync(st.path, "utf8");
3065
- } catch {}
3066
- // "Still in use" is decided by the FILE, not the spelling: an entry
3067
- // written through a symlinked or differently-cased path, or living in
3068
- // the committed or the user's settings, still runs it (the third review
3069
- // of the 2026-10-03 fixes — a string comparison of the local file alone
3070
- // deleted a launcher the status line was running).
3071
- const named =
3072
- entryNames(p.projectDir, i.harness, st.path) || settingsRunFile(p.projectDir, i.harness, st.path, home);
3073
- // Moved, never just deleted: the legacy launcher goes only once the new
3074
- // one exists (a dev root produces none — contract 7 leaves it in place)
3075
- // — unless the status-line slot is not ours (any wiring of ours counts).
3076
- // Under a foreign or empty slot the new launcher is never made, and
3077
- // keeping the old one kept layout-legacy alive forever (the critic of
3078
- // the layout spec's 2026-10-03 amendment, case S1).
3079
- const moved = existsSync(layoutPaths(p.projectDir).launcher(i.harness));
3080
- const slotOurs = slotIsOurs(p.projectDir, i.harness, { home, root: p.root });
3081
- if (text === null) out.steps.push({ kind: st.kind, ok: true, removed: false });
3082
- else if (!moved && slotOurs)
3083
- out.steps.push({
3084
- kind: st.kind,
3085
- ok: true,
3086
- removed: false,
3087
- reason: "no launcher at the new path yet (this root does not produce one) — left in place",
3088
- });
3089
- else if (!isOurFile(text))
3090
- out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "not ours — left in place" });
3091
- else if (named)
3092
- out.steps.push({
3093
- kind: st.kind,
3094
- ok: true,
3095
- removed: false,
3096
- reason: "a settings file the host reads still runs it as the status line — left in place",
3097
- });
3098
- else out.steps.push({ kind: st.kind, ok: true, removed: removeInside(st.path, within) });
3099
- } else if (st.kind === "rmdir-legacy") {
3100
- // Prune the legacy runtime dir when only its own .gitignore (and an empty state/) remain.
3101
- let left = [];
3102
- try {
3103
- left = readdirSync(st.path).filter((n) => n !== ".gitignore");
3104
- } catch {
3105
- out.steps.push({ kind: st.kind, ok: true, removed: false });
3106
- continue;
3107
- }
3108
- if (left.length === 1 && left[0] === "state") {
3109
- try {
3110
- if (readdirSync(join(st.path, "state")).length === 0) {
3111
- rmdirSync(join(st.path, "state"));
3112
- left = [];
3113
- }
3114
- } catch {}
3115
- }
3116
- if (left.length) {
3117
- out.steps.push({ kind: st.kind, ok: true, removed: false, reason: `${left.join(", ")} remain` });
3118
- continue;
3119
- }
3120
- removeInside(st.path, within, { recursive: true });
3121
- out.steps.push({ kind: st.kind, ok: true, removed: true });
3122
- } else if (st.kind === "remove-legacy-runtime") {
3123
- removeInside(st.path, within, { recursive: true });
3124
- out.steps.push({ kind: st.kind, ok: true, removed: true });
3125
- } else if (st.kind === "rmdir-state") {
3126
- // rmdirSync on an empty directory only: whatever is left — read now,
3127
- // after the items before it ran — keeps it, and is named.
3128
- let left = [];
3129
- try {
3130
- left = readdirSync(st.path).sort();
3131
- } catch {
3132
- out.steps.push({ kind: st.kind, ok: true, removed: false });
3133
- continue;
3134
- }
3135
- if (left.length) {
3136
- out.steps.push({ kind: st.kind, ok: true, removed: false, reason: `${left.join(", ")} remain` });
3137
- continue;
3138
- }
3139
- rmdirSync(st.path);
3140
- out.steps.push({ kind: st.kind, ok: true, removed: true });
3141
- }
3142
- } catch (e) {
3143
- return fail(st.kind, e && e.message ? e.message : String(e));
3144
- } finally {
3145
- if (onStep) onStep(st, "end", stepResult(out, seen));
3146
- }
3147
- }
3148
- return out;
3149
- }
3150
-
3151
- // What one step left behind, for the APPLY line: its record when it pushed
3152
- // one, and a failure when the item failed under it.
3153
- function stepResult(out, seen) {
3154
- const rec = out.steps.length > seen ? out.steps[out.steps.length - 1] : null;
3155
- return { ok: !out.failed && (!rec || rec.ok !== false), kept: Boolean(rec && rec.removed === false && rec.reason) };
3156
- }
3157
-
3158
- // Does any settings file the host reads run this file as its status line?
3159
- // Compared by identity (device and inode), so a path spelled through a symlink
3160
- // or in another case still counts; the project-directory variable a command
3161
- // may carry is substituted first.
3162
- function settingsRunFile(projectDir, harnessId, file, home) {
3163
- const id = (f) => {
3164
- try {
3165
- const s = statSync(f);
3166
- return `${s.dev}:${s.ino}`;
3167
- } catch {
3168
- return null;
3169
- }
3170
- };
3171
- const want = id(file);
3172
- if (!want) return false;
3173
- const h = loadHarness(harnessId);
3174
- const dir = h.runtime?.harness_dir || ".claude",
3175
- v = h.runtime?.project_dir_env;
3176
- const files = [
3177
- join(projectDir, h.surfaces?.statusline?.file || join(dir, "settings.local.json")),
3178
- join(projectDir, dir, "settings.json"),
3179
- join(claudeHome(home), "settings.json"),
3180
- ];
3181
- return files.some((f) => {
3182
- try {
3183
- let cmd = JSON.parse(readFileSync(f, "utf8"))?.statusLine?.command;
3184
- if (typeof cmd !== "string") return false;
3185
- if (v)
3186
- cmd = cmd
3187
- .split("${" + v + "}")
3188
- .join(projectDir)
3189
- .split("$" + v)
3190
- .join(projectDir);
3191
- const sp = statusLineScriptPath(cmd);
3192
- return Boolean(sp) && id(isAbsolute(sp) ? sp : join(projectDir, sp)) === want;
3193
- } catch {
3194
- return false;
3195
- }
3196
- });
3197
- }
3198
-
3199
- // Is the harness's status-line slot wired to anything of ours (any wiring)?
3200
- function slotIsOurs(projectDir, harnessId, { home, root }) {
3201
- try {
3202
- const s = loadHarness(harnessId).surfaces?.statusline;
3203
- if (!s || !s.file) return false;
3204
- const cmd = JSON.parse(readFileSync(join(projectDir, s.file), "utf8"))?.statusLine?.command;
3205
- return typeof cmd === "string" && statusLineIsOurWiring(cmd, projectDir, home, root);
3206
- } catch {
3207
- return false;
3208
- }
3209
- }
3210
-
3211
- // Does the harness's settings entry still name this launcher path?
3212
- function entryNames(projectDir, harnessId, launcherPath) {
3213
- try {
3214
- const h = loadHarness(harnessId);
3215
- const s = h.surfaces?.statusline;
3216
- if (!s || !s.file) return false;
3217
- const settings = JSON.parse(readFileSync(join(projectDir, s.file), "utf8"));
3218
- const cmd = settings?.statusLine?.command;
3219
- const named = statusLineScriptPath(typeof cmd === "string" ? cmd : null);
3220
- return Boolean(named) && resolve(named) === resolve(launcherPath);
3221
- } catch {
3222
- return false;
3223
- }
3224
- }
3225
-
3226
- // Contract 14: the host installs and updates these itself; say how, from the
3227
- // manifest, and write nothing. (PR #13's installElsewhere, as a plan line.)
3228
- function hostManagedReport(m, rows, registration = null) {
3229
- const inst = m.install || {};
3230
- const lines = [
3231
- `${m.display_name}: ${rows.join(", ")} are installed by ${inst.mechanism || "the host"} — nothing to write.`,
3232
- ];
3233
- if (registration && registration.entry) {
3234
- const how =
3235
- registration.action === "skip" && registration.state === "current"
3236
- ? "registered here"
3237
- : registration.action === "refuse"
3238
- ? "refused, see below"
3239
- : registration.action === "skip"
3240
- ? "not registered on this run, see below"
3241
- : `registered by this run (${registration.action})`;
3242
- // A host plugin registration is a marketplace added from a local path, and
3243
- // the host loads its plugin in place, from the plugin's root inside that
3244
- // directory (`payload`, set by plan()); the install path is the registry's
3245
- // copy, which the launcher renders (the story "The SessionStart hook and
3246
- // install agree on the status line…", measured on host 2.1.293 and
3247
- // 2.1.296). A portable registration's host loads the copy.
3248
- const where = !registration.root
3249
- ? ""
3250
- : registration.payload
3251
- ? ` — install path ${registration.root}; the host loads them in place from ${registration.payload}`
3252
- : ` — the host loads them from ${registration.root}`;
3253
- lines.push(` They come from the registration ${registration.entry}, ${how}${where}.`);
3254
- }
3255
- if (inst.why_not_scripted) lines.push(` ${inst.why_not_scripted}`);
3256
- for (const s of inst.steps || []) lines.push(` ${s}`);
3257
- for (const n of inst.notes || []) lines.push(` ${n}`);
3258
- if (inst.docs) lines.push(` ${inst.docs}`);
3259
- return lines.join("\n");
3260
- }
3261
-
3262
- const isWrite = (i) => !["skip", "refuse"].includes(i.action);
3263
-
3264
- // ─── preview ───────────────────────────────────────────────────────────
3265
- //
3266
- // PLAN (presentation spec contract 7, which revises the layout clauses of
3267
- // install spec contract 18): the badge, the project path, a summary line of
3268
- // counts, then the rows grouped under each run harness's display name, in run
3269
- // order. A row is one aligned line — glyph, action word, target, note — and
3270
- // beneath it what the reader consents to (install spec contract 9: every path
3271
- // written and every host argv, with the files it touches), the reason when
3272
- // there is one, and a consequence the reader must know. Internal words —
3273
- // kinds, provenance states, entry markers, roadmap ids — and each step's why
3274
- // are behind `--verbose`. Groups and counts are computed here, never added to
3275
- // the plan: plan --json keeps its keys. Colour and glyphs are the caller's
3276
- // stream's (`caps`, term.mjs); the default is plain text in Unicode.
3277
- const unicodeGlyph = (name) => termIcon({ ascii: false }, name);
3278
-
3279
- // Every action a plan item can carry, as its glyph. A write never wears the
3280
- // skip glyph: `add` and `replace-entry` are changes as much as `create` and
3281
- // `update`.
3282
- // A copy from another marketplace turned off for this checkout is a channel
3283
- // switch, `disable` (the converge story; contract 7's `switch` word, ⇄).
3284
- const ACTION_ICON = {
3285
- create: "create",
3286
- add: "create",
3287
- update: "update",
3288
- "replace-entry": "update",
3289
- migrate: "migrate",
3290
- disable: "switch",
3291
- cleanup: "cleanup",
3292
- remove: "remove",
3293
- prune: "remove",
3294
- skip: "skip",
3295
- refuse: "refuse",
3296
- };
3297
- // …and as a person's word (contract 7's table). A registration's write takes
3298
- // `register`; a skip reads `unchanged` when the item is current and carries
3299
- // no reason, and `skipped` otherwise — deferred, opted out, unsupported, or
3300
- // left in place: a current row with a reason is one the run did not act on
3301
- // for that reason (uninstall keeping a user-global registration, a host CLI
3302
- // not on PATH), and the reason beneath it is what the reader acts on.
3303
- const ACTION_WORD = {
3304
- create: "add",
3305
- add: "add",
3306
- update: "update",
3307
- "replace-entry": "update",
3308
- migrate: "update",
3309
- disable: "switch",
3310
- cleanup: "remove",
3311
- remove: "remove",
3312
- prune: "remove",
3313
- refuse: "refused",
3314
- };
3315
- // Each word's colour role (contract 3): a refusal's ▲, a channel switch and a
3316
- // removal are attention; red is for what failed.
3317
- const WORD_ROLE = {
3318
- add: "done",
3319
- register: "done",
3320
- update: "action",
3321
- switch: "attention",
3322
- remove: "attention",
3323
- refused: "attention",
3324
- unchanged: "explanation",
3325
- skipped: "explanation",
3326
- };
3327
- // APPLY says a word while its step runs and once it has (contract 8).
3328
- const WORD_TENSES = {
3329
- add: ["adding", "added"],
3330
- register: ["registering", "registered"],
3331
- update: ["updating", "updated"],
3332
- switch: ["switching", "switched"],
3333
- remove: ["removing", "removed"],
3334
- };
3335
-
3336
- const CURRENT_STATES = new Set(["current", "ours-current", "ours"]);
3337
- const isUnchanged = (i) => i.action === "skip" && !i.deferred && !i.reason && CURRENT_STATES.has(i.state);
3338
- // A surface this harness does not have: folded into one line per harness.
3339
- const isUnsupported = (i) => i.action === "skip" && i.state === "unsupported";
3340
- // A row of something another project wrote last (install spec contract 12):
3341
- // never collapsed, and its note says whose it is.
3342
- const writtenElsewhere = (i) => i.state === "current" && Boolean(i.writtenBy) && !i.sameProject;
3343
- // A row that writes nothing and asks nothing of the reader collapses into
3344
- // one line per group naming it (contract 7).
3345
- const isQuiet = (i) => isUnchanged(i) && !writtenElsewhere(i) && !(i.steps || []).length;
3346
- // The registration row a fetched shell's registry lines sit beneath, when the
3347
- // run fetched one from a registry that is not the user's.
3348
- const fetchedFor = (p, i) =>
3349
- p.fetchedFrom &&
3350
- i.kind === "registration" &&
3351
- !String(i.surface).endsWith("_others") &&
3352
- p.fetchedFrom.harnesses.includes(i.harness)
3353
- ? p.fetchedFrom
3354
- : null;
3355
-
3356
- function actionWord(i) {
3357
- if (i.action === "skip") return isUnchanged(i) ? "unchanged" : "skipped";
3358
- if (i.kind === "registration" && ["create", "add", "update"].includes(i.action)) return "register";
3359
- return ACTION_WORD[i.action] || "update";
3360
- }
3361
- const glyphName = (i) => ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip");
3362
- // The surface key in words: `agents_block` reads "agents block".
3363
- const surfaceWords = (key) => String(key).replace(/_/g, " ");
3364
- // What a row names: a registration by its plugin id — its paths are the
3365
- // consent lines beneath it — anything else by its path, project-relative.
3366
- function rowTarget(p, i, home = homedir()) {
3367
- // …and the keep (kind `channel`) by the key of the copy it keeps.
3368
- if ((i.kind === "registration" || i.kind === "channel") && i.entry) return i.entry;
3369
- if (i.path === null) return `${i.surface} [${i.harness}, no filesystem path]`;
3370
- // Setup's vault and state directories, as directories; a vault outside the
3371
- // project — beside it, usually — by its path from home, as the project's.
3372
- if (i.kind === "vault") {
3373
- const r = rel(p.projectDir, i.path);
3374
- return `${r === i.path ? tilde(i.path, home) : r}/`;
3375
- }
3376
- if (i.kind === "state") return `${rel(p.projectDir, i.path)}/`;
3377
- return rel(p.projectDir, i.path);
3378
- }
3379
- // The heading of the group of rows that belong to no harness: setup's vault
3380
- // (presentation spec contract 7's VAULT group, first when present).
3381
- const VAULT_GROUP = "Vault";
3382
- function rowNote(i, { verbose, glyph }) {
3383
- const words = writtenElsewhere(i) ? `last written by ${i.writtenBy}` : surfaceWords(i.surface);
3384
- if (!verbose) return words;
3385
- return `${words} ${glyph("dot")} ${i.kind}${i.entry && i.kind !== "registration" ? ` [${i.entry}]` : ""}`;
3386
- }
3387
- // --verbose's line beneath a row: the provenance state, the reason and the
3388
- // plan's own action, as install spec contract 18 wrote them.
3389
- function transitionText(i, glyph) {
3390
- let state = writtenElsewhere(i) ? `current, last written by ${i.writtenBy}` : i.state;
3391
- // A reason with commands gives its prose here; the commands follow, alone.
3392
- const reason = i.advice ? i.advice.text : i.reason;
3393
- if (reason && i.action !== "refuse") state += ` (${reason})`;
3394
- // A dry run's unchanged registration: right by every host fact, its
3395
- // payload digest still to compare. Said only where the reasoning is.
3396
- if (i.digestDeferred) state += " (the payload digest is compared when install or upgrade fetches the shell)";
3397
- return `${state} ${glyph("transition")} ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
3398
- }
3399
-
3400
- // One step of an item as the lines it shows: always the action and its target,
3401
- // then — under --verbose — why it runs.
3402
- function stepLines(p, st, { verbose, paint, width = 0, glyph = unicodeGlyph, home = homedir() }) {
3403
- const r = (x) => rel(p.projectDir, x);
3404
- // A path outside the project — setup's vault, beside it usually — from
3405
- // home, as its row and its APPLY lines name it.
3406
- const t = (x) => tilde(r(x), home);
3407
- const to = ` ${glyph("transition")} `;
3408
- const lead = " ";
3409
- const sub = " ";
3410
- const why =
3411
- verbose && st.why
3412
- ? wrap(st.why, width, sub)
3413
- .split("\n")
3414
- .map((l, k) => (k ? "" : sub) + paint("explanation", l))
3415
- : [];
3416
- // A consent line (install spec contract 9): the step's verb and what it
3417
- // touches, in the explanation role (presentation spec contracts 3 and 7).
3418
- const said = (text) => `${lead}${paint("explanation", text)}`;
3419
- switch (st.kind) {
3420
- case "host": {
3421
- // The keep step's argv is whole (its bin is the Node that runs us), and
3422
- // its touches are the kept copy's planned writes, each with its action.
3423
- const out = [said(`$ ${(st.bin ? [st.bin, ...st.argv] : st.argv).join(" ")}`)];
3424
- if (st.touches.length)
3425
- out.push(
3426
- `${sub}${paint("explanation", `touches ${st.touches.map((t) => (typeof t === "string" ? r(t) : `${t.path ? r(t.path) : t.surface} (${t.action})`)).join(", ")}`)}`,
3427
- );
3428
- return [...out, ...why];
3429
- }
3430
- // The fetch a dry run would run first (the shell-fetch story's rule 6),
3431
- // with the fetch glyph (presentation spec contract 4).
3432
- case "fetch":
3433
- return [
3434
- `${lead}${paint("action", glyph("fetch"))} ${paint("explanation", [st.bin, ...st.argv].join(" "))}`,
3435
- ...why,
3436
- ];
3437
- case "note":
3438
- return wrap(`note: ${st.why}`, width, sub)
3439
- .split("\n")
3440
- .map((l, k) => (k ? l : lead + paint("attention", "note:") + l.slice(5)));
3441
- case "write":
3442
- return [
3443
- said(`write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`),
3444
- ...why,
3445
- ];
3446
- // A dry run names no file count: the payload is counted after the fetch.
3447
- case "portable-write":
3448
- return [
3449
- said(
3450
- `stage ${st.path} (${Array.isArray(st.files) ? `${st.files.length} payload files` : "the fetched payload"} + catalogue + ownership)`,
3451
- ),
3452
- ...why,
3453
- ];
3454
- case "rmdir-state":
3455
- return [
3456
- said(
3457
- `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` : ""}`,
3458
- ),
3459
- ...why,
3460
- ];
3461
- case "portable-remove":
3462
- case "remove":
3463
- return [said(`remove ${st.path}`), ...why];
3464
- case "unregister":
3465
- return [said(`edit ${r(st.path)} [${st.pointer}.${st.name}]${to}removed`), ...why];
3466
- // The channel switch's record in the binding (the converge story's rules
3467
- // 2 and 4): its time is the apply's, unknown to a preview.
3468
- case "mark":
3469
- return [said(`edit ${r(st.path)} [${st.key}]${to}by "setup", at the time of apply`), ...why];
3470
- case "unmark":
3471
- return [said(`edit ${r(st.path)} [${st.key}]${to}removed`), ...why];
3472
- // A `binding` item's value (the setup story's status-line flag).
3473
- case "set":
3474
- return [said(`edit ${r(st.path)} [${st.key}]${to}${JSON.stringify(st.value)}`), ...why];
3475
- // Setup's vault (rule 10), the binding plan's own steps: every path is in
3476
- // the default view. A scaffold step's paths are the vault's, beneath the
3477
- // row that names it.
3478
- case "mkdir":
3479
- return [said(`create ${t(st.path)}/`), ...why];
3480
- case "bind":
3481
- return [
3482
- said(
3483
- `write ${r(st.path)} (${st.from ? `the binding of ${st.from}${st.drop?.length ? `, without ${st.drop.join(" and ")}` : ""}` : `vault_path, layout ${st.layout}, language ${st.language}`})`,
3484
- ),
3485
- ...why,
3486
- ];
3487
- case "git-init":
3488
- return [
3489
- said(`$ ${st.argv.join(" ")}`),
3490
- `${sub}${paint("explanation", `in ${t(st.path)}, with no commit`)}`,
3491
- ...why,
3492
- ];
3493
- case "scaffold":
3494
- return [said(`create ${st.rels.join(" and ")}`), ...why];
3495
- case "ensure":
3496
- return [said(`ensure ${r(st.path)}/`), ...why];
3497
- case "move-state":
3498
- return [said(`move ${r(st.from)}/${to}${r(st.to)}/ (${st.files} entries)`), ...why];
3499
- case "merge-log":
3500
- return [said(`merge ${r(st.from)}${to}${r(st.to)}`), ...why];
3501
- case "delete":
3502
- return [said(`delete ${r(st.path)}`), ...why];
3503
- case "move-marker":
3504
- return [said(`move ${r(st.from)}${to}${r(st.to)}`), ...why];
3505
- case "move-binding":
3506
- return [said(`move ${r(st.from)}${to}${r(st.to)} (agents${to}${r(st.overlay)})`), ...why];
3507
- case "remove-legacy-launcher":
3508
- case "rmdir-legacy":
3509
- case "remove-legacy-runtime":
3510
- return [said(`remove ${r(st.path)}`), ...why];
3511
- default:
3512
- return [];
3513
- }
3514
- }
3515
-
3516
- const displayName = (id) => loadHarness(id)?.display_name || id;
3517
- // The harnesses a plan names, by their display names — for the badge.
3518
- function displayNames(p) {
3519
- return p.harnesses.map(displayName).join(", ") || "(no harness)";
3520
- }
3521
-
3522
- const tilde = (path, home = homedir()) =>
3523
- path === home || path.startsWith(home + "/") ? "~" + path.slice(home.length) : path;
3524
-
3525
- // The badge (presentation spec contract 7): `projectstore <verb> · <display
3526
- // names>`, the first line of a verb's output. A run that fetches prints it
3527
- // before the fetch line, and its preview then leaves it out (`badge: false`),
3528
- // so it is printed once.
3529
- export function badgeLine(p, { verb = null, caps: c = PLAIN } = {}) {
3530
- return badgeText(c, `projectstore ${verb || p.mode}`, [displayNames(p)]);
3531
- }
3532
-
3533
- // The update notice in the badge's slot (the converge story's rule 8;
3534
- // presentation spec contracts 6 and 7): the note glyph, the text in the
3535
- // explanation role, then the command alone on its line after a blank one, in
3536
- // the action role, and a blank line before the preview.
3537
- export function noticeText(n, c = PLAIN) {
3538
- const paint = painter(c);
3539
- return (
3540
- [
3541
- ` ${paint("attention", termIcon(c, "note"))} ${paint("explanation", n.text)}`,
3542
- ...commandBlock(c, [n.command], { indent: 4 }),
3543
- "",
3544
- ].join("\n") + "\n"
3545
- );
3546
- }
3547
-
3548
- // The summary line: the counts by kind of change, each in its word's role.
3549
- // Unsupported rows are not counted: they are the harness's, not the run's.
3550
- const COUNTED = [
3551
- ["add", (n) => `${n} to add`],
3552
- ["register", (n) => `${n} to register`],
3553
- ["update", (n) => `${n} to update`],
3554
- ["switch", (n) => `${n} to switch`],
3555
- ["remove", (n) => `${n} to remove`],
3556
- ["unchanged", (n) => `${n} unchanged`],
3557
- ["skipped", (n) => `${n} skipped`],
3558
- ];
3559
- function planSummary(p, c) {
3560
- if (!p.ok) return heading(c, "Plan", [["refused", "attention"]]);
3561
- const n = {};
3562
- for (const i of p.items) if (!isUnsupported(i)) n[actionWord(i)] = (n[actionWord(i)] || 0) + 1;
3563
- const counts = COUNTED.filter(([w]) => n[w]).map(([w, say]) => [say(n[w]), WORD_ROLE[w]]);
3564
- return heading(c, "Plan", counts.length ? counts : [["nothing to change", "explanation"]]);
3565
- }
3566
-
3567
- // `home` shortens the project path to ~ (the caller's home; a test's fixed
3568
- // one); everything else the screen needs is in `p`. `header` is a caller's
3569
- // lines beneath the project path — setup's, naming the git work tree's top
3570
- // when it runs in a subfolder (the setup story, rule 4).
3571
- export function renderPreview(
3572
- p,
3573
- { verbose = false, verb = null, caps: c = PLAIN, badge = true, home = homedir(), header = [] } = {},
3574
- ) {
3575
- const paint = painter(c);
3576
- const glyph = (name) => termIcon(c, name);
3577
- const width = c.live ? c.width : 0;
3578
- const writes = p.items.filter(isWrite);
3579
- // A text's lines, each of its own lines wrapped on its own, returned without
3580
- // the indent for the caller to lay out. wrap indents only the lines it
3581
- // breaks, so a line the text carried itself must never lose the indent's
3582
- // width of characters (a refusal's "--harness claude-code" read "laude-code").
3583
- const textLines = (text, indent) =>
3584
- String(text)
3585
- .split("\n")
3586
- .flatMap((para) =>
3587
- wrap(para, width, indent)
3588
- .split("\n")
3589
- .map((l, k) => (k ? l.slice(indent.length) : l)),
3590
- );
3591
- // Prose, wrapped to the terminal with every line indented; a path or a
3592
- // command inside it is one word and never broken (term.mjs wrap).
3593
- const prose = (indent, text, role = "explanation") => textLines(text, indent).map((l) => indent + paint(role, l));
3594
- const lines = [
3595
- ...(badge ? [badgeLine(p, { verb, caps: c })] : []),
3596
- ` ${paint("explanation", tilde(p.projectDir, home))}`,
3597
- ...(header || []).flatMap((h) => prose(" ", h)),
3598
- "",
3599
- ];
3600
- if (verbose) {
3601
- for (const [h, r] of Object.entries(p.plannedAgainst || {}))
3602
- lines.push(
3603
- ...prose(
3604
- " ",
3605
- `${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`,
3606
- ),
3607
- "",
3608
- );
3609
- for (const r of p.reports) lines.push(...r.split("\n").flatMap((l) => (l.trim() ? prose(" ", l) : [""])), "");
3610
- }
3611
- lines.push(planSummary(p, c));
3612
- // An emptied state directory is pruned with the launcher removed from it;
3613
- // said once, beneath the first such row.
3614
- let pruneSaid = false;
3615
- const beneath = (i) => {
3616
- const out = [];
3617
- // A user-scope registration reaches past this project: contract 7's
3618
- // consequence line, on the line beneath its row, before anything else.
3619
- if (isWrite(i) && i.kind === "registration" && i.scope === "user")
3620
- out.push(
3621
- ...prose(
3622
- " ",
3623
- `user-wide: the plugin is ${i.action === "remove" ? "removed" : "registered"} for every project ${displayName(i.harness)} opens, not only this one`,
3624
- ),
3625
- );
3626
- // …and a channel switch's (the converge story's rule 2), carried as its
3627
- // note step: first, before the reason and the consent lines.
3628
- for (const st of (i.steps || []).filter(isConsequence)) out.push(...prose(" ", st.why));
3629
- if (verbose) out.push(...prose(" ", transitionText(i, glyph)));
3630
- else if (i.reason && !isUnsupported(i))
3631
- out.push(
3632
- ...prose(" ", i.advice ? i.advice.text : i.reason, i.action === "refuse" ? "attention" : "explanation"),
3633
- );
3634
- // The reason's ways out (the converge story): each lead, then its command
3635
- // alone on its line after a blank one (contract 6), and a blank line after.
3636
- if (i.advice?.blocks?.length) {
3637
- for (const b of i.advice.blocks)
3638
- out.push(...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 10 }), "");
3639
- }
3640
- // A shell this run fetched from a registry that is not the user's (the
3641
- // converge story's rule 8, as amended 2026-10-09): both named. Neutral
3642
- // words: the fetch's URL can come from the project's .npmrc or from the
3643
- // run's own environment (`npx --registry`), and either reads the same.
3644
- const from = fetchedFor(p, i);
3645
- if (from)
3646
- out.push(
3647
- ...prose(" ", `fetched from ${from.registry}`),
3648
- ...prose(" ", `your npm's own registry is ${from.user}`),
3649
- );
3650
- for (const st of (i.steps || []).filter((x) => !isConsequence(x)))
3651
- out.push(...stepLines(p, st, { verbose, paint, width, glyph, home }));
3652
- if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others"))
3653
- out.push(` ${paint("explanation", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
3654
- if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim())
3655
- out.push(` ${paint("explanation", "(the file would hold nothing else and is removed)")}`);
3656
- if (!pruneSaid && i.action === "remove" && i.kind === "exclusive") {
3657
- pruneSaid = true;
3658
- out.push(` ${paint("explanation", `(an emptied ${rel(p.projectDir, dirname(i.path))}/ is pruned)`)}`);
3659
- }
3660
- return out;
3661
- };
3662
- // The run's one agents block, named again where each other harness's row
3663
- // would have been (contract 7; the story "One run plans the agents block
3664
- // once…", rule 1): in that harness's group, before its first item at or
3665
- // after the recorded index, else at the group's end. Not a step and not an
3666
- // item: nothing is planned there.
3667
- const sameBlock = () =>
3668
- ` ${paint("explanation", glyph("skip"))} ${p.sharedBlock.file} ${glyph("dash")} the same block as ${p.sharedBlock.display}, above`;
3669
- const hostLines = (h) => {
3670
- const from = h.entry ? `from the registration ${h.entry}` : "from the host's own plugin system";
3671
- return wrap(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`, width, " ")
3672
- .split("\n")
3673
- .map((l, k) =>
3674
- k
3675
- ? ` ${paint("explanation", l.slice(4))}`
3676
- : ` ${paint("explanation", glyph("skip"))} ${paint("explanation", l)}`,
3677
- );
3678
- };
3679
- // The VAULT group first, when setup plans one: the rows of no harness.
3680
- const ids = [
3681
- ...(p.items.some((i) => i.harness === null) ? [null] : []),
3682
- ...p.harnesses,
3683
- ...new Set(p.items.map((i) => i.harness).filter((h) => h !== null && !p.harnesses.includes(h))),
3684
- ];
3685
- for (const id of ids) {
3686
- const entries = p.items.map((i, at) => ({ i, at })).filter((e) => e.i.harness === id);
3687
- const others = (p.sharedBlock?.others || []).filter((o) => o.harness === id).sort((a, b) => a.at - b.at);
3688
- const host = (p.hostManaged || []).filter((h) => h.harness === id);
3689
- if (!entries.length && !others.length && !host.length) continue;
3690
- const folded = verbose ? [] : entries.filter((e) => isUnsupported(e.i));
3691
- // A registration this run fetched from another registry keeps its row, so
3692
- // the registry lines beneath it show on every run, not only on a change.
3693
- const quiet = verbose ? [] : entries.filter((e) => isQuiet(e.i) && !fetchedFor(p, e.i));
3694
- const shown = entries.filter((e) => !folded.includes(e) && !quiet.includes(e));
3695
- const specs = shown.map(({ i }) => ({
3696
- glyph: glyphName(i),
3697
- role: WORD_ROLE[actionWord(i)],
3698
- action: actionWord(i),
3699
- target: rowTarget(p, i, home),
3700
- note: rowNote(i, { verbose, glyph }),
3701
- }));
3702
- if (quiet.length)
3703
- specs.push({
3704
- glyph: "unchanged",
3705
- role: "explanation",
3706
- action: "unchanged",
3707
- target: paint("explanation", quiet.map((e) => surfaceWords(e.i.surface)).join(", ")),
3708
- });
3709
- // Unsupported rows keep one line per harness (contract 18), named in
3710
- // words as the unchanged line names its rows; --verbose lists each with
3711
- // the manifest's reason.
3712
- if (folded.length)
3713
- specs.push({
3714
- glyph: "unchanged",
3715
- role: "explanation",
3716
- action: "skipped",
3717
- target: paint(
3718
- "explanation",
3719
- `not on ${displayName(id)}: ${folded.map((e) => surfaceWords(e.i.surface)).join(", ")}`,
3720
- ),
3721
- note: "--verbose says why",
3722
- });
3723
- const drawn = rowLines(c, specs);
3724
- lines.push("", paint("emphasis", id === null ? VAULT_GROUP : displayName(id)));
3725
- const pending = [...others];
3726
- shown.forEach(({ i, at }, k) => {
3727
- while (pending.length && pending[0].at <= at) {
3728
- pending.shift();
3729
- lines.push(sameBlock());
3730
- }
3731
- lines.push(...drawn[k], ...beneath(i));
3732
- });
3733
- for (const _ of pending) lines.push(sameBlock());
3734
- for (const extra of drawn.slice(shown.length)) lines.push(...extra);
3735
- for (const h of host) lines.push(...hostLines(h));
3736
- }
3737
- if (p.refusals.length) lines.push("");
3738
- // A plan-level refusal: its message in the attention role, as an item's
3739
- // refusal reason is (presentation spec contract 3 as amended 2026-10-11).
3740
- p.refusals.forEach((r, k) => {
3741
- const lead = ` ${paint("attention", glyph("refuse"))} ${paint("attention", "refused")} `;
3742
- const pad = " ".repeat(plain(lead).length);
3743
- // The stop (the converge story's rule 6) carries its commands apart from
3744
- // its words: each is printed alone on its line after a blank one, with
3745
- // nothing after it (contract 6), the way a person copies it.
3746
- const stop = k === 0 && p.stop ? p.stop : null;
3747
- // The same for a refusal that names its ways out (the channel "git" with
3748
- // nothing to keep): its prose, then each lead and its command alone.
3749
- const advice = k === 0 && !stop && p.refusalAdvice ? p.refusalAdvice : null;
3750
- const [first, ...rest] = textLines(stop ? stop.lead : advice ? advice.text : r, pad);
3751
- lines.push(lead + paint("attention", first), ...rest.map((l) => pad + paint("attention", l)));
3752
- if (stop)
3753
- lines.push(
3754
- ...commandBlock(c, [stop.commands[0]], { indent: 4 }),
3755
- "",
3756
- ...prose(" ", stop.between),
3757
- ...commandBlock(c, stop.commands.slice(1), { indent: 4 }),
3758
- );
3759
- if (advice)
3760
- for (const b of advice.blocks)
3761
- lines.push("", ...prose(" ", b.lead), ...commandBlock(c, [b.command], { indent: 4 }));
3762
- });
3763
- // The closing lines, in a person's words: only what is listed is written,
3764
- // and a $ line is the host's own CLI at work.
3765
- const closing = [];
3766
- if (p.ok && writes.length)
3767
- closing.push(
3768
- ...prose(
3769
- " ",
3770
- `Only the entries listed above are written ${glyph("dash")} nothing else in those files is read, rewritten or removed.`,
3771
- ),
3772
- );
3773
- if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host" && s.name !== "keep")))
3774
- closing.push(
3775
- ...prose(" ", "Each $ line runs the host's own CLI, which writes the host-owned files named after it."),
3776
- );
3777
- if (p.items.some((i) => isWrite(i) && (i.steps || []).some((s) => s.kind === "host" && s.name === "keep")))
3778
- closing.push(
3779
- ...prose(
3780
- " ",
3781
- "The kept copy's $ line runs that copy's own projectstore, which writes the files named after it.",
3782
- ),
3783
- );
3784
- if (closing.length) lines.push("", ...closing);
3785
- if (!p.ok) lines.push("", ` ${paint("attention", "Nothing will be written: resolve the refusals above first.")}`);
3786
- // A plan that wrote nothing because part of it could not be planned never
3787
- // reads as converged (the converge story's keep, a copy with work pending).
3788
- else if (!writes.length)
3789
- lines.push(
3790
- "",
3791
- p.incomplete
3792
- ? " Nothing will be written: one surface could not be planned (see above)."
3793
- : " Nothing to change.",
3794
- );
3795
- else if (p.incomplete) lines.push("", " One surface could not be planned (see above); the rest proceeds.");
3796
- return lines.join("\n") + "\n";
3797
- }
3798
-
3799
2021
  // ─── gate ──────────────────────────────────────────────────────────────
3800
2022
 
3801
2023
  // Contract 9, amended 2026-10-04: a person at a terminal is asked, even when
@@ -3835,7 +2057,7 @@ export function asksAtTerminal({ stdin = null, stdout = null, ask = null, env =
3835
2057
  // confirm it, a bare run is refused for it too, and nothing is written:
3836
2058
  // `channel`, ahead of the whole non-interactive branch. A plan built without
3837
2059
  // plan() (no `channel` on it) reads as no answer.
3838
- export const switchUnanswered = (p) => (p.channel ?? null) === null && p.items.some(isSwitch);
2060
+ export const switchUnanswered = (p) => (p.channel ?? null) === null && p.items.some((i) => isSwitch(i) || isShadow(i));
3839
2061
  export async function confirm(
3840
2062
  p,
3841
2063
  {
@@ -3926,7 +2148,11 @@ export function apply(
3926
2148
  return r;
3927
2149
  }
3928
2150
  if (i.kind === "binding") {
3929
- const r = applyBinding(p, i, { env, onStep, switched: switched.get(i.harness) || [] });
2151
+ // A directory copy's "switch" is the registration itself (PS-DIRECTORY):
2152
+ // the mark waits on it as on a switch.
2153
+ const shadowed = p.items.some((x) => x.harness === i.harness && isShadow(x));
2154
+ const outcome = [...(switched.get(i.harness) || []), ...(shadowed ? [registered.get(i.harness) === true] : [])];
2155
+ const r = applyBinding(p, i, { env, onStep, switched: outcome });
3930
2156
  if (r.failed) done.failed = r.failed;
3931
2157
  return r;
3932
2158
  }
@@ -5482,189 +3708,6 @@ export async function runVerb(verb, projectDir, opts = {}) {
5482
3708
  }
5483
3709
  }
5484
3710
 
5485
- // APPLY, one line per step as it finishes (presentation spec contract 8):
5486
- // the glyph, what was done in the past tense — "added CLAUDE.md", having read
5487
- // "adding CLAUDE.md" while it ran on a terminal — and the duration. An item
5488
- // with steps — a registration, the layout move — prints its own line as it
5489
- // starts ("registering …"), one line per step beneath it — each host
5490
- // command, staging write, write and move — and, once all of them have run,
5491
- // its finished line in the past tense ("registered …") with the item's whole
5492
- // time, so a transcript never ends on a line that reads as still running.
5493
- // No heading: the plan's last line and the question are above it. `now` is
5494
- // the test's clock; `home` shortens paths to ~.
5495
- export function applyReporter(out, c, p, { now = Date.now, home = homedir() } = {}) {
5496
- const paint = painter(c);
5497
- const glyph = (name) => termIcon(c, name);
5498
- const item = stepReporter(out, c, { indent: 2, now });
5499
- const step = stepReporter(out, c, { indent: 6, now });
5500
- const said = (i) => {
5501
- const word = actionWord(i);
5502
- const [running, done] = WORD_TENSES[word] || [word, word];
5503
- const target = rowTarget(p, i, home);
5504
- return { word, running: `${running} ${target}`, done: `${done} ${target}` };
5505
- };
5506
- // The mark too: a skip prints its reason (the project not bound, the switch
5507
- // not done) beneath the line it started.
5508
- // …and the keep, whose $ line and the kept copy's own items print beneath it.
5509
- // …and setup's vault, whose binding, git and scaffold steps print beneath it.
5510
- const stepped = (i) =>
5511
- i.kind === "registration" ||
5512
- i.kind === "layout" ||
5513
- i.kind === "binding" ||
5514
- i.kind === "channel" ||
5515
- i.kind === "vault";
5516
- const began = new Map();
5517
- return {
5518
- onItem(i, phase, r) {
5519
- const s = said(i);
5520
- if (stepped(i)) {
5521
- if (phase === "start") {
5522
- began.set(i, now());
5523
- out.write(` ${paint(WORD_ROLE[s.word], glyph(glyphName(i)))} ${s.running}\n`);
5524
- return;
5525
- }
5526
- const t0 = began.get(i) ?? now();
5527
- began.delete(i);
5528
- if (phase === "abort") step.abort();
5529
- else if (r && (r.action === "skipped" || r.action === "skip"))
5530
- out.write(
5531
- ` ${paint("explanation", glyph("skip"))} ${paint("explanation", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`,
5532
- );
5533
- else if (r && r.failed) out.write(` ${paint("error", glyph("fail"))} ${paint("error", "stopped")}\n`);
5534
- else item.finish(s.done, t0);
5535
- return;
5536
- }
5537
- if (phase === "start") item.start(s.running);
5538
- else if (phase === "abort") item.abort();
5539
- else {
5540
- // An item that failed or was skipped keeps the words it ran under.
5541
- const failed = Boolean(r && (r.failed || r.action === "skipped"));
5542
- item.end(!failed, r && r.action === "skipped" ? "skipped" : "", failed ? {} : { label: s.done });
5543
- }
5544
- },
5545
- onStep(st, phase, r) {
5546
- const l = stepLabel(p, st, { home, glyph });
5547
- if (!l) return;
5548
- if (phase === "start") {
5549
- step.start(l.running);
5550
- return;
5551
- }
5552
- const ok = r ? r.ok : true;
5553
- const kept = Boolean(r && r.kept);
5554
- // A step that failed without stopping the run (setup's git init) is ▲,
5555
- // in its own words.
5556
- if (r && r.warn) {
5557
- step.end(true, "", { warn: true, label: r.warn });
5558
- return;
5559
- }
5560
- // A step that left its target in place did not do its verb; one that
5561
- // says why in its own words (a git init skipped) needs no note.
5562
- const own = r && r.label ? r.label : null;
5563
- step.end(ok, kept && !own ? "kept" : "", ok ? { kept, label: own || (kept ? l.base : l.done) } : {});
5564
- },
5565
- // The wrapped spawn prints each host command as a step. Its `unwrapped`
5566
- // is the caller's own spawn, for an item that reports its commands as its
5567
- // own steps (setup's vault: `$ git init --quiet`).
5568
- spawn(inner) {
5569
- const wrapped = (bin, argv, o) => {
5570
- step.start(`$ ${basename(String(bin))} ${argv.join(" ")}`);
5571
- let r;
5572
- try {
5573
- r = inner(bin, argv, o);
5574
- } catch (e) {
5575
- step.abort();
5576
- throw e;
5577
- }
5578
- step.end(!r.error && r.status === 0);
5579
- return r;
5580
- };
5581
- Object.defineProperty(wrapped, "unwrapped", { value: inner });
5582
- return wrapped;
5583
- },
5584
- };
5585
- }
5586
-
5587
- // A step as its APPLY line names it: the verb and the target, short — the
5588
- // full form, file counts and entry pointers, was in PLAN. `base` is the plain
5589
- // verb, for a step that left its target in place.
5590
- const STEP_TENSES = {
5591
- stage: ["staging", "staged"],
5592
- lift: ["lifting", "lifted"],
5593
- write: ["writing", "wrote"],
5594
- edit: ["editing", "edited"],
5595
- ensure: ["ensuring", "ensured"],
5596
- move: ["moving", "moved"],
5597
- merge: ["merging", "merged"],
5598
- remove: ["removing", "removed"],
5599
- create: ["creating", "created"],
5600
- };
5601
- function stepLabel(p, st, { home = homedir(), glyph = unicodeGlyph } = {}) {
5602
- const r = (x) => tilde(rel(p.projectDir, x), home);
5603
- const to = ` ${glyph("transition")} `;
5604
- const said = (verb, rest) => ({
5605
- base: `${verb} ${rest}`,
5606
- running: `${STEP_TENSES[verb][0]} ${rest}`,
5607
- done: `${STEP_TENSES[verb][1]} ${rest}`,
5608
- });
5609
- switch (st.kind) {
5610
- case "portable-write":
5611
- return said("stage", r(st.path));
5612
- case "recover":
5613
- return said(
5614
- "lift",
5615
- `${r(st.path)} ${glyph("dash")} the previous state proved${st.lifted ? `; it held: ${String(st.lifted).split("\n")[0]}` : ""}`,
5616
- );
5617
- case "write":
5618
- return said("write", r(st.path));
5619
- case "unregister":
5620
- return said("edit", `${r(st.path)} [${st.pointer}.${st.name}]`);
5621
- case "mark":
5622
- case "unmark":
5623
- case "set":
5624
- return said("edit", `${r(st.path)} [${st.key}]`);
5625
- case "ensure":
5626
- return said("ensure", `${r(st.path)}/`);
5627
- // Setup's vault (rule 10): the directory, the binding, git, the scaffold
5628
- // — a scaffold step's paths are the vault's, beneath the vault's line.
5629
- case "mkdir":
5630
- return said("create", `${r(st.path)}/`);
5631
- case "bind":
5632
- return said("write", r(st.path));
5633
- case "git-init": {
5634
- const line = `$ ${st.argv.join(" ")}`;
5635
- return { base: line, running: line, done: line };
5636
- }
5637
- case "scaffold":
5638
- return said("create", st.rels.join(" and "));
5639
- case "move-state":
5640
- return said("move", `${r(st.from)}/${to}${r(st.to)}/`);
5641
- case "merge-log":
5642
- return said("merge", `${r(st.from)}${to}${r(st.to)}`);
5643
- case "move-marker":
5644
- case "move-binding":
5645
- return said("move", `${r(st.from)}${to}${r(st.to)}`);
5646
- case "delete":
5647
- case "portable-remove":
5648
- case "remove":
5649
- case "remove-legacy-launcher":
5650
- case "rmdir-legacy":
5651
- case "remove-legacy-runtime":
5652
- return said("remove", r(st.path));
5653
- case "rmdir-state":
5654
- return said("remove", `${r(st.path)}/`);
5655
- // An item the kept copy applied (the converge story's rule 5), in this
5656
- // run's words for its action; one the preview did not list says so.
5657
- case "kept": {
5658
- const word = ACTION_WORD[st.action] || "update";
5659
- const [running, done] = WORD_TENSES[word] || [word, word];
5660
- const target = `${st.path ? r(st.path) : st.surface}${st.unplanned ? " (not in the preview)" : ""}`;
5661
- return { base: `${word} ${target}`, running: `${running} ${target}`, done: `${done} ${target}` };
5662
- }
5663
- default:
5664
- return null;
5665
- }
5666
- }
5667
-
5668
3711
  // DONE and STOPPED (presentation spec contract 8). DONE: the count and the
5669
3712
  // time; Next, one line per harness — its display name in a column, its
5670
3713
  // manifest's next steps beside it, deduplicated; the facts the run settled
@@ -5757,7 +3800,9 @@ export function renderDone(r, { caps: c = PLAIN, verbose = false, cwd = process.
5757
3800
  // that could not run the keep needs, its own upgrade when its plan printed
5758
3801
  // no result, the npm row's uninstall — each lead, then its command alone on
5759
3802
  // its line (contract 6).
5760
- const kept = p.items.filter((i) => i.kind === "channel" && Array.isArray(i.next)).flatMap((i) => i.next);
3803
+ // An uninstall's registration row carries one too: the directory copy that
3804
+ // loads again (PS-DIRECTORY).
3805
+ const kept = p.items.filter((i) => Array.isArray(i.next)).flatMap((i) => i.next);
5761
3806
  const more = extra?.blocks || [];
5762
3807
  if (next.length || kept.length || more.length) lines.push("", paint("emphasis", "Next"));
5763
3808
  if (next.length) {
@@ -5889,9 +3934,12 @@ export function gateText(r, { verb, harnesses = [], caps: c = PLAIN, args = "" }
5889
3934
  if (why === "dry-run") return "Nothing written: this was a dry run.\n";
5890
3935
  if (why === "channel") {
5891
3936
  const keys = [...new Set(r.plan.items.filter(isSwitch).map((i) => i.entry))];
3937
+ const directory = [...new Set(r.plan.items.filter(isShadow).map((i) => i.entry))];
5892
3938
  return (
5893
3939
  channelText(
5894
- channelChoices({ gate: { args: { harnesses }, keys, version: pinFor(r.plan.root, r.plan.version) } }),
3940
+ channelChoices({
3941
+ gate: { args: { harnesses }, keys, directory, version: pinFor(r.plan.root, r.plan.version) },
3942
+ }),
5895
3943
  c,
5896
3944
  ) + "\n"
5897
3945
  );