projectstore-claude 0.29.2 → 0.29.4

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 (76) hide show
  1. package/README.md +1 -1
  2. package/bin/projectstore-claude.mjs +1 -1
  3. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  4. package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
  5. package/node_modules/projectstore/README.md +44 -7
  6. package/node_modules/projectstore/commands/bar.md +30 -0
  7. package/node_modules/projectstore/commands/bind.md +16 -20
  8. package/node_modules/projectstore/commands/doctor.md +12 -7
  9. package/node_modules/projectstore/commands/scaffold.md +24 -16
  10. package/node_modules/projectstore/commands/status.md +1 -1
  11. package/node_modules/projectstore/commands/statusline.md +2 -2
  12. package/node_modules/projectstore/commands/story.md +15 -2
  13. package/node_modules/projectstore/docs/alongside.md +167 -0
  14. package/node_modules/projectstore/docs/extending.md +17 -3
  15. package/node_modules/projectstore/docs/getting-started.md +18 -9
  16. package/node_modules/projectstore/docs/harnesses.md +1 -1
  17. package/node_modules/projectstore/docs/how-it-works.md +12 -3
  18. package/node_modules/projectstore/harnesses/claude-code.json +23 -8
  19. package/node_modules/projectstore/harnesses/codex.json +16 -6
  20. package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
  21. package/node_modules/projectstore/hooks/band.tsx +622 -0
  22. package/node_modules/projectstore/hooks/hooks.json +1 -0
  23. package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
  24. package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
  25. package/node_modules/projectstore/hooks/session-start.mjs +56 -20
  26. package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
  27. package/node_modules/projectstore/package.json +12 -2
  28. package/node_modules/projectstore/scaffold/headings.json +8 -0
  29. package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
  30. package/node_modules/projectstore/scripts/band.mjs +207 -0
  31. package/node_modules/projectstore/scripts/binding.mjs +733 -62
  32. package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
  33. package/node_modules/projectstore/scripts/cli.mjs +1463 -267
  34. package/node_modules/projectstore/scripts/codemap.mjs +22 -7
  35. package/node_modules/projectstore/scripts/convergence.mjs +638 -0
  36. package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
  37. package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
  38. package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
  39. package/node_modules/projectstore/scripts/draft.mjs +5 -2
  40. package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
  41. package/node_modules/projectstore/scripts/graph.mjs +19 -13
  42. package/node_modules/projectstore/scripts/harness.mjs +283 -37
  43. package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
  44. package/node_modules/projectstore/scripts/kanban.mjs +6 -21
  45. package/node_modules/projectstore/scripts/lib.mjs +933 -199
  46. package/node_modules/projectstore/scripts/mcp.mjs +386 -105
  47. package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
  48. package/node_modules/projectstore/scripts/provenance.mjs +26 -10
  49. package/node_modules/projectstore/scripts/query.mjs +441 -72
  50. package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
  51. package/node_modules/projectstore/scripts/run-host.mjs +139 -0
  52. package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
  53. package/node_modules/projectstore/scripts/setup.mjs +987 -0
  54. package/node_modules/projectstore/scripts/stages.mjs +84 -0
  55. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
  56. package/node_modules/projectstore/scripts/statusline.mjs +55 -14
  57. package/node_modules/projectstore/scripts/story-section.mjs +39 -17
  58. package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
  59. package/node_modules/projectstore/scripts/term.mjs +1027 -52
  60. package/node_modules/projectstore/scripts/tokens.mjs +106 -28
  61. package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
  62. package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
  63. package/node_modules/projectstore/scripts/worktree.mjs +16 -5
  64. package/node_modules/projectstore/templates/de/strings.json +63 -1
  65. package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
  66. package/node_modules/projectstore/templates/en/strings.json +63 -1
  67. package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
  68. package/node_modules/projectstore/templates/es/strings.json +63 -1
  69. package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
  70. package/node_modules/projectstore/templates/fr/strings.json +63 -1
  71. package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
  72. package/node_modules/projectstore/templates/ru/strings.json +63 -1
  73. package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
  74. package/node_modules/projectstore/templates/zh/strings.json +63 -1
  75. package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
  76. 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,64 @@ 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";
47
- 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";
58
+ import {
59
+ projectRootDeclared,
60
+ childEnv,
61
+ harnessIds,
62
+ harnessForOverlay,
63
+ pinPluginRoot,
64
+ invokedAs,
65
+ shellHarnessId,
66
+ CORE_NPX,
67
+ packageCommand,
68
+ loadHarnesses,
69
+ invocation,
70
+ } from "./harness.mjs";
71
+ import {
72
+ readConfigAt,
73
+ readOverlayAt,
74
+ resolveAgentModel,
75
+ writeOverlayAt,
76
+ overlayId,
77
+ layoutRoster,
78
+ commandForm,
79
+ projectDirRefusal,
80
+ fromTerminalIn,
81
+ doctorFallbackText,
82
+ refreshSessionPointer,
83
+ } from "./lib.mjs";
84
+ import {
85
+ READ_OPERATIONS,
86
+ LINEAGE_KINDS,
87
+ LINEAGE_DEFAULT_DEPTH,
88
+ SEARCH_DEFAULT_LIMIT,
89
+ GRAPH_EDGE_CAP,
90
+ DIRECTIONS,
91
+ } from "./query.mjs";
92
+ // binding.mjs and scaffold.mjs beneath it are write modules imported
93
+ // statically where the install family is lazy: dependency-free, with no side
94
+ // effects at import, so the MCP server's module graph gains nothing it could
95
+ // trip on.
96
+ import {
97
+ planBind,
98
+ applyBind,
99
+ renderBindPlan,
100
+ bindResult,
101
+ bindFailed,
102
+ normaliseVaultPath,
103
+ shellQuote,
104
+ DEFAULT_LAYOUT,
105
+ DEFAULT_LANGUAGE,
106
+ } from "./binding.mjs";
107
+ import { planScaffold, applyScaffold, scaffoldResult, renderScaffoldPlan } from "./scaffold.mjs";
108
+ import {
109
+ planBand,
110
+ applyBand,
111
+ bandStatus,
112
+ renderBandStatus,
113
+ renderBandPlan,
114
+ SUBCOMMANDS as BAND_SUBCOMMANDS,
115
+ } from "./band.mjs";
52
116
 
53
117
  export const SCHEMA_VERSION = 1;
54
118
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -56,7 +120,11 @@ export const PACKAGE_ROOT = dirname(HERE);
56
120
  const script = (name) => resolve(HERE, name);
57
121
 
