projectstore-claude 0.29.2 → 0.29.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  4. package/node_modules/projectstore/README.md +27 -7
  5. package/node_modules/projectstore/commands/bar.md +30 -0
  6. package/node_modules/projectstore/commands/bind.md +14 -7
  7. package/node_modules/projectstore/commands/scaffold.md +24 -16
  8. package/node_modules/projectstore/commands/status.md +1 -1
  9. package/node_modules/projectstore/commands/statusline.md +1 -1
  10. package/node_modules/projectstore/docs/alongside.md +167 -0
  11. package/node_modules/projectstore/docs/extending.md +17 -3
  12. package/node_modules/projectstore/docs/getting-started.md +18 -9
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/harnesses/claude-code.json +18 -3
  16. package/node_modules/projectstore/harnesses/codex.json +12 -1
  17. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  18. package/node_modules/projectstore/hooks/band.tsx +594 -0
  19. package/node_modules/projectstore/hooks/hooks.json +1 -0
  20. package/node_modules/projectstore/hooks/session-start.mjs +6 -2
  21. package/node_modules/projectstore/package.json +2 -1
  22. package/node_modules/projectstore/scaffold/headings.json +8 -0
  23. package/node_modules/projectstore/scripts/band-shared.mjs +329 -0
  24. package/node_modules/projectstore/scripts/band.mjs +156 -0
  25. package/node_modules/projectstore/scripts/binding.mjs +157 -17
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +26 -7
  27. package/node_modules/projectstore/scripts/cli.mjs +545 -179
  28. package/node_modules/projectstore/scripts/convergence.mjs +468 -0
  29. package/node_modules/projectstore/scripts/doctor-report.mjs +185 -0
  30. package/node_modules/projectstore/scripts/doctor.mjs +80 -62
  31. package/node_modules/projectstore/scripts/fetch-shell.mjs +283 -0
  32. package/node_modules/projectstore/scripts/harness.mjs +178 -7
  33. package/node_modules/projectstore/scripts/install-harness.mjs +1488 -257
  34. package/node_modules/projectstore/scripts/kanban.mjs +4 -20
  35. package/node_modules/projectstore/scripts/lib.mjs +257 -58
  36. package/node_modules/projectstore/scripts/portable-registration.mjs +62 -15
  37. package/node_modules/projectstore/scripts/query.mjs +83 -12
  38. package/node_modules/projectstore/scripts/run-host.mjs +94 -0
  39. package/node_modules/projectstore/scripts/scaffold.mjs +183 -0
  40. package/node_modules/projectstore/scripts/stages.mjs +80 -0
  41. package/node_modules/projectstore/scripts/statusline.mjs +25 -2
  42. package/node_modules/projectstore/scripts/surfaces.mjs +78 -13
  43. package/node_modules/projectstore/scripts/term.mjs +729 -39
  44. package/node_modules/projectstore/scripts/version-guard.mjs +5 -0
  45. package/node_modules/projectstore/templates/de/strings.json +63 -1
  46. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  47. package/node_modules/projectstore/templates/en/strings.json +63 -1
  48. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  49. package/node_modules/projectstore/templates/es/strings.json +63 -1
  50. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  51. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  52. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  53. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  54. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  55. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  56. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  57. package/package.json +2 -2
@@ -14,11 +14,24 @@
14
14
  // command files and the tests depend on it). Exit codes: 0 ok, 1 findings or
15
15
  // a refusal, 2 usage or an internal failure, 3 not bound.
16
16
  //
17
+ // The parse is per verb. A scan finds the verb first, knowing only the
18
+ // globals (--project <dir>, --json, --help/-h, --version/-v — the only options
19
+ // that may come before it); then one strict parseArgs runs on that verb's row
20
+ // plus the globals, so one name can be a flag on one verb and take a value on
21
+ // another (`doctor --vault` beside the front-door ADR's `setup --vault
22
+ // <path>`). A row's `usage` forms are both its help's Usage lines and the
23
+ // bound on its arguments: an argument past the form is a usage error that
24
+ // names it, never dropped. Every error raised before a verb runs names the
25
+ // verb the scan found, in the envelope and on stderr.
26
+ //
17
27
  // The gate (distribution ADR decision 6) binds every write verb this bin
18
28
  // exposes: the install family through install-harness.mjs's own preview and
19
29
  // confirmation, and `reconcile --write` through the same rule — naming what
20
30
  // is written (`--only <target>`) is the non-interactive confirmation, a bare
21
- // `--write` asks on a terminal and refuses without one. There is no --yes.
31
+ // `--write` asks on a terminal and refuses without one. `scaffold --write`
32
+ // differs on purpose: its write set is closed — create-only, what the layout
33
+ // declares, in the vault the binding names — so it asks at a terminal and,
34
+ // without one, `--write` is the confirmation. There is no --yes.
22
35
  //
23
36
  // The project is resolved once, here — --project, then the neutral
24
37
  // PROJECTSTORE_PROJECT_DIR, then a project-dir variable a harness declared,
@@ -42,13 +55,16 @@ import { resolve, dirname } from "node:path";
42
55
  import { fileURLToPath } from "node:url";
43
56
  import { parseArgs } from "node:util";
44
57
  import * as term from "./term.mjs";
45
- import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
46
- import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm, projectDirRefusal } from "./lib.mjs";
58
+ import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot, invokedAs, loadHarnesses, invocation } from "./harness.mjs";
59
+ import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm, projectDirRefusal, doctorFallbackText } from "./lib.mjs";
47
60
  import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
48
- // binding.mjs is a write module imported statically where the install family
49
- // is lazy: it is a dependency-free leaf with no side effects, so the MCP
50
- // server's module graph gains nothing it could trip on.
51
- import { planBind, applyBind, renderBindPlan, bindResult, DEFAULT_LAYOUT, DEFAULT_LANGUAGE } from "./binding.mjs";
61
+ // binding.mjs and scaffold.mjs beneath it are write modules imported
62
+ // statically where the install family is lazy: dependency-free, with no side
63
+ // effects at import, so the MCP server's module graph gains nothing it could
64
+ // trip on.
65
+ import { planBind, applyBind, renderBindPlan, bindResult, bindFailed, normaliseVaultPath, shellQuote, DEFAULT_LAYOUT, DEFAULT_LANGUAGE } from "./binding.mjs";
66
+ import { planScaffold, applyScaffold, scaffoldResult, renderScaffoldPlan } from "./scaffold.mjs";
67
+ import { planBand, applyBand, bandStatus, renderBandStatus, renderBandPlan, SUBCOMMANDS as BAND_SUBCOMMANDS } from "./band.mjs";
52
68
 
53
69
  export const SCHEMA_VERSION = 1;
54
70
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -79,9 +95,22 @@ export function resolveProject({ project = null, env = process.env, cwd = proces
79
95
  // wraps: "script" (spawned), "module" (imported), "new" (code that exists
80
96
  // only for the CLI). output: "envelope" (--json wraps), "text". mcp: the MCP
81
97
  // tools that mirror the verb (MCP ADR decision 2), empty when none.
98
+ // options: the row's parser — opt()'s arg is the type (false a flag, a
99
+ // placeholder a value), multiple the repetition. usage: the forms the help
100
+ // prints after the verb, verbatim, which also bound its arguments: a literal
101
+ // sub-word (`neighbors`), a <placeholder> (`<phrase…>` takes the rest), or a
102
+ // --name the form requires (its placeholder is the option's value, not an
103
+ // argument). NO_FORMS: the verb takes no argument.
82
104
 
83
- const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
105
+ export const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
84
106
  const JSON_OPT = opt("json", false, "the envelope");
107
+ // The options every verb takes, and the only ones that may come before the
108
+ // verb: the scan knows their arity before it knows the verb (--project takes
109
+ // a value, the rest do not). Last in every parser map, so a row cannot
110
+ // retype one. SHORT is their one-letter forms (`-hv`: version wins).
111
+ const GLOBAL_OPTS = Object.freeze([opt("project", "<dir>", "the project"), JSON_OPT, opt("help", false, "the help"), opt("version", false, "the package version")]);
112
+ const SHORT = Object.freeze({ help: "h", version: "v" });
113
+ const NO_FORMS = Object.freeze([]);
85
114
  const READ_JSON = [JSON_OPT];
86
115
  const HARNESS_OPT = opt("harness", "<id>", "the harness — and, without a terminal, the confirmation (a terminal is asked); there is no --yes", true);
87
116
  const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
@@ -90,101 +119,116 @@ const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it",
90
119
  // "no host command" true by construction instead of by recognising the root.
91
120
  const NO_REGISTER_OPT = opt("no-register", false, "leave the plugin registration alone: change only this project's files");
92
121
  const VERBOSE_OPT = opt("verbose", false, "every row's reasoning, each step's why and the host's own notes");
122
+ // doctor's --verbose is the same flag in its own words: on a terminal, the
123
+ // report the pipe gets — every note, every instance, each message whole.
124
+ const DOCTOR_VERBOSE_OPT = opt(VERBOSE_OPT.name, VERBOSE_OPT.arg, "on a terminal too: every note, every instance, whole messages");
93
125
  const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, VERBOSE_OPT, JSON_OPT];
94
126
  const UNINSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, opt("global", false, "also remove the harness-global plugin registration"), VERBOSE_OPT, JSON_OPT];
