projectstore-codex 0.28.2 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,12 +23,14 @@
23
23
  // pure string. confirm() takes its streams as parameters. apply() is the only
24
24
  // function that writes, and it writes only through lib.mjs writeFileAtomic.
25
25
  //
26
- // The gate (contract 9, distribution ADR decision 6): an interactive call
27
- // prints the plan and asks; a non-interactive call that NAMES its harness
28
- // counts as the confirmation; a bare install in a non-TTY refuses. There is
29
- // no --yes flag. --surface narrows the plan (by prefix, so `statusline`
30
- // covers the launcher too); it confirms nothing — except that naming the
31
- // statusline surface is how a user opts into it without the config flag.
26
+ // The gate (contract 9 as amended 2026-10-04, distribution ADR decision 6):
27
+ // the plan is always printed first. A person at a terminal is then asked,
28
+ // even when the harness is named; without one (a pipe, an agent's tool, CI,
29
+ // --json, a host session) a call that NAMES its harness is the confirmation
30
+ // and a bare one refuses. There is no --yes flag. --surface narrows the plan
31
+ // (by prefix, so `statusline` covers the launcher too); it confirms nothing —
32
+ // except that naming the statusline surface is how a user opts into it
33
+ // without the config flag.
32
34
  //
33
35
  // Surface handlers are keyed by the manifest's surfaces.<kind>.format, never
34
36
  // by a harness id: adding a harness is adding harnesses/<id>.json, and this
@@ -56,13 +58,13 @@
56
58
  // (MultiProjectStore); the host-managed report shape is Maxim
57
59
  // Podreshetnikov's (PR #13, installElsewhere). Pure node, no external deps.
58
60
 
59
- import { mkdirSync, unlinkSync, rmdirSync, readdirSync, existsSync, readFileSync, openSync, closeSync, statSync } from "node:fs";
60
- import { join, resolve, dirname, relative, isAbsolute } from "node:path";
61
+ import { mkdirSync, unlinkSync, rmdirSync, readdirSync, existsSync, readFileSync, openSync, closeSync, statSync, realpathSync } from "node:fs";
62
+ import { join, resolve, dirname, relative, isAbsolute, basename } from "node:path";
61
63
  import { homedir } from "node:os";
62
64
  import { randomUUID } from "node:crypto";
63
65
  import { fileURLToPath } from "node:url";
64
- import { createInterface } from "node:readline/promises";
65
66
  import { spawnSync } from "node:child_process";
67
+ import { caps as termCaps, painter, icon as termIcon, duration, stepReporter, wrap, askLine } from "./term.mjs";
66
68
  import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses, harnessRefusal, packageCommand } from "./harness.mjs";
67
69
  import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
68
70
  import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