58
122
  export function packageVersion() {
59
- try { return String(JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8")).version || ""); } catch { return ""; }
123
+ try {
124
+ return String(JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8")).version || "");
125
+ } catch {
126
+ return "";
127
+ }
60
128
  }
61
129
 
62
130
  export function envelope(verb, project, ok, result) {
@@ -79,114 +147,441 @@ export function resolveProject({ project = null, env = process.env, cwd = proces
79
147
  // wraps: "script" (spawned), "module" (imported), "new" (code that exists
80
148
  // only for the CLI). output: "envelope" (--json wraps), "text". mcp: the MCP
81
149
  // tools that mirror the verb (MCP ADR decision 2), empty when none.
150
+ // options: the row's parser — opt()'s arg is the type (false a flag, a
151
+ // placeholder a value), multiple the repetition. usage: the forms the help
152
+ // prints after the verb, verbatim, which also bound its arguments: a literal
153
+ // sub-word (`neighbors`), a <placeholder> (`<phrase…>` takes the rest), or a
154
+ // --name the form requires (its placeholder is the option's value, not an
155
+ // argument). NO_FORMS: the verb takes no argument.
82
156
 
83
- const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
157
+ export const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
84
158
  const JSON_OPT = opt("json", false, "the envelope");
159
+ // The options every verb takes, and the only ones that may come before the
160
+ // verb: the scan knows their arity before it knows the verb (--project takes
161
+ // a value, the rest do not). Last in every parser map, so a row cannot
162
+ // retype one. SHORT is their one-letter forms (`-hv`: version wins).
163
+ const GLOBAL_OPTS = Object.freeze([
164
+ opt("project", "<dir>", "the project"),
165
+ JSON_OPT,
166
+ opt("help", false, "the help"),
167
+ opt("version", false, "the package version"),
168
+ ]);
169
+ const SHORT = Object.freeze({ help: "h", version: "v" });
170
+ const NO_FORMS = Object.freeze([]);
85
171
  const READ_JSON = [JSON_OPT];
86
- const HARNESS_OPT = opt("harness", "<id>", "the harness — and, without a terminal, the confirmation (a terminal is asked); there is no --yes", true);
172
+ const HARNESS_OPT = opt(
173
+ "harness",
174
+ "<id>",
175
+ "the harness — and, without a terminal, the confirmation (a terminal is asked); there is no --yes",
176
+ true,
177
+ );
87
178
  const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
88
179
  // The layout move's remedy names this for any copy but the package's own
89
180
  // registration (the layout spec, contract 12 as amended 2026-10-03): it makes
90
181
  // "no host command" true by construction instead of by recognising the root.
91
- const NO_REGISTER_OPT = opt("no-register", false, "leave the plugin registration alone: change only this project's files");
182
+ const NO_REGISTER_OPT = opt(
183
+ "no-register",
184
+ false,
185
+ "leave the plugin registration alone: change only this project's files",
186
+ );
92
187
  const VERBOSE_OPT = opt("verbose", false, "every row's reasoning, each step's why and the host's own notes");
188
+ // doctor's --verbose is the same flag in its own words: on a terminal, the
189
+ // report the pipe gets — every note, every instance, each message whole.
190
+ const DOCTOR_VERBOSE_OPT = opt(
191
+ VERBOSE_OPT.name,
192
+ VERBOSE_OPT.arg,
193
+ "on a terminal too: every note, every instance, whole messages",
194
+ );
93
195
  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];
196
+ const UNINSTALL_OPTS = [
197
+ HARNESS_OPT,
198
+ SURFACE_OPT,
199
+ opt("global", false, "also remove the harness-global plugin registration"),
200
+ VERBOSE_OPT,
201
+ JSON_OPT,
202
+ ];
203
+
204
+ // setup's own options (the setup story, rule 1): seven, beside the globals.
205
+ // `--vault <path>` takes a value here where doctor's `--vault` is a flag —
206
+ // the per-verb parse's own case.
207
+ const SETUP_OPTS = [
208
+ HARNESS_OPT,
209
+ opt(
210
+ "vault",
211
+ "<path>",
212
+ "the vault: an existing folder is bound, a missing or empty one is created (naming it confirms)",
213
+ ),
214
+ opt("layout", "<name>", `a new vault's layout (default ${DEFAULT_LAYOUT})`),
215
+ opt("language", "<code>", `a new vault's template language (default ${DEFAULT_LANGUAGE})`),
216
+ opt(
217
+ "channel",
218
+ "<npm|git>",
219
+ "with a git-marketplace copy enabled, without a terminal: npm switches to the npm install, git keeps the git marketplace",
220
+ ),
221
+ opt("dry-run", false, "the questions and the plan; nothing is written or fetched"),
222
+ VERBOSE_OPT,
223
+ ];
95
224
 
96
225
  export const VERBS = Object.freeze([
97
226
  Object.freeze({
98
- verb: "doctor", summary: "Check the install wiring and the vault's consistency.",
99
- 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],
227
+ verb: "setup",
228
+ summary: "Set up this project: every harness it uses, and a vault.",
229
+ usage: NO_FORMS,
230
+ module: "./setup.mjs",
231
+ wraps: "new",
232
+ how: "import",
233
+ output: "envelope",
234
+ writes: true,
235
+ requiresBinding: false,
236
+ mcp: Object.freeze([]),
237
+ options: [...SETUP_OPTS, JSON_OPT],
238
+ run: runSetupVerb,
239
+ }),
240
+ Object.freeze({
241
+ verb: "doctor",
242
+ summary: "Check the install wiring and the vault's consistency.",
243
+ usage: NO_FORMS,
244
+ module: "./doctor.mjs",
245
+ wraps: "script",
246
+ how: "spawn",
247
+ output: "envelope",
248
+ writes: false,
249
+ requiresBinding: false,
250
+ mcp: Object.freeze(["doctor"]),
251
+ options: [
252
+ opt("install", false, "only the install section"),
253
+ opt("vault", false, "only the vault section"),
254
+ DOCTOR_VERBOSE_OPT,
255
+ JSON_OPT,
256
+ ],
101
257
  run: runDoctor,
102
258
  }),
103
259
  Object.freeze({
104
- verb: "reconcile", summary: "Regenerate the derived views (kanban, code map, graph, indexes).",
105
- module: "./reconcile.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: true, mcp: Object.freeze([]),
106
- 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],
260
+ verb: "reconcile",
261
+ summary: "Regenerate the derived views (kanban, code map, graph, indexes).",
262
+ usage: NO_FORMS,
263
+ module: "./reconcile.mjs",
264
+ wraps: "module",
265
+ how: "import",
266
+ output: "envelope",
267
+ writes: true,
268
+ requiresBinding: true,
269
+ mcp: Object.freeze([]),
270
+ options: [
271
+ opt(
272
+ "write",
273
+ false,
274
+ "apply the regeneration (asks on a terminal; --only names what is written and confirms headless)",
275
+ ),
276
+ opt("only", "<target>", "one derived view"),
277
+ JSON_OPT,
278
+ ],
107
279
  run: runReconcile,
108
280
  }),
109
281
  Object.freeze({
110
- verb: "plan", summary: "Show what install would write for a harness, without writing.",
111
- module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
112
- options: INSTALL_OPTS, run: runInstallVerb,
282
+ verb: "plan",
283
+ summary: "Show what install would write for a harness, without writing.",
284
+ usage: NO_FORMS,
285
+ module: "./install-harness.mjs",
286
+ wraps: "module",
287
+ how: "import",
288
+ output: "envelope",
289
+ writes: false,
290
+ requiresBinding: false,
291
+ mcp: Object.freeze([]),
292
+ options: INSTALL_OPTS,
293
+ run: runInstallVerb,
113
294
  }),
114
295
  Object.freeze({
115
- verb: "install", summary: "Install projectstore's surfaces for a harness, behind a preview.",
116
- module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
117
- options: INSTALL_OPTS, run: runInstallVerb,
296
+ verb: "install",
297
+ summary: "Install projectstore's surfaces for a harness, behind a preview.",
298
+ usage: NO_FORMS,
299
+ module: "./install-harness.mjs",
300
+ wraps: "module",
301
+ how: "import",
302
+ output: "envelope",
303
+ writes: true,
304
+ requiresBinding: false,
305
+ mcp: Object.freeze([]),
306
+ options: INSTALL_OPTS,
307
+ run: runInstallVerb,
118
308
  }),
119
309
  Object.freeze({
120
- verb: "uninstall", summary: "Remove what install wrote, and only that.",
121
- module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
122
- options: UNINSTALL_OPTS, run: runInstallVerb,
310
+ verb: "uninstall",
311
+ summary: "Remove what install wrote, and only that.",
312
+ usage: NO_FORMS,
313
+ module: "./install-harness.mjs",
314
+ wraps: "module",
315
+ how: "import",
316
+ output: "envelope",
317
+ writes: true,
318
+ requiresBinding: false,
319
+ mcp: Object.freeze([]),
320
+ options: UNINSTALL_OPTS,
321
+ run: runInstallVerb,
123
322
  }),
124
323
  Object.freeze({
125
- verb: "upgrade", summary: "Re-run install after a plugin update; re-stamps what this installation wrote and leaves the rest.",
126
- module: "./install-harness.mjs", wraps: "module", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
127
- options: INSTALL_OPTS, run: runInstallVerb,
324
+ verb: "upgrade",
325
+ summary: "After an update, refresh what install wrote and leave the rest.",
326
+ usage: NO_FORMS,
327
+ module: "./install-harness.mjs",
328
+ wraps: "module",
329
+ how: "import",
330
+ output: "envelope",
331
+ writes: true,
332
+ requiresBinding: false,
333
+ mcp: Object.freeze([]),
334
+ options: INSTALL_OPTS,
335
+ run: runInstallVerb,
128
336
  }),
129
337
  Object.freeze({
130
- verb: "status", summary: "The binding, what is in progress, and whether the derived views are fresh.",
131
- module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze(["status"]),
132
- options: READ_JSON, run: runRead("status"),
338
+ verb: "status",
339
+ summary: "What is bound, what is in progress, and whether views are fresh.",
340
+ usage: NO_FORMS,
341
+ module: "./query.mjs",
342
+ wraps: "new",
343
+ how: "import",
344
+ output: "envelope",
345
+ writes: false,
346
+ requiresBinding: false,
347
+ mcp: Object.freeze(["status"]),
348
+ options: READ_JSON,
349
+ run: runRead("status"),
133
350
  }),
134
351
  Object.freeze({
135
- verb: "orientation", summary: "The SessionStart skeleton and the facts behind it.",
136
- module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["orientation"]),
137
- options: READ_JSON, run: runRead("orientation"),
352
+ verb: "orientation",
353
+ summary: "The SessionStart skeleton and the facts behind it.",
354
+ usage: NO_FORMS,
355
+ module: "./query.mjs",
356
+ wraps: "module",
357
+ how: "import",
358
+ output: "envelope",
359
+ writes: false,
360
+ requiresBinding: true,
361
+ mcp: Object.freeze(["orientation"]),
362
+ options: READ_JSON,
363
+ run: runRead("orientation"),
138
364
  }),
139
365
  Object.freeze({
140
- verb: "search", summary: "Find a phrase in the vault's artifacts — deterministic, bounded, no shell.",
141
- module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["search"]),
142
- 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],
366
+ verb: "search",
367
+ summary: "Find a phrase in the vault's artifacts, literal and bounded.",
368
+ usage: Object.freeze(["<phrase…>"]),
369
+ module: "./query.mjs",
370
+ wraps: "new",
371
+ how: "import",
372
+ output: "envelope",
373
+ writes: false,
374
+ requiresBinding: true,
375
+ mcp: Object.freeze(["search"]),
376
+ options: [
377
+ opt("kind", "<type>", "only artifacts of this kind", true),
378
+ opt("status", "<status>", "only artifacts in this status"),
379
+ opt("limit", "<n>", `at most n matches (default ${SEARCH_DEFAULT_LIMIT}, hard cap 100)`),
380
+ opt("include-derived", false, "search the derived views too"),
381
+ opt("case-sensitive", false, "match case"),
382
+ JSON_OPT,
383
+ ],
143
384
  run: runRead("search"),
144
385
  }),
145
386
  Object.freeze({
146
- verb: "show", summary: "One artifact: its frontmatter, and its body or one section on request.",
147
- module: "./query.mjs", wraps: "module", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["get_artifact"]),
148
- options: [opt("body", false, "include the body"), opt("section", "<id>", "one section by its registry id (description, acceptance, …)"), JSON_OPT],
387
+ verb: "show",
388
+ summary: "One artifact's frontmatter; its body or a section on request.",
389
+ usage: Object.freeze(["<path>"]),
390
+ module: "./query.mjs",
391
+ wraps: "module",
392
+ how: "import",
393
+ output: "envelope",
394
+ writes: false,
395
+ requiresBinding: true,
396
+ mcp: Object.freeze(["get_artifact"]),
397
+ options: [
398
+ opt("body", false, "include the body"),
399
+ opt("section", "<id>", "one section by its registry id (description, acceptance, …)"),
400
+ JSON_OPT,
401
+ ],
149
402
  run: runRead("show"),
150
403
  }),
151
404
  Object.freeze({
152
- verb: "graph", summary: "graph neighbors <path> | graph lineage <path> — the live link graph by vault path.",
153
- 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],
405
+ verb: "graph",
406
+ summary: "The live link graph by vault path.",
407
+ usage: Object.freeze(["neighbors <path>", "lineage <path>"]),
408
+ module: "./query.mjs",
409
+ wraps: "new",
410
+ how: "import",
411
+ output: "envelope",
412
+ writes: false,
413
+ requiresBinding: true,
414
+ mcp: Object.freeze(["neighbors", "lineage"]),
415
+ options: [
416
+ opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its four)", true),
417
+ opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"),
418
+ opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`),
419
+ opt("limit", "<n>", `neighbors: cap per direction (at most ${GRAPH_EDGE_CAP})`),
420
+ JSON_OPT,
421
+ ],
155
422
  run: runGraph,
156
423
  }),
157
424
  Object.freeze({
158
- verb: "codemap", summary: "codemap --for <selector> — which code an epic or artifact maps to, or which artifacts map to a path.",
159
- module: "./query.mjs", wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: true, mcp: Object.freeze(["code_refs"]),
160
- 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],
425
+ verb: "codemap",
426
+ summary: "The code an epic or artifact maps to, or a path's artifacts.",
427
+ usage: Object.freeze(["--for <selector>"]),
428
+ module: "./query.mjs",
429
+ wraps: "new",
430
+ how: "import",
431
+ output: "envelope",
432
+ writes: false,
433
+ requiresBinding: true,
434
+ mcp: Object.freeze(["code_refs"]),
435
+ options: [
436
+ opt("for", "<selector>", "an epic id, an artifact, or a repo path"),
437
+ opt("reverse", false, "read the selector as a path even if it names an artifact"),
438
+ JSON_OPT,
439
+ ],
161
440
  run: runCodemap,
162
441
  }),
163
442
  Object.freeze({
164
- verb: "agents", summary: "agents model <name> | agents show | agents configure — the harness overlay's agents block (ADR-008, read per invocation).",
165
- module: "./lib.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
166
- 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],
443
+ verb: "agents",
444
+ summary: "The harness overlay's agents block: the model each agent runs.",
445
+ usage: Object.freeze(["model <name>", "show", "configure"]),
446
+ module: "./lib.mjs",
447
+ wraps: "new",
448
+ how: "import",
449
+ output: "envelope",
450
+ writes: true,
451
+ requiresBinding: false,
452
+ mcp: Object.freeze([]),
453
+ options: [
454
+ opt(
455
+ "harness",
456
+ "<id>",
457
+ "configure: the overlay to write — and, non-interactively, the confirmation; there is no --yes",
458
+ true,
459
+ ),
460
+ opt(
461
+ "default",
462
+ "<model>",
463
+ "configure: agents.default.model (pins the clerk to sonnet unless --agent clerk=… says otherwise; an empty model clears it)",
464
+ ),
465
+ opt("agent", "<name>=<model>", "configure: agents.per_agent.<name>.model (an empty model removes the key)", true),
466
+ opt("reset", false, "configure: empty the agents block first; --default and --agent given with it apply on top"),
467
+ JSON_OPT,
468
+ ],
167
469
  run: runAgents,
168
470
  }),
169
471
  Object.freeze({
170
- verb: "bind", summary: "bind <vault> — bind this project to an existing vault (naming the vault is the confirmation).",
171
- module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
172
- 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],
472
+ verb: "band",
473
+ summary: "The band above the prompt: switch it, pin an epic, ask why.",
474
+ usage: Object.freeze(["on", "off", "status", "epic <ID>"]),
475
+ module: "./band.mjs",
476
+ wraps: "new",
477
+ how: "import",
478
+ output: "envelope",
479
+ writes: true,
480
+ requiresBinding: true,
481
+ mcp: Object.freeze([]),
482
+ options: [JSON_OPT],
483
+ run: runBand,
484
+ }),
485
+ Object.freeze({
486
+ verb: "session",
487
+ summary: "Refresh this session's current story from its file.",
488
+ usage: Object.freeze(["refresh <path>"]),
489
+ module: "./lib.mjs",
490
+ wraps: "new",
491
+ how: "import",
492
+ output: "envelope",
493
+ writes: true,
494
+ requiresBinding: true,
495
+ mcp: Object.freeze([]),
496
+ options: [JSON_OPT],
497
+ run: runSession,
498
+ }),
499
+ Object.freeze({
500
+ verb: "bind",
501
+ summary: "Bind this project to an existing vault; naming it confirms.",
502
+ usage: Object.freeze(["<vault>"]),
503
+ module: "./binding.mjs",
504
+ wraps: "new",
505
+ how: "import",
506
+ output: "envelope",
507
+ writes: true,
508
+ requiresBinding: false,
509
+ mcp: Object.freeze([]),
510
+ options: [
511
+ opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`),
512
+ opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`),
513
+ opt("rebind", false, "point an already bound project at another vault; every other setting is kept"),
514
+ JSON_OPT,
515
+ ],
173
516
  run: runBind(false),
174
517
  }),
175
518
  Object.freeze({
176
- verb: "init", summary: `init <vault> — create the vault directory and bind to it; the layout's folders come from ${commandForm("scaffold")}.`,
177
- module: "./binding.mjs", wraps: "new", how: "import", output: "envelope", writes: true, requiresBinding: false, mcp: Object.freeze([]),
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],
519
+ verb: "init",
520
+ summary: "Create a vault (folder, git repository, READMEs) and bind to it.",
521
+ usage: Object.freeze(["<vault>"]),
522
+ module: "./binding.mjs",
523
+ wraps: "new",
524
+ how: "import",
525
+ output: "envelope",
526
+ writes: true,
527
+ requiresBinding: false,
528
+ mcp: Object.freeze([]),
529
+ options: [
530
+ opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`),
531
+ opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`),
532
+ opt(
533
+ "rebind",
534
+ false,
535
+ "an already bound project: create the new vault and point the project at it; every other setting is kept",
536
+ ),
537
+ JSON_OPT,
538
+ ],
179
539
  run: runBind(true),
180
540
  }),
181
541
  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.",