95
127
 
96
128
  export const VERBS = Object.freeze([
97
129
  Object.freeze({
98
- verb: "doctor", summary: "Check the install wiring and the vault's consistency.",
130
+ verb: "doctor", summary: "Check the install wiring and the vault's consistency.", usage: NO_FORMS,
99
131
  module: "./doctor.mjs", wraps: "script", how: "spawn", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze(["doctor"]),
100
- options: [opt("install", false, "only the install section"), opt("vault", false, "only the vault section"), JSON_OPT],
132
+ options: [opt("install", false, "only the install section"), opt("vault", false, "only the vault section"), DOCTOR_VERBOSE_OPT, JSON_OPT],
101
133
  run: runDoctor,
102
134
  }),
103
135
  Object.freeze({
104
- verb: "reconcile", summary: "Regenerate the derived views (kanban, code map, graph, indexes).",
136
+ verb: "reconcile", summary: "Regenerate the derived views (kanban, code map, graph, indexes).", usage: NO_FORMS,
105
137
  module: "./reconcile.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
106
138
  options: [opt("write", false, "apply the regeneration (asks on a terminal; --only names what is written and confirms headless)"), opt("only", "<target>", "one derived view"), JSON_OPT],
107
139
  run: runReconcile,
108
140
  }),
109
141
  Object.freeze({
110
- verb: "plan", summary: "Show what install would write for a harness, without writing.",
142
+ verb: "plan", summary: "Show what install would write for a harness, without writing.", usage: NO_FORMS,
111
143
  module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
112
144
  options: INSTALL_OPTS, run: runInstallVerb,
113
145
  }),
114
146
  Object.freeze({
115
- verb: "install", summary: "Install projectstore's surfaces for a harness, behind a preview.",
147
+ verb: "install", summary: "Install projectstore's surfaces for a harness, behind a preview.", usage: NO_FORMS,
116
148
  module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
117
149
  options: INSTALL_OPTS, run: runInstallVerb,
118
150
  }),
119
151
  Object.freeze({
120
- verb: "uninstall", summary: "Remove what install wrote, and only that.",
152
+ verb: "uninstall", summary: "Remove what install wrote, and only that.", usage: NO_FORMS,
121
153
  module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
122
154
  options: UNINSTALL_OPTS, run: runInstallVerb,
123
155
  }),
124
156
  Object.freeze({
125
- verb: "upgrade", summary: "Re-run install after a plugin update; re-stamps what this installation wrote and leaves the rest.",
157
+ verb: "upgrade", summary: "After an update, refresh what install wrote and leave the rest.", usage: NO_FORMS,
126
158
  module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
127
159
  options: INSTALL_OPTS, run: runInstallVerb,
128
160
  }),
129
161
  Object.freeze({
130
- verb: "status", summary: "The binding, what is in progress, and whether the derived views are fresh.",
162
+ verb: "status", summary: "What is bound, what is in progress, and whether views are fresh.", usage: NO_FORMS,
131
163
  module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze(["status"]),
132
164
  options: READ_JSON, run: runRead("status"),
133
165
  }),
134
166
  Object.freeze({
135
- verb: "orientation", summary: "The SessionStart skeleton and the facts behind it.",
167
+ verb: "orientation", summary: "The SessionStart skeleton and the facts behind it.", usage: NO_FORMS,
136
168
  module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["orientation"]),
137
169
  options: READ_JSON, run: runRead("orientation"),
138
170
  }),
139
171
  Object.freeze({
140
- verb: "search", summary: "Find a phrase in the vault's artifacts — deterministic, bounded, no shell.",
172
+ verb: "search", summary: "Find a phrase in the vault's artifacts, literal and bounded.", usage: Object.freeze(["<phrase…>"]),
141
173
  module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["search"]),
142
174
  options: [opt("kind", "<type>", "only artifacts of this kind", true), opt("status", "<status>", "only artifacts in this status"), opt("limit", "<n>", `at most n matches (default ${SEARCH_DEFAULT_LIMIT}, hard cap 100)`), opt("include-derived", false, "search the derived views too"), opt("case-sensitive", false, "match case"), JSON_OPT],
143
175
  run: runRead("search"),
144
176
  }),
145
177
  Object.freeze({
146
- verb: "show", summary: "One artifact: its frontmatter, and its body or one section on request.",
178
+ verb: "show", summary: "One artifact's frontmatter; its body or a section on request.", usage: Object.freeze(["<path>"]),
147
179
  module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["get_artifact"]),
148
180
  options: [opt("body", false, "include the body"), opt("section", "<id>", "one section by its registry id (description, acceptance, …)"), JSON_OPT],
149
181
  run: runRead("show"),
150
182
  }),
151
183
  Object.freeze({
152
- verb: "graph", summary: "graph neighbors <path> | graph lineage <path> — the live link graph by vault path.",
184
+ verb: "graph", summary: "The live link graph by vault path.", usage: Object.freeze(["neighbors <path>", "lineage <path>"]),
153
185
  module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["neighbors", "lineage"]),
154
- options: [opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its four)", true), opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"), opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`), opt("limit", "<n>", `neighbors: cap per direction (≤ ${GRAPH_EDGE_CAP})`), JSON_OPT],
186
+ options: [opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its four)", true), opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"), opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`), opt("limit", "<n>", `neighbors: cap per direction (at most ${GRAPH_EDGE_CAP})`), JSON_OPT],
155
187
  run: runGraph,
156
188
  }),
157
189
  Object.freeze({
158
- verb: "codemap", summary: "codemap --for <selector> — which code an epic or artifact maps to, or which artifacts map to a path.",
190
+ verb: "codemap", summary: "The code an epic or artifact maps to, or a path's artifacts.", usage: Object.freeze(["--for <selector>"]),
159
191
  module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["code_refs"]),
160
192
  options: [opt("for", "<selector>", "an epic id, an artifact, or a repo path"), opt("reverse", false, "read the selector as a path even if it names an artifact"), JSON_OPT],
161
193
  run: runCodemap,
162
194
  }),
163
195
  Object.freeze({
164
- verb: "agents", summary: "agents model <name> | agents show | agents configure — the harness overlay's agents block (ADR-008, read per invocation).",
196
+ verb: "agents", summary: "The harness overlay's agents block: the model each agent runs.", usage: Object.freeze(["model <name>", "show", "configure"]),
165
197
  module: "./lib.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
166
198
  options: [opt("harness", "<id>", "configure: the overlay to write — and, non-interactively, the confirmation; there is no --yes", true), opt("default", "<model>", "configure: agents.default.model (pins the clerk to sonnet unless --agent clerk=… says otherwise; an empty model clears it)"), opt("agent", "<name>=<model>", "configure: agents.per_agent.<name>.model (an empty model removes the key)", true), opt("reset", false, "configure: empty the agents block first; --default and --agent given with it apply on top"), JSON_OPT],
167
199
  run: runAgents,
168
200
  }),
