projectstore-claude 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.
package/README.md CHANGED
@@ -6,7 +6,9 @@ The Claude Code installer for [projectstore](https://www.npmjs.com/package/proje
6
6
  npx projectstore-claude install --project "$PWD"
7
7
  ```
8
8
 
9
- It registers the plugin for that checkout at the host's local scope, previews every write and every host command before it runs, and asks for nothing else — naming the shell is the confirmation. Restart Claude Code afterwards.
9
+ It registers the plugin for that checkout at the host's local scope. It prints its plan first — every write and every host command, verbatim — and at a terminal asks `Apply N changes? [Y/n]` before anything runs; then each step as it runs, and what to do next. Without a terminal (a script, CI, an agent's tool) naming the shell is the confirmation; `--json` never asks. Restart Claude Code afterwards.
10
+
11
+ - See before you write: `npx projectstore-claude plan --project "$PWD"` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples.
10
12
 
11
13
  - Upgrade, or pin: `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run.
12
14
  - Uninstall: `npx projectstore-claude uninstall --project "$PWD"` — forgets the registration for that checkout and nothing else; your vault is plain markdown and stays yours.
@@ -8,8 +8,10 @@
8
8
  // `--harness claude-code` inserted after a verb that takes it. Every other
9
9
  // argument passes through, so `projectstore-claude <verb> …` is exactly
10
10
  // `projectstore <verb> --harness claude-code …` — the same preview, the
11
- // same files, the same exit code. Naming the shell is the confirmation the
12
- // core's install gate asks for, exactly as naming --harness is.
11
+ // same files, the same exit code. Naming the shell names the harness, exactly
12
+ // as --harness does: without a terminal that is the confirmation the core's
13
+ // install gate asks for; at a terminal the core shows the plan and asks (the
14
+ // install spec, contract 9 as amended 2026-10-04).
13
15
  import { existsSync } from "node:fs";
14
16
  import { spawnSync } from "node:child_process";
15
17
  import { constants as osConstants } from "node:os";
@@ -72,17 +74,20 @@ if (!core) {
72
74
  process.stderr.write(`${SHELL}: ${fixed.error}\n`);
73
75
  process.exitCode = 2;
74
76
  } else {
75
- // stdio inherited: the core's install gate asks on a terminal and refuses
76
- // without one, so the child must see the real stdin and stdout. No
77
- // timeout — the child waits on a human at the preview. exitCode, not
78
- // exit(): the core's own bin says why (a pending write on a pipe).
77
+ // stdio inherited: the core's install gate shows the plan and asks on a
78
+ // terminal, and refuses a bare run without one, so the child must see the
79
+ // real stdin and stdout. No timeout — the child waits on a human at the
80
+ // question. exitCode, not exit(): the core's own bin says why (a pending
81
+ // write on a pipe). PROJECTSTORE_SHELL lets the core's --help speak this
82
+ // shell's name; nothing the core plans or writes reads it.
79
83
  const r = spawnSync(process.execPath, [core, ...fixed.argv], {
80
84
  stdio: "inherit",
81
- env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root },
85
+ env: { ...process.env, PROJECTSTORE_DISTRIBUTION_ROOT: root, PROJECTSTORE_SHELL: SHELL },
82
86
  });
83
87
  if (r.error) process.stderr.write(`${SHELL}: ${r.error.message}\n`);
84
- // A signal is relayed the shell way (128 + its number): Ctrl-C at the
85
- // preview is 130 here as it would be on the core itself.
88
+ // A signal is relayed the shell way (128 + its number): the core reads
89
+ // its answer in the terminal's line mode, so Ctrl-C at the question is a
90
+ // real SIGINT — 130 here as it would be on the core itself.
86
91
  process.exitCode = r.status ?? (r.signal ? 128 + (osConstants.signals[r.signal] || 0) : 2);
87
92
  }
88
93
  }
@@ -12,7 +12,7 @@
12
12
  "name": "projectstore",
13
13
  "displayName": "projectstore",
14
14
  "description": "📚 Your agent runs the project through a verified loop: task → artifact (ADR · spec · epic · story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in an Obsidian-friendly vault, every write approved by you — and any model can pick the project up tomorrow.",
15
- "version": "0.28.2",
15
+ "version": "0.29.0",
16
16
  "author": {
17
17
  "name": "Evgenii Konev",
18
18
  "email": "ekonev@smartandpoint.com",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "projectstore",
3
3
  "displayName": "projectstore",
4
- "version": "0.28.2",
4
+ "version": "0.29.0",
5
5
  "description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
6
6
  "author": {
7
7
  "name": "Evgenii Konev @ SmartAndPoint",
@@ -94,7 +94,7 @@ Contributors: `git clone` this repo, then `claude --plugin-dir ./ProjectStore`.
94
94
  npx projectstore-claude install --project "$PWD"
95
95
  ```
96
96
 
97
- The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. Every host command is printed before it runs; naming the harness is the confirmation. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code …`, is exactly what the shell runs. bun works the same on the packed bin.
97
+ The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) — one source package carrying every harness's manifest — and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. It prints its plan first — every file it writes and every host command, verbatim — and at a terminal asks `Apply N changes? [Y/n]`; then it shows each step as it runs and what to do next. Without a terminal (a script, CI, an agent's tool) naming the harness is the confirmation. `plan` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code …`, is exactly what the shell runs. bun works the same on the packed bin.
98
98
 
99
99
  **Codex has its own shell with the same one-command shape:**
100
100
  `npx projectstore-codex install --project "$PWD"`. It carries a Codex
@@ -120,7 +120,7 @@ The package also carries a `bin`. Without a session — in CI, or in a shell —
120
120
 
121
121
  ```
