projectstore-claude 0.28.2 → 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +3 -1
  2. package/bin/projectstore-claude.mjs +14 -9
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  5. package/node_modules/projectstore/README.md +2 -2
  6. package/node_modules/projectstore/commands/review.md +1 -1
  7. package/node_modules/projectstore/harnesses/claude-code.json +20 -2
  8. package/node_modules/projectstore/harnesses/codex.json +24 -4
  9. package/node_modules/projectstore/hooks/pre-compact.mjs +2 -1
  10. package/node_modules/projectstore/hooks/session-rules.mjs +19 -7
  11. package/node_modules/projectstore/hooks/session-start.mjs +20 -18
  12. package/node_modules/projectstore/package.json +2 -2
  13. package/node_modules/projectstore/scripts/binding.mjs +3 -3
  14. package/node_modules/projectstore/scripts/build-adapters.mjs +51 -18
  15. package/node_modules/projectstore/scripts/cli.mjs +176 -29
  16. package/node_modules/projectstore/scripts/codemap.mjs +2 -2
  17. package/node_modules/projectstore/scripts/doctor.mjs +110 -58
  18. package/node_modules/projectstore/scripts/draft.mjs +4 -3
  19. package/node_modules/projectstore/scripts/graph.mjs +3 -2
  20. package/node_modules/projectstore/scripts/harness.mjs +89 -0
  21. package/node_modules/projectstore/scripts/install-harness.mjs +336 -93
  22. package/node_modules/projectstore/scripts/kanban.mjs +3 -2
  23. package/node_modules/projectstore/scripts/lib.mjs +69 -8
  24. package/node_modules/projectstore/scripts/mcp.mjs +2 -2
  25. package/node_modules/projectstore/scripts/query.mjs +2 -1
  26. package/node_modules/projectstore/scripts/reconcile.mjs +4 -3
  27. package/node_modules/projectstore/scripts/story-section.mjs +2 -1
  28. package/node_modules/projectstore/scripts/surfaces.mjs +2 -1
  29. package/node_modules/projectstore/scripts/term.mjs +149 -0
  30. package/node_modules/projectstore/scripts/touch-session.mjs +6 -1
  31. package/node_modules/projectstore/scripts/worktree.mjs +12 -7
  32. package/package.json +2 -2
@@ -41,9 +41,9 @@ 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
- import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster } from "./lib.mjs";
46
+ import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm } from "./lib.mjs";
47
47
  import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
48
48
  // binding.mjs is a write module imported statically where the install family
49
49
  // is lazy: it is a dependency-free leaf with no side effects, so the MCP
@@ -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({
@@ -172,7 +173,7 @@ export const VERBS = Object.freeze([
172
173
  run: runBind(false),
173
174
  }),
174
175
  Object.freeze({
175
- verb: "init", summary: "init <vault> — create the vault directory and bind to it; the layout's folders come from /projectstore:scaffold.",
176
+ verb: "init", summary: `init <vault> — create the vault directory and bind to it; the layout's folders come from ${commandForm("scaffold")}.`,
176
177
  module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
177
178
  options: [opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`), opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`), opt("rebind", false, "an already bound project: create the new vault and point the project at it; every other setting is kept"), JSON_OPT],
178
179
  run: runBind(true),
@@ -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,10 +395,13 @@ 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
- 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);
404
+ if (row.requiresBinding && !cfg) return fail(verb, project, `${project} is not bound to a vault — run ${commandForm("bind", { args: "<vault>", env })} in a session, or \`projectstore bind <vault>\` (\`projectstore init <vault>\` also creates the vault).`, 3);
264
405
  try {
265
406
  return await row.run({ row, values, positionals: positionals.slice(1), cfg, project, env, cwd, stdin, stdout, stderr, ask });
266
407
  } catch (e) {
@@ -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
  }
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, readdirSync } from "node:fs";
12
12
  import { join } from "node:path";
13
- import { readConfig, loadLayout, folderByKind, parseFrontmatter, nowIso, isMain } from "./lib.mjs";
13
+ import { readConfig, loadLayout, folderByKind, parseFrontmatter, nowIso, isMain, commandForm } from "./lib.mjs";
14
14
 
15
15
  function die(msg) {
16
16
  process.stderr.write(`projectstore/codemap: ${msg}\n`);
@@ -29,7 +29,7 @@ function parseRefs(raw) {
29
29
 
30
30
  function main() {
31
31
  const cfg = readConfig();
32
- if (!cfg) die("No projectstore config. Run /projectstore:bind first.");
32
+ if (!cfg) die(`No projectstore config. Run ${commandForm("bind")} first.`);
33
33
  const layout = loadLayout(cfg.layout);
34
34
  const folder = folderByKind(layout, "epic");
35
35
  if (!folder) die("Layout has no epic folder — nothing to map.");