183
- module: "./mcp.mjs", wraps: "new", how: "import", output: "text", writes: false, requiresBinding: false, mcp: Object.freeze([]),
184
- options: [], run: runMcp,
542
+ verb: "scaffold",
543
+ summary: "Create the vault's missing folders and READMEs; rewrite nothing.",
544
+ usage: NO_FORMS,
545
+ module: "./scaffold.mjs",
546
+ wraps: "new",
547
+ how: "import",
548
+ output: "envelope",
549
+ writes: true,
550
+ requiresBinding: true,
551
+ mcp: Object.freeze([]),
552
+ options: [
553
+ opt("write", false, "create them (asks at a terminal; without one, --write is the confirmation)"),
554
+ JSON_OPT,
555
+ ],
556
+ run: runScaffold,
557
+ }),
558
+ Object.freeze({
559
+ verb: "mcp",
560
+ summary: "Serve the read tools over MCP (stdio) for the project you name.",
561
+ usage: NO_FORMS,
562
+ module: "./mcp.mjs",
563
+ wraps: "new",
564
+ how: "import",
565
+ output: "text",
566
+ writes: false,
567
+ requiresBinding: false,
568
+ mcp: Object.freeze([]),
569
+ options: [],
570
+ run: runMcp,
185
571
  }),
186
572
  Object.freeze({
187
- verb: "version", summary: "Print the package version (also --version).",
188
- module: null, wraps: "new", how: "import", output: "envelope", writes: false, requiresBinding: false, mcp: Object.freeze([]),
189
- options: [JSON_OPT], run: runVersion,
573
+ verb: "version",
574
+ summary: "Print the package version (also --version).",
575
+ usage: NO_FORMS,
576
+ module: null,
577
+ wraps: "new",
578
+ how: "import",
579
+ output: "envelope",
580
+ writes: false,
581
+ requiresBinding: false,
582
+ mcp: Object.freeze([]),
583
+ options: [JSON_OPT],
584
+ run: runVersion,
190
585
  }),
191
586
  ]);
192
587
 
@@ -196,24 +591,60 @@ export const VERBS = Object.freeze([
196
591
  // story that adds a verb in slices.
197
592
  export const PLANNED_VERBS = Object.freeze([]);
198
593
 
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.
594
+ // The verbs as a person meets them (presentation spec contract 10): starting,
595
+ // reading the vault, keeping it consistent. Every verb not listed here is
596
+ // ADVANCED, one line of names in table order — so a new row is never hidden
597
+ // by this table, and init and scaffold, which the contract names in no group,
598
+ // are there too. A group lists only the verbs this run's table has. With
599
+ // setup, START is setup and uninstall, and install and bind are ADVANCED's
600
+ // (contract 10; the setup story, rule 1).
202
601
  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"]],
602
+ ["START", ["setup", "uninstall"]],
603
+ ["VAULT", ["status", "search", "show", "graph", "codemap"]],
604
+ ["CHECK AND REPAIR", ["doctor", "reconcile", "upgrade"]],
207
605
  ];
606
+ // The column a group's summaries start at: two spaces, the verb in twelve,
607
+ // one space. A summary fits 80 columns from there, so each is at most 65
608
+ // characters, in ASCII (tests/presentation.test.mjs, criterion 5, holds every
609
+ // row to it).
610
+ const VERB_COLUMN = 12;
611
+ export const SUMMARY_MAX = 80 - (2 + VERB_COLUMN + 1);
208
612
 
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.
613
+ // Examples per verb. They are what to run next, so they are the core's form
614
+ // through a shell too (the story "User-facing text says where to run and
615
+ // names no project path and no shell", rule 6): `{cmd}` is the core through
616
+ // npx, and `{h}` the harness argument — ` --harness <id>` from the core, and
617
+ // through a shell ` --harness <the shell's own harness>` (shellHarnessId),
618
+ // never a session's identity. `{fixed}` is that argument through a shell
619
+ // only: the shell inserts --harness for every verb that declares it
620
+ // (packaging/shells.mjs, HARNESS_VERBS), so an example the core runs bare —
621
+ // setup, which asks; agents show, which reads the session's overlay — names
622
+ // the shell's harness through it and stays bare from the core. A `{core}`
623
+ // example is the core's alone: a shell's help leaves it out, since it says
624
+ // what the shell's own harness does not (every harness, the way back from a
625
+ // switch). `{switch.surface}`,
626
+ // `{switch.host}` and `{switch.doctor}` name the harness whose project can
627
+ // switch channels — the one with a host plugin registration — from its
628
+ // manifest: its surface key, its display name and its doctor form
629
+ // (switchHarness, below); an example naming them is left out when no
630
+ // manifest has one.
213
631
  const EXAMPLES = {
632
+ setup: [
633
+ "{cmd} setup{fixed}",
634
+ "{cmd} setup{fixed} --dry-run # the questions and the plan; nothing is written or fetched",
635
+ "{cmd} setup{h} --vault ~/vaults/my-project # without a terminal: --harness confirms, --vault names the vault",
636
+ ],
214
637
  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"],
638
+ upgrade: [
639
+ "{cmd}@<version> upgrade{h} # the version you name is the version that runs",
640
+ "{cmd} upgrade{h} --verbose",
641
+ ],
642
+ uninstall: [
643
+ "{core}{cmd} uninstall # every harness this project uses",
644
+ "{cmd} uninstall{h}",
645
+ "{cmd} uninstall{h} --surface statusline",
646
+ "{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",
647
+ ],
217
648
  plan: ["{cmd} plan{h}", "{cmd} plan{h} --json # one envelope, for scripts and agents"],
218
649
  status: ["{cmd} status", "{cmd} status --json"],
219
650
  orientation: ["{cmd} orientation --json"],
@@ -225,93 +656,219 @@ const EXAMPLES = {
225
656
  reconcile: ["{cmd} reconcile # what would change; nothing is written", "{cmd} reconcile --write"],
226
657
  bind: ["{cmd} bind ~/vaults/my-project", "{cmd} bind ~/vaults/other --rebind"],
227
658
  init: ["{cmd} init ~/vaults/new-project --language ru"],
228
- agents: ["{cmd} agents show", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
659
+ scaffold: ["{cmd} scaffold # what would be created; nothing is written", "{cmd} scaffold --write"],
660
+ agents: ["{cmd} agents show{fixed}", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
661
+ band: [
662
+ "{cmd} band status",
663
+ "{cmd} band off",
664
+ "{cmd} band epic PS-CORE # a session with no epic of its own shows this one",
665
+ ],
666
+ session: ["{cmd} session refresh epics/PS-CORE/stories/story-x.md --json # after a write no hook saw"],
229
667
  mcp: ['{cmd} mcp --project "$PWD"'],
230
668
  };
231
669
 
232
- // Positional arguments a summary does not already name.
233
- const ARGS = { search: "<phrase>", show: "<path>" };
670
+ // How this run was invoked is invokedAs (harness.mjs), the one spelling of
671
+ // the binary that ran: the help's first line, its Usage lines, the
672
+ // fixed-harness line and the usage errors. What a person runs next is the
673
+ // core's (CORE_NPX, packageCommand), as status's and bind's Next lines are.
234
674
 
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) };
675
+ // The harness whose project can switch channels (the converge story's rule
676
+ // 4): the one whose manifest declares a host plugin registration. Its surface
677
+ // key, display name and doctor form, from the manifest — `invocation()` in
678
+ // harness.mjs, never a literal; null when no manifest has one.
679
+ function switchHarness() {
680
+ for (const m of loadHarnesses().values()) {
681
+ const row = Object.entries(m.surfaces || {}).find(
682
+ ([k, s]) => !k.startsWith("_") && s?.format === "host-plugin-registration",
683
+ );
684
+ if (row)
685
+ return {
686
+ "switch.surface": row[0],
687
+ "switch.host": m.display_name,
688
+ "switch.doctor": invocation(m, "doctor", { args: "--fix" }),
689
+ };
242
690
  }
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" };
691
+ return null;
249
692
  }
250
693
 
251
- function optionLines(options) {
252
- const rows = options.map((o) => [`--${o.name}${o.arg ? " " + o.arg : ""}`, `${o.summary}${o.multiple ? " (repeatable)" : ""}`]);
694
+ // An aligned options block: each option's form in a column at most 28 wide,
695
+ // its description beside it — or, past that column, on the next line under
696
+ // it. On a live terminal a description wraps with its column as the hanging
697
+ // indent; anywhere else it stays one line, whole, for a reader that greps.
698
+ function optionLines(options, c = term.PLAIN) {
699
+ const rows = options.map((o) => [
700
+ term.glyphText(c, `--${o.name}${o.arg ? " " + o.arg : ""}`),
701
+ term.glyphText(c, `${o.summary}${o.multiple ? " (repeatable)" : ""}`),
702
+ ]);
253
703
  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}`));
704
+ const pad = " ".repeat(2 + w + 2);
705
+ const text = (r) => term.wrap(r, c.live ? c.width : 0, pad);
706
+ return rows.map(([l, r]) => (l.length > w ? ` ${l}\n${pad}${text(r)}` : ` ${l.padEnd(w)} ${text(r)}`));
255
707
  }
256
708
 
257
- const EXIT_CODES = "Exit codes 0 ok · 1 findings or a refusal · 2 usage · 3 not bound";
709
+ const EXIT_CODES = "0 ok, 1 findings or a refusal, 2 usage, 3 not bound, 130 cancelled";
710
+ // The facts that close a help screen (`Harnesses`, `Exit codes`): the key in
711
+ // the explanation role, its value at one column for both.
712
+ const FACT_KEY = "Exit codes".length;
713
+ const fact = (c, key, value) =>
714
+ `${term.painter(c)("explanation", key)}${" ".repeat(FACT_KEY - key.length + 2)}${value}`;
715
+
716
+ // What the bin is, in the help's badge: one line with the name and version,
717
+ // short enough that a prerelease version (0.30.0-rc.12) still fits 80 columns.
718
+ const PURPOSE = "project memory for coding agents, in markdown";
719
+
720
+ // What a verb's help says beneath its Usage when the summary has no room for
721
+ // it: here, how mcp is told its project.
722
+ const NOTES = {
723
+ mcp: "It serves the project --project names, else PROJECTSTORE_PROJECT_DIR or the host session's project; never the directory it was started in.",
724
+ };
725
+
726
+ // The global options, as every verb takes them.
727
+ const EVERY_VERB = Object.freeze([
728
+ opt("project", "<dir>", "the project (default: the session's, else this directory)"),
729
+ opt("json", false, "the result as one JSON envelope, for scripts and agents"),
730
+ opt("version", false, "the package version"),
731
+ ]);
258
732
 
259
- export function usage(env = process.env) {
260
- const { cmd } = invocation(env);
733
+ // Top-level help (presentation spec contract 10): the badge — name, version,
734
+ // purpose; Usage; the named groups, each verb in the action role and its
735
+ // summary on one line; ADVANCED, one line of the remaining verbs; the options
736
+ // every verb takes; examples, each command alone on its line (contract 6);
737
+ // the tips, whose plan command stands alone too; the harnesses and the exit
738
+ // codes. `verbs` is the table this run answers for — VERBS, or a test's own.
739
+ // `caps` is the writing stream's (term.mjs): painted at a terminal, plain on a
740
+ // pipe, and every line of it fits 80 columns either way.
741
+ export function usage(env = process.env, verbs = VERBS, { caps: c = term.PLAIN, version = packageVersion() } = {}) {
742
+ const { shell, cmd } = invokedAs(env);
743
+ const paint = term.painter(c);
744
+ const width = c.live ? c.width : 0;
745
+ const prose = (text) => " " + term.wrap(term.glyphText(c, text), width, " ");
261
746
  const lines = [
262
- "projectstore — project memory for coding agents: decisions, specs, epics and stories as plain markdown.",
747
+ term.badge(c, "projectstore", [version, PURPOSE]),
263
748
  "",
264
- "Usage",
265
- ` ${cmd} <verb> [options]`,
266
- ` ${cmd} <verb> --help one verb's options and examples`,
749
+ term.heading(c, "Usage"),
750
+ ...term.rows(c, [
751
+ { target: `${cmd} <verb> [options]`, note: "run it inside your code project" },
752
+ { target: `${cmd} <verb> --help`, note: "one verb's options and examples" },
753
+ ]),
267
754
  ];
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) {
755
+ const listed = new Set(HELP_GROUPS.flatMap(([, names]) => names));
756
+ for (const [title, names] of HELP_GROUPS) {
757
+ const rows = names.map((n) => verbs.find((v) => v.verb === n)).filter(Boolean);
274
758
  if (!rows.length) continue;
275
- lines.push("", title);
276
- for (const v of rows) lines.push(` ${v.verb.padEnd(12)} ${v.summary}`);
759
+ lines.push("", term.heading(c, title));
760
+ for (const v of rows) {
761
+ const lead = ` ${paint("action", v.verb)}${" ".repeat(Math.max(0, VERB_COLUMN - v.verb.length))} `;
762
+ lines.push(lead + term.wrap(term.glyphText(c, v.summary), width, " ".repeat(term.plain(lead).length)));
763
+ }
277
764
  }
765
+ // ADVANCED is one line of verbs, broken at 80 columns in every mode: with
766
+ // install and bind among them (contract 10, once setup exists) it no longer
767
+ // fits one row, and a pipe gets the same lines a terminal does.
768
+ const advanced = verbs.filter((v) => !listed.has(v.verb));
769
+ if (advanced.length)
770
+ lines.push(
771
+ "",
772
+ term.heading(c, "ADVANCED"),
773
+ " " + term.wrap(advanced.map((v) => paint("action", v.verb)).join(` ${term.icon(c, "dot")} `), 80, " "),
774
+ );
278
775
  if (PLANNED_VERBS.length) lines.push("", `Planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
776
+ // A command is offered only for a verb this table has. The examples and the
777
+ // tips' plan are what to run next: the core's form, through a shell with
778
+ // the shell's own harness (rule 6 of the story "User-facing text says
779
+ // where to run…"); setup names it only through a shell, as EXAMPLES'
780
+ // `{fixed}` does.
781
+ const h = ` --harness ${(shell && shellHarnessId(env)) || "<id>"}`;
782
+ const has = (verb) => verbs.some((v) => v.verb === verb);
783
+ const examples = [
784
+ ["setup", `${CORE_NPX} setup${shell ? h : ""}`],
785
+ ["status", `${CORE_NPX} status`],
786
+ ["doctor", `${CORE_NPX} doctor`],
787
+ ]
788
+ .filter(([v]) => has(v))
789
+ .map(([, x]) => x);
790
+ lines.push("", term.heading(c, "Options"), ...optionLines(EVERY_VERB, c));
791
+ if (examples.length) lines.push("", term.heading(c, "Examples"), ...term.commandBlock(c, examples));
279
792
  lines.push(
280
793
  "",
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,
794
+ term.heading(c, "Tips"),
795
+ prose("setup, install, upgrade and uninstall show a plan, then ask at a terminal."),
796
+ prose(
797
+ `Without a terminal, ${shell ? "the verb is its own" : "--harness is the"} confirmation ${term.icon(c, "dash")} there is no --yes.`,
798
+ ),
799
+ prose("Unattended in a terminal: pass --json, set CI=1, or give stdin </dev/null."),
800
+ prose("--verbose on those verbs adds every row's reasoning and the host's notes."),
294
801
  );
802
+ if (has("plan"))
803
+ lines.push(
804
+ prose("plan prints the same plan and writes nothing:"),
805
+ ...term.commandBlock(c, [`${CORE_NPX} plan${h}`]),
806
+ );
807
+ lines.push("", fact(c, "Harnesses", harnessIds().join(", ")), fact(c, "Exit codes", EXIT_CODES));
295
808
  return lines.join("\n");
296
809
  }
297
810
 
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);
811
+ // One verb: what it does, how to call it, every option, examples. The Usage
812
+ // lines are the row's own forms, the same declaration that bounds its
813
+ // arguments — never parsed out of the summary, so help and the parser cannot
814
+ // disagree about what a verb takes. Its first line is the summary the
815
+ // top-level help lists, after the command; `caps` as usage()'s.
816
+ export function verbHelp(row, env = process.env, { caps: c = term.PLAIN } = {}) {
817
+ const { shell, cmd } = invokedAs(env);
818
+ const paint = term.painter(c);
301
819
  const fixed = Boolean(shell) && row.options.some((o) => o.name === "harness");
302
820
  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);
821
+ const prose = (text) => " " + term.wrap(term.glyphText(c, text), c.live ? c.width : 0, " ");
822
+ const head = `${paint("emphasis", `${cmd} ${row.verb}`)} ${term.icon(c, "dash")} ${term.glyphText(c, row.summary)}`;
823
+ const lines = [term.wrap(head, c.live ? c.width : 0, " "), "", term.heading(c, "Usage")];
824
+ for (const f of row.usage.length ? row.usage : [""])
825
+ lines.push(` ${cmd} ${row.verb}${f ? " " + term.glyphText(c, f) : ""}${opts.length ? " [options]" : ""}`);
826
+ if (NOTES[row.verb]) lines.push("", prose(NOTES[row.verb]));
827
+ if (opts.length) lines.push("", term.heading(c, "Options"), ...optionLines(opts, c));
828
+ if (fixed)
829
+ lines.push(
830
+ "",
831
+ term.wrap(
832
+ `${shell} names the harness itself: without a terminal, the verb is its own confirmation.`,
833
+ c.live ? c.width : 0,
834
+ " ",
835
+ ),
836
+ );
837
+ else if (row.options.some((o) => o.name === "harness")) lines.push("", fact(c, "Harnesses", harnessIds().join(", ")));
838
+ const sw = switchHarness();
839
+ const ex = (EXAMPLES[row.verb] || [])
840
+ .filter((e) => !(fixed && e.startsWith("{core}")) && (sw || !e.includes("{switch.")))
841
+ .map((e) => e.replace(/^\{core\}/, "").replace(/\{(switch\.\w+)\}/g, (_, k) => sw[k]));
842
+ // What to run next is the core's form: through a shell, its own harness.
843
+ const h = ` --harness ${(fixed && shellHarnessId(env)) || "<id>"}`;
844
+ // Each example command stands alone on its line, after a blank one
845
+ // (contract 6): nothing after it, so a paste takes exactly the command — a
846
+ // trailing `# note` broke a paste into a shell that reads no comments. A
847
+ // note leads its command, a blank line between them, as every other
848
+ // introduced command does (DONE's tip, doctor's fix, bind's Next);
849
+ // commands with no note share one block.
850
+ const blocks = [];
851
+ const render = (e) =>
852
+ e
853
+ .split("{cmd}")
854
+ .join(CORE_NPX)
855
+ .split("{h}")
856
+ .join(h)
857
+ .split("{fixed}")
858
+ .join(fixed ? h : "");
859
+ for (const [x, note] of ex.map((e) => render(e).split(" # "))) {
860
+ const last = blocks.at(-1);
861
+ if (!note && last && !last.note) last.cmds.push(x);
862
+ else blocks.push({ note: note ? `${note[0].toUpperCase()}${note.slice(1)}:` : null, cmds: [x] });
863
+ }
864
+ if (blocks.length) lines.push("", term.heading(c, "Examples"));
865
+ for (const b of blocks)
866
+ lines.push(
867
+ "",
868
+ ...(b.note ? [prose(paint("explanation", b.note)), ""] : []),
869
+ ...b.cmds.map((x) => ` ${paint("action", x)}`),
870
+ );
871
+ lines.push("", fact(c, "Exit codes", EXIT_CODES));
315
872
  return lines.join("\n");
316
873
  }
317
874
 
@@ -320,90 +877,322 @@ export function nearest(word, names) {
320
877
  const d = (a, b) => {
321
878
  const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
322
879
  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));
880
+ for (let i = 1; i <= a.length; i++)
881
+ for (let j = 1; j <= b.length; j++)
882
+ 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
883
  return m[a.length][b.length];
325
884
  };
326
- let best = null, score = 3;
885
+ let best = null,
886
+ score = 3;
327
887
  for (const n of names) {
328
888
  const k = n.startsWith(word) && word.length >= 3 ? 0 : d(word, n);
329
- if (k < score) { best = n; score = k; }
889
+ if (k < score) {
890
+ best = n;
891
+ score = k;
892
+ }
330
893
  }
331
894
  return best;
332
895
  }