122
122
  npx projectstore doctor --json
123
- npx projectstore install --harness claude-code # the low-level form the shell runs: previews, then writes the agents block and the status line; naming the harness is the confirmation, there is no --yes
123
+ npx projectstore install --harness claude-code # the low-level form the shell runs: shows the plan, asks at a terminal, then writes; without a terminal naming the harness is the confirmation — there is no --yes
124
124
  npx projectstore reconcile --write --only kanban
125
125
  ```
126
126
 
@@ -20,6 +20,9 @@
20
20
  "/plugin marketplace add SmartAndPoint/ProjectStore && /plugin install projectstore@SmartAndPoint (git marketplace, user-wide)",
21
21
  "restart Claude Code"
22
22
  ],
23
+ "next": [
24
+ "restart Claude Code in this project"
25
+ ],
23
26
  "notes": [
24
27
  "Git marketplace only: auto-update is OFF by default for third-party plugins: /plugin -> Marketplaces -> SmartAndPoint -> toggle auto-update. The npm registration updates when you run `npx projectstore-claude upgrade --project <dir>`.",
25
28
  "Pin a git-marketplace release with: /plugin marketplace add SmartAndPoint/ProjectStore#<tag>; pin the npm registration with npx projectstore-claude@<version>.",
@@ -298,6 +298,10 @@
298
298
  "restart Codex and approve the ProjectStore hooks when prompted",
299
299
  "run `$projectstore-bind <vault-path>` in Codex"
300
300
  ],
301
+ "next": [
302
+ "restart Codex from a terminal and approve the ProjectStore hooks when it asks",
303
+ "in an unbound project, run `$projectstore-bind <vault-path>` in Codex"
304
+ ],
301
305
  "notes": [
302
306
  "The installation is user-global because Codex's marketplace, plugin row and cache live in CODEX_HOME; the AGENTS.md block is project-local.",
303
307
  "Upgrade with `npx projectstore-codex@<version> upgrade --project \"$PWD\"`; the named package version is also the plugin version.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "Your agent runs the project through a verified loop: task → artifact (ADR / spec / epic / story) → adversarial critic → backlog → planner → reviewer → done. Plain markdown in git — any model can pick the project up tomorrow.",
5
5
  "keywords": [
6
6
  "project-management",
@@ -80,10 +80,11 @@ const COMMAND_OVERRIDES = {
80
80
 
81
81
  ## register / unregister
82
82
 
83
- Preview the requested change and ask for explicit approval. On approval, run the
84
- core's \`install\` or \`uninstall\` verb with \`--harness codex --surface
85
- agents_block --project "$PWD"\`. Print its output verbatim. Never edit the
86
- managed block by hand.
83
+ Preview the requested change with \`plan --harness codex --surface agents_block
84
+ --project "$PWD"\` and ask for explicit approval. On approval, run the core's
85
+ \`install\` or \`uninstall\` verb with \`--harness codex --surface agents_block
86
+ --project "$PWD" --json\` — \`--json\` never waits on a terminal's question — and
87
+ report the envelope's result. Never edit the managed block by hand.
87
88
 
88
89
  ## status
89
90
 
@@ -134,7 +135,9 @@ arguments and \`--json\`. Summarize every finding without re-deriving it.
134
135
  When \`--fix\` is absent, remain read-only. When it is present, separate fixes
135
136
  by owner: derived vault views use \`$projectstore-reconcile\`; Codex plugin or
136
137
  agents-block drift uses the core's \`upgrade --harness codex\` path. Preview
137
- each mutation and ask for explicit approval before running it. Unsupported
138
+ each mutation with \`plan --harness codex\` and ask for explicit approval before
139
+ running it; then run \`upgrade --harness codex --json\`, whose envelope is the
140
+ result — \`--json\` never waits on a terminal's question. Unsupported
138
141
  surfaces remain unsupported; do not create host configuration by hand. Never
139
142
  claim a fix after a non-zero exit.`,
140
143
  },
@@ -41,7 +41,7 @@ import { readFileSync, existsSync } from "node:fs";
41
41
  import { resolve, dirname } from "node:path";
42
42
  import { fileURLToPath } from "node:url";
43
43
  import { parseArgs } from "node:util";
44
- import { createInterface } from "node:readline/promises";
44
+ import * as term from "./term.mjs";
45
45
  import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
46
46
  import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster } from "./lib.mjs";