169
201
  Object.freeze({
170
- verb: "bind", summary: "bind <vault> — bind this project to an existing vault (naming the vault is the confirmation).",
202
+ verb: "band", summary: "The band above the prompt: switch it, pin an epic, ask why.", usage: Object.freeze(["on", "off", "status", "epic <ID>"]),
203
+ module: "./band.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
204
+ options: [JSON_OPT],
205
+ run: runBand,
206
+ }),
207
+ Object.freeze({
208
+ verb: "bind", summary: "Bind this project to an existing vault; naming it confirms.", usage: Object.freeze(["<vault>"]),
171
209
  module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
172
210
  options: [opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`), opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`), opt("rebind", false, "point an already bound project at another vault; every other setting is kept"), JSON_OPT],
173
211
  run: runBind(false),
174
212
  }),
175
213
  Object.freeze({
176
- verb: "init", summary: `init <vault> — create the vault directory and bind to it; the layout's folders come from ${commandForm("scaffold")}.`,
214
+ verb: "init", summary: "Create a vault (folder, git repository, READMEs) and bind to it.", usage: Object.freeze(["<vault>"]),
177
215
  module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
178
216
  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],
179
217
  run: runBind(true),
180
218
  }),
181
219
  Object.freeze({
182
- verb: "mcp", summary: "Serve the read tools over MCP (stdio) for the project named by --project or PROJECTSTORE_PROJECT_DIR; never the ambient cwd.",
220
+ verb: "scaffold", summary: "Create the vault's missing folders and READMEs; rewrite nothing.", usage: NO_FORMS,
221
+ module: "./scaffold.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
222
+ options: [opt("write", false, "create them (asks at a terminal; without one, --write is the confirmation)"), JSON_OPT],
223
+ run: runScaffold,
224
+ }),
225
+ Object.freeze({
226
+ verb: "mcp", summary: "Serve the read tools over MCP (stdio) for the project you name.", usage: NO_FORMS,
183
227
  module: "./mcp.mjs", wraps: "new", how: "import", output: "text", writes: false, requiresBinding: false, mcp: Object.freeze([]),
184
228
  options: [], run: runMcp,
185
229
  }),
186
230
  Object.freeze({
187
- verb: "version", summary: "Print the package version (also --version).",
231
+ verb: "version", summary: "Print the package version (also --version).", usage: NO_FORMS,
188
232
  module: null, wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
189
233
  options: [JSON_OPT], run: runVersion,
190
234
  }),