333
896
 
334
897
  // ─── run ───────────────────────────────────────────────────────────────
335
898
 
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;
899
+ // Rule 1 of the per-verb parse: the verb is found before anything is parsed.
900
+ // Before it only the globals' arity is known; after it, its row's too. Left
901
+ // to right, to the first "--": a "--" before the verb makes the next token
902
+ // the verb (`projectstore -- doctor`), and one after it leaves the rest to
903
+ // the verb, so the MCP server's `-- <query>` is never read as a global. Only
904
+ // the exact tokens set a flag: `--json=v` is a global with a bad value, which
905
+ // the parse words (rule 6), not a verb's option. A bare --project takes the
906
+ // next token whatever it is, as parseArgs does, and so does a bare option of
907
+ // the verb's that takes a value: `reconcile --only --help` and the MCP
908
+ // server's `--kind -h` hand the parse a value it refuses, never the help.
909
+ // Any other option before the verb is misplaced (rule 3). A bare word right
910
+ // after one is presumed to be its value when it is not a verb and some verb
911
+ // gives that option a value — for the message only (`--harness codex plan`
912
+ // reads back as `plan --harness codex`); nothing is parsed on that guess.
913
+ function scanArgv(argv, verbs) {
914
+ const scan = { verb: null, at: -1, json: false, help: false, version: false, misplaced: [] };
915
+ const takesValue = (row, name) => row.options.some((o) => o.name === name && o.arg);
916
+ let row = null;
917
+ for (let i = 0; i < argv.length; i++) {
918
+ const a = argv[i];
919
+ if (a === "--") {
920
+ if (scan.at === -1 && i + 1 < argv.length) {
921
+ scan.verb = argv[i + 1];
922
+ scan.at = i + 1;
923
+ }
924
+ break;
925
+ }
926
+ if (a.length > 1 && a.startsWith("-")) {
927
+ if (/^-[hv]+$/.test(a)) {
928
+ if (a.includes("h")) scan.help = true;
929
+ if (a.includes("v")) scan.version = true;
930
+ continue;
931
+ }
932
+ if (a === "--json") scan.json = true;
933
+ if (a === "--help") scan.help = true;
934
+ if (a === "--version") scan.version = true;
935
+ const name = a.startsWith("--") ? a.slice(2).split("=")[0] : null;
936
+ const inline = a.includes("=");
937
+ if (!inline && (name === "project" || (row && takesValue(row, name)))) i++;
938
+ else if (scan.at === -1 && !GLOBAL_OPTS.some((o) => o.name === name))
939
+ scan.misplaced.push({ name, raw: a, at: i, inline, value: undefined });
940
+ continue;
941
+ }
942
+ if (scan.at !== -1) continue;
943
+ const prev = scan.misplaced[scan.misplaced.length - 1];
944
+ if (
945
+ !verbs.some((v) => v.verb === a) &&
946
+ prev &&
947
+ prev.at === i - 1 &&
948
+ !prev.inline &&
949
+ verbs.some((v) => takesValue(v, prev.name))
950
+ ) {
951
+ prev.value = a;
952
+ continue;
953
+ }
954
+ scan.verb = a;
955
+ scan.at = i;
956
+ row = verbs.find((v) => v.verb === a) || null;
365
957
  }
366
- const { values, positionals } = parsed;
958
+ return scan;
959
+ }
960
+
961
+ // A row's options and the globals, in parser order: the later declaration
962
+ // wins, so a row cannot retype a global. No row (no verb) is the globals alone.
963
+ const optionsOf = (row) => [...(row ? row.options : []), ...GLOBAL_OPTS];
964
+
965
+ // Rule 2: the row is the parser — arg is the type, multiple the repetition.
966
+ function parserOptions(row) {
967
+ const options = {};
968
+ for (const o of optionsOf(row))
969
+ options[o.name] = {
970
+ type: o.arg ? "string" : "boolean",
971
+ ...(o.multiple ? { multiple: true } : {}),
972
+ ...(SHORT[o.name] ? { short: SHORT[o.name] } : {}),
973
+ };
974
+ return options;
975
+ }
976
+
977
+ // "install does not take --verbos — did you mean --verbose?" With a row the
978
+ // candidates are its options and the globals; without one (no verb, or a
979
+ // word that is none), every option the table declares, as 0.29.2 offered.
980
+ function unknownOption(row, raw, verbs) {
981
+ const names = (row ? optionsOf(row) : [...verbs.flatMap((v) => v.options), ...GLOBAL_OPTS]).map((o) => o.name);
982
+ const hint = nearest(raw.replace(/^-+/, ""), [...new Set(names)]);
983
+ return `${row ? `${row.verb} does not take` : "unknown option"} ${raw}${hint ? ` — did you mean --${hint}?` : ""}`;
984
+ }
985
+
986
+ // Rule 6: Node's own wording explains "--" positionals; a person who typed
987
+ // --verbos wants the option they meant, and one who left --only bare wants
988
+ // its form. A bad value comes in three shapes — an inline value on a flag
989
+ // (`--json=v`), none at all (`--only` last), one that looks like an option
990
+ // (`--only --json`) — and each quotes the long name, with ` <value>` or a
991
+ // `-h, ` short form around it: the declaration words the answer. Node's text
992
+ // is the fallback when no name can be read.
993
+ function parseFailure(e, row, verbs) {
994
+ const raw = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'(-{1,2}[^']+)'/.exec(e.message)?.[1] : null;
995
+ if (raw) return unknownOption(row, raw, verbs);
996
+ const name =
997
+ e.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE"
998
+ ? /'(?:-[^-], )?(--[^' ]+)(?: <value>)?'/.exec(e.message)?.[1]
999
+ : null;
1000
+ const o = name ? optionsOf(row).findLast((x) => `--${x.name}` === name) : null;
1001
+ if (!o) return e.message;
1002
+ return o.arg ? `\`--${o.name}\` needs a value: \`--${o.name} ${o.arg}\`` : `\`--${o.name}\` takes no value`;
1003
+ }
1004
+
1005
+ // Rule 4: a usage form, word by word — a --name the form requires (the word
1006
+ // after it is the option's value when it takes one, not an argument), a
1007
+ // <placeholder> (`<phrase…>` takes the rest), or a literal sub-word. The
1008
+ // bound is how many arguments the form holds.
1009
+ function formOf(row, form) {
1010
+ const f = { form, literals: [], required: [], placeholders: 0, variadic: false };
1011
+ const words = form.split(" ");
1012
+ for (let i = 0; i < words.length; i++) {
1013
+ const w = words[i];
1014
+ if (w.startsWith("--")) {
1015
+ f.required.push(w.slice(2));
1016
+ if (row.options.find((o) => o.name === w.slice(2))?.arg) i++;
1017
+ } else if (w.startsWith("<")) {
1018
+ f.placeholders++;
1019
+ if (w.endsWith("…>")) f.variadic = true;
1020
+ } else f.literals.push(w);
1021
+ }
1022
+ f.bound = f.variadic ? Infinity : f.literals.length + f.placeholders;
1023
+ return f;
1024
+ }
1025
+
1026
+ // The form the arguments take: the first whose literal words lead them and
1027
+ // whose required options were given. null when none does, and the verb's own
1028
+ // message then says what it takes ("agents takes model <name>, show, or
1029
+ // configure") — which is how `codemap PS-CORE` keeps its --for hint while
1030
+ // `codemap --for X extra` names `extra`. A row with no forms binds nothing.
1031
+ function matchForm(row, args, values) {
1032
+ if (!row.usage.length) return { form: "", bound: 0 };
1033
+ for (const form of row.usage) {
1034
+ const f = formOf(row, form);
1035
+ if (f.literals.every((w, i) => args[i] === w) && f.required.every((n) => values[n] !== undefined)) return f;
1036
+ }
1037
+ return null;
1038
+ }
1039
+
1040
+ // `verbs` is the table this run answers for: VERBS, or a test's own — the
1041
+ // scan, the help, the verb lookup and every hint read it, never VERBS behind it.
1042
+ export async function run(
1043
+ argv,
1044
+ {
1045
+ verbs = VERBS,
1046
+ env = process.env,
1047
+ cwd = process.cwd(),
1048
+ stdin = process.stdin,
1049
+ stdout = process.stdout,
1050
+ stderr = process.stderr,
1051
+ ask = null,
1052
+ } = {},
1053
+ ) {
1054
+ const scan = scanArgv(argv, verbs);
1055
+ const row = verbs.find((v) => v.verb === scan.verb) || null;
367
1056
  // 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" : ""));
1057
+ // a consumer (the MCP server, a script) always has something to parse. The
1058
+ // verb is the scan's, whichever step failed (rule 7) — never the first
1059
+ // bare word, which may be --project's value.
1060
+ const fail = (message, code, { project = null, help = false } = {}) => {
1061
+ if (scan.json)
1062
+ stdout.write(JSON.stringify(envelope(scan.verb, project, false, { error: message, exit: code }), null, 2) + "\n");
1063
+ stderr.write(message + "\n" + (help ? usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n" : ""));
372
1064
  return code;
373
1065
  };
1066
+ // A usage error says where the options are: the verb's help, else the top
1067
+ // (a word that is not a verb has no help to point at).
1068
+ const refuse = (message) => fail(`${message}\nRun ${row ? `\`${row.verb} --help\`` : "--help"} for the options.`, 2);
374
1069
  // In-process reads resolve layouts and registries from THIS package, as the
375
1070
  // children already do through ownEnv — not from whichever copy the host
376
1071
  // session's variable points at.
377
1072
  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);
1073
+ // Rule 8: help and version answer first, whatever else the line holds, so
1074
+ // `doctor extra --help` and `--harness codex plan --help` print the help.
1075
+ if (scan.version) return runVersion({ values: { json: scan.json }, stdout });
1076
+ // Help is painted at a terminal and plain on a pipe: the caps are stdout's.
1077
+ if (scan.help) {
1078
+ const caps = term.caps(stdout, env);
1079
+ stdout.write((row ? verbHelp(row, env, { caps }) : usage(env, verbs, { caps })) + "\n");
1080
+ return 0;
1081
+ }
1082
+ // With no verb to scope it, an option no verb declares is unknown before
1083
+ // anything else is said, as in 0.29.2: `--bogus frobnicate` names --bogus,
1084
+ // and `--verbos instal` offers --verbose.
1085
+ const stray = row ? null : scan.misplaced.find((m) => !verbs.some((v) => v.options.some((o) => o.name === m.name)));
1086
+ if (stray) return refuse(unknownOption(null, stray.raw.split("=")[0], verbs));
1087
+ if (scan.verb === null) {
1088
+ // No verb. A verb's option (`--harness codex`, `--verbose`) is the usage,
1089
+ // as before — not "unknown verb: codex"; otherwise the globals alone are
1090
+ // parsed, so a bad value of one (`--project` last) is named.
1091
+ if (!scan.misplaced.length) {
1092
+ try {
1093
+ parseArgs({ args: argv, options: parserOptions(null), strict: true, allowPositionals: true });
1094
+ } catch (e) {
1095
+ return refuse(parseFailure(e, null, verbs));
1096
+ }
1097
+ }
1098
+ stderr.write(usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n");
1099
+ return 2;
1100
+ }
1101
+ const verb = scan.verb;
386
1102
  if (!row) {
387
1103
  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);
1104
+ const guess = planned
1105
+ ? null
1106
+ : nearest(
1107
+ verb,
1108
+ verbs.map((v) => v.verb),
1109
+ );
1110
+ if (guess) return fail(`unknown verb: ${verb} — did you mean ${guess}?\nRun --help for every verb.`, 2);
1111
+ return fail(
1112
+ planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`,
1113
+ 2,
1114
+ { help: true },
1115
+ );
1116
+ }
1117
+ // Rule 3: only the globals come before the verb (git's model). A verb's
1118
+ // own option there is shown in its place; one the verb does not declare is
1119
+ // unknown, scoped to the verb. Accepting it would need its arity before the
1120
+ // verb is known, which is what per-verb types make ambiguous.
1121
+ if (scan.misplaced.length) {
1122
+ const m = scan.misplaced[0];
1123
+ const o = m.name === null ? null : row.options.find((x) => x.name === m.name);
1124
+ if (!o) return refuse(unknownOption(row, m.raw.split("=")[0], verbs));
1125
+ // Through a shell the harness is fixed (verbHelp's test: the row declares
1126
+ // it): the order shown drops it, since the shell inserts it after the verb.
1127
+ // The rest of the line follows, so the command shown is the one to run.
1128
+ const { shell, cmd } = invokedAs(env);
1129
+ const moved = shell && o.name === "harness" ? [] : [m.raw, ...(o.arg && m.value !== undefined ? [m.value] : [])];
1130
+ return refuse(
1131
+ `\`--${o.name}\` belongs after the verb: \`${[cmd, verb, ...moved, ...argv.slice(scan.at + 1)].join(" ")}\``,
1132
+ );
1133
+ }
1134
+ // Rule 2: one strict parse with this row's options plus the globals, on the
1135
+ // full argv so a token's index is the user's (rule 4's hints read them).
1136
+ let parsed;
1137
+ try {
1138
+ parsed = parseArgs({ args: argv, options: parserOptions(row), strict: true, allowPositionals: true, tokens: true });
1139
+ } catch (e) {
1140
+ return refuse(parseFailure(e, row, verbs));
1141
+ }
1142
+ const { values, positionals, tokens } = parsed;
1143
+ // Rule 4: an argument past the matched form is named, never dropped. The
1144
+ // hint says how it became one: the flag before it takes no value, or a
1145
+ // "--" made everything after it an argument.
1146
+ const args = tokens.filter((t) => t.kind === "positional" && t.index !== scan.at);
1147
+ const form = matchForm(
1148
+ row,
1149
+ args.map((t) => t.value),
1150
+ values,
1151
+ );
1152
+ if (form && args.length > form.bound) {
1153
+ const extra = args[form.bound];
1154
+ const prev = tokens.find((t) => t.index === extra.index - 1);
1155
+ const end = tokens.find((t) => t.kind === "option-terminator");
1156
+ const why =
1157
+ prev && prev.kind === "option" && prev.value === undefined
1158
+ ? ` — \`--${prev.name}\` takes no value`
1159
+ : end && extra.index > end.index
1160
+ ? " — everything after `--` is an argument"
1161
+ : "";
1162
+ return refuse(
1163
+ `${form.bound ? `\`${verb} ${form.form}\` takes no further argument` : `${verb} takes no argument`} \`${extra.value}\`${why}`,
1164
+ );
401
1165
  }