@@ -141,8 +143,9 @@ function planAgentsBlock(ctx, key, s) {
141
143
  .filter((m) => m.id !== ctx.harness?.id)
142
144
  .some((m) => (m.surfaces?.agents_block?.files || []).includes(file));
143
145
  for (const e of withBlock) {
144
- // Naming the surface IS the confirmation, as it is for every other
145
- // write this bin makes: `--surface agents_block` removes it regardless.
146
+ // Naming the surface removes it regardless: `--surface agents_block`
147
+ // is the confirmation without a terminal, and a terminal is asked
148
+ // (contract 9 as amended 2026-10-04).
146
149
  if (!(ctx.surfaces || []).includes(key) && alsoRead(e.file)) {
147
150
  items.push({ surface: key, kind: "shared", path: e.path, entry: `projectstore:agents v${e.block.v}`, state: "ours-current", action: "skip",
148
151
  reason: `${e.file} is read by another harness too — a per-harness uninstall leaves the project's block alone. Remove it with --surface ${key}` });
@@ -658,7 +661,14 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
658
661
  // Items planned before the render root was known (none today: the registration sorts first) are not re-planned.
659
662
  }
660
663
  if (ctx.incomplete) out.incomplete = true;
661
- if (hostRows.length && !surfaces) out.reports.push(hostManagedReport(harness, hostRows, registration));
664
+ if (hostRows.length && !surfaces) {
665
+ out.reports.push(hostManagedReport(harness, hostRows, registration));
666
+ // The same fact in one line, for the compact preview (contract 18). Not
667
+ // enumerable: the preview reads it, and plan --json keeps the shape it
668
+ // had (the planner's review, 2026-10-05).
669
+ if (!Object.hasOwn(out, "hostManaged")) Object.defineProperty(out, "hostManaged", { value: [], enumerable: false });
670
+ out.hostManaged.push({ harness: harness.id, display: harness.display_name, rows: hostRows, entry: registration?.entry || null, action: registration?.action || null });
671
+ }
662
672
  // An unsupported host surface gets the same row shape a shared one does
663
673
  // (planJsonEntry's unsupported branch): state "unsupported", action "skip",
664
674
  // and the manifest's own reason. One treatment for one fact, so a reader —
@@ -729,12 +739,14 @@ function planLayout(ctx) {
729
739
  return { first, last };
730
740
  }
731
741
 
732
- function applyLayout(p, i, { failed, home = homedir() }) {
742
+ function applyLayout(p, i, { failed, home = homedir(), onStep = null }) {
733
743
  const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
734
744
  const within = p.projectDir;
735
745
  if (i.action === "cleanup" && failed) { out.action = "skipped"; out.reason = "an earlier item failed; the legacy files stay until the next run"; return out; }
736
746
  const fail = (step, message) => { out.failed = { step, status: null, stderr: message }; return out; };
737
747
  for (const st of i.steps || []) {
748
+ const seen = out.steps.length;
749
+ if (onStep) onStep(st, "start");
738
750
  try {
739
751
  if (st.kind === "ensure") { ensureRuntimeDir(within); out.steps.push({ kind: st.kind, ok: true }); }
740
752
  else if (st.kind === "move-state") { const r = moveStateDir(st.from, st.to, within); ensureStateDir(within); out.steps.push({ kind: st.kind, ok: true, ...r }); }
@@ -788,10 +800,18 @@ function applyLayout(p, i, { failed, home = homedir() }) {
788
800
  }
789
801
  else if (st.kind === "remove-legacy-runtime") { removeInside(st.path, within, { recursive: true }); out.steps.push({ kind: st.kind, ok: true, removed: true }); }
790
802
  } catch (e) { return fail(st.kind, e && e.message ? e.message : String(e)); }
803
+ finally { if (onStep) onStep(st, "end", stepResult(out, seen)); }
791
804
  }
792
805
  return out;
793
806
  }
794
807
 
808
+ // What one step left behind, for the APPLY line: its record when it pushed
809
+ // one, and a failure when the item failed under it.
810
+ function stepResult(out, seen) {
811
+ const rec = out.steps.length > seen ? out.steps[out.steps.length - 1] : null;
812
+ return { ok: !out.failed && (!rec || rec.ok !== false), kept: Boolean(rec && rec.removed === false && rec.reason) };
813
+ }
814
+
795
815
  // Does any settings file the host reads run this file as its status line?
796
816
  // Compared by identity (device and inode), so a path spelled through a symlink
797
817
  // or in another case still counts; the project-directory variable a command
@@ -856,96 +876,183 @@ function hostManagedReport(m, rows, registration = null) {
856
876
  const isWrite = (i) => !["skip", "refuse"].includes(i.action);
857
877
 
858
878
  // ─── preview ───────────────────────────────────────────────────────────
879
+ //
880
+ // Contract 18: a header, then PLAN — one line per item (what, where, the state
881
+ // transition) with its steps beneath. Every path written and every host argv
882
+ // with the files it touches stays in the default view: that is contract 9's
883
+ // consent content, and a reader that is an agent needs it as much as a person.
884
+ // The explanations — the host-managed report, per-row reasons, each step's why,
885
+ // the planned-against note — are folded behind `--verbose`. Colour comes from
886
+ // the caller (term.mjs decides); the default is plain text.
887
+
888
+ // Every action a plan item can carry. A write never wears the skip glyph:
889
+ // `add` and `replace-entry` are changes as much as `create` and `update`.
890
+ const ACTION_ICON = { create: "create", add: "create", update: "update", "replace-entry": "update", migrate: "migrate", disable: "update", cleanup: "cleanup", remove: "remove", prune: "remove", skip: "skip", refuse: "refuse" };
891
+ const ACTION_COLOR = { create: "green", add: "green", update: "cyan", "replace-entry": "cyan", migrate: "cyan", disable: "cyan", cleanup: "yellow", remove: "yellow", prune: "yellow", skip: "gray", refuse: "red" };
892
+
893
+ // One step of an item as the lines it shows: always the action and its target,
894
+ // then — under --verbose — why it runs.
895
+ function stepLines(p, st, { verbose, paint, width = 0 }) {
896
+ const r = (x) => rel(p.projectDir, x);
897
+ const lead = " ";
898
+ const sub = " ";
899
+ const why = verbose && st.why ? wrap(st.why, width, sub).split("\n").map((l, k) => (k ? "" : sub) + paint("gray", l)) : [];
900
+ switch (st.kind) {
901
+ case "host": {
902
+ const out = [`${lead}${paint("cyan", "$")} ${[st.bin, ...st.argv].join(" ")}`];
903
+ if (st.touches.length) out.push(`${sub}${paint("gray", `touches ${st.touches.map(r).join(", ")}`)}`);
904
+ return [...out, ...why];
905
+ }
906
+ case "note": return wrap(`note: ${st.why}`, width, sub).split("\n").map((l, k) => (k ? l : lead + paint("yellow", "note:") + l.slice(5)));
907
+ case "write": return [`${lead}write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`, ...why];
908
+ case "portable-write": return [`${lead}stage ${st.path} (${st.files.length} payload files + catalogue + ownership)`, ...why];
909
+ case "portable-remove":
910
+ case "remove": return [`${lead}remove ${st.path}`, ...why];
911
+ case "unregister": return [`${lead}edit ${r(st.path)} [${st.pointer}.${st.name}] → removed`, ...why];
912
+ case "ensure": return [`${lead}ensure ${r(st.path)}/`, ...why];
913
+ case "move-state": return [`${lead}move ${r(st.from)}/ → ${r(st.to)}/ (${st.files} entries)`, ...why];
914
+ case "merge-log": return [`${lead}merge ${r(st.from)} → ${r(st.to)}`, ...why];
915
+ case "delete": return [`${lead}delete ${r(st.path)}`, ...why];
916
+ case "move-marker": return [`${lead}move ${r(st.from)} → ${r(st.to)}`, ...why];
917
+ case "move-binding": return [`${lead}move ${r(st.from)} → ${r(st.to)} (agents → ${r(st.overlay)})`, ...why];
918
+ case "remove-legacy-launcher":
919
+ case "rmdir-legacy":
920
+ case "remove-legacy-runtime": return [`${lead}remove ${r(st.path)}`, ...why];
921
+ default: return [];
922
+ }
923
+ }
924
+
925
+ // The harnesses a plan names, by their display names — for the header.
926
+ function displayNames(p) {
927
+ return p.harnesses.map((id) => loadHarness(id)?.display_name || id).join(", ") || "(no harness)";
928
+ }
929
+
930
+ const tilde = (path) => {
931
+ const h = homedir();
932
+ return path === h || path.startsWith(h + "/") ? "~" + path.slice(h.length) : path;
933
+ };
859
934
 
860
- export function renderPreview(p) {
861
- const lines = [`projectstore ${p.mode} — ${p.harnesses.join(", ") || "(no harness)"} — ${p.projectDir}`, ""];
862
- for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(` ${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`, "");
863
- for (const r of p.reports) lines.push(...r.split("\n").map((l) => " " + l), "");
935
+ export function renderPreview(p, { verbose = false, verb = null, paint = (_style, text) => String(text), icon = null, width = 0 } = {}) {
936
+ const glyph = icon || ((name) => ({ create: "+", update: "↻", migrate: "↻", cleanup: "✕", remove: "✕", skip: "·", refuse: "!" }[name] || "·"));
864
937
  const writes = p.items.filter(isWrite);
938
+ const lines = [
939
+ `${paint("bold", "projectstore")} · ${verb || p.mode} · ${displayNames(p)}`,
940
+ ` ${paint("gray", tilde(p.projectDir))}`,
941
+ "",
942
+ ];
943
+ // Prose, wrapped to the terminal with every line indented; a path or a
944
+ // command inside it is one word and never broken (term.mjs wrap).
945
+ const prose = (indent, text, style = "gray") => wrap(text, width, indent).split("\n").map((l, k) => (k ? "" : indent) + paint(style, l));
946
+ if (verbose) {
947
+ for (const [h, r] of Object.entries(p.plannedAgainst || {})) lines.push(...prose(" ", `${h}: the surfaces below are planned against the host's install path ${r}, not this package at ${p.root}.`), "");
948
+ for (const r of p.reports) lines.push(...r.split("\n").flatMap((l) => (l.trim() ? prose(" ", l) : [""])), "");
949
+ }
950
+ const count = writes.length === 1 ? "1 change" : `${writes.length} changes`;
951
+ lines.push(`${paint("bold", "PLAN")} — ${p.ok ? count : "refused"}`);
952
+ // Unsupported host rows (no path, nothing this harness has) fold into one
953
+ // line per harness by default; --verbose lists each with the manifest's reason.
954
+ const folded = new Map();
865
955
  for (const i of p.items) {
866
- const target = i.path === null
867
- ? `harness=${i.harness} surface=${i.surface} [no filesystem path]`
868
- : rel(p.projectDir, i.path);
956
+ if (!verbose && i.path === null && i.state === "unsupported" && i.action === "skip") {
957
+ const k = loadHarness(i.harness)?.display_name || i.harness;
958
+ if (!folded.has(k)) folded.set(k, []);
959
+ folded.get(k).push(i.surface);
960
+ continue;
961
+ }
962
+ const target = i.path === null ? `${i.surface} [${i.harness}, no filesystem path]` : rel(p.projectDir, i.path);
869
963
  const where = target + (i.entry ? ` [${i.entry}]` : "");
870
964
  let state = i.state;
871
965
  if (i.state === "current" && i.writtenBy && !i.sameProject) state = `current, last written by ${i.writtenBy}`;
966
+ // A row's reason is shown whenever it has one: a row of something
967
+ // already right carries none, and every other reason — a change, a skip
968
+ // that needs a terminal or a PATH, a block kept for another harness —
969
+ // is something the reader acts on (the reviewer's pass, 2026-10-05).
872
970
  if (i.reason && i.action !== "refuse") state += ` (${i.reason})`;
873
- lines.push(` ${i.kind.padEnd(9)} ${where}`);
874
- lines.push(` ${state.padEnd(44)} → ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`);
875
- for (const st of i.steps || []) {
876
- if (st.kind === "host") lines.push(` $ ${[st.bin, ...st.argv].join(" ")}`, ` ${st.why}${st.touches.length ? `; touches ${st.touches.map((t) => rel(p.projectDir, t)).join(", ")}` : ""}`);
877
- else if (st.kind === "write") lines.push(` write ${st.path}${st.manifestOnly ? " (manifest only)" : ` (${st.files} files + the manifest)`}`, ` ${st.why}`);
878
- else if (st.kind === "portable-write") lines.push(` stage ${st.path} (${st.files.length} payload files + catalogue + ownership)`, ` ${st.why}`);
879
- else if (st.kind === "portable-remove") lines.push(` remove ${st.path}`, ` ${st.why}`);
880
- else if (st.kind === "remove") lines.push(` remove ${st.path}`, ` ${st.why}`);
881
- else if (st.kind === "unregister") lines.push(` edit ${rel(p.projectDir, st.path)} [${st.pointer}.${st.name}] → removed`, ` ${st.why}`);
882
- else if (st.kind === "note") lines.push(` note: ${st.why}`);
883
- else if (st.kind === "ensure") lines.push(` ensure ${rel(p.projectDir, st.path)}/`, ` ${st.why}`);
884
- else if (st.kind === "move-state") lines.push(` move ${rel(p.projectDir, st.from)}/ → ${rel(p.projectDir, st.to)}/ (${st.files} entries)`, ` ${st.why}`);
885
- else if (st.kind === "merge-log") lines.push(` merge ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)}`, ` ${st.why}`);
886
- else if (st.kind === "delete") lines.push(` delete ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
887
- else if (st.kind === "move-marker") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)}`, ` ${st.why}`);
888
- else if (st.kind === "move-binding") lines.push(` move ${rel(p.projectDir, st.from)} → ${rel(p.projectDir, st.to)} (agents → ${rel(p.projectDir, st.overlay)})`, ` ${st.why}`);
889
- else if (st.kind === "remove-legacy-launcher" || st.kind === "rmdir-legacy" || st.kind === "remove-legacy-runtime") lines.push(` remove ${rel(p.projectDir, st.path)}`, ` ${st.why}`);
890
- }
891
- if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` (harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`);
892
- if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` (the file would hold nothing else and is removed)`);
971
+ const color = ACTION_COLOR[i.action] || (isWrite(i) ? "cyan" : "gray");
972
+ const mark = paint(color, glyph(ACTION_ICON[i.action] || (isWrite(i) ? "update" : "skip")));
973
+ const transition = `${state} → ${i.action}${i.action === "refuse" && i.reason ? ": " + i.reason : ""}`;
974
+ lines.push(` ${mark} ${paint("bold", i.kind.padEnd(12))} ${where}`);
975
+ lines.push(...prose(" ", transition));
976
+ for (const st of i.steps || []) lines.push(...stepLines(p, st, { verbose, paint, width }));
977
+ if (i.kind === "registration" && i.home && i.surface && !i.surface.endsWith("_others")) lines.push(` ${paint("gray", `(harness home ${i.home}${i.scope ? `, scope ${i.scope}` : ""})`)}`);
978
+ if (i.deleteIfEmpty && typeof i.after === "string" && !i.after.trim()) lines.push(` ${paint("gray", "(the file would hold nothing else and is removed)")}`);
979
+ }
980
+ const hostLine = (text) => { const [first, ...rest] = wrap(text, width, " ".repeat(17)).split("\n"); return [` ${paint("gray", glyph("skip"))} ${paint("bold", "host".padEnd(12))} ${first}`, ...rest]; };
981
+ for (const h of p.hostManaged || []) {
982
+ const from = h.entry ? `from the registration ${h.entry}` : "from the host's own plugin system";
983
+ lines.push(...hostLine(`${h.display} installs ${h.rows.join(", ")} itself, ${from}`));
893
984
  }
985
+ for (const [display, rows] of folded) lines.push(...hostLine(`not on ${display}: ${rows.join(", ")} — unsupported → skip ${paint("gray", "(--verbose says why)")}`));
894
986
  const exclusiveRemoval = p.items.find((i) => i.action === "remove" && i.kind === "exclusive");
895
- if (exclusiveRemoval) lines.push(` (an emptied ${rel(p.projectDir, dirname(exclusiveRemoval.path))}/ is pruned)`);
896
- for (const r of p.refusals) lines.push(` refused ${r}`);
897
- lines.push("", " Nothing outside a marked entry is read, rewritten or removed.");
898
- if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push(" Each $ line runs the host's own CLI, which writes the host-owned files named after it.");
899
- if (!p.ok) lines.push("", " Nothing will be written: resolve the refusals above first.");
987
+ if (exclusiveRemoval) lines.push(` ${paint("gray", `(an emptied ${rel(p.projectDir, dirname(exclusiveRemoval.path))}/ is pruned)`)}`);
988
+ for (const r of p.refusals) { const [first, ...rest] = wrap(r, width, " ".repeat(17)).split("\n"); lines.push(` ${paint("red", glyph("refuse"))} ${paint("bold", "refused".padEnd(12))} ${first}`, ...rest); }
989
+ lines.push("", ` ${paint("gray", "Nothing outside a marked entry is read, rewritten or removed.")}`);
990
+ if (p.items.some((i) => (i.steps || []).some((s) => s.kind === "host"))) lines.push(` ${paint("gray", "Each $ line runs the host's own CLI, which writes the host-owned files named after it.")}`);
991
+ if (!p.ok) lines.push("", ` ${paint("red", "Nothing will be written: resolve the refusals above first.")}`);
900
992
  else if (!writes.length) lines.push("", " Nothing to change." + (p.incomplete ? " One surface could not be planned (see above)." : ""));
901
- else lines.push("", ` ${writes.length} change(s) to apply.${p.incomplete ? " One surface could not be planned (see above); the rest proceeds." : ""}`);
993
+ else if (p.incomplete) lines.push("", " One surface could not be planned (see above); the rest proceeds.");
902
994
  return lines.join("\n") + "\n";
903
995
  }
904
996
 
905
997
  // ─── gate ──────────────────────────────────────────────────────────────
906
998
 
907
- // A named harness is the explicit confirmation (contract 9). Otherwise ask on
908
- // a TTY, and refuse without one. Streams are parameters so the TTY branch is
909
- // testable without a pseudo-terminal.
910
- export async function confirm(p, { stdin = process.stdin, stdout = process.stdout, ask = null } = {}) {
999
+ // Contract 9, amended 2026-10-04: a person at a terminal is asked, even when
1000
+ // the harness is named — the shells always name it, so naming alone had
1001
+ // stopped meaning a person agreed. Without one (a pipe, an agent's tool call,
1002
+ // CI, --json, a command run inside a host session) a named harness is the
1003
+ // confirmation and a bare one refuses, exactly as before.
1004
+ export function isInteractive({ stdin = null, stdout = null, env = process.env, json = false } = {}) {
1005
+ if (json) return false;
1006
+ if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return false;
1007
+ if (env.CI && !["0", "false"].includes(String(env.CI).toLowerCase())) return false;
1008
+ // A host session's own tool may run us in a pseudo-terminal; the manifests'
1009
+ // session markers say when that is happening — every manifest's, not only
1010
+ // the planned harness's: a Claude Code agent running the Codex shell is
1011
+ // still an agent. (Codex's own markers are unmeasured; for it the TTY test
1012
+ // above carries the rule.)
1013
+ if ([...loadHarnesses().values()].some((m) => insideHostSession(env, m))) return false;
1014
+ return true;
1015
+ }
1016
+
1017
+ // Streams and `ask` are parameters so the terminal branch is testable without
1018
+ // a pseudo-terminal; passing `ask` means "this is a terminal". Without streams
1019
+ // the library never asks — the bin and main() pass theirs.
1020
+ export async function confirm(p, { stdin = null, stdout = null, ask = null, env = process.env, json = false, paint = (_style, text) => String(text) } = {}) {
911
1021
  if (!p.ok) return { confirmed: false, why: "refused" };
912
1022
  const writes = p.items.filter(isWrite);
913
1023
  if (!writes.length) return { confirmed: false, why: "nothing-to-do" };
914
- if (p.named) return { confirmed: true, why: "named" };
915
- const interactive = Boolean(stdin && stdin.isTTY && stdout && stdout.isTTY);
916
- if (!interactive && !ask) return { confirmed: false, why: "non-tty" };
917
- const answer = ask ? await ask(`Apply these ${writes.length} change(s)? [y/N] `) : await (async () => {
918
- const rl = createInterface({ input: stdin, output: stdout });
919
- try { return await rl.question(`Apply these ${writes.length} change(s)? [y/N] `); } finally { rl.close(); }
920
- })();
921
- return /^y(es)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
1024
+ const interactive = !json && (ask ? true : isInteractive({ stdin, stdout, env, json }));
1025
+ if (!interactive) return p.named ? { confirmed: true, why: "named" } : { confirmed: false, why: "non-tty" };
1026
+ const question = `${paint("bold", `Apply ${writes.length === 1 ? "1 change" : `${writes.length} changes`}?`)} ${paint("gray", "[Y/n]")} `;
1027
+ const answer = ask ? await ask(question) : await askLine(question, stdin, stdout);
1028
+ if (answer === null || answer === undefined) return { confirmed: false, why: "declined" };
1029
+ return /^(y(es)?)?$/i.test(String(answer).trim()) ? { confirmed: true, why: "answered" } : { confirmed: false, why: "declined" };
922
1030
  }
923
1031
 
924
1032
  // ─── apply ─────────────────────────────────────────────────────────────
925
1033
 
926
- export function apply(p, { env = process.env, spawn = spawnSync, home = homedir() } = {}) {
1034
+ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(), onItem = null, onStep = null } = {}) {
927
1035
  if (!p.ok) throw new Error("apply: the plan carries refusals; nothing is written");
928
1036
  const done = [];
929
1037
  let registrationFailed = false;
930
1038
  let layoutFailed = false;
931
- for (const i of p.items) {
932
- if (!isWrite(i)) continue;
1039
+ // One item's writes, returning the record apply reports for it. The order
1040
+ // and the failure rules are the loop's; onItem only watches (contract 18).
1041
+ const one = (i) => {
933
1042
  if (i.kind === "layout") {
934
- const r = applyLayout(p, i, { failed: layoutFailed || registrationFailed || Boolean(done.failed), home });
935
- done.push(r);
1043
+ const r = applyLayout(p, i, { failed: layoutFailed || registrationFailed || Boolean(done.failed), home, onStep });
936
1044
  if (r.failed) { done.failed = r.failed; layoutFailed = true; }
937
- continue;
1045
+ return r;
938
1046
  }
939
1047
  if (i.kind === "registration") {
940
- const r = applyRegistration(p, i, { env, spawn, home });
941
- done.push(r);
1048
+ const r = applyRegistration(p, i, { env, spawn, home, onStep });
942
1049
  // A registration that did not complete leaves the surfaces planned against
943
1050
  // its install path unwritten: a launcher pointing at nothing is worse than
944
1051
  // none. Surfaces rendered from the package root (the block) still apply.
945
1052
  if (r.failed) { done.failed = r.failed; registrationFailed = true; }
946
- continue;
1053
+ return r;
947
1054
  }
948
- if (registrationFailed && i.plannedAgainst) { done.push({ path: i.path, action: "skipped", surface: i.surface, reason: "the registration did not complete; this surface was planned against its install path" }); continue; }
1055
+ if (registrationFailed && i.plannedAgainst) return { path: i.path, action: "skipped", surface: i.surface, reason: "the registration did not complete; this surface was planned against its install path" };
949
1056
  if (i.kind === "shared" && typeof i.after === "object" && i.after !== null && !Array.isArray(i.after)) {
950
1057
  mkdirSync(dirname(i.path), { recursive: true });
951
1058
  // Re-read at write time: a host command run earlier in this apply (the
@@ -970,7 +1077,16 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
970
1077
  mkdirSync(dirname(i.path), { recursive: true });
971
1078
  writeFileAtomic(i.path, i.after, { sweep: false });
972
1079
  }
973
- done.push({ path: i.path, action: i.action, surface: i.surface });
1080
+ return { path: i.path, action: i.action, surface: i.surface };
1081
+ };
1082
+ for (const i of p.items) {
1083
+ if (!isWrite(i)) continue;
1084
+ if (onItem) onItem(i, "start");
1085
+ let r;
1086
+ try { r = one(i); }
1087
+ catch (e) { if (onItem) onItem(i, "abort"); throw e; }
1088
+ done.push(r);
1089
+ if (onItem) onItem(i, "end", r);
974
1090
  }
975
1091
  return done;
976
1092
  }
@@ -980,7 +1096,7 @@ export function apply(p, { env = process.env, spawn = spawnSync, home = homedir(
980
1096
  // same home the plan was read from — and with the project as its cwd, which is
981
1097
  // how the host resolves `--scope local`. A non-zero exit stops the item and
982
1098
  // is recorded, never retried, never masked.
983
- function applyRegistration(p, i, { env, spawn, home }) {
1099
+ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
984
1100
  const out = { path: i.path, action: i.action, surface: i.surface, steps: [] };
985
1101
  const harness = loadHarness(i.harness);
986
1102
  const childEnv = { ...env, [homeEnvName(i.harness)]: i.home || claudeHome(home) };
@@ -1187,6 +1303,11 @@ function applyRegistration(p, i, { env, spawn, home }) {
1187
1303
  return out;
1188
1304
  };
1189
1305
  for (const st of i.steps || []) {
1306
+ // Host commands report through the spawn the caller passed (one line per
1307
+ // argv, rollbacks included); the filesystem steps report here.
1308
+ const seen = out.steps.length;
1309
+ const watched = onStep && st.kind !== "host";
1310
+ if (watched) onStep(st, "start");
1190
1311
  try {
1191
1312
  if (st.kind === "portable-write") {
1192
1313
  const token = `${process.pid}-${randomUUID()}`;
@@ -1242,6 +1363,8 @@ function applyRegistration(p, i, { env, spawn, home }) {
1242
1363
  }
1243
1364
  } catch (e) {
1244
1365
  return fail(st.kind, null, e && e.message ? e.message : String(e));
1366
+ } finally {
1367
+ if (watched) onStep(st, "end", stepResult(out, seen));
1245
1368
  }
1246
1369
  }
1247
1370
  // The host's registry is read back: the install path the rest of the plan
@@ -1298,24 +1421,151 @@ function pruneEmptyDir(dir, projectDir) {
1298
1421
  export async function runVerb(verb, projectDir, opts = {}) {
1299
1422
  const mode = verb === "uninstall" ? "uninstall" : "install"; // upgrade is install re-run (contract 14)
1300
1423
  const p = plan(projectDir, { ...opts, mode });
1301
- const preview = renderPreview(p);
1302
- const gate = await confirm(p, opts);
1303
- const result = { verb, plan: p, preview, gate, applied: [], failed: null };
1424
+ const env = opts.env || process.env;
1425
+ // `out` is the text-mode caller's stdout. With it, this prints: the plan
1426
+ // first, then the question, then each step as it runs, then DONE.
1427
+ const out = opts.out || null;
1428
+ const c = out ? termCaps(out, env) : null;
1429
+ const paint = c ? painter(c) : (_style, text) => String(text);
1430
+ const glyph = c ? (name) => termIcon(c, name) : null;
1431
+ const preview = renderPreview(p, { verbose: Boolean(opts.verbose), verb, paint, icon: glyph, width: c && c.live ? c.width : 0 });
1432
+ if (out) out.write(preview + "\n");
1433
+ const gate = await confirm(p, { ...opts, env, paint });
1434
+ const result = { verb, plan: p, preview, gate, applied: [], failed: null, elapsed: 0 };
1304
1435
  if (gate.confirmed) {
1305
- result.applied = apply(p, { env: opts.env || process.env, spawn: opts.spawn || spawnSync, home: opts.home || homedir() });
1436
+ const t0 = Date.now();
1437
+ const reporter = out ? applyReporter(out, c, p) : null;
1438
+ const spawn = opts.spawn || spawnSync;
1439
+ result.applied = apply(p, { env, spawn: reporter ? reporter.spawn(spawn) : spawn, home: opts.home || homedir(), onItem: reporter ? reporter.onItem : null, onStep: reporter ? reporter.onStep : null });
1306
1440
  result.failed = result.applied.failed || null;
1441
+ result.elapsed = Date.now() - t0;
1442
+ if (out) out.write(renderDone(result, { paint, glyph: glyph || undefined, verbose: Boolean(opts.verbose) }));
1307
1443
  }
1308
1444
  return result;
1309
1445
  }
1310
1446
 
1447
+ // APPLY, one line per step (contract 18). An item with steps — a
1448
+ // registration, the layout move — prints its name, then one line per step
1449
+ // beneath it: each host command, staging write, write and move. Any other
1450
+ // item is one line.
1451
+ function applyReporter(out, c, p) {
1452
+ const paint = painter(c);
1453
+ const item = stepReporter(out, c, { indent: 2 });
1454
+ const step = stepReporter(out, c, { indent: 6 });
1455
+ let opened = false;
1456
+ const open = () => { if (!opened) { out.write(`${paint("bold", "APPLY")}\n`); opened = true; } };
1457
+ const label = (i) => {
1458
+ const target = i.path === null ? i.surface : rel(p.projectDir, i.path);
1459
+ return `${i.kind} ${target}${i.entry ? ` [${i.entry}]` : ""}`;
1460
+ };
1461
+ const stepped = (i) => i.kind === "registration" || i.kind === "layout";
1462
+ return {
1463
+ onItem(i, phase, r) {
1464
+ open();
1465
+ if (stepped(i)) {
1466
+ if (phase === "start") out.write(` ${paint(ACTION_COLOR[i.action] || "cyan", termIcon(c, ACTION_ICON[i.action] || "update"))} ${label(i)}\n`);
1467
+ else if (phase === "abort") step.abort();
1468
+ else if (r && (r.action === "skipped" || r.action === "skip")) out.write(` ${paint("gray", termIcon(c, "skip"))} ${paint("gray", `${r.action === "skip" ? "nothing to do" : "skipped"}: ${r.reason || "an earlier item failed"}`)}\n`);
1469
+ else if (r && r.failed) out.write(` ${paint("red", termIcon(c, "fail"))} ${paint("red", "stopped")}\n`);
1470
+ return;
1471
+ }
1472
+ if (phase === "start") item.start(label(i));
1473
+ else if (phase === "abort") item.abort();
1474
+ else item.end(!(r && (r.failed || r.action === "skipped")), r && r.action === "skipped" ? "skipped" : "");
1475
+ },
1476
+ onStep(st, phase, r) {
1477
+ const text = stepLabel(p, st);
1478
+ if (!text) return;
1479
+ if (phase === "start") step.start(text);
1480
+ else step.end(r ? r.ok : true, r && r.kept ? "kept" : "", { kept: Boolean(r && r.kept) });
1481
+ },
1482
+ spawn(inner) {
1483
+ return (bin, argv, o) => {
1484
+ open();
1485
+ step.start(`$ ${basename(String(bin))} ${argv.join(" ")}`);
1486
+ let r;
1487
+ try { r = inner(bin, argv, o); }
1488
+ catch (e) { step.abort(); throw e; }
1489
+ step.end(!r.error && r.status === 0);
1490
+ return r;
1491
+ };
1492
+ },
1493
+ };
1494
+ }
1495
+
1496
+ // A step as its APPLY line names it: the verb and the target, short. The
1497
+ // full form — file counts, entry pointers — was in PLAN.
1498
+ function stepLabel(p, st) {
1499
+ const r = (x) => tilde(rel(p.projectDir, x));
1500
+ switch (st.kind) {
1501
+ case "portable-write": return `stage ${r(st.path)}`;
1502
+ case "write": return `write ${r(st.path)}`;
1503
+ case "unregister": return `edit ${r(st.path)} [${st.pointer}.${st.name}]`;
1504
+ case "ensure": return `ensure ${r(st.path)}/`;
1505
+ case "move-state": return `move ${r(st.from)}/ → ${r(st.to)}/`;
1506
+ case "merge-log": return `merge ${r(st.from)} → ${r(st.to)}`;
1507
+ case "move-marker":
1508
+ case "move-binding": return `move ${r(st.from)} → ${r(st.to)}`;
1509
+ case "delete":
1510
+ case "portable-remove":
1511
+ case "remove":
1512
+ case "remove-legacy-launcher":
1513
+ case "rmdir-legacy":
1514
+ case "remove-legacy-runtime": return `remove ${r(st.path)}`;
1515
+ default: return null;
1516
+ }
1517
+ }
1518
+
1519
+ // DONE: what happened, how long it took, what to do next — from the manifests,
1520
+ // never from a harness-id branch — and at most two tips.
1521
+ export function renderDone(r, { paint = (_style, text) => String(text), glyph = (n) => termIcon({ ascii: false }, n), verbose = false } = {}) {
1522
+ const p = r.plan;
1523
+ // What applied: a record that neither failed nor was skipped — a recheck
1524
+ // that found the work already done is not a change.
1525
+ const n = r.applied.filter((a) => !a.failed && !["skipped", "skip"].includes(a.action)).length;
1526
+ const changes = n === 1 ? "1 change" : `${n} changes`;
1527
+ const lines = [""];
1528
+ if (r.failed) {
1529
+ const f = r.failed;
1530
+ const record = r.applied.find((a) => a.failed);
1531
+ const item = record ? p.items.find((i) => i.surface === record.surface && i.path === record.path) : null;
1532
+ const what = f.argv ? "a host command failed" : `${f.step} failed`;
1533
+ lines.push(`${paint("bold", "STOPPED")} — ${changes} applied, then ${what}`);
1534
+ if (f.argv) lines.push(` ${paint("red", glyph("fail"))} $ ${f.argv.join(" ")}${f.status === null || f.status === undefined ? "" : ` exited ${f.status}`}`);
1535
+ else lines.push(` ${paint("red", glyph("fail"))} ${f.step}${item ? ` (${item.kind} ${item.path === null ? item.surface : rel(p.projectDir, item.path)})` : ""}`);
1536
+ if (f.stderr) for (const l of String(f.stderr).split("\n")) lines.push(` ${l}`);
1537
+ if (item && item.kind === "registration") lines.push(" The surfaces planned against its install path were not written; run the verb again once it succeeds.");
1538
+ else lines.push(" Run the verb again once the cause is fixed: its plan starts from what is on disk now.");
1539
+ return lines.join("\n") + "\n";
1540
+ }
1541
+ lines.push(`${paint("bold", "DONE")} — ${changes} in ${duration(r.elapsed || 0)}`);
1542
+ const next = [], tips = [];
1543
+ const here = (() => { try { return realpathSync(p.projectDir) === realpathSync(process.cwd()); } catch { return p.projectDir === process.cwd(); } })();
1544
+ for (const id of p.harnesses) {
1545
+ const m = loadHarness(id);
1546
+ if (!m) continue;
1547
+ const steps = r.verb === "uninstall" ? [`restart ${m.display_name}`] : (m.install?.next || []);
1548
+ for (const s of steps) if (!next.includes(s)) next.push(s);
1549
+ // `plan` previews an install: after an uninstall it would preview the
1550
+ // opposite of what just ran.
1551
+ if (r.verb === "uninstall") continue;
1552
+ const preview = packageCommand(m, "plan", { args: `--project ${here ? '"$PWD"' : `"${p.projectDir}"`}` });
1553
+ if (!tips.some((t) => t.includes(preview))) tips.push(`preview without writing: ${preview}`);
1554
+ }
1555
+ if (!verbose) tips.push("every row's reasoning: add --verbose");
1556
+ for (const s of next) lines.push(` ${paint("cyan", "next")} ${s}`);
1557
+ for (const t of tips.slice(0, 2)) lines.push(` ${paint("gray", "tip")} ${paint("gray", t)}`);
1558
+ return lines.join("\n") + "\n";
1559
+ }
1560
+
1311
1561
  // ─── main ──────────────────────────────────────────────────────────────
1312
1562
 
1313
1563
  function usage() {
1314
1564
  return [
1315
- "usage: install-harness.mjs <install|uninstall|upgrade|plan> [--harness <id>]... [--surface <key>]... [--project <dir>] [--global] [--no-register] [--json]",
1565
+ "usage: install-harness.mjs <install|uninstall|upgrade|plan> [--harness <id>]... [--surface <key>]... [--project <dir>] [--global] [--no-register] [--verbose] [--json]",
1316
1566
  ` harnesses: ${harnessIds().join(", ")}`,
1317
1567
  " --surface narrows the plan to a surface and the surfaces beneath it (statusline covers statusline_launcher)",
1318
- " --harness names the harness — and, non-interactively, is the confirmation; there is no --yes",
1568
+ " --harness names the harness — and, without a terminal, is the confirmation (a terminal is asked); there is no --yes",
1319
1569
  ].join("\n");
1320
1570
  }
1321
1571
 
@@ -1344,7 +1594,7 @@ async function main() {
1344
1594
  const verb = argv[0];
1345
1595
  if (!["install", "uninstall", "upgrade", "plan"].includes(verb)) { process.stderr.write(usage() + "\n"); process.exit(2); }
1346
1596
  const harnesses = [], surfaces = [];
1347
- let projectDir = null, json = false, globalRemoval = false, register = true;
1597
+ let projectDir = null, json = false, globalRemoval = false, register = true, verbose = false;
1348
1598
  for (let i = 1; i < argv.length; i++) {
1349
1599
  const a = argv[i];
1350
1600
  const value = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) { process.stderr.write(`${a} needs a value\n${usage()}\n`); process.exit(2); } return v; };
@@ -1354,34 +1604,27 @@ async function main() {
1354
1604
  else if (a === "--global") globalRemoval = true;
1355
1605
  else if (a === "--no-register" && verb !== "uninstall") register = false;
1356
1606
  else if (a === "--json") json = true;
1607
+ else if (a === "--verbose") verbose = true;
1357
1608
  else { process.stderr.write(`unknown argument ${a}\n${usage()}\n`); process.exit(2); }
1358
1609
  }
1359
1610
  const src = sourceHarness();
1360
1611
  projectDir = resolve(projectDir || (src && process.env[src.runtime?.project_dir_env]) || process.cwd());
1361
- const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register };
1612
+ const opts = { harnesses, surfaces: surfaces.length ? surfaces : null, globalRemoval, register, json, verbose, stdin: process.stdin, stdout: process.stdout };
1362
1613
  if (verb === "plan") {
1363
1614
  const p = plan(projectDir, opts);
1364
- process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p));
1615
+ const c = termCaps(process.stdout, process.env);
1616
+ process.stdout.write(json ? JSON.stringify({ ...p, items: p.items.map(publicItem) }, null, 2) + "\n" : renderPreview(p, { verbose, verb, paint: painter(c), icon: (n) => termIcon(c, n), width: c.live ? c.width : 0 }));
1365
1617
  process.exit(p.ok && !p.incomplete ? 0 : 1);
1366
1618
  }
1367
- const r = await runVerb(verb, projectDir, opts);
1619
+ const r = await runVerb(verb, projectDir, json ? opts : { ...opts, out: process.stdout });
1368
1620
  if (json) {
1369
1621
  process.stdout.write(JSON.stringify({ verb, ok: r.plan.ok && !r.plan.incomplete && !r.failed, gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, items: r.plan.items.map(publicItem), refusals: r.plan.refusals, reports: r.plan.reports }, null, 2) + "\n");
1370
- } else {
1371
- process.stdout.write(r.preview);
1372
- if (r.gate.confirmed) process.stdout.write(appliedLine(r));
1373
- else if (r.gate.why === "non-tty") process.stdout.write(`a bare ${verb} in a non-TTY refuses; name a harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
1374
- else if (r.gate.why === "declined") process.stdout.write("nothing written.\n");
1622
+ } else if (r.gate.why === "non-tty") {
1623
+ process.stdout.write(`Nothing written: without a terminal, a bare ${verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
1624
+ } else if (r.gate.why === "declined") {
1625
+ process.stdout.write("Nothing written.\n");
1375
1626
  }
1376
1627
  process.exit(r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do") ? 0 : 1);
1377
1628
  }
1378
1629
 
1379
- // What apply did, for a terminal: the count, and a failed host command with
1380
- // its stderr — the user sees what the host said, verbatim.
1381
- export function appliedLine(r) {
1382
- let s = `applied ${r.applied.length} change(s).\n`;
1383
- if (r.failed) s += `stopped: ${r.failed.argv ? "$ " + r.failed.argv.join(" ") : r.failed.step} exited ${r.failed.status ?? "without running"}${r.failed.stderr ? "\n " + r.failed.stderr.split("\n").join("\n ") : ""}\n the surfaces planned against its install path were not written; run the verb again once it succeeds.\n`;
1384
- return s;
1385
- }
1386
-
1387
1630
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main();