@@ -196,24 +240,39 @@ export const VERBS = Object.freeze([
196
240
  // story that adds a verb in slices.
197
241
  export const PLANNED_VERBS = Object.freeze([]);
198
242
 
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.
243
+ // The verbs as a person meets them (presentation spec contract 10): starting,
244
+ // reading the vault, keeping it consistent. Every verb not listed here is
245
+ // ADVANCED, one line of names in table order — so a new row is never hidden
246
+ // by this table, and init and scaffold, which the contract names in no group,
247
+ // are there too. A group lists only the verbs this run's table has. Once
248
+ // setup exists, START becomes setup and uninstall, and install and bind move
249
+ // to ADVANCED.
202
250
  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"]],
251
+ ["START", ["install", "bind", "uninstall"]],
252
+ ["VAULT", ["status", "search", "show", "graph", "codemap"]],
253
+ ["CHECK AND REPAIR", ["doctor", "reconcile", "upgrade"]],
207
254
  ];
255
+ // The column a group's summaries start at: two spaces, the verb in twelve,
256
+ // one space. A summary fits 80 columns from there, so each is at most 65
257
+ // characters, in ASCII (tests/presentation.test.mjs, criterion 5, holds every
258
+ // row to it).
259
+ const VERB_COLUMN = 12;
260
+ export const SUMMARY_MAX = 80 - (2 + VERB_COLUMN + 1);
208
261
 
209
262
  // Examples per verb. `{cmd}` is how this run was invoked (a shell's own name
210
263
  // when PROJECTSTORE_SHELL says so, else the core — a shell passes the read
211
264
  // verbs through, so its name serves them too), `{h}` the harness argument the
212
- // core needs and a shell fixes.
265
+ // core needs and a shell fixes. A `{core}` example is the core's alone: under
266
+ // a shell `{h}` is empty, so it would repeat the line above it and say what
267
+ // the shell's own run does not. `{switch.surface}`, `{switch.host}` and
268
+ // `{switch.doctor}` name the harness whose project can switch channels — the
269
+ // one with a host plugin registration — from its manifest: its surface key,
270
+ // its display name and its doctor form (switchHarness, below); an example
271
+ // naming them is left out when no manifest has one.
213
272
  const EXAMPLES = {
214
273
  install: ["{cmd} install{h}", "{cmd} plan{h} # the same plan; nothing is written"],
215
274
  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"],
275
+ uninstall: ["{core}{cmd} uninstall # every harness this project uses", "{cmd} uninstall{h}", "{cmd} uninstall{h} --surface statusline", "{core}{cmd} uninstall{h} --surface {switch.surface} # back to the git marketplace after a switch, then restart {switch.host} and run {switch.doctor} in it"],
217
276
  plan: ["{cmd} plan{h}", "{cmd} plan{h} --json # one envelope, for scripts and agents"],
218
277
  status: ["{cmd} status", "{cmd} status --json"],
219
278
  orientation: ["{cmd} orientation --json"],
@@ -225,93 +284,149 @@ const EXAMPLES = {
225
284
  reconcile: ["{cmd} reconcile # what would change; nothing is written", "{cmd} reconcile --write"],
226
285
  bind: ["{cmd} bind ~/vaults/my-project", "{cmd} bind ~/vaults/other --rebind"],
227
286
  init: ["{cmd} init ~/vaults/new-project --language ru"],
287
+ scaffold: ["{cmd} scaffold # what would be created; nothing is written", "{cmd} scaffold --write"],
228
288
  agents: ["{cmd} agents show", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
289
+ band: ["{cmd} band status", "{cmd} band off", "{cmd} band epic PS-CORE # a session with no epic of its own shows this one"],
229
290
  mcp: ['{cmd} mcp --project "$PWD"'],
230
291
  };
231
292
 
232
- // Positional arguments a summary does not already name.
233
- const ARGS = { search: "<phrase>", show: "<path>" };
293
+ // How this run was invoked is invokedAs (harness.mjs), the one spelling,
294
+ // which status's and bind's next commands print too.
234
295
 
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) };
296
+ // The harness whose project can switch channels (the converge story's rule
297
+ // 4): the one whose manifest declares a host plugin registration. Its surface
298
+ // key, display name and doctor form, from the manifest — `invocation()` in
299
+ // harness.mjs, never a literal; null when no manifest has one.
300
+ function switchHarness() {
301
+ for (const m of loadHarnesses().values()) {
302
+ const row = Object.entries(m.surfaces || {}).find(([k, s]) => !k.startsWith("_") && s?.format === "host-plugin-registration");
303
+ if (row) return { "switch.surface": row[0], "switch.host": m.display_name, "switch.doctor": invocation(m, "doctor", { args: "--fix" }) };
242
304
  }
243
- return { forms: [ARGS[row.verb] || ""], about: row.summary };
305
+ return null;
244
306
  }
245
307
 
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)" : ""}`]);
308
+ // An aligned options block: each option's form in a column at most 28 wide,
309
+ // its description beside it — or, past that column, on the next line under
310
+ // it. On a live terminal a description wraps with its column as the hanging
311
+ // indent; anywhere else it stays one line, whole, for a reader that greps.
312
+ function optionLines(options, c = term.PLAIN) {
313
+ const rows = options.map((o) => [term.glyphText(c, `--${o.name}${o.arg ? " " + o.arg : ""}`), term.glyphText(c, `${o.summary}${o.multiple ? " (repeatable)" : ""}`)]);
253
314
  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}`));
315
+ const pad = " ".repeat(2 + w + 2);
316
+ const text = (r) => term.wrap(r, c.live ? c.width : 0, pad);
317
+ return rows.map(([l, r]) => (l.length > w ? ` ${l}\n${pad}${text(r)}` : ` ${l.padEnd(w)} ${text(r)}`));
255
318
  }
256
319
 
257
- const EXIT_CODES = "Exit codes 0 ok · 1 findings or a refusal · 2 usage · 3 not bound";
320
+ const EXIT_CODES = "0 ok, 1 findings or a refusal, 2 usage, 3 not bound, 130 cancelled";
321
+ // The facts that close a help screen (`Harnesses`, `Exit codes`): the key in
322
+ // the explanation role, its value at one column for both.
323
+ const FACT_KEY = "Exit codes".length;
324
+ const fact = (c, key, value) => `${term.painter(c)("explanation", key)}${" ".repeat(FACT_KEY - key.length + 2)}${value}`;
325
+
326
+ // What the bin is, in the help's badge: one line with the name and version,
327
+ // short enough that a prerelease version (0.30.0-rc.12) still fits 80 columns.
328
+ const PURPOSE = "project memory for coding agents, in markdown";
329
+
330
+ // What a verb's help says beneath its Usage when the summary has no room for
331
+ // it: here, how mcp is told its project.
332
+ const NOTES = {
333
+ mcp: "It serves the project --project names, else PROJECTSTORE_PROJECT_DIR or the host session's project; never the directory it was started in.",
334
+ };
335
+
336
+ // The global options, as every verb takes them.
337
+ const EVERY_VERB = Object.freeze([
338
+ opt("project", "<dir>", "the project (default: the session's, else this directory)"),
339
+ opt("json", false, "the result as one JSON envelope, for scripts and agents"),
340
+ opt("version", false, "the package version"),
341
+ ]);
258
342
 
259
- export function usage(env = process.env) {
260
- const { cmd } = invocation(env);
343
+ // Top-level help (presentation spec contract 10): the badge — name, version,
344
+ // purpose; Usage; the named groups, each verb in the action role and its
345
+ // summary on one line; ADVANCED, one line of the remaining verbs; the options
346
+ // every verb takes; examples, each command alone on its line (contract 6);
347
+ // the tips, whose plan command stands alone too; the harnesses and the exit
348
+ // codes. `verbs` is the table this run answers for — VERBS, or a test's own.
349
+ // `caps` is the writing stream's (term.mjs): painted at a terminal, plain on a
350
+ // pipe, and every line of it fits 80 columns either way.
351
+ export function usage(env = process.env, verbs = VERBS, { caps: c = term.PLAIN, version = packageVersion() } = {}) {
352
+ const { shell, cmd } = invokedAs(env);
353
+ const paint = term.painter(c);
354
+ const width = c.live ? c.width : 0;
355
+ const prose = (text) => " " + term.wrap(term.glyphText(c, text), width, " ");
261
356
  const lines = [
262
- "projectstore — project memory for coding agents: decisions, specs, epics and stories as plain markdown.",
357
+ term.badge(c, "projectstore", [version, PURPOSE]),
263
358
  "",
264
- "Usage",
265
- ` ${cmd} <verb> [options]`,
266
- ` ${cmd} <verb> --help one verb's options and examples`,
359
+ term.heading(c, "Usage"),
360
+ ...term.rows(c, [{ target: `${cmd} <verb> [options]`, note: "run it inside your code project" }, { target: `${cmd} <verb> --help`, note: "one verb's options and examples" }]),
267
361
  ];
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) {
362
+ const listed = new Set(HELP_GROUPS.flatMap(([, names]) => names));
363
+ for (const [title, names] of HELP_GROUPS) {
364
+ const rows = names.map((n) => verbs.find((v) => v.verb === n)).filter(Boolean);
274
365
  if (!rows.length) continue;
275
- lines.push("", title);
276
- for (const v of rows) lines.push(` ${v.verb.padEnd(12)} ${v.summary}`);
366
+ lines.push("", term.heading(c, title));
367
+ for (const v of rows) {
368
+ const lead = ` ${paint("action", v.verb)}${" ".repeat(Math.max(0, VERB_COLUMN - v.verb.length))} `;
369
+ lines.push(lead + term.wrap(term.glyphText(c, v.summary), width, " ".repeat(term.plain(lead).length)));
370
+ }
277
371
  }
372
+ const advanced = verbs.filter((v) => !listed.has(v.verb));
373
+ if (advanced.length) lines.push("", term.heading(c, "ADVANCED"), " " + term.wrap(advanced.map((v) => paint("action", v.verb)).join(` ${term.icon(c, "dot")} `), width, " "));
278
374
  if (PLANNED_VERBS.length) lines.push("", `Planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
375
+ // A command is offered only for a verb this table has.
376
+ const h = shell ? "" : " --harness <id>";
377
+ const has = (verb) => verbs.some((v) => v.verb === verb);
378
+ const examples = [["install", `${cmd} install${h}`], ["status", `${cmd} status`], ["doctor", `${cmd} doctor`]].filter(([v]) => has(v)).map(([, x]) => x);
379
+ lines.push("", term.heading(c, "Options"), ...optionLines(EVERY_VERB, c));
380
+ if (examples.length) lines.push("", term.heading(c, "Examples"), ...term.commandBlock(c, examples));
279
381
  lines.push(
280
382
  "",
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,
383
+ term.heading(c, "Tips"),
384
+ prose("install, upgrade and uninstall show their plan, then ask at a terminal."),
385
+ prose(`Without a terminal, ${shell ? "the verb is its own" : "--harness is the"} confirmation ${term.icon(c, "dash")} there is no --yes.`),
386
+ prose("Unattended in a terminal: pass --json, set CI=1, or give stdin </dev/null."),
387
+ prose("--verbose on those verbs adds every row's reasoning and the host's notes."),
294
388
  );
389
+ if (has("plan")) lines.push(prose("plan prints the same plan and writes nothing:"), ...term.commandBlock(c, [`${cmd} plan${h}`]));
390
+ lines.push("", fact(c, "Harnesses", harnessIds().join(", ")), fact(c, "Exit codes", EXIT_CODES));
295
391
  return lines.join("\n");
296
392
  }
297
393
 
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);
394
+ // One verb: what it does, how to call it, every option, examples. The Usage
395
+ // lines are the row's own forms, the same declaration that bounds its
396
+ // arguments — never parsed out of the summary, so help and the parser cannot
397
+ // disagree about what a verb takes. Its first line is the summary the
398
+ // top-level help lists, after the command; `caps` as usage()'s.
399
+ export function verbHelp(row, env = process.env, { caps: c = term.PLAIN } = {}) {
400
+ const { shell, cmd } = invokedAs(env);
401
+ const paint = term.painter(c);
301
402
  const fixed = Boolean(shell) && row.options.some((o) => o.name === "harness");
302
403
  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] || [];
404
+ const prose = (text) => " " + term.wrap(term.glyphText(c, text), c.live ? c.width : 0, " ");
405
+ const head = `${paint("emphasis", `${cmd} ${row.verb}`)} ${term.icon(c, "dash")} ${term.glyphText(c, row.summary)}`;
406
+ const lines = [term.wrap(head, c.live ? c.width : 0, " "), "", term.heading(c, "Usage")];
407
+ for (const f of row.usage.length ? row.usage : [""]) lines.push(` ${cmd} ${row.verb}${f ? " " + term.glyphText(c, f) : ""}${opts.length ? " [options]" : ""}`);
408
+ if (NOTES[row.verb]) lines.push("", prose(NOTES[row.verb]));
409
+ if (opts.length) lines.push("", term.heading(c, "Options"), ...optionLines(opts, c));
410
+ if (fixed) lines.push("", term.wrap(`${shell} names the harness itself: without a terminal, the verb is its own confirmation.`, c.live ? c.width : 0, " "));
411
+ else if (row.options.some((o) => o.name === "harness")) lines.push("", fact(c, "Harnesses", harnessIds().join(", ")));
412
+ const sw = switchHarness();
413
+ const ex = (EXAMPLES[row.verb] || []).filter((e) => !(fixed && e.startsWith("{core}")) && (sw || !e.includes("{switch."))).map((e) => e.replace(/^\{core\}/, "").replace(/\{(switch\.\w+)\}/g, (_, k) => sw[k]));
310
414
  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);
415
+ // Each example command stands alone on its line, after a blank one
416
+ // (contract 6): nothing after it, so a paste takes exactly the command — a
417
+ // trailing `# note` broke a paste into a shell that reads no comments. A
418
+ // note leads its command, a blank line between them, as every other
419
+ // introduced command does (DONE's tip, doctor's fix, bind's Next);
420
+ // commands with no note share one block.
421
+ const blocks = [];
422
+ for (const [x, note] of ex.map((e) => e.split("{cmd}").join(cmd).split("{h}").join(h).split(" # "))) {
423
+ const last = blocks.at(-1);
424
+ if (!note && last && !last.note) last.cmds.push(x);
425
+ else blocks.push({ note: note ? `${note[0].toUpperCase()}${note.slice(1)}:` : null, cmds: [x] });
426
+ }
427
+ if (blocks.length) lines.push("", term.heading(c, "Examples"));
428
+ for (const b of blocks) lines.push("", ...(b.note ? [prose(paint("explanation", b.note)), ""] : []), ...b.cmds.map((x) => ` ${paint("action", x)}`));
429
+ lines.push("", fact(c, "Exit codes", EXIT_CODES));
315
430
  return lines.join("\n");
316
431
  }
317
432
 
@@ -333,75 +448,218 @@ export function nearest(word, names) {
333
448
 
334
449
  // ─── run ───────────────────────────────────────────────────────────────
335
450
 
336
- export async function run(argv, { env = process.env, cwd = process.cwd(), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, ask = null } = {}) {
337
- let parsed;
338
- try {
339
- parsed = parseArgs({
340
- args: argv,
341
- allowPositionals: true,
342
- strict: true,
343
- options: {
344
- project: { type: "string" }, json: { type: "boolean" }, help: { type: "boolean", short: "h" }, version: { type: "boolean", short: "v" },
345
- harness: { type: "string", multiple: true }, surface: { type: "string", multiple: true }, global: { type: "boolean" }, "no-register": { type: "boolean" }, verbose: { type: "boolean" },
346
- write: { type: "boolean" }, only: { type: "string" }, install: { type: "boolean" }, vault: { type: "boolean" },
347
- kind: { type: "string", multiple: true }, status: { type: "string" }, limit: { type: "string" }, "include-derived": { type: "boolean" }, "case-sensitive": { type: "boolean" },
348
- body: { type: "boolean" }, section: { type: "string" }, direction: { type: "string" }, depth: { type: "string" }, for: { type: "string" }, reverse: { type: "boolean" },
349
- layout: { type: "string" }, language: { type: "string" }, rebind: { type: "boolean" },
350
- default: { type: "string" }, agent: { type: "string", multiple: true }, reset: { type: "boolean" },
351
- },
352
- });
353
- } catch (e) {
354
- // --json cannot be known before parsing; a raw scan is enough here.
355
- if (argv.includes("--json")) stdout.write(JSON.stringify(envelope(argv.find((a) => !a.startsWith("-")) || null, null, false, { error: e.message }), null, 2) + "\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`);
364
- return 2;
451
+ // Rule 1 of the per-verb parse: the verb is found before anything is parsed.
452
+ // Before it only the globals' arity is known; after it, its row's too. Left
453
+ // to right, to the first "--": a "--" before the verb makes the next token
454
+ // the verb (`projectstore -- doctor`), and one after it leaves the rest to
455
+ // the verb, so the MCP server's `-- <query>` is never read as a global. Only
456
+ // the exact tokens set a flag: `--json=v` is a global with a bad value, which
457
+ // the parse words (rule 6), not a verb's option. A bare --project takes the
458
+ // next token whatever it is, as parseArgs does, and so does a bare option of
459
+ // the verb's that takes a value: `reconcile --only --help` and the MCP
460
+ // server's `--kind -h` hand the parse a value it refuses, never the help.
461
+ // Any other option before the verb is misplaced (rule 3). A bare word right
462
+ // after one is presumed to be its value when it is not a verb and some verb
463
+ // gives that option a value — for the message only (`--harness codex plan`
464
+ // reads back as `plan --harness codex`); nothing is parsed on that guess.
465
+ function scanArgv(argv, verbs) {
466
+ const scan = { verb: null, at: -1, json: false, help: false, version: false, misplaced: [] };
467
+ const takesValue = (row, name) => row.options.some((o) => o.name === name && o.arg);
468
+ let row = null;
469
+ for (let i = 0; i < argv.length; i++) {
470
+ const a = argv[i];
471
+ if (a === "--") {
472
+ if (scan.at === -1 && i + 1 < argv.length) { scan.verb = argv[i + 1]; scan.at = i + 1; }
473
+ break;
474
+ }
475
+ if (a.length > 1 && a.startsWith("-")) {
476
+ if (/^-[hv]+$/.test(a)) {
477
+ if (a.includes("h")) scan.help = true;
478
+ if (a.includes("v")) scan.version = true;
479
+ continue;
480
+ }
481
+ if (a === "--json") scan.json = true;
482
+ if (a === "--help") scan.help = true;
483
+ if (a === "--version") scan.version = true;
484
+ const name = a.startsWith("--") ? a.slice(2).split("=")[0] : null;
485
+ const inline = a.includes("=");
486
+ if (!inline && (name === "project" || (row && takesValue(row, name)))) i++;
487
+ else if (scan.at === -1 && !GLOBAL_OPTS.some((o) => o.name === name)) scan.misplaced.push({ name, raw: a, at: i, inline, value: undefined });
488
+ continue;
489
+ }
490
+ if (scan.at !== -1) continue;
491
+ const prev = scan.misplaced[scan.misplaced.length - 1];
492
+ if (!verbs.some((v) => v.verb === a) && prev && prev.at === i - 1 && !prev.inline && verbs.some((v) => takesValue(v, prev.name))) { prev.value = a; continue; }
493
+ scan.verb = a;
494
+ scan.at = i;
495
+ row = verbs.find((v) => v.verb === a) || null;
496
+ }
497
+ return scan;
498
+ }
499
+
500
+ // A row's options and the globals, in parser order: the later declaration
501
+ // wins, so a row cannot retype a global. No row (no verb) is the globals alone.
502
+ const optionsOf = (row) => [...(row ? row.options : []), ...GLOBAL_OPTS];
503
+
504
+ // Rule 2: the row is the parser — arg is the type, multiple the repetition.
505
+ function parserOptions(row) {
506
+ const options = {};
507
+ for (const o of optionsOf(row)) options[o.name] = { type: o.arg ? "string" : "boolean", ...(o.multiple ? { multiple: true } : {}), ...(SHORT[o.name] ? { short: SHORT[o.name] } : {}) };
508
+ return options;
509
+ }
510
+
511
+ // "install does not take --verbos — did you mean --verbose?" With a row the
512
+ // candidates are its options and the globals; without one (no verb, or a
513
+ // word that is none), every option the table declares, as 0.29.2 offered.
514
+ function unknownOption(row, raw, verbs) {
515
+ const names = (row ? optionsOf(row) : [...verbs.flatMap((v) => v.options), ...GLOBAL_OPTS]).map((o) => o.name);
516
+ const hint = nearest(raw.replace(/^-+/, ""), [...new Set(names)]);
517
+ return `${row ? `${row.verb} does not take` : "unknown option"} ${raw}${hint ? ` — did you mean --${hint}?` : ""}`;
518
+ }
519
+
520
+ // Rule 6: Node's own wording explains "--" positionals; a person who typed
521
+ // --verbos wants the option they meant, and one who left --only bare wants
522
+ // its form. A bad value comes in three shapes — an inline value on a flag
523
+ // (`--json=v`), none at all (`--only` last), one that looks like an option
524
+ // (`--only --json`) — and each quotes the long name, with ` <value>` or a
525
+ // `-h, ` short form around it: the declaration words the answer. Node's text
526
+ // is the fallback when no name can be read.
527
+ function parseFailure(e, row, verbs) {
528
+ const raw = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'(-{1,2}[^']+)'/.exec(e.message)?.[1] : null;
529
+ if (raw) return unknownOption(row, raw, verbs);
530
+ const name = e.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE" ? /'(?:-[^-], )?(--[^' ]+)(?: <value>)?'/.exec(e.message)?.[1] : null;
531
+ const o = name ? optionsOf(row).findLast((x) => `--${x.name}` === name) : null;
532
+ if (!o) return e.message;
533
+ return o.arg ? `\`--${o.name}\` needs a value: \`--${o.name} ${o.arg}\`` : `\`--${o.name}\` takes no value`;
534
+ }
535
+
536
+ // Rule 4: a usage form, word by word — a --name the form requires (the word
537
+ // after it is the option's value when it takes one, not an argument), a
538
+ // <placeholder> (`<phrase…>` takes the rest), or a literal sub-word. The
539
+ // bound is how many arguments the form holds.
540
+ function formOf(row, form) {
541
+ const f = { form, literals: [], required: [], placeholders: 0, variadic: false };
542
+ const words = form.split(" ");
543
+ for (let i = 0; i < words.length; i++) {
544
+ const w = words[i];
545
+ if (w.startsWith("--")) {
546
+ f.required.push(w.slice(2));
547
+ if (row.options.find((o) => o.name === w.slice(2))?.arg) i++;
548
+ } else if (w.startsWith("<")) {
549
+ f.placeholders++;
550
+ if (w.endsWith("…>")) f.variadic = true;
551
+ } else f.literals.push(w);
552
+ }
553
+ f.bound = f.variadic ? Infinity : f.literals.length + f.placeholders;
554
+ return f;
555
+ }
556
+
557
+ // The form the arguments take: the first whose literal words lead them and
558
+ // whose required options were given. null when none does, and the verb's own
559
+ // message then says what it takes ("agents takes model <name>, show, or
560
+ // configure") — which is how `codemap PS-CORE` keeps its --for hint while
561
+ // `codemap --for X extra` names `extra`. A row with no forms binds nothing.
562
+ function matchForm(row, args, values) {
563
+ if (!row.usage.length) return { form: "", bound: 0 };
564
+ for (const form of row.usage) {
565
+ const f = formOf(row, form);
566
+ if (f.literals.every((w, i) => args[i] === w) && f.required.every((n) => values[n] !== undefined)) return f;
365
567
  }
366
- const { values, positionals } = parsed;
568
+ return null;
569
+ }
570
+
571
+ // `verbs` is the table this run answers for: VERBS, or a test's own — the
572
+ // scan, the help, the verb lookup and every hint read it, never VERBS behind it.
573
+ export async function run(argv, { verbs = VERBS, env = process.env, cwd = process.cwd(), stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, ask = null } = {}) {
574
+ const scan = scanArgv(argv, verbs);
575
+ const row = verbs.find((v) => v.verb === scan.verb) || null;
367
576
  // A failure before a verb runs still answers in the envelope under --json:
368
- // a consumer (the MCP server, a script) always has something to parse.
369
- const fail = (verb, project, message, code, { help = false } = {}) => {
370
- if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: message, exit: code }), null, 2) + "\n");
371
- stderr.write(message + "\n" + (help ? usage(env) + "\n" : ""));
577
+ // a consumer (the MCP server, a script) always has something to parse. The
578
+ // verb is the scan's, whichever step failed (rule 7) — never the first
579
+ // bare word, which may be --project's value.
580
+ const fail = (message, code, { project = null, help = false } = {}) => {
581
+ if (scan.json) stdout.write(JSON.stringify(envelope(scan.verb, project, false, { error: message, exit: code }), null, 2) + "\n");
582
+ stderr.write(message + "\n" + (help ? usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n" : ""));
372
583
  return code;
373
584
  };
585
+ // A usage error says where the options are: the verb's help, else the top
586
+ // (a word that is not a verb has no help to point at).
587
+ const refuse = (message) => fail(`${message}\nRun ${row ? `\`${row.verb} --help\`` : "--help"} for the options.`, 2);
374
588
  // In-process reads resolve layouts and registries from THIS package, as the
375
589
  // children already do through ownEnv — not from whichever copy the host
376
590
  // session's variable points at.
377
591
  pinPluginRoot(PACKAGE_ROOT);
378
- if (values.version) return runVersion({ values, stdout });
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; }
384
- const verb = positionals[0];
385
- const row = VERBS.find((v) => v.verb === verb);
592
+ // Rule 8: help and version answer first, whatever else the line holds, so
593
+ // `doctor extra --help` and `--harness codex plan --help` print the help.
594
+ if (scan.version) return runVersion({ values: { json: scan.json }, stdout });
595
+ // Help is painted at a terminal and plain on a pipe: the caps are stdout's.
596
+ if (scan.help) {
597
+ const caps = term.caps(stdout, env);
598
+ stdout.write((row ? verbHelp(row, env, { caps }) : usage(env, verbs, { caps })) + "\n");
599
+ return 0;
600
+ }
601
+ // With no verb to scope it, an option no verb declares is unknown before
602
+ // anything else is said, as in 0.29.2: `--bogus frobnicate` names --bogus,
603
+ // and `--verbos instal` offers --verbose.
604
+ const stray = row ? null : scan.misplaced.find((m) => !verbs.some((v) => v.options.some((o) => o.name === m.name)));
605
+ if (stray) return refuse(unknownOption(null, stray.raw.split("=")[0], verbs));
606
+ if (scan.verb === null) {
607
+ // No verb. A verb's option (`--harness codex`, `--verbose`) is the usage,
608
+ // as before — not "unknown verb: codex"; otherwise the globals alone are
609
+ // parsed, so a bad value of one (`--project` last) is named.
610
+ if (!scan.misplaced.length) {
611
+ try { parseArgs({ args: argv, options: parserOptions(null), strict: true, allowPositionals: true }); } catch (e) { return refuse(parseFailure(e, null, verbs)); }
612
+ }
613
+ stderr.write(usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n");
614
+ return 2;
615
+ }
616
+ const verb = scan.verb;
386
617
  if (!row) {
387
618
  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);
390
- return fail(verb, null, (planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`), 2, { help: true });
391
- }
392
- // The options map is global (parseArgs), the rows are not: an option a row
393
- // does not declare is a usage error, so help cannot lie about what a verb
394
- // takes.
395
- const GLOBAL = new Set(["project", "json", "help", "version"]);
396
- const declared = new Set(row.options.map((o) => o.name));
397
- const stray = Object.keys(values).filter((k) => !GLOBAL.has(k) && !declared.has(k));
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);
619
+ const guess = planned ? null : nearest(verb, verbs.map((v) => v.verb));
620
+ if (guess) return fail(`unknown verb: ${verb} — did you mean ${guess}?\nRun --help for every verb.`, 2);
621
+ return fail((planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`), 2, { help: true });
622
+ }
623
+ // Rule 3: only the globals come before the verb (git's model). A verb's
624
+ // own option there is shown in its place; one the verb does not declare is
625
+ // unknown, scoped to the verb. Accepting it would need its arity before the
626
+ // verb is known, which is what per-verb types make ambiguous.
627
+ if (scan.misplaced.length) {
628
+ const m = scan.misplaced[0];
629
+ const o = m.name === null ? null : row.options.find((x) => x.name === m.name);
630
+ if (!o) return refuse(unknownOption(row, m.raw.split("=")[0], verbs));
631
+ // Through a shell the harness is fixed (verbHelp's test: the row declares
632
+ // it): the order shown drops it, since the shell inserts it after the verb.
633
+ // The rest of the line follows, so the command shown is the one to run.
634
+ const { shell, cmd } = invokedAs(env);
635
+ const moved = shell && o.name === "harness" ? [] : [m.raw, ...(o.arg && m.value !== undefined ? [m.value] : [])];
636
+ return refuse(`\`--${o.name}\` belongs after the verb: \`${[cmd, verb, ...moved, ...argv.slice(scan.at + 1)].join(" ")}\``);
637
+ }
638
+ // Rule 2: one strict parse with this row's options plus the globals, on the
639
+ // full argv so a token's index is the user's (rule 4's hints read them).
640
+ let parsed;
641
+ try {
642
+ parsed = parseArgs({ args: argv, options: parserOptions(row), strict: true, allowPositionals: true, tokens: true });
643
+ } catch (e) {
644
+ return refuse(parseFailure(e, row, verbs));
645
+ }
646
+ const { values, positionals, tokens } = parsed;
647
+ // Rule 4: an argument past the matched form is named, never dropped. The
648
+ // hint says how it became one: the flag before it takes no value, or a
649
+ // "--" made everything after it an argument.
650
+ const args = tokens.filter((t) => t.kind === "positional" && t.index !== scan.at);
651
+ const form = matchForm(row, args.map((t) => t.value), values);
652
+ if (form && args.length > form.bound) {
653
+ const extra = args[form.bound];
654
+ const prev = tokens.find((t) => t.index === extra.index - 1);
655
+ const end = tokens.find((t) => t.kind === "option-terminator");
656
+ const why = prev && prev.kind === "option" && prev.value === undefined ? ` — \`--${prev.name}\` takes no value`
657
+ : end && extra.index > end.index ? " — everything after `--` is an argument" : "";
658
+ return refuse(`${form.bound ? `\`${verb} ${form.form}\` takes no further argument` : `${verb} takes no argument`} \`${extra.value}\`${why}`);
401
659
  }