402
1166
  const project = resolveProject({ project: values.project, env, cwd });
403
1167
  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);
1168
+ // Each form alone on its line after the lead, the terminal's in the core's
1169
+ // form, the directory in the lead (the story "User-facing text says where to
1170
+ // run and names no project path and no shell", rules 1, 4 and 6).
1171
+ if (row.requiresBinding && !cfg)
1172
+ return fail(
1173
+ [
1174
+ `${project} is not bound to a vault. Bind it in a session, or ${fromTerminalIn(project)}, where init creates the vault too:`,
1175
+ commandForm("bind", { args: "<vault>", env }),
1176
+ packageCommand([], "bind", { args: "<vault>" }),
1177
+ packageCommand([], "init", { args: "<vault>" }),
1178
+ ].join("\n"),
1179
+ 3,
1180
+ { project },
1181
+ );
405
1182
  try {
406
- return await row.run({ row, values, positionals: positionals.slice(1), cfg, project, env, cwd, stdin, stdout, stderr, ask });
1183
+ return await row.run({
1184
+ row,
1185
+ values,
1186
+ positionals: positionals.slice(1),
1187
+ cfg,
1188
+ project,
1189
+ env,
1190
+ cwd,
1191
+ stdin,
1192
+ stdout,
1193
+ stderr,
1194
+ ask,
1195
+ });
407
1196
  } catch (e) {
408
1197
  // An internal failure is not "findings" (1) — it is 2, with an envelope
409
1198
  // when one was asked for.
@@ -435,10 +1224,12 @@ async function confirmWrite(question, { stdin, stdout, ask }) {
435
1224
 
436
1225
  // The read verbs: one call into query.mjs, the result in the envelope or
437
1226
  // 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) {
1227
+ // is exit 2 with the message, never a stack trace. A renderer gets the
1228
+ // stream's caps — painted at a terminal, plain on a pipe — and the env its
1229
+ // command forms are named for; one that takes neither ignores them.
1230
+ function emitRead({ verb, values, project, stdout, env }, op, result, ok = true) {
440
1231
  if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n");
441
- else stdout.write(op.render(result));
1232
+ else stdout.write(op.render(result, { caps: term.caps(stdout, env), env }));
442
1233
  return ok ? 0 : 1;
443
1234
  }
444
1235
 
@@ -456,8 +1247,16 @@ function runRead(name) {
456
1247
  let result;
457
1248
  if (name === "status") result = op.fn(cfg, { project });
458
1249
  else if (name === "orientation") result = await op.fn(cfg);
459
- else if (name === "search") result = op.fn(cfg, positionals.join(" "), { kinds: values.kind || null, status: values.status ?? null, limit: values.limit, includeDerived: Boolean(values["include-derived"]), caseSensitive: Boolean(values["case-sensitive"]) });
460
- else if (name === "show") result = op.fn(cfg, positionals[0], { body: Boolean(values.body), section: values.section ?? null });
1250
+ else if (name === "search")
1251
+ result = op.fn(cfg, positionals.join(" "), {
1252
+ kinds: values.kind || null,
1253
+ status: values.status ?? null,
1254
+ limit: values.limit,
1255
+ includeDerived: Boolean(values["include-derived"]),
1256
+ caseSensitive: Boolean(values["case-sensitive"]),
1257
+ });
1258
+ else if (name === "show")
1259
+ result = op.fn(cfg, positionals[0], { body: Boolean(values.body), section: values.section ?? null });
461
1260
  return emitRead({ verb: row.verb, ...ctx }, op, result);
462
1261
  } catch (e) {
463
1262
  if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
@@ -490,26 +1289,108 @@ function gitAuthor(project, env) {
490
1289
 
491
1290
  // bind / init: the vault named on the command line is the confirmation (the
492
1291
  // 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.
1292
+ // nothing to ask, at a terminal either: init's command line names what it
1293
+ // writes); a change of vault needs --rebind. Exit 1 on a refusal with the
1294
+ // reason, or when init's git or scaffold step failed after the binding was
1295
+ // written; 2 on usage; 0 when already bound to the same vault.
495
1296
  function runBind(init) {
496
1297
  return async (ctx) => {
497
1298
  const { row, values, positionals, project, env, stdout, stderr } = ctx;
498
1299
  const vault = positionals[0];
499
- if (!vault) return usageFail(Object.assign(new Error(`${row.verb} takes the vault path`), { code: "USAGE" }), { verb: row.verb, ...ctx });
1300
+ if (!vault)
1301
+ return usageFail(Object.assign(new Error(`${row.verb} takes the vault path`), { code: "USAGE" }), {
1302
+ verb: row.verb,
1303
+ ...ctx,
1304
+ });
500
1305
  // 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 });
1306
+ const plan = planBind(project, {
1307
+ vault,
1308
+ layout: values.layout ?? null,
1309
+ language: values.language ?? null,
1310
+ rebind: Boolean(values.rebind),
1311
+ init,
1312
+ author: gitAuthor(project, env),
1313
+ env,
1314
+ root: PACKAGE_ROOT,
1315
+ });
502
1316
  const usage = plan.refusals.find((r) => r.code === "USAGE");
503
1317
  if (usage) return usageFail(Object.assign(new Error(usage.message), { code: "USAGE" }), { verb: row.verb, ...ctx });
504
1318
  let done = null;
505
- if (plan.ok && plan.writes) done = applyBind(plan);
1319
+ if (plan.ok && (plan.writes || plan.buildsVault)) done = applyBind(plan, { env });
1320
+ const ok = plan.ok && !bindFailed(done);
506
1321
  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;
1322
+ if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, ok, result), null, 2) + "\n");
1323
+ else {
1324
+ const stream = plan.ok ? stdout : stderr;
1325
+ stream.write(renderBindPlan(plan, done, { env, caps: term.caps(stream, env) }));
1326
+ }
1327
+ return ok ? 0 : 1;
510
1328
  };
511
1329
  }
512
1330
 
1331
+ // scaffold: the bound vault's skeleton, from the binding's layout and
1332
+ // language (`bind --rebind` changes them; the verb takes no layout). Bare, the
1333
+ // plan, and nothing is written. --write creates the `create` rows: at an
1334
+ // interactive terminal (install spec contract 9's definition) after the plan
1335
+ // and `Apply N changes? [Y/n]`; without one, --write is the confirmation —
1336
+ // the write set is closed, so `scaffold --write --json` writes at a terminal
1337
+ // too, which is how the session command and the Codex skill confirm. A plan
1338
+ // with nothing to create asks nothing and exits 0. A vault directory that
1339
+ // is gone is refused: recreating it here would hide that it vanished, and
1340
+ // `init` is the verb that makes a vault.
1341
+ async function runScaffold(ctx) {
1342
+ const { row, values, cfg, project, env, stdin, stdout, stderr, ask } = ctx;
1343
+ const emit = (ok, result, text, code) => {
1344
+ if (values.json) stdout.write(JSON.stringify(envelope(row.verb, project, ok, result), null, 2) + "\n");
1345
+ else (ok ? stdout : stderr).write(text);
1346
+ return code;
1347
+ };
1348
+ // readConfigAt hands back the stored path raw: `~`, relative, a trailing slash.
1349
+ const norm = normaliseVaultPath(cfg.vault_path, project);
1350
+ const vault = typeof norm === "string" ? norm : null;
1351
+ // Each refusal ends with its command alone on its line, in the core's form,
1352
+ // the directory in the lead (the story "User-facing text says where to
1353
+ // run…", rules 1, 4 and 6). In a session, bind asks before a rebind.
1354
+ if (!vault) {
1355
+ const why = [
1356
+ `the binding names no usable vault path${norm && norm.error ? ` (${norm.error})` : ""}. Rebind it in a session, or ${fromTerminalIn(project)}:`,
1357
+ commandForm("bind", { args: "<vault>", env }),
1358
+ packageCommand([], "bind", { args: "<vault> --rebind" }),
1359
+ ].join("\n");
1360
+ return emit(false, { error: why }, why + "\n", 1);
1361
+ }
1362
+ if (!existsSync(vault)) {
1363
+ const why = [
1364
+ `${vault} does not exist: the binding names a vault directory that is not there. init creates it under this binding — the directory, its git repository and the layout's folders — ${fromTerminalIn(project)}:`,
1365
+ packageCommand([], "init", { args: shellQuote(vault) }),
1366
+ ].join("\n");
1367
+ return emit(false, { error: why, vault_path: vault }, why + "\n", 1);
1368
+ }
1369
+ const plan = planScaffold(vault, {
1370
+ layout: cfg.layout || DEFAULT_LAYOUT,
1371
+ language: cfg.language || DEFAULT_LANGUAGE,
1372
+ root: PACKAGE_ROOT,
1373
+ });
1374
+ if (!plan.ok) return emit(false, scaffoldResult(plan), renderScaffoldPlan(plan, null, { env }), 1);
1375
+ if (!values.write || plan.creates === 0)
1376
+ return emit(true, scaffoldResult(plan), renderScaffoldPlan(plan, null, { env, project }), 0);
1377
+ // An injected `ask` is a terminal, as for install. Otherwise the installer's
1378
+ // definition of one (TTYs, CI, a host session's markers) is the one
1379
+ // contract 9 names — imported lazily, as runInstallVerb imports it.
1380
+ const interactive =
1381
+ !values.json &&
1382
+ (ask ? true : (await import("./install-harness.mjs")).isInteractive({ stdin, stdout, env, json: false }));
1383
+ if (interactive) {
1384
+ stdout.write(renderScaffoldPlan(plan, null, { env, asking: true }));
1385
+ if (!(await term.askApply(plan.creates, { stdin, stdout, ask, caps: term.caps(stdout, env) }))) {
1386
+ stdout.write("Nothing written.\n");
1387
+ return 1;
1388
+ }
1389
+ }
1390
+ const done = applyScaffold(plan);
1391
+ return emit(true, scaffoldResult(plan, done), renderScaffoldPlan(plan, done, { env }), 0);
1392
+ }
1393
+
513
1394
  // agents model <name> — the model a plugin surface passes for that agent, from
514
1395
  // the active harness's overlay (ADR-008's two terms); agents show — the overlay
515
1396
  // as read, with the keys the allowlist rejected; agents configure — the one
@@ -522,21 +1403,32 @@ async function runAgents(ctx) {
522
1403
  if (!["model", "show", "configure"].includes(sub)) return usage("agents takes model <name>, show, or configure");
523
1404
  // The flag IS the confirmation, so it names exactly one overlay: two are a
524
1405
  // question, not an answer.
525
- if (values.harness && values.harness.length > 1) return usage("--harness names exactly one overlay (it is the confirmation); given twice");
1406
+ if (values.harness && values.harness.length > 1)
1407
+ return usage("--harness names exactly one overlay (it is the confirmation); given twice");
526
1408
  const named = values.harness && values.harness[0];
527
- if (named && !harnessIds().includes(named)) return usage(`unknown harness: ${named} — known: ${harnessIds().join(", ")}`);
1409
+ if (named && !harnessIds().includes(named))
1410
+ return usage(`unknown harness: ${named} — known: ${harnessIds().join(", ")}`);
528
1411
  const harness = named || overlayId(env);
529
1412
  const forConfigure = ["default", "agent", "reset"].filter((o) => values[o] !== undefined);
530
1413
  if (sub !== "configure" && forConfigure.length) return usage(`--${forConfigure[0]} is an option of agents configure`);
531
1414
  // A refusal goes to stderr, as bind's does; --json keeps its envelope on stdout.
532
- 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; };
1415
+ const emit = (verb, ok, result, text) => {
1416
+ if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n");
1417
+ else (ok ? stdout : stderr).write(text);
1418
+ return ok ? 0 : 1;
1419
+ };
533
1420
  const cfg = readConfigAt(project);
534
1421
  const roster = layoutRoster(cfg);
535
1422
  if (sub === "model") {
536
1423
  const name = positionals[1];
537
1424
  if (!name) return usage("agents model takes the agent's bare name (critic, planner, …)");
538
1425
  const r = resolveAgentModel(project, name, { harness });
539
- return emit("agents model", true, r, `${name}: ${r.model ? `${r.model} (${r.source}, ${r.overlay})` : "no model configured — the agent's frontmatter decides"}\n`);
1426
+ return emit(
1427
+ "agents model",
1428
+ true,
1429
+ r,
1430
+ `${name}: ${r.model ? `${r.model} (${r.source}, ${r.overlay})` : "no model configured — the agent's frontmatter decides"}\n`,
1431
+ );
540
1432
  }
541
1433
  const before = readOverlayAt(project, harness);
542
1434
  const inBinding = Boolean(cfg && cfg.agents && typeof cfg.agents === "object");
@@ -544,34 +1436,72 @@ async function runAgents(ctx) {
544
1436
  // Resolved per roster agent (per configured name when no layout loads), so
545
1437
  // a reader reports what would run without restating the two-term rule.
546
1438
  const names = [...new Set([...(roster || []), ...Object.keys(before.agents.per_agent)])].sort();
547
- const resolved = Object.fromEntries(names.map((n) => { const r = resolveAgentModel(project, n, { harness }); return [n, { model: r.model, source: r.source }]; }));
1439
+ const resolved = Object.fromEntries(
1440
+ names.map((n) => {
1441
+ const r = resolveAgentModel(project, n, { harness });
1442
+ return [n, { model: r.model, source: r.source }];
1443
+ }),
1444
+ );
548
1445
  const unknown = roster ? Object.keys(before.agents.per_agent).filter((n) => !roster.includes(n)) : [];
549
- const lines = [`overlay: ${before.path || "(none: no harness detected and none named)"}${before.path && !before.present ? " (absent)" : ""}`];
1446
+ const lines = [
1447
+ `overlay: ${before.path || "(none: no harness detected and none named)"}${before.path && !before.present ? " (absent)" : ""}`,
1448
+ ];
550
1449
  if (before.unparseable) lines.push(" not valid JSON");
551
1450
  if (before.agents.default) lines.push(` default: ${before.agents.default}`);
552
- for (const n of names) lines.push(` ${n}: ${resolved[n].model ? `${resolved[n].model} (${resolved[n].source})` : "— (the agent's frontmatter)"}${unknown.includes(n) ? " — not in the roster: nothing runs under this name" : ""}`);
1451
+ for (const n of names)
1452
+ lines.push(
1453
+ ` ${n}: ${resolved[n].model ? `${resolved[n].model} (${resolved[n].source})` : "— (the agent's frontmatter)"}${unknown.includes(n) ? " — not in the roster: nothing runs under this name" : ""}`,
1454
+ );
553
1455
  if (before.rejected.length) lines.push(` ignored (not an overlay key): ${before.rejected.join(", ")}`);
554
1456
  if (inBinding) lines.push(" the binding still carries an agents block — a pre-0.28 leftover; run upgrade");
555
- return emit("agents show", true, { harness, path: before.path, present: before.present, unparseable: before.unparseable, agents: before.agents, resolved, roster, unknown, rejected: before.rejected, agents_in_binding: inBinding }, lines.join("\n") + "\n");
1457
+ return emit(
1458
+ "agents show",
1459
+ true,
1460
+ {
1461
+ harness,
1462
+ path: before.path,
1463
+ present: before.present,
1464
+ unparseable: before.unparseable,
1465
+ agents: before.agents,
1466
+ resolved,
1467
+ roster,
1468
+ unknown,
1469
+ rejected: before.rejected,
1470
+ agents_in_binding: inBinding,
1471
+ },
1472
+ lines.join("\n") + "\n",
1473
+ );
556
1474
  }
557
1475
  // configure — in a project that exists (the install spec, contract 19;
558
1476
  // the layout spec, contract 4): refused before the preview and the gate.
559
1477
  const missingProject = projectDirRefusal(project);
560
1478
  if (missingProject) return emit("agents configure", false, { error: missingProject }, `${missingProject}\n`);
561
1479
  if (!harness) return usage("no harness detected and none named: agents configure names --harness <id>");
562
- if (before.unparseable) return emit("agents configure", false, { error: `${before.path} is not valid JSON; fix or remove it first`, harness, path: before.path }, `${before.path} is not valid JSON; fix or remove it first\n`);
563
- if (!values.reset && values.default === undefined && !(values.agent || []).length) return usage("agents configure takes --default <model>, --agent <name>=<model> (repeatable) or --reset");
1480
+ if (before.unparseable)
1481
+ return emit(
1482
+ "agents configure",
1483
+ false,
1484
+ { error: `${before.path} is not valid JSON; fix or remove it first`, harness, path: before.path },
1485
+ `${before.path} is not valid JSON; fix or remove it first\n`,
1486
+ );
1487
+ if (!values.reset && values.default === undefined && !(values.agent || []).length)
1488
+ return usage("agents configure takes --default <model>, --agent <name>=<model> (repeatable) or --reset");
564
1489
  // --reset empties the block first; --default and --agent then apply on top of
565
1490
  // the emptied block — "reset, then set" is one call, and nothing given is
566
1491
  // silently dropped.
567
1492
  const base = values.reset ? { default: null, per_agent: {} } : before.agents;
568
- const next = { default: values.default !== undefined ? (values.default || null) : base.default, per_agent: { ...base.per_agent } };
1493
+ const next = {
1494
+ default: values.default !== undefined ? values.default || null : base.default,
1495
+ per_agent: { ...base.per_agent },
1496
+ };
569
1497
  for (const spec of values.agent || []) {
570
1498
  const m = /^([a-z][a-z0-9-]*)=(.*)$/.exec(spec);
571
1499
  if (!m) return usage(`--agent takes <name>=<model>, got ${spec}`);
572
1500
  // A name outside the roster would be written, listed, and never run.
573
- if (roster && !roster.includes(m[1])) return usage(`no agent named ${m[1]} in the ${cfg.layout} roster — known: ${roster.join(", ")}`);
574
- if (m[2]) next.per_agent[m[1]] = m[2]; else delete next.per_agent[m[1]];
1501
+ if (roster && !roster.includes(m[1]))
1502
+ return usage(`no agent named ${m[1]} in the ${cfg.layout} roster — known: ${roster.join(", ")}`);
1503
+ if (m[2]) next.per_agent[m[1]] = m[2];
1504
+ else delete next.per_agent[m[1]];
575
1505
  }
576
1506
  // The clerk transcribes; a strong default must not lift it (commands/agents.md,
577
1507
  // ADR-008). WHICH model it is pinned to is the harness's vocabulary, so the
@@ -590,45 +1520,189 @@ async function runAgents(ctx) {
590
1520
  const clerkAsked = (values.agent || []).some((s) => s.startsWith("clerk="));
591
1521
  if (next.default && !next.per_agent.clerk && !clerkAsked) {
592
1522
  const cheap = harnessForOverlay(harness)?.agent_translation?.cheap_model || null;
593
- if (cheap) { next.per_agent.clerk = cheap; pinnedClerk = true; }
594
- else clerkPinUnavailable = true;
1523
+ if (cheap) {
1524
+ next.per_agent.clerk = cheap;
1525
+ pinnedClerk = true;
1526
+ } else clerkPinUnavailable = true;
595
1527
  }
596
1528
  // Keys inside the agents block that the allowlist rejected (an `effort`, a
597
1529
  // stray shape) are rewritten out by the write and announced here; keys
598
1530
  // outside the block are kept, and stay doctor's to name.
599
1531
  const dropped = before.rejected.filter((k) => k.startsWith("agents."));
600
- const same = !dropped.length && JSON.stringify({ d: before.agents.default, p: before.agents.per_agent }) === JSON.stringify({ d: next.default, p: next.per_agent });
601
- const preview = [`agents configure — ${harness} — ${before.path}`, ` default: ${before.agents.default || "—"} → ${next.default || "—"}`];
1532
+ const same =
1533
+ !dropped.length &&
1534
+ JSON.stringify({ d: before.agents.default, p: before.agents.per_agent }) ===
1535
+ JSON.stringify({ d: next.default, p: next.per_agent });
1536
+ const preview = [
1537
+ `agents configure — ${harness} — ${before.path}`,
1538
+ ` default: ${before.agents.default || "—"} → ${next.default || "—"}`,
1539
+ ];
602
1540
  const names = new Set([...Object.keys(before.agents.per_agent), ...Object.keys(next.per_agent)]);
603
- for (const n of [...names].sort()) preview.push(` ${n}: ${before.agents.per_agent[n] || "—"} → ${next.per_agent[n] || "—"}${pinnedClerk && n === "clerk" ? " (pinned: the clerk stays cheap under a strong default)" : ""}`);
1541
+ for (const n of [...names].sort())
1542
+ preview.push(
1543
+ ` ${n}: ${before.agents.per_agent[n] || "—"} → ${next.per_agent[n] || "—"}${pinnedClerk && n === "clerk" ? " (pinned: the clerk stays cheap under a strong default)" : ""}`,
1544
+ );
604
1545
  // Silence here would read as "the clerk is fine", and it is not: it takes the
605
1546
  // strong default, which is the one thing ADR-008 says must not happen to it.
606
- if (clerkPinUnavailable) preview.push(` clerk: takes the default — ${harness} declares no cheap model to pin it to, so name one with --agent clerk=<model>`);
607
- if (dropped.length) preview.push(` drops from the agents block (an overlay carries only models): ${dropped.join(", ")}`);
608
- if (same) return emit("agents configure", true, { harness, path: before.path, wrote: false, agents: next, pinnedClerk, clerkPinUnavailable, dropped }, preview.join("\n") + "\n Nothing to change.\n");
1547
+ if (clerkPinUnavailable)
1548
+ preview.push(
1549
+ ` clerk: takes the default — ${harness} declares no cheap model to pin it to, so name one with --agent clerk=<model>`,
1550
+ );
1551
+ if (dropped.length)
1552
+ preview.push(` drops from the agents block (an overlay carries only models): ${dropped.join(", ")}`);
1553
+ if (same)
1554
+ return emit(
1555
+ "agents configure",
1556
+ true,
1557
+ { harness, path: before.path, wrote: false, agents: next, pinnedClerk, clerkPinUnavailable, dropped },
1558
+ preview.join("\n") + "\n Nothing to change.\n",
1559
+ );
609
1560
  // The gate: a named harness confirms; otherwise ask, and refuse without a terminal.
610
1561
  let confirmed = Boolean(named);
611
- if (!confirmed) { const a = await confirmWrite(preview.join("\n") + `\nWrite ${before.path}? [y/N] `, { stdin, stdout, ask }); if (a === null) return usage("a non-interactive agents configure names --harness <id> to confirm; there is no --yes"); confirmed = a; }
612
- if (!confirmed) return emit("agents configure", false, { harness, path: before.path, wrote: false, declined: true, agents: next, dropped }, preview.join("\n") + "\n nothing written.\n");
1562
+ if (!confirmed) {
1563
+ const a = await confirmWrite(preview.join("\n") + `\nWrite ${before.path}? [y/N] `, { stdin, stdout, ask });
1564
+ if (a === null)
1565
+ return usage("a non-interactive agents configure names --harness <id> to confirm; there is no --yes");
1566
+ confirmed = a;
1567
+ }
1568
+ if (!confirmed)
1569
+ return emit(
1570
+ "agents configure",
1571
+ false,
1572
+ { harness, path: before.path, wrote: false, declined: true, agents: next, dropped },
1573
+ preview.join("\n") + "\n nothing written.\n",
1574
+ );
613
1575
  const path = writeOverlayAt(project, harness, next);
614
1576
  const after = readOverlayAt(project, harness);
615
- 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`);
1577
+ return emit(
1578
+ "agents configure",
1579
+ true,
1580
+ {
1581
+ harness,
1582
+ path,
1583
+ wrote: true,
1584
+ agents: after.agents,
1585
+ pinnedClerk,
1586
+ clerkPinUnavailable,
1587
+ dropped,
1588
+ rejected: after.rejected,
1589
+ agents_in_binding: inBinding,
1590
+ },
1591
+ preview.join("\n") +
1592
+ `\n wrote ${path}.${inBinding ? " The binding still carries an agents block (pre-0.28); run upgrade to move it." : ""}\n`,
1593
+ );
1594
+ }
1595
+
1596
+ // The band's switch (the band spec, contract 13): one atomic binding write per
1597
+ // change, previewed; a terminal is asked, --json is the confirmation (the
1598
+ // command that runs it has asked already), and without either it refuses.
1599
+ // `status` writes nothing.
1600
+ async function runBand(ctx) {
1601
+ const { row, values, positionals, cfg, project, env, stdout, stderr, stdin, ask } = ctx;
1602
+ const sub = positionals[0];
1603
+ const arg = positionals[1];
1604
+ const usage = (m) => usageFail(Object.assign(new Error(m), { code: "USAGE" }), { verb: row.verb, ...ctx });
1605
+ if (!BAND_SUBCOMMANDS.includes(sub))
1606
+ return usage(`band takes ${BAND_SUBCOMMANDS.join(", ")} (epic takes an epic id, or none to release the pin)`);
1607
+ const emit = (verb, ok, result, text) => {
1608
+ if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n");
1609
+ else (ok ? stdout : stderr).write(text);
1610
+ return ok ? 0 : 1;
1611
+ };
1612
+ if (sub === "status") {
1613
+ const s = bandStatus(project, cfg, { env, root: PACKAGE_ROOT });
1614
+ return emit("band status", true, s, renderBandStatus(s));
1615
+ }
1616
+ const plan = planBand(project, sub, arg, { env, root: PACKAGE_ROOT });
1617
+ if (plan.refusal)
1618
+ return emit(
1619
+ `band ${sub}`,
1620
+ false,
1621
+ { error: plan.refusal.message, code: plan.refusal.code, path: plan.path },
1622
+ `${plan.refusal.message}\n`,
1623
+ );
1624
+ const preview = renderBandPlan(plan, sub, arg);
1625
+ if (!plan.changed)
1626
+ return emit(
1627
+ `band ${sub}`,
1628
+ true,
1629
+ { path: plan.path, wrote: false, band: plan.after },
1630
+ preview + " Nothing to change.\n",
1631
+ );
1632
+ let confirmed = Boolean(values.json);
1633
+ if (!confirmed) {
1634
+ const a = await confirmWrite(preview + `Write ${plan.path}? [y/N] `, { stdin, stdout, ask });
1635
+ if (a === null) return usage("a non-interactive band write passes --json; there is no --yes");
1636
+ confirmed = a;
1637
+ }
1638
+ if (!confirmed)
1639
+ return emit(
1640
+ `band ${sub}`,
1641
+ false,
1642
+ { path: plan.path, wrote: false, declined: true, band: plan.after },
1643
+ preview + " nothing written.\n",
1644
+ );
1645
+ const done = applyBand(plan);
1646
+ return emit(
1647
+ `band ${sub}`,
1648
+ true,
1649
+ { path: plan.path, wrote: done.wrote, band: plan.after },
1650
+ preview + ` wrote ${plan.path}. The band follows within a few seconds; no restart.\n`,
1651
+ );
1652
+ }
1653
+
1654
+ // `session refresh <path>`: this session's pointer from a story file, after a
1655
+ // write no hook saw (the close ceremony's copy). Machine-local state, like the
1656
+ // hooks' own writes — no confirmation. No session: nothing written, exit 0.
1657
+ async function runSession(ctx) {
1658
+ const { row, values, positionals, cfg, project, env, stdout, stderr } = ctx;
1659
+ if (positionals[0] !== "refresh" || !positionals[1] || positionals.length > 2)
1660
+ return usageFail(
1661
+ Object.assign(new Error("session takes refresh <path> (a story file in the vault)"), { code: "USAGE" }),
1662
+ {
1663
+ verb: row.verb,
1664
+ ...ctx,
1665
+ },
1666
+ );
1667
+ const r = refreshSessionPointer(project, cfg, positionals[1], { env });
1668
+ const ok = !r.refusal;
1669
+ const result = ok ? r : { error: r.refusal.message, code: r.refusal.code };
1670
+ if (values.json) stdout.write(JSON.stringify(envelope("session refresh", project, ok, result), null, 2) + "\n");
1671
+ else if (!ok) stderr.write(`${r.refusal.message}\n`);
1672
+ else if (!r.refreshed) stdout.write("No session id from the host: nothing written.\n");
1673
+ else {
1674
+ const p = r.pointer;
1675
+ stdout.write(
1676
+ `This session's story: ${p.story_title || p.active_story}${p.story_status ? ` (${p.story_status})` : ""}.\n`,
1677
+ );
1678
+ }
1679
+ return ok ? 0 : 1;
616
1680
  }