47
47
  import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
@@ -83,14 +83,15 @@ export function resolveProject({ project = null, env = process.env, cwd = proces
83
83
  const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
84
84
  const JSON_OPT = opt("json", false, "the envelope");
85
85
  const READ_JSON = [JSON_OPT];
86
- const HARNESS_OPT = opt("harness", "<id>", "the harness — and, non-interactively, the confirmation; there is no --yes", true);
86
+ const HARNESS_OPT = opt("harness", "<id>", "the harness — and, without a terminal, the confirmation (a terminal is asked); there is no --yes", true);
87
87
  const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
88
88
  // The layout move's remedy names this for any copy but the package's own
89
89
  // registration (the layout spec, contract 12 as amended 2026-10-03): it makes
90
90
  // "no host command" true by construction instead of by recognising the root.
91
91
  const NO_REGISTER_OPT = opt("no-register", false, "leave the plugin registration alone: change only this project's files");
92
- const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, JSON_OPT];
93
- const UNINSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, opt("global", false, "also remove the harness-global plugin registration"), JSON_OPT];
92
+ const VERBOSE_OPT = opt("verbose", false, "every row's reasoning, each step's why and the host's own notes");
93
+ const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, VERBOSE_OPT, JSON_OPT];
94
+ const UNINSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, opt("global", false, "also remove the harness-global plugin registration"), VERBOSE_OPT, JSON_OPT];
94
95
 