402
660
  const project = resolveProject({ project: values.project, env, cwd });
403
661
  const cfg = readConfigAt(project);
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);
662
+ if (row.requiresBinding && !cfg) return fail(`${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, { project });
405
663
  try {
406
664
  return await row.run({ row, values, positionals: positionals.slice(1), cfg, project, env, cwd, stdin, stdout, stderr, ask });
407
665
  } catch (e) {
@@ -435,10 +693,12 @@ async function confirmWrite(question, { stdin, stdout, ask }) {
435
693
 
436
694
  // The read verbs: one call into query.mjs, the result in the envelope or
437
695
  // rendered. A usage error from the operation (a bad path, a missing query)
438
- // is exit 2 with the message, never a stack trace.
439
- function emitRead({ verb, values, project, stdout }, op, result, ok = true) {
696
+ // is exit 2 with the message, never a stack trace. A renderer gets the
697
+ // stream's caps — painted at a terminal, plain on a pipe — and the env its
698
+ // command forms are named for; one that takes neither ignores them.
699
+ function emitRead({ verb, values, project, stdout, env }, op, result, ok = true) {
440
700
  if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n");
441
- else stdout.write(op.render(result));
701
+ else stdout.write(op.render(result, { caps: term.caps(stdout, env), env }));
442
702
  return ok ? 0 : 1;
443
703
  }
444
704
 
@@ -490,26 +750,78 @@ function gitAuthor(project, env) {
490
750
 
491
751
  // bind / init: the vault named on the command line is the confirmation (the
492
752
  // distribution ADR's decision 6 read for a binding — there is no --yes and
493
- // nothing to ask); a change of vault needs --rebind. Exit 1 on a refusal with
494
- // the reason, 2 on usage, 0 when already bound to the same vault.
753
+ // nothing to ask, at a terminal either: init's command line names what it
754
+ // writes); a change of vault needs --rebind. Exit 1 on a refusal with the
755
+ // reason, or when init's git or scaffold step failed after the binding was
756
+ // written; 2 on usage; 0 when already bound to the same vault.
495
757
  function runBind(init) {
496
758
  return async (ctx) => {
497
759
  const { row, values, positionals, project, env, stdout, stderr } = ctx;
498
760
  const vault = positionals[0];
499
761
  if (!vault) return usageFail(Object.assign(new Error(`${row.verb} takes the vault path`), { code: "USAGE" }), { verb: row.verb, ...ctx });
500
762
  // The author is the caller's to find: the plan reads nothing ambient.
501
- const plan = planBind(project, { vault, layout: values.layout ?? null, language: values.language ?? null, rebind: Boolean(values.rebind), init, author: gitAuthor(project, env), env });
763
+ const plan = planBind(project, { vault, layout: values.layout ?? null, language: values.language ?? null, rebind: Boolean(values.rebind), init, author: gitAuthor(project, env), env, root: PACKAGE_ROOT });
502
764
  const usage = plan.refusals.find((r) => r.code === "USAGE");
503
765
  if (usage) return usageFail(Object.assign(new Error(usage.message), { code: "USAGE" }), { verb: row.verb, ...ctx });
504
766
  let done = null;
505
- if (plan.ok && plan.writes) done = applyBind(plan);
767
+ if (plan.ok && (plan.writes || plan.buildsVault)) done = applyBind(plan, { env });
768
+ const ok = plan.ok && !bindFailed(done);
506
769
  const result = bindResult(plan, done);
507
- if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, plan.ok, result), null, 2) + "\n");
508
- else (plan.ok ? stdout : stderr).write(renderBindPlan(plan, done));
509
- return plan.ok ? 0 : 1;
770
+ if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, ok, result), null, 2) + "\n");
771
+ else {
772
+ const stream = plan.ok ? stdout : stderr;
773
+ stream.write(renderBindPlan(plan, done, { env, caps: term.caps(stream, env) }));
774
+ }
775
+ return ok ? 0 : 1;
510
776
  };
511
777
  }
512
778
 
779
+ // scaffold: the bound vault's skeleton, from the binding's layout and
780
+ // language (`bind --rebind` changes them; the verb takes no layout). Bare, the
781
+ // plan, and nothing is written. --write creates the `create` rows: at an
782
+ // interactive terminal (install spec contract 9's definition) after the plan
783
+ // and `Apply N changes? [Y/n]`; without one, --write is the confirmation —
784
+ // the write set is closed, so `scaffold --write --json` writes at a terminal
785
+ // too, which is how the session command and the Codex skill confirm. A plan
786
+ // with nothing to create asks nothing and exits 0. A vault directory that
787
+ // is gone is refused: recreating it here would hide that it vanished, and
788
+ // `init` is the verb that makes a vault.
789
+ async function runScaffold(ctx) {
790
+ const { row, values, cfg, project, env, stdin, stdout, stderr, ask } = ctx;
791
+ const emit = (ok, result, text, code) => {
792
+ if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, ok, result), null, 2) + "\n");
793
+ else (ok ? stdout : stderr).write(text);
794
+ return code;
795
+ };
796
+ // readConfigAt hands back the stored path raw: `~`, relative, a trailing slash.
797
+ const norm = normaliseVaultPath(cfg.vault_path, project);
798
+ const vault = typeof norm === "string" ? norm : null;
799
+ if (!vault) {
800
+ const why = `the binding names no usable vault path${norm && norm.error ? ` (${norm.error})` : ""} — rebind with \`projectstore bind <vault> --rebind\``;
801
+ return emit(false, { error: why }, why + "\n", 1);
802
+ }
803
+ if (!existsSync(vault)) {
804
+ const why = `${vault} does not exist — the binding names a vault directory that is not there. \`projectstore init ${shellQuote(vault)}\` creates it under this binding: the directory, its git repository and the layout's folders.`;
805
+ return emit(false, { error: why, vault_path: vault }, why + "\n", 1);
806
+ }
807
+ const plan = planScaffold(vault, { layout: cfg.layout || DEFAULT_LAYOUT, language: cfg.language || DEFAULT_LANGUAGE, root: PACKAGE_ROOT });
808
+ if (!plan.ok) return emit(false, scaffoldResult(plan), renderScaffoldPlan(plan, null, { env }), 1);
809
+ if (!values.write || plan.creates === 0) return emit(true, scaffoldResult(plan), renderScaffoldPlan(plan, null, { env }), 0);
810
+ // An injected `ask` is a terminal, as for install. Otherwise the installer's
811
+ // definition of one (TTYs, CI, a host session's markers) is the one
812
+ // contract 9 names — imported lazily, as runInstallVerb imports it.
813
+ const interactive = !values.json && (ask ? true : (await import("./install-harness.mjs")).isInteractive({ stdin, stdout, env, json: false }));
814
+ if (interactive) {
815
+ stdout.write(renderScaffoldPlan(plan, null, { env, asking: true }));
816
+ if (!(await term.askApply(plan.creates, { stdin, stdout, ask, caps: term.caps(stdout, env) }))) {
817
+ stdout.write("Nothing written.\n");
818
+ return 1;
819
+ }
820
+ }
821
+ const done = applyScaffold(plan);
822
+ return emit(true, scaffoldResult(plan, done), renderScaffoldPlan(plan, done, { env }), 0);
823
+ }
824
+
513
825
  // agents model <name> — the model a plugin surface passes for that agent, from