617
1681
 
618
1682
  async function runGraph(ctx) {
619
1683
  const { row, values, positionals, cfg } = ctx;
620
1684
  const sub = positionals[0];
621
1685
  const path = positionals[1];
622
- if (!["neighbors", "lineage"].includes(sub)) return usageFail(Object.assign(new Error("graph takes neighbors <path> or lineage <path>"), { code: "USAGE" }), { verb: row.verb, ...ctx });
1686
+ if (!["neighbors", "lineage"].includes(sub))
1687
+ return usageFail(Object.assign(new Error("graph takes neighbors <path> or lineage <path>"), { code: "USAGE" }), {
1688
+ verb: row.verb,
1689
+ ...ctx,
1690
+ });
623
1691
  const op = READ_OPERATIONS[sub];
624
1692
  try {
625
1693
  // The row declares the union; each mode takes its own — an option the
626
1694
  // mode would ignore is a usage error, not a silent no-op.
627
1695
  const notFor = sub === "neighbors" ? ["depth"] : ["direction", "limit"];
628
- for (const o of notFor) if (values[o] !== undefined) throw Object.assign(new Error(`--${o} is not an option of graph ${sub}`), { code: "USAGE" });
629
- const result = sub === "neighbors"
630
- ? op.fn(cfg, path, { kinds: values.kind || null, direction: values.direction, limit: values.limit })
631
- : op.fn(cfg, path, { depth: values.depth, kinds: values.kind && values.kind.length ? values.kind : LINEAGE_KINDS });
1696
+ for (const o of notFor)
1697
+ if (values[o] !== undefined)
1698
+ throw Object.assign(new Error(`--${o} is not an option of graph ${sub}`), { code: "USAGE" });
1699
+ const result =
1700
+ sub === "neighbors"
1701
+ ? op.fn(cfg, path, { kinds: values.kind || null, direction: values.direction, limit: values.limit })
1702
+ : op.fn(cfg, path, {
1703
+ depth: values.depth,
1704
+ kinds: values.kind && values.kind.length ? values.kind : LINEAGE_KINDS,
1705
+ });
632
1706
  return emitRead({ verb: `graph ${sub}`, ...ctx }, op, result);
633
1707
  } catch (e) {
634
1708
  if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
@@ -640,7 +1714,10 @@ async function runCodemap(ctx) {
640
1714
  const { row, values, cfg } = ctx;
641
1715
  const op = READ_OPERATIONS.codeRefs;
642
1716
  try {
643
- if (!values.for) throw Object.assign(new Error("codemap takes --for <selector>; regeneration is reconcile --only codemap"), { code: "USAGE" });
1717
+ if (!values.for)
1718
+ throw Object.assign(new Error("codemap takes --for <selector>; regeneration is reconcile --only codemap"), {
1719
+ code: "USAGE",
1720
+ });
644
1721
  return emitRead({ verb: row.verb, ...ctx }, op, op.fn(cfg, values.for, { reverse: Boolean(values.reverse) }));
645
1722
  } catch (e) {
646
1723
  if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
@@ -650,22 +1727,32 @@ async function runCodemap(ctx) {
650
1727
 
651
1728
  function runVersion({ values, stdout }) {
652
1729
  const version = packageVersion();
653
- stdout.write(values.json ? JSON.stringify(envelope("version", null, true, { version }), null, 2) + "\n" : version + "\n");
1730
+ stdout.write(
1731
+ values.json ? JSON.stringify(envelope("version", null, true, { version }), null, 2) + "\n" : version + "\n",
1732
+ );
654
1733
  return 0;
655
1734
  }
656
1735
 
657
1736
  function spawnDoctor(project, env, flags) {
658
1737
  // A project that does not exist is still a project doctor can report on
659
1738
  // ("not bound") — so the cwd is only set when it can be.
660
- return spawnSync(process.execPath, [script("doctor.mjs"), ...flags], { encoding: "utf8", cwd: existsSync(project) ? project : undefined, env: ownEnv(env, project), timeout: 60000, maxBuffer: 1 << 24 });
1739
+ return spawnSync(process.execPath, [script("doctor.mjs"), ...flags], {
1740
+ encoding: "utf8",
1741
+ cwd: existsSync(project) ? project : undefined,
1742
+ env: ownEnv(env, project),
1743
+ timeout: 60000,
1744
+ maxBuffer: 1 << 24,
1745
+ });
661
1746
  }
662
1747
 
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.
1748
+ // Spawned, never imported (MCP ADR decision 2): doctor's main writes to
1749
+ // stdout and is not exported, and importing its checks would be a second
1750
+ // doctor. One spawn, always --json: its findings decide the exit code
1751
+ // (doctor.mjs sets none of its own), and the text a person reads is drawn
1752
+ // from those same findings here, in this process, by doctor-report.mjs — a
1753
+ // renderer that imports no check (the presentation spec, contract 11). So a
1754
+ // terminal gets colour, which a piped child could not tell it had, and the
1755
+ // report and the exit code can never come from two different runs.
669
1756
  async function runDoctor({ values, project, env, stdout, stderr }) {
670
1757
  const sections = [values.install && "--install", values.vault && "--vault"].filter(Boolean);
671
1758
  const r = spawnDoctor(project, env, ["--json", ...sections]);
@@ -677,29 +1764,76 @@ async function runDoctor({ values, project, env, stdout, stderr }) {
677
1764
  if (r.error) return fail(r.error.message);
678
1765
  if (r.status !== 0 && !r.stdout) return fail((r.stderr || "").trim() || `exit ${r.status}`);
679
1766
  let findings;
680
- try { findings = JSON.parse(r.stdout); } catch { return fail("unparseable output"); }
1767
+ try {
1768
+ findings = JSON.parse(r.stdout);
1769
+ } catch {
1770
+ return fail("unparseable output");
1771
+ }
681
1772
  const ok = !findings.some((f) => f.level === "issue");
682
1773
  if (values.json) stdout.write(JSON.stringify(envelope("doctor", project, ok, findings), null, 2) + "\n");
683
1774
  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`);
1775
+ // The sections as doctor.mjs derives them: no flag is both.
1776
+ const groups = sections.length
1777
+ ? [values.install && "install", values.vault && "vault"].filter(Boolean)
1778
+ : ["install", "vault"];
1779
+ stdout.write(
1780
+ await doctorText(findings, groups, {
1781
+ caps: term.caps(stdout, env),
1782
+ verbose: Boolean(values.verbose),
1783
+ project,
1784
+ env: ownEnv(env, project),
1785
+ stderr,
1786
+ }),
1787
+ );
686
1788
  }
687
1789
  return ok ? 0 : 1;
688
1790
  }
689
1791
 
1792
+ // doctor's text from its findings. `env` is the child's environment
1793
+ // (ownEnv), so every command form the report names is the one doctor itself
1794
+ // would name. A renderer that cannot load, or throws, must not cost the
1795
+ // reader the findings: they are printed one per line, `level check message
1796
+ // — file`, and the Summary line still ends them (lib.mjs). `load` stands in
1797
+ // for the import in a test.
1798
+ export async function doctorText(
1799
+ findings,
1800
+ groups,
1801
+ { caps, verbose = false, project = null, env = process.env, stderr = null } = {},
1802
+ { load = () => import("./doctor-report.mjs") } = {},
1803
+ ) {
1804
+ try {
1805
+ const { report } = await load();
1806
+ return report(findings, groups, { caps, verbose, version: packageVersion(), project, env });
1807
+ } catch (e) {
1808
+ if (stderr)
1809
+ stderr.write(
1810
+ `doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`,
1811
+ );
1812
+ return doctorFallbackText(findings, { env });
1813
+ }
1814
+ }
1815
+
690
1816
  async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }) {
691
1817
  const write = Boolean(values.write);
692
1818
  const only = values.only ?? null;
693
1819
  // The gate: --only names what is written and confirms headless; a bare
694
1820
  // --write asks on a terminal and refuses without one.
695
1821
  if (write && !only) {
696
- const yes = await confirmWrite(`Regenerate every derived view of ${project}'s vault? [y/N] `, { stdin, stdout, ask });
1822
+ const yes = await confirmWrite(`Regenerate every derived view of ${project}'s vault? [y/N] `, {
1823
+ stdin,
1824
+ stdout,
1825
+ ask,
1826
+ });
697
1827
  if (yes === null) {
698
1828
  stderr.write("a bare reconcile --write in a non-TTY refuses; name what is written to confirm: --only <target>\n");
699
- if (values.json) stdout.write(JSON.stringify(envelope("reconcile", project, false, { refused: "non-tty" }), null, 2) + "\n");
1829
+ if (values.json)
1830
+ stdout.write(JSON.stringify(envelope("reconcile", project, false, { refused: "non-tty" }), null, 2) + "\n");
700
1831
  return 1;
701
1832
  }
702
- if (!yes) { stdout.write("nothing written.\n"); return 0; }
1833
+ if (!yes) {
1834
+ stdout.write("nothing written.\n");
1835
+ return 0;
1836
+ }
703
1837
  }
704
1838
  const { runReconcile: core } = await import("./reconcile.mjs");
705
1839
  let out;
@@ -708,7 +1842,8 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
708
1842
  } catch (e) {
709
1843
  const msg = e && e.message ? e.message : String(e);
710
1844
  stderr.write(msg + "\n");
711
- if (values.json) stdout.write(JSON.stringify(envelope("reconcile", project, false, { error: msg }), null, 2) + "\n");
1845
+ if (values.json)
1846
+ stdout.write(JSON.stringify(envelope("reconcile", project, false, { error: msg }), null, 2) + "\n");
712
1847
  return e && e.code === "UNBOUND" ? 3 : 2;
713
1848
  }
714
1849
  const failed = write && out.summary && out.summary.failed > 0;
@@ -716,15 +1851,57 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
716
1851
  return failed ? 1 : 0;
717
1852
  }
718
1853
 
719
- async function runInstallVerb({ row, values, project, env, stdin, stdout, ask }) {
1854
+ // setup (the setup story): its own module, imported lazily as the install
1855
+ // family is, run with this package as its root and in the environment every
1856
+ // installer run gets (ownEnv, for the project setup settles on). The envelope
1857
+ // is written here, as every verb's is: its result is the write verbs' keys
1858
+ // plus `setup`. A usage error is exit 2 with its envelope.
1859
+ async function runSetupVerb(ctx) {
1860
+ const { runSetup } = await import("./setup.mjs");
1861
+ const { values, env, stdout } = ctx;
1862
+ const r = await runSetup({
1863
+ ...ctx,
1864
+ root: PACKAGE_ROOT,
1865
+ ownEnv: (p) => ownEnv(env, p),
1866
+ author: (p) => gitAuthor(p, env),
1867
+ });
1868
+ if (r.error) return usageFail(Object.assign(new Error(r.error), { code: "USAGE" }), { verb: "setup", ...ctx });
1869
+ if (values.json && r.result)
1870
+ stdout.write(JSON.stringify(envelope("setup", r.project, r.ok, r.result), null, 2) + "\n");
1871
+ return r.code;
1872
+ }
1873
+
1874
+ async function runInstallVerb({ row, values, project, env, cwd, stdin, stdout, ask }) {
720
1875
  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) };
1876
+ const opts = {
1877
+ harnesses: values.harness || [],
1878
+ surfaces: values.surface && values.surface.length ? values.surface : null,
1879
+ globalRemoval: Boolean(values.global),
1880
+ register: !values["no-register"],
1881
+ root: PACKAGE_ROOT,
1882
+ env: ownEnv(env, project),
1883
+ cwd,
1884
+ stdin,
1885
+ stdout,
1886
+ ask,
1887
+ json: Boolean(values.json),
1888
+ verbose: Boolean(values.verbose),
1889
+ };
1890
+ // plan goes through the installer's own planOptions, as its main() does, so
1891
+ // the two entry points read the same root version for a dry-run row.
722
1892
  if (row.verb === "plan") {
723
- const p = ih.plan(project, opts);
724
- if (values.json) stdout.write(JSON.stringify(envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }), null, 2) + "\n");
1893
+ const p = ih.plan(project, ih.planOptions(opts));
1894
+ if (values.json)
1895
+ stdout.write(
1896
+ JSON.stringify(
1897
+ envelope("plan", project, p.ok && !p.incomplete, { ...p, items: p.items.map(ih.publicItem) }),
1898
+ null,
1899
+ 2,
1900
+ ) + "\n",
1901
+ );
725
1902
  else {
726
1903
  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 }));
1904
+ stdout.write(ih.renderPreview(p, { verbose: opts.verbose, verb: "plan", caps: c }));
728
1905
  }