95
96
  export const VERBS = Object.freeze([
96
97
  Object.freeze({
@@ -195,17 +196,141 @@ export const VERBS = Object.freeze([
195
196
  // story that adds a verb in slices.
196
197
  export const PLANNED_VERBS = Object.freeze([]);
197
198
 
198
- export function usage() {
199
- const lines = ["usage: projectstore <verb> [options] [--project <dir>] [--json]", "", "verbs:"];
200
- for (const v of VERBS) {
201
- lines.push(` ${v.verb.padEnd(11)} ${v.summary}`);
202
- for (const o of v.options) lines.push(` --${o.name}${o.arg ? " " + o.arg : ""}${o.multiple ? " (repeatable)" : ""} ${o.summary}`);
199
+ // The verbs as a person meets them: setting a project up, reading the vault,
200
+ // keeping it consistent, serving it. A verb not listed lands in "Other", so a
201
+ // new row is never hidden by this table.
202
+ const HELP_GROUPS = [
203
+ ["Set up", ["install", "upgrade", "uninstall", "plan", "bind", "init", "agents"]],
204
+ ["Read", ["status", "search", "show", "graph", "codemap", "orientation"]],
205
+ ["Check and repair", ["doctor", "reconcile"]],
206
+ ["Serve", ["mcp", "version"]],
207
+ ];
208
+
209
+ // Examples per verb. `{cmd}` is how this run was invoked (a shell's own name
210
+ // when PROJECTSTORE_SHELL says so, else the core — a shell passes the read
211
+ // verbs through, so its name serves them too), `{h}` the harness argument the
212
+ // core needs and a shell fixes.
213
+ const EXAMPLES = {
214
+ install: ["{cmd} install{h}", "{cmd} plan{h} # the same plan; nothing is written"],
215
+ upgrade: ["{cmd}@<version> upgrade{h} # the version you name is the version that runs", "{cmd} upgrade{h} --verbose"],
216
+ uninstall: ["{cmd} uninstall{h}", "{cmd} uninstall{h} --surface statusline"],
217
+ plan: ["{cmd} plan{h}", "{cmd} plan{h} --json # one envelope, for scripts and agents"],
218
+ status: ["{cmd} status", "{cmd} status --json"],
219
+ orientation: ["{cmd} orientation --json"],
220
+ search: ['{cmd} search "entry rule" --kind spec', "{cmd} search queue --status accepted --limit 5"],
221
+ show: ["{cmd} show adr/README.md", "{cmd} show epics/PS-CORE/epic.md --section acceptance"],
222
+ graph: ["{cmd} graph neighbors adr/README.md --direction out", "{cmd} graph lineage epics/PS-CORE/epic.md --depth 2"],
223
+ codemap: ["{cmd} codemap --for PS-CORE", "{cmd} codemap --for scripts/lib.mjs --reverse"],
224
+ doctor: ["{cmd} doctor", "{cmd} doctor --install --json"],
225
+ reconcile: ["{cmd} reconcile # what would change; nothing is written", "{cmd} reconcile --write"],
226
+ bind: ["{cmd} bind ~/vaults/my-project", "{cmd} bind ~/vaults/other --rebind"],
227
+ init: ["{cmd} init ~/vaults/new-project --language ru"],
228
+ agents: ["{cmd} agents show", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
229
+ mcp: ['{cmd} mcp --project "$PWD"'],
230
+ };
231
+
232
+ // Positional arguments a summary does not already name.
233
+ const ARGS = { search: "<phrase>", show: "<path>" };
234
+
235
+ // "bind <vault> — bind this project…" names its own form; "graph neighbors
236
+ // <path> | graph lineage <path> — …" names two.
237
+ function forms(row) {
238
+ const m = /^(.+?) — (.+)$/.exec(row.summary);
239
+ if (m && m[1].startsWith(row.verb + " ")) {
240
+ const list = m[1].split(" | ").map((f) => f.replace(new RegExp(`^${row.verb} `), ""));
241
+ return { forms: list, about: m[2][0].toUpperCase() + m[2].slice(1) };
242
+ }
243
+ return { forms: [ARGS[row.verb] || ""], about: row.summary };
244
+ }
245
+
246
+ function invocation(env = process.env) {
247
+ const shell = env.PROJECTSTORE_SHELL || null;
248
+ return { shell, cmd: shell ? `npx ${shell}` : "npx projectstore" };
249
+ }
250
+
251
+ function optionLines(options) {
252
+ const rows = options.map((o) => [`--${o.name}${o.arg ? " " + o.arg : ""}`, `${o.summary}${o.multiple ? " (repeatable)" : ""}`]);
253
+ const w = Math.min(28, Math.max(0, ...rows.map(([l]) => l.length)));
254
+ return rows.map(([l, r]) => (l.length > w ? ` ${l}\n ${" ".repeat(w)} ${r}` : ` ${l.padEnd(w)} ${r}`));
255
+ }
256
+
257
+ const EXIT_CODES = "Exit codes 0 ok · 1 findings or a refusal · 2 usage · 3 not bound";
258
+
259
+ export function usage(env = process.env) {
260
+ const { cmd } = invocation(env);
261
+ const lines = [
262
+ "projectstore — project memory for coding agents: decisions, specs, epics and stories as plain markdown.",
263
+ "",
264
+ "Usage",
265
+ ` ${cmd} <verb> [options]`,
266
+ ` ${cmd} <verb> --help one verb's options and examples`,
267
+ ];
268
+ const seen = new Set();
269
+ const groups = HELP_GROUPS.map(([title, names]) => [title, names.map((n) => VERBS.find((v) => v.verb === n)).filter(Boolean)]);
270
+ for (const [, rows] of groups) for (const r of rows) seen.add(r.verb);
271
+ const other = VERBS.filter((v) => !seen.has(v.verb));
272
+ if (other.length) groups.push(["Other", other]);
273
+ for (const [title, rows] of groups) {
274
+ if (!rows.length) continue;
275
+ lines.push("", title);
276
+ for (const v of rows) lines.push(` ${v.verb.padEnd(12)} ${v.summary}`);
203
277
  }
204
- if (PLANNED_VERBS.length) lines.push("", ` planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
205
- lines.push("", ` harnesses: ${harnessIds().join(", ")}`, " --version print the package version", " exit codes: 0 ok, 1 findings or refusal, 2 usage, 3 not bound");
278
+ if (PLANNED_VERBS.length) lines.push("", `Planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
279
+ lines.push(
280
+ "",
281
+ "Every verb",
282
+ " --project <dir> the project (default: the host session's project, else the current directory)",
283
+ " --json one envelope { schema_version, verb, project, ok, result } — for scripts and agents",
284
+ " --version the package version",
285
+ "",
286
+ "Tips",
287
+ " install, upgrade and uninstall show their plan first; `plan` prints the same plan and writes nothing.",
288
+ " At a terminal they ask before writing. Without one, --harness is the confirmation — there is no --yes.",
289
+ " Unattended in a terminal (a script, a Makefile): pass --json, set CI=1, or give stdin no terminal (`</dev/null`).",
290
+ " --verbose on those verbs adds every row's reasoning and the host's own notes.",
291
+ "",
292
+ `Harnesses ${harnessIds().join(", ")}`,
293
+ EXIT_CODES,
294
+ );
206
295
  return lines.join("\n");
207
296
  }
208
297
 
298
+ // One verb: what it does, how to call it, every option, examples.
299
+ export function verbHelp(row, env = process.env) {
300
+ const { shell, cmd } = invocation(env);
301
+ const fixed = Boolean(shell) && row.options.some((o) => o.name === "harness");
302
+ const opts = row.options.filter((o) => !(fixed && o.name === "harness"));
303
+ const { forms: list, about } = forms(row);
304
+ const lines = [`${cmd} ${row.verb} — ${about}`, "", "Usage"];
305
+ for (const f of list) lines.push(` ${cmd} ${row.verb}${f ? " " + f : ""}${opts.length ? " [options]" : ""}`);
306
+ if (opts.length) lines.push("", "Options", ...optionLines(opts));
307
+ if (fixed) lines.push("", `${shell} names the harness itself: without a terminal, the verb is its own confirmation.`);
308
+ else if (row.options.some((o) => o.name === "harness")) lines.push("", `Harnesses ${harnessIds().join(", ")}`);
309
+ const ex = EXAMPLES[row.verb] || [];
310
+ const h = fixed ? "" : " --harness <id>";
311
+ const rendered = ex.map((e) => e.split("{cmd}").join(cmd).split("{h}").join(h).split(" # "));
312
+ const col = Math.max(0, ...rendered.filter((r) => r.length > 1).map(([c]) => c.length));
313
+ if (ex.length) lines.push("", "Examples", ...rendered.map(([c, note]) => ` ${note ? `${c.padEnd(col)} # ${note}` : c}`));
314
+ lines.push("", EXIT_CODES);
315
+ return lines.join("\n");
316
+ }
317
+
318
+ // The nearest name within two edits — for a mistyped verb or option.
319
+ export function nearest(word, names) {
320
+ const d = (a, b) => {
321
+ const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
322
+ for (let j = 1; j <= b.length; j++) m[0][j] = j;
323
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) m[i][j] = Math.min(m[i - 1][j] + 1, m[i][j - 1] + 1, m[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
324
+ return m[a.length][b.length];
325
+ };
326
+ let best = null, score = 3;
327
+ for (const n of names) {
328
+ const k = n.startsWith(word) && word.length >= 3 ? 0 : d(word, n);
329
+ if (k < score) { best = n; score = k; }
330
+ }
331
+ return best;
332
+ }
333
+
209
334
  // ─── run ───────────────────────────────────────────────────────────────
210
335
 
211
336
  export async function run(argv, { env = process.env, cwd = process.cwd(), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, ask = null } = {}) {
@@ -217,7 +342,7 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
217
342
  strict: true,
218
343
  options: {
219
344
  project: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
220
- harness: { type: "string", multiple: true }, surface: { type: "string", multiple: true }, global: { type: "boolean" }, "no-register": { type: "boolean" },
345
+ harness: { type: "string", multiple: true }, surface: { type: "string", multiple: true }, global: { type: "boolean" }, "no-register": { type: "boolean" }, verbose: { type: "boolean" },
221
346
  write: { type: "boolean" }, only: { type: "string" }, install: { type: "boolean" }, vault: { type: "boolean" },
222
347
  kind: { type: "string", multiple: true }, status: { type: "string" }, limit: { type: "string" }, "include-derived": { type: "boolean" }, "case-sensitive": { type: "boolean" },
223
348
  body: { type: "boolean" }, section: { type: "string" }, direction: { type: "string" }, depth: { type: "string" }, for: { type: "string" }, reverse: { type: "boolean" },
@@ -228,7 +353,14 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
228
353
  } catch (e) {
229
354
  // --json cannot be known before parsing; a raw scan is enough here.
230
355
  if (argv.includes("--json")) stdout.write(JSON.stringify(envelope(argv.find((a) => !a.startsWith("-")) || null, null, false, { error: e.message }), null, 2) + "\n");
231
- stderr.write(`${e.message}\n${usage()}\n`);
356
+ // Node's own wording for an unknown option explains "--" positionals; a
357
+ // person who typed --verbos wants the option they meant.
358
+ const unknown = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'(-{1,2}[^']+)'/.exec(e.message)?.[1] : null;
359
+ const row = VERBS.find((v) => v.verb === argv.find((a) => !a.startsWith("-")));
360
+ const names = [...(row ? row.options.map((o) => o.name) : VERBS.flatMap((v) => v.options.map((o) => o.name))), "project", "json", "help", "version"];
361
+ const hint = unknown ? nearest(unknown.replace(/^-+/, ""), [...new Set(names)]) : null;
362
+ const message = unknown ? `${row ? row.verb + " does not take" : "unknown option"} ${unknown}${hint ? ` — did you mean --${hint}?` : ""}` : e.message;
363
+ stderr.write(`${message}\nRun ${row ? `\`${row.verb} --help\`` : "--help"} for the options.\n`);
232
364
  return 2;
233
365
  }
234
366
  const { values, positionals } = parsed;
@@ -236,7 +368,7 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
236
368
  // a consumer (the MCP server, a script) always has something to parse.
237
369
  const fail = (verb, project, message, code, { help = false } = {}) => {
238
370
  if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: message, exit: code }), null, 2) + "\n");
239
- stderr.write(message + "\n" + (help ? usage() + "\n" : ""));
371
+ stderr.write(message + "\n" + (help ? usage(env) + "\n" : ""));
240
372
  return code;
241
373
  };
242
374
  // In-process reads resolve layouts and registries from THIS package, as the
@@ -244,11 +376,17 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
244
376
  // session's variable points at.
245
377
  pinPluginRoot(PACKAGE_ROOT);
246
378
  if (values.version) return runVersion({ values, stdout });
247
- if (values.help || !positionals.length) { (values.help ? stdout : stderr).write(usage() + "\n"); return values.help ? 0 : 2; }
379
+ if (values.help && positionals.length) {
380
+ const row = VERBS.find((v) => v.verb === positionals[0]);
381
+ if (row) { stdout.write(verbHelp(row, env) + "\n"); return 0; }
382
+ }
383
+ if (values.help || !positionals.length) { (values.help ? stdout : stderr).write(usage(env) + "\n"); return values.help ? 0 : 2; }
248
384
  const verb = positionals[0];
249
385
  const row = VERBS.find((v) => v.verb === verb);
250
386
  if (!row) {
251
387
  const planned = PLANNED_VERBS.find((v) => v.verb === verb);
388
+ const guess = planned ? null : nearest(verb, VERBS.map((v) => v.verb));
389
+ if (guess) return fail(verb, null, `unknown verb: ${verb} — did you mean ${guess}?\nRun --help for every verb.`, 2);
252
390
  return fail(verb, null, (planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`), 2, { help: true });
253
391
  }
254
392
  // The options map is global (parseArgs), the rows are not: an option a row
@@ -257,7 +395,10 @@ export async function run(argv, { env = process.env, cwd = process.cwd(), stdin
257
395
  const GLOBAL = new Set(["project", "json", "help", "version"]);
258
396
  const declared = new Set(row.options.map((o) => o.name));
259
397
  const stray = Object.keys(values).filter((k) => !GLOBAL.has(k) && !declared.has(k));
260
- if (stray.length) return fail(verb, null, `${verb} does not take --${stray[0]}`, 2, { help: true });
398
+ if (stray.length) {
399
+ const guess = nearest(stray[0], row.options.map((o) => o.name));
400
+ return fail(verb, null, `${verb} does not take --${stray[0]}${guess ? ` — did you mean --${guess}?` : ""}\nRun \`${verb} --help\` for the options.`, 2);
401
+ }
261
402
  const project = resolveProject({ project: values.project, env, cwd });
262
403
  const cfg = readConfigAt(project);
263
404
  if (row.requiresBinding && !cfg) return fail(verb, project, `${project} is not bound to a vault — run /projectstore:bind <vault> in a session, or \`projectstore bind <vault>\` (\`projectstore init <vault>\` also creates the vault).`, 3);
@@ -285,8 +426,9 @@ function ownEnv(env, project) {
285
426
  async function confirmWrite(question, { stdin, stdout, ask }) {
286
427
  if (ask) return /^y(es)?$/i.test(String(await ask(question)).trim());
287
428
  if (!(stdin && stdin.isTTY && stdout && stdout.isTTY)) return null; // no terminal: refuse
288
- const rl = createInterface({ input: stdin, output: stdout });
289
- try { return /^y(es)?$/i.test(String(await rl.question(question)).trim()); } finally { rl.close(); }
429
+ // End of input is a no (term.mjs askLine), never a question left pending.
430
+ const answer = await term.askLine(question, stdin, stdout);
431
+ return answer !== null && /^y(es)?$/i.test(answer.trim());
290
432
  }
291
433
 
292
434
  // ─── verbs ─────────────────────────────────────────────────────────────
@@ -573,23 +715,28 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
573
715
 
574
716
  async function runInstallVerb({ row, values, project, env, stdin, stdout, ask }) {
575
717
  const ih = await import("./install-harness.mjs");
576
- const opts = { harnesses: values.harness || [], surfaces: values.surface && values.surface.length ? values.surface : null, globalRemoval: Boolean(values.global), register: !values["no-register"], root: PACKAGE_ROOT, env: ownEnv(env, project), stdin, stdout, ask };
718
+ const opts = { harnesses: values.harness || [], surfaces: values.surface && values.surface.length ? values.surface : null, globalRemoval: Boolean(values.global), register: !values["no-register"], root: PACKAGE_ROOT, env: ownEnv(env, project), stdin, stdout, ask, json: Boolean(values.json), verbose: Boolean(values.verbose) };
577
719
  if (row.verb === "plan") {
578
720
  const p = ih.plan(project, opts);
579
- stdout.write(values.json ? JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n" : ih.renderPreview(p));
721
+ if (values.json) stdout.write(JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n");
722
+ else {
723
+ const c = term.caps(stdout, env);
724
+ stdout.write(ih.renderPreview(p, { verbose: opts.verbose, verb: "plan", paint: term.painter(c), icon: (n) => term.icon(c, n), width: c.live ? c.width : 0 }));
725
+ }
580
726
  return p.ok && !p.incomplete ? 0 : 1;
581
727
  }
582
- const r = await ih.runVerb(row.verb, project, opts);
728
+ // Text mode hands runVerb the stream: the plan is printed before any
729
+ // question, then each step, then DONE (install spec contracts 9 and 18).
730
+ const r = await ih.runVerb(row.verb, project, values.json ? opts : { ...opts, out: stdout });
583
731
  // A registration the plan could not make (no host CLI) or a host command
584
732
  // that failed is exit 1 with the rest applied (install spec contract 4′).
585
733
  const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
586
734
  if (values.json) {
587
735
  stdout.write(JSON.stringify(envelope(row.verb, project, ok, { gate: r.gate, applied: r.applied, failed: r.failed, incomplete: r.plan.incomplete, plannedAgainst: r.plan.plannedAgainst, items: r.plan.items.map(ih.publicItem), refusals: r.plan.refusals, reports: r.plan.reports }), null, 2) + "\n");
588
- } else {
589
- stdout.write(r.preview);
590
- if (r.gate.confirmed) stdout.write(ih.appliedLine(r));
591
- else if (r.gate.why === "non-tty") stdout.write(`a bare ${row.verb} in a non-TTY refuses; name a harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
592
- else if (r.gate.why === "declined") stdout.write("nothing written.\n");
736
+ } else if (r.gate.why === "non-tty") {
737
+ stdout.write(`Nothing written: without a terminal, a bare ${row.verb} refuses. Name the harness to confirm: --harness ${r.plan.detected.map((d) => d.id).join(" | ") || harnessIds().join(" | ")}\n`);
738
+ } else if (r.gate.why === "declined") {
739
+ stdout.write("Nothing written.\n");
593
740
  }
594
741
  return ok ? 0 : 1;
595
742
  }
@@ -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();
@@ -0,0 +1,147 @@
1
+ // projectstore — term.mjs: terminal presentation for the write verbs, with no
2
+ // dependency (the zero-dependency property the MCP and link-graph ADRs keep).
3
+ //
4
+ // One question decides everything here: is this a person at a terminal? Then
5
+ // colour and in-place step lines. Otherwise — a pipe, an agent's tool call,
6
+ // CI — the same layout as plain text, so a reader that is not a terminal loses
7
+ // nothing but the escapes (install spec, contract 18).
8
+ //
9
+ // caps(stream, env) → { color, live, width, ascii }
10
+ // painter(caps) → paint(style, text)
11
+ // icon(caps, name) → the glyph for an action or a result
12
+ // duration(ms) → "0.4s", "12s", "1m 03s"
13
+ // wrap(text, width, indent) → prose broken at spaces, hanging indent
14
+ // stepReporter(stream, caps) → { start(label), end(ok, note), abort() }
15
+ // askLine(question, in, out) → the answer, or null on end of input
16
+
17
+ import { createInterface } from "node:readline/promises";
18
+
19
+ // FORCE_COLOR decides when it is set, as Node's own tty reads it: "", "1",
20
+ // "2", "3" or "true" turn colour on even without a terminal, anything else
21
+ // ("0", "false") turns it off. Otherwise NO_COLOR (https://no-color.org) turns
22
+ // colour off when set to anything non-empty, and TERM=dumb means a terminal
23
+ // that understands no escapes.
24
+ export function caps(stream = process.stdout, env = process.env) {
25
+ const tty = Boolean(stream && stream.isTTY);
26
+ const dumb = env.TERM === "dumb";
27
+ const off = env.NO_COLOR !== undefined && env.NO_COLOR !== "";
28
+ const color = env.FORCE_COLOR !== undefined ? ["", "1", "2", "3", "true"].includes(String(env.FORCE_COLOR)) : tty && !dumb && !off;
29
+ const columns = tty && Number(stream.columns) > 0 ? Number(stream.columns) : 100;
30
+ // `width` lays out prose (never below 40); `columns` is the terminal's
31
+ // real width, which a live line must not reach.
32
+ return { color, live: tty && !dumb, width: Math.max(40, Math.min(columns, 120)), columns, ascii: dumb };
33
+ }
34
+
35
+ const SGR = {
36
+ bold: [1, 22], dim: [2, 22], red: [31, 39], green: [32, 39], yellow: [33, 39],
37
+ blue: [34, 39], magenta: [35, 39], cyan: [36, 39], gray: [90, 39],
38
+ };
39
+
40
+ // Raw SGR rather than util.styleText: that arrived in Node 20.12 and 21.7, and
41
+ // the package promises >=20.0.0.
42
+ export function painter(c) {
43
+ return (style, text) => {
44
+ const s = String(text);
45
+ if (!c.color || !SGR[style] || !s) return s;
46
+ return `\x1b[${SGR[style][0]}m${s}\x1b[${SGR[style][1]}m`;
47
+ };
48
+ }
49
+
50
+ const ICONS = {
51
+ create: ["+", "+"], update: ["↻", "~"], migrate: ["↻", "~"], refresh: ["↻", "~"],
52
+ remove: ["✕", "x"], cleanup: ["✕", "x"], skip: ["·", "."], refuse: ["!", "!"],
53
+ ok: ["✓", "ok"], fail: ["✗", "FAIL"], running: ["…", "..."], note: ["!", "!"],
54
+ };
55
+ export function icon(c, name) {
56
+ const pair = ICONS[name] || ICONS.update;
57
+ return c.ascii ? pair[1] : pair[0];
58
+ }
59
+
60
+ export function duration(ms) {
61
+ if (ms < 10_000) return `${(ms / 1000).toFixed(1)}s`;
62
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
63
+ const m = Math.floor(ms / 60_000), s = Math.round((ms % 60_000) / 1000);
64
+ return `${m}m ${String(s).padStart(2, "0")}s`;
65
+ }
66
+
67
+ // Strip SGR escapes — for measuring a painted string and for tests.
68
+ export const plain = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, "");
69
+
70
+ // Prose only: broken at spaces to `width` columns, every line after the first
71
+ // indented by `indent`. A word longer than the line — a path, a command — is
72
+ // never broken: a path cut in two cannot be copied back. Width 0 is a reader
73
+ // that is not a terminal: nothing is broken, so a search never meets a split.
74
+ export function wrap(text, width, indent = "") {
75
+ if (!width) return String(text);
76
+ const room = Math.max(20, width - indent.length);
77
+ const lines = [];
78
+ let line = "";
79
+ for (const word of String(text).split(" ")) {
80
+ if (line && plain(line).length + 1 + plain(word).length > room) { lines.push(line); line = word; }
81
+ else line = line ? `${line} ${word}` : word;
82
+ }
83
+ lines.push(line);
84
+ return lines.map((l, k) => (k ? indent + l : l)).join("\n");
85
+ }
86
+
87
+ // A live line must fit one physical row: "\r\x1b[2K" clears only the row the
88
+ // cursor is on, so a label that wrapped would leave its first half behind.
89
+ const fit = (text, room) => (text.length <= room ? text : text.slice(0, Math.max(1, room - 1)) + "…");
90
+
91
+ // One line per step. On a live terminal the line appears when the step starts
92
+ // (ending in "…") and is rewritten in place with the result and duration when
93
+ // it ends; anything else gets the finished line only. Steps run synchronously
94
+ // (spawnSync), so nothing animates in between — the "…" is the honest state.
95
+ export function stepReporter(stream = process.stdout, c = caps(stream), { indent = 2 } = {}) {
96
+ const paint = painter(c);
97
+ let open = null;
98
+ // One column short of the terminal: a line that fills the last column
99
+ // leaves some terminals waiting to wrap.
100
+ const width = Math.min(c.width, c.columns || c.width, 100) - 1;
101
+ const line = (mark, label, right) => {
102
+ const room = width - indent - 2 - plain(right).length - 1;
103
+ const left = `${" ".repeat(indent)}${mark} ${c.live ? fit(label, room) : label}`;
104
+ const gap = Math.max(1, width - plain(left).length - plain(right).length);
105
+ return left + " ".repeat(gap) + right;
106
+ };
107
+ const self = {
108
+ start(label) {
109
+ // A step started while another is still open (a host command run by a
110
+ // rollback) closes the open line where it stands rather than overwrite it.
111
+ if (open && c.live) stream.write("\n");
112
+ open = { label, t0: Date.now() };
113
+ if (c.live) stream.write(line(paint("gray", icon(c, "running")), label, ""));
114
+ },
115
+ end(ok = true, note = "", { kept = false } = {}) {
116
+ if (!open) return;
117
+ const ms = Date.now() - open.t0;
118
+ // A step that ran and left its target in place is neither ✓ nor ✗.
119
+ const mark = !ok ? paint("red", icon(c, "fail")) : kept ? paint("gray", icon(c, "skip")) : paint("green", icon(c, "ok"));
120
+ const right = paint("gray", [note, duration(ms)].filter(Boolean).join(" "));
121
+ const text = line(mark, open.label, right);
122
+ stream.write((c.live ? "\r\x1b[2K" : "") + text + "\n");
123
+ open = null;
124
+ },
125
+ // An exception left a step open: end its line as failed, so whatever is
126
+ // printed next starts on a line of its own.
127
+ abort() { if (open) self.end(false); },
128
+ };
129
+ return self;
130
+ }
131
+
132
+ // One line of input, read the way a shell's own prompt is: in the terminal's
133
+ // line mode, so readline writes nothing but the question (no cursor escapes),
134
+ // the terminal does the editing, and Ctrl+C is a real SIGINT (exit 130, before
135
+ // anything is written). End of input — Ctrl+D, a closed pipe — is null, which
136
+ // every caller reads as no; it never leaves the question pending (an unsettled
137
+ // promise is exit 13 with a warning). The promise API rejects on close in
138
+ // some Node versions and stays pending in others; both settle here.
139
+ export async function askLine(question, input, output) {
140
+ const rl = createInterface({ input, output, terminal: false });
141
+ return await new Promise((settle) => {
142
+ let done = false;
143
+ const finish = (answer) => { if (done) return; done = true; settle(answer); rl.close(); };
144
+ rl.on("close", () => finish(null));
145
+ rl.question(question).then((a) => finish(a), () => finish(null));
146
+ });
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.28.2",
3
+ "version": "0.29.0",
4
4
  "description": "Installs projectstore for Claude Code from npm: the core pinned and bundled, the harness fixed — npx projectstore-claude install --project \"$PWD\".",
5
5
  "keywords": [
6
6
  "projectstore",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "projectstore": "=0.28.2"
40
+ "projectstore": "=0.29.0"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"