514
826
  // the active harness's overlay (ADR-008's two terms); agents show — the overlay
515
827
  // as read, with the keys the allowlist rejected; agents configure — the one
@@ -615,6 +927,36 @@ async function runAgents(ctx) {
615
927
  return emit("agents configure", true, { harness, path, wrote: true, agents: after.agents, pinnedClerk, clerkPinUnavailable, dropped, rejected: after.rejected, agents_in_binding: inBinding }, preview.join("\n") + `\n wrote ${path}.${inBinding ? " The binding still carries an agents block (pre-0.28); run upgrade to move it." : ""}\n`);
616
928
  }
617
929
 
930
+ // The band's switch (the band spec, contract 13): one atomic binding write per
931
+ // change, previewed; a terminal is asked, --json is the confirmation (the
932
+ // command that runs it has asked already), and without either it refuses.
933
+ // `status` writes nothing.
934
+ async function runBand(ctx) {
935
+ const { row, values, positionals, cfg, project, env, stdout, stderr, stdin, ask } = ctx;
936
+ const sub = positionals[0];
937
+ const arg = positionals[1];
938
+ const usage = (m) => usageFail(Object.assign(new Error(m), { code: "USAGE" }), { verb: row.verb, ...ctx });
939
+ if (!BAND_SUBCOMMANDS.includes(sub)) return usage(`band takes ${BAND_SUBCOMMANDS.join(", ")} (epic takes an epic id, or none to release the pin)`);
940
+ const emit = (verb, ok, result, text) => { if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n"); else (ok ? stdout : stderr).write(text); return ok ? 0 : 1; };
941
+ if (sub === "status") {
942
+ const s = bandStatus(project, cfg, { env, root: PACKAGE_ROOT });
943
+ return emit("band status", true, s, renderBandStatus(s));
944
+ }
945
+ const plan = planBand(project, sub, arg, { env, root: PACKAGE_ROOT });
946
+ if (plan.refusal) return emit(`band ${sub}`, false, { error: plan.refusal.message, code: plan.refusal.code, path: plan.path }, `${plan.refusal.message}\n`);
947
+ const preview = renderBandPlan(plan, sub, arg);
948
+ if (!plan.changed) return emit(`band ${sub}`, true, { path: plan.path, wrote: false, band: plan.after }, preview + " Nothing to change.\n");
949
+ let confirmed = Boolean(values.json);
950
+ if (!confirmed) {
951
+ const a = await confirmWrite(preview + `Write ${plan.path}? [y/N] `, { stdin, stdout, ask });
952
+ if (a === null) return usage("a non-interactive band write passes --json; there is no --yes");
953
+ confirmed = a;
954
+ }
955
+ if (!confirmed) return emit(`band ${sub}`, false, { path: plan.path, wrote: false, declined: true, band: plan.after }, preview + " nothing written.\n");
956
+ const done = applyBand(plan);
957
+ return emit(`band ${sub}`, true, { path: plan.path, wrote: done.wrote, band: plan.after }, preview + ` wrote ${plan.path}. The band follows within a few seconds; no restart.\n`);
958
+ }
959
+
618
960
  async function runGraph(ctx) {
619
961
  const { row, values, positionals, cfg } = ctx;
620
962
  const sub = positionals[0];
@@ -660,12 +1002,14 @@ function spawnDoctor(project, env, flags) {
660
1002
  return spawnSync(process.execPath, [script("doctor.mjs"), ...flags], { encoding: "utf8", cwd: existsSync(project) ? project : undefined, env: ownEnv(env, project), timeout: 60000, maxBuffer: 1 << 24 });
661
1003
  }
662
1004
 
663
- // Spawned, never imported (MCP ADR decision 2): doctor's report and main are
664
- // not exported, and importing them would be a second doctor. doctor.mjs sets
665
- // no exit code of its own and prints either JSON or text, so text mode is
666
- // two spawns — one for the findings that decide the exit code, one for the
667
- // report the user reads. Deliberate; do not "optimise" the second away
668
- // without giving doctor an exit code first.
1005
+ // Spawned, never imported (MCP ADR decision 2): doctor's main writes to
1006
+ // stdout and is not exported, and importing its checks would be a second
1007
+ // doctor. One spawn, always --json: its findings decide the exit code
1008
+ // (doctor.mjs sets none of its own), and the text a person reads is drawn
1009
+ // from those same findings here, in this process, by doctor-report.mjs — a
1010
+ // renderer that imports no check (the presentation spec, contract 11). So a
1011
+ // terminal gets colour, which a piped child could not tell it had, and the
1012
+ // report and the exit code can never come from two different runs.
669
1013
  async function runDoctor({ values, project, env, stdout, stderr }) {
670
1014
  const sections = [values.install && "--install", values.vault && "--vault"].filter(Boolean);
671
1015
  const r = spawnDoctor(project, env, ["--json", ...sections]);
@@ -681,12 +1025,29 @@ async function runDoctor({ values, project, env, stdout, stderr }) {
681
1025
  const ok = !findings.some((f) => f.level === "issue");
682
1026
  if (values.json) stdout.write(JSON.stringify(envelope("doctor", project, ok, findings), null, 2) + "\n");
683
1027
  else {
684
- const t = spawnDoctor(project, env, sections);
685
- stdout.write(typeof t.stdout === "string" && t.stdout ? t.stdout : `${(t.stderr || "").trim() || "doctor printed nothing"}\n`);
1028
+ // The sections as doctor.mjs derives them: no flag is both.
1029
+ const groups = sections.length ? [values.install && "install", values.vault && "vault"].filter(Boolean) : ["install", "vault"];
1030
+ stdout.write(await doctorText(findings, groups, { caps: term.caps(stdout, env), verbose: Boolean(values.verbose), project, env: ownEnv(env, project), stderr }));
686
1031
  }
687
1032
  return ok ? 0 : 1;
688
1033
  }
689
1034
 
1035
+ // doctor's text from its findings. `env` is the child's environment
1036
+ // (ownEnv), so every command form the report names is the one doctor itself
1037
+ // would name. A renderer that cannot load, or throws, must not cost the
1038
+ // reader the findings: they are printed one per line, `level check message
1039
+ // — file`, and the Summary line still ends them (lib.mjs). `load` stands in
1040
+ // for the import in a test.
1041
+ export async function doctorText(findings, groups, { caps, verbose = false, project = null, env = process.env, stderr = null } = {}, { load = () => import("./doctor-report.mjs") } = {}) {
1042
+ try {
1043
+ const { report } = await load();
1044
+ return report(findings, groups, { caps, verbose, version: packageVersion(), project, env });
1045
+ } catch (e) {
1046
+ if (stderr) stderr.write(`doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`);
1047
+ return doctorFallbackText(findings, { env });
1048
+ }
1049
+ }
1050
+
690
1051
  async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }) {
691
1052
  const write = Boolean(values.write);
692
1053
  const only = values.only ?? null;
@@ -716,15 +1077,17 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
716
1077
  return failed ? 1 : 0;
717
1078
  }
718
1079
 
719
- async function runInstallVerb({ row, values, project, env, stdin, stdout, ask }) {
1080
+ async function runInstallVerb({ row, values, project, env, cwd, stdin, stdout, ask }) {
720
1081
  const ih = await import("./install-harness.mjs");
721
- 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) };
1082
+ 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), cwd, stdin, stdout, ask, json: Boolean(values.json), verbose: Boolean(values.verbose) };
1083
+ // plan goes through the installer's own planOptions, as its main() does, so
1084
+ // the two entry points read the same root version for a dry-run row.
722
1085
  if (row.verb === "plan") {
723
- const p = ih.plan(project, opts);
1086
+ const p = ih.plan(project, ih.planOptions(opts));
724
1087
  if (values.json) stdout.write(JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n");
725
1088
  else {
726
1089
  const c = term.caps(stdout, env);
727
- 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 }));
1090
+ stdout.write(ih.renderPreview(p, { verbose: opts.verbose, verb: "plan", caps: c }));
728
1091
  }
729
1092
  return p.ok && !p.incomplete ? 0 : 1;
730
1093
  }
@@ -735,11 +1098,14 @@ async function runInstallVerb({ row, values, project, env, stdin, stdout, ask })
735
1098
  // that failed is exit 1 with the rest applied (install spec contract 4′).
736
1099
  const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
737
1100
  if (values.json) {
738
- 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");
739
- } else if (r.gate.why === "non-tty") {
740
- 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`);
741
- } else if (r.gate.why === "declined") {
742
- stdout.write("Nothing written.\n");
1101
+ // `fetched` is additive: the shells a run fetched before its plan, [] for
1102
+ // a run that fetched none (uninstall always).
1103
+ 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, fetched: r.fetched || [] }), null, 2) + "\n");
1104
+ } else {
1105
+ // The installer's own words for a gate that wrote nothing — a bare run
1106
+ // without a terminal, a declined question, a channel switch without a
1107
+ // terminal — shared with its main(), so the two entries say the same.
1108
+ stdout.write(ih.gateText(r, { verb: row.verb, harnesses: opts.harnesses, surfaces: opts.surfaces, caps: term.caps(stdout, env) }));
743
1109
  }
744
1110
  return ok ? 0 : 1;
745
1111
  }