729
1906
  return p.ok && !p.incomplete ? 0 : 1;
730
1907
  }
@@ -735,11 +1912,30 @@ async function runInstallVerb({ row, values, project, env, stdin, stdout, ask })
735
1912
  // that failed is exit 1 with the rest applied (install spec contract 4′).
736
1913
  const ok = r.plan.ok && !r.plan.incomplete && !r.failed && (r.gate.confirmed || r.gate.why === "nothing-to-do");
737
1914
  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");
1915
+ // `fetched` is additive: the shells a run fetched before its plan, [] for
1916
+ // a run that fetched none (uninstall always).
1917
+ stdout.write(
1918
+ JSON.stringify(
1919
+ envelope(row.verb, project, ok, {
1920
+ gate: r.gate,
1921
+ applied: r.applied,
1922
+ failed: r.failed,
1923
+ incomplete: r.plan.incomplete,
1924
+ plannedAgainst: r.plan.plannedAgainst,
1925
+ items: r.plan.items.map(ih.publicItem),
1926
+ refusals: r.plan.refusals,
1927
+ reports: r.plan.reports,
1928
+ fetched: r.fetched || [],
1929
+ }),
1930
+ null,
1931
+ 2,
1932
+ ) + "\n",
1933
+ );
1934
+ } else {
1935
+ // The installer's own words for a gate that wrote nothing — a bare run
1936
+ // without a terminal, a declined question, a channel switch without a
1937
+ // terminal — shared with its main(), so the two entries say the same.
1938
+ stdout.write(ih.gateText(r, { verb: row.verb, harnesses: opts.harnesses, caps: term.caps(stdout, env) }));
743
1939
  }
744
1940
  return ok ? 0 : 1;
745
1941
  }