projectstore-claude 0.30.4 → 0.30.6

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 (50) hide show
  1. package/node_modules/projectstore/.claude-plugin/marketplace.json +2 -2
  2. package/node_modules/projectstore/.claude-plugin/plugin.json +6 -2
  3. package/node_modules/projectstore/.mcp.json +1 -2
  4. package/node_modules/projectstore/README.md +65 -16
  5. package/node_modules/projectstore/agents/archaeologist.md +1 -1
  6. package/node_modules/projectstore/agents/librarian.md +1 -1
  7. package/node_modules/projectstore/agents/planner.md +1 -1
  8. package/node_modules/projectstore/commands/doctor.md +14 -11
  9. package/node_modules/projectstore/commands/reconcile.md +1 -1
  10. package/node_modules/projectstore/commands/review.md +2 -2
  11. package/node_modules/projectstore/commands/story.md +1 -1
  12. package/node_modules/projectstore/docs/getting-started.md +3 -4
  13. package/node_modules/projectstore/docs/harnesses.md +1 -1
  14. package/node_modules/projectstore/docs/how-it-works.md +7 -0
  15. package/node_modules/projectstore/docs/images/icon.png +0 -0
  16. package/node_modules/projectstore/harnesses/claude-code.json +61 -11
  17. package/node_modules/projectstore/harnesses/codex.json +45 -15
  18. package/node_modules/projectstore/hooks/band.tsx +19 -8
  19. package/node_modules/projectstore/hooks/hooks.json +6 -6
  20. package/node_modules/projectstore/hooks/session-start.mjs +55 -24
  21. package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
  22. package/node_modules/projectstore/package.json +11 -12
  23. package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
  24. package/node_modules/projectstore/scripts/band.mjs +20 -2
  25. package/node_modules/projectstore/scripts/binding.mjs +1 -1
  26. package/node_modules/projectstore/scripts/build-adapters.mjs +45 -4
  27. package/node_modules/projectstore/scripts/cli.mjs +74 -1373
  28. package/node_modules/projectstore/scripts/convergence.mjs +59 -16
  29. package/node_modules/projectstore/scripts/doctor.mjs +199 -36
  30. package/node_modules/projectstore/scripts/harness.mjs +88 -6
  31. package/node_modules/projectstore/scripts/install-handlers.mjs +925 -0
  32. package/node_modules/projectstore/scripts/install-harness.mjs +129 -2081
  33. package/node_modules/projectstore/scripts/install-layout.mjs +453 -0
  34. package/node_modules/projectstore/scripts/install-preview.mjs +747 -0
  35. package/node_modules/projectstore/scripts/lib.mjs +387 -107
  36. package/node_modules/projectstore/scripts/mcp-server.mjs +33 -0
  37. package/node_modules/projectstore/scripts/mcp.mjs +60 -60
  38. package/node_modules/projectstore/scripts/model-spec.mjs +24 -0
  39. package/node_modules/projectstore/scripts/models.mjs +6 -10
  40. package/node_modules/projectstore/scripts/query.mjs +7 -6
  41. package/node_modules/projectstore/scripts/read-verbs.mjs +277 -0
  42. package/node_modules/projectstore/scripts/setup.mjs +62 -12
  43. package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -3
  44. package/node_modules/projectstore/scripts/surfaces.mjs +18 -5
  45. package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
  46. package/node_modules/projectstore/scripts/verbs.mjs +1202 -0
  47. package/node_modules/projectstore/scripts/version-guard.mjs +57 -7
  48. package/package.json +3 -3
  49. package/node_modules/projectstore/docs/images/loop-light.svg +0 -94
  50. package/node_modules/projectstore/docs/images/team-light.svg +0 -79
@@ -2,27 +2,16 @@
2
2
  //
3
3
  // The token-free front-end: `projectstore <verb>` as a thin argv/output
4
4
  // shell over the same core operations the hooks and the command files call
5
- // (distribution ADR decisions 3 and 5). No logic lives here — each verb row
6
- // names the module it wraps and how (spawned, imported, or new), and the
7
- // table is exported because it is a CONTRACT: the MCP read surface maps one
8
- // tool per CLI verb (MCP ADR decision 2), and its drift test reads this file.
9
- //
10
- // Every --json result travels in one envelope, {schema_version, verb,
11
- // project, ok, result}, built here and nowhere else — failures included, so
12
- // a consumer always has something to parse. The bare scripts keep their own
13
- // output (`node scripts/doctor.mjs --json` is still a bare array — the
14
- // command files and the tests depend on it). Exit codes: 0 ok, 1 findings or
15
- // a refusal, 2 usage or an internal failure, 3 not bound.
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.
5
+ // (distribution ADR decisions 3 and 5). No logic lives here: the verb table,
6
+ // the envelope and the per-verb parse are verbs.mjs's, the read verbs'
7
+ // handlers are read-verbs.mjs's, and this module holds the handlers of the
8
+ // verbs that write (and of mcp), and run(), which dispatches every verb of
9
+ // the one table through both halves. It re-exports what it exported before
10
+ // the split — the table is a CONTRACT that packaging/shells.mjs and the
11
+ // tests read from here — and the MCP server imports none of it: it runs the
12
+ // read runner (the story "The MCP server's entry reaches no install verb
13
+ // through a read-verb runner that shares the CLI's verb table…",
14
+ // PS-DIRECTORY).
26
15
  //
27
16
  // The gate (distribution ADR decision 6) binds every write verb this bin
28
17
  // exposes: the install family through install-harness.mjs's own preview and
@@ -33,40 +22,15 @@
33
22
  // declares, in the vault the binding names — so it asks at a terminal and,
34
23
  // without one, `--write` is the confirmation. There is no --yes.
35
24
  //
36
- // The project is resolved once, here — --project, then the neutral
37
- // PROJECTSTORE_PROJECT_DIR, then a project-dir variable a harness declared,
38
- // then cwd — and handed to every child through childEnv(), the one place a
39
- // branded name is written. bin/ reads no branded variable; the portability
40
- // suite greps it too.
41
- //
42
- // The bin runs ITS OWN copy of the core: sibling scripts resolve from this
43
- // file's URL, and the plugin root handed to children and to the installer
44
- // is this package's root — so `npx projectstore doctor` inside a Claude Code
45
- // session runs the npm copy's doctor over the npm copy's templates, and
46
- // reports the npm copy's version, rather than the marketplace copy's.
47
- //
48
- // The install verbs are imported lazily: hooks never import this module
49
- // (the suite asserts it), but the MCP server will, and it needs no installer.
25
+ // The install verbs, setup, configure, reconcile and the MCP server are
26
+ // imported lazily, on the run that needs them: hooks never import this module
27
+ // (the suite asserts it), and a verb that runs none of them loads none.
50
28
  // Pure node, no external deps.
51
29
 
52
30
  import { spawnSync } from "node:child_process";
53
- import { readFileSync, existsSync } from "node:fs";
54
- import { resolve, dirname } from "node:path";
55
- import { fileURLToPath } from "node:url";
56
- import { parseArgs } from "node:util";
31
+ import { existsSync } from "node:fs";
57
32
  import * as term from "./term.mjs";
58
- import {
59
- projectRootDeclared,
60
- childEnv,
61
- harnessIds,
62
- pinPluginRoot,
63
- invokedAs,
64
- shellHarnessId,
65
- CORE_NPX,
66
- packageCommand,
67
- loadHarnesses,
68
- invocation,
69
- } from "./harness.mjs";
33
+ import { harnessIds, packageCommand } from "./harness.mjs";
70
34
  import {
71
35
  readConfigAt,
72
36
  readOverlayAt,
@@ -77,21 +41,11 @@ import {
77
41
  commandForm,
78
42
  projectDirRefusal,
79
43
  fromTerminalIn,
80
- doctorFallbackText,
81
44
  refreshSessionPointer,
82
45
  } from "./lib.mjs";
83
- import {
84
- READ_OPERATIONS,
85
- LINEAGE_KINDS,
86
- LINEAGE_DEFAULT_DEPTH,
87
- SEARCH_DEFAULT_LIMIT,
88
- GRAPH_EDGE_CAP,
89
- DIRECTIONS,
90
- } from "./query.mjs";
91
46
  // binding.mjs and scaffold.mjs beneath it are write modules imported
92
47
  // statically where the install family is lazy: dependency-free, with no side
93
- // effects at import, so the MCP server's module graph gains nothing it could
94
- // trip on.
48
+ // effects at import.
95
49
  import {
96
50
  planBind,
97
51
  applyBind,
@@ -113,1119 +67,26 @@ import {
113
67
  renderBandPlan,
114
68
  SUBCOMMANDS as BAND_SUBCOMMANDS,
115
69
  } from "./band.mjs";
116
-
117
- export const SCHEMA_VERSION = 1;
118
- const HERE = dirname(fileURLToPath(import.meta.url));
119
- export const PACKAGE_ROOT = dirname(HERE);
120
- const script = (name) => resolve(HERE, name);
121
-
122
- export function packageVersion() {
123
- try {
124
- return String(JSON.parse(readFileSync(resolve(PACKAGE_ROOT, "package.json"), "utf8")).version || "");
125
- } catch {
126
- return "";
127
- }
128
- }
129
-
130
- export function envelope(verb, project, ok, result) {
131
- return { schema_version: SCHEMA_VERSION, verb, project, ok, result };
132
- }
133
-
134
- // --project, then the neutral variable (MCP ADR decision 6), then a
135
- // project-dir variable a harness declared (null when none is set — never
136
- // cwd-by-inference from harness.mjs, which is what an in-process caller with
137
- // its own cwd would otherwise inherit), then the caller's cwd.
138
- export function resolveProject({ project = null, env = process.env, cwd = process.cwd() } = {}) {
139
- if (project) return resolve(cwd, project);
140
- if (env.PROJECTSTORE_PROJECT_DIR) return resolve(cwd, env.PROJECTSTORE_PROJECT_DIR);
141
- const declared = projectRootDeclared(env);
142
- return declared ? resolve(cwd, declared) : resolve(cwd);
143
- }
144
-
145
- // ─── The verb table ────────────────────────────────────────────────────
146
- //
147
- // wraps: "script" (spawned), "module" (imported), "new" (code that exists
148
- // only for the CLI). output: "envelope" (--json wraps), "text". mcp: the MCP
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.
156
-
157
- export const opt = (name, arg, summary, multiple = false) => Object.freeze({ name, arg, summary, multiple });
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([]);
171
- const READ_JSON = [JSON_OPT];
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
- );
178
- const SURFACE_OPT = opt("surface", "<key>", "one surface and those beneath it", true);
179
- // The layout move's remedy names this for any copy but the package's own
180
- // registration (the layout spec, contract 12 as amended 2026-10-03): it makes
181
- // "no host command" true by construction instead of by recognising the root.
182
- const NO_REGISTER_OPT = opt(
183
- "no-register",
184
- false,
185
- "leave the plugin registration alone: change only this project's files",
186
- );
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
- );
195
- const INSTALL_OPTS = [HARNESS_OPT, SURFACE_OPT, NO_REGISTER_OPT, 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
- // configure's own --harness: it names the overlay agents.* keys write, and
205
- // is no confirmation — naming a change is (its spec, contracts 1 and 8).
206
- const CONFIGURE_OPTS = [
207
- opt("set", "<key>=<value>", "change one setting; without a terminal, naming it is the confirmation", true),
208
- opt("harness", "<id>", "the overlay agents.* keys write; other keys ignore it", true),
209
- opt("dry-run", false, "the plan; nothing is written"),
210
- ];
211
-
212
- // setup's own options (the setup story, rule 1): seven, beside the globals.
213
- // `--vault <path>` takes a value here where doctor's `--vault` is a flag —
214
- // the per-verb parse's own case.
215
- const SETUP_OPTS = [
216
- HARNESS_OPT,
217
- opt(
218
- "vault",
219
- "<path>",
220
- "the vault: an existing folder is bound, a missing or empty one is created (naming it confirms)",
221
- ),
222
- opt("layout", "<name>", `a new vault's layout (default ${DEFAULT_LAYOUT})`),
223
- opt("language", "<code>", `a new vault's template language (default ${DEFAULT_LANGUAGE})`),
224
- opt(
225
- "channel",
226
- "<npm|git>",
227
- "with a git-marketplace copy enabled, without a terminal: npm switches to the npm install, git keeps the git marketplace",
228
- ),
229
- opt("dry-run", false, "the questions and the plan; nothing is written or fetched"),
230
- VERBOSE_OPT,
231
- ];
232
-
233
- export const VERBS = Object.freeze([
234
- Object.freeze({
235
- verb: "setup",
236
- summary: "Set up this project: every harness it uses, and a vault.",
237
- usage: NO_FORMS,
238
- module: "./setup.mjs",
239
- wraps: "new",
240
- how: "import",
241
- output: "envelope",
242
- writes: true,
243
- requiresBinding: false,
244
- mcp: Object.freeze([]),
245
- options: [...SETUP_OPTS, JSON_OPT],
246
- run: runSetupVerb,
247
- }),
248
- Object.freeze({
249
- verb: "configure",
250
- summary: "Change this project's settings after setup, behind one plan.",
251
- usage: NO_FORMS,
252
- module: "./configure.mjs",
253
- wraps: "new",
254
- how: "import",
255
- output: "envelope",
256
- writes: true,
257
- requiresBinding: false,
258
- mcp: Object.freeze([]),
259
- options: [...CONFIGURE_OPTS, JSON_OPT],
260
- run: runConfigureVerb,
261
- }),
262
- Object.freeze({
263
- verb: "doctor",
264
- summary: "Check the install wiring and the vault's consistency.",
265
- usage: NO_FORMS,
266
- module: "./doctor.mjs",
267
- wraps: "script",
268
- how: "spawn",
269
- output: "envelope",
270
- writes: false,
271
- requiresBinding: false,
272
- mcp: Object.freeze(["doctor"]),
273
- options: [
274
- opt("install", false, "only the install section"),
275
- opt("vault", false, "only the vault section"),
276
- DOCTOR_VERBOSE_OPT,
277
- JSON_OPT,
278
- ],
279
- run: runDoctor,
280
- }),
281
- Object.freeze({
282
- verb: "reconcile",
283
- summary: "Regenerate the derived views (kanban, code map, graph, indexes).",
284
- usage: NO_FORMS,
285
- module: "./reconcile.mjs",
286
- wraps: "module",
287
- how: "import",
288
- output: "envelope",
289
- writes: true,
290
- requiresBinding: true,
291
- mcp: Object.freeze([]),
292
- options: [
293
- opt(
294
- "write",
295
- false,
296
- "apply the regeneration (asks on a terminal; --only names what is written and confirms headless)",
297
- ),
298
- opt("only", "<target>", "one derived view"),
299
- JSON_OPT,
300
- ],
301
- run: runReconcile,
302
- }),
303
- Object.freeze({
304
- verb: "plan",
305
- summary: "Show what install would write for a harness, without writing.",
306
- usage: NO_FORMS,
307
- module: "./install-harness.mjs",
308
- wraps: "module",
309
- how: "import",
310
- output: "envelope",
311
- writes: false,
312
- requiresBinding: false,
313
- mcp: Object.freeze([]),
314
- options: INSTALL_OPTS,
315
- run: runInstallVerb,
316
- }),
317
- Object.freeze({
318
- verb: "install",
319
- summary: "Install projectstore's surfaces for a harness, behind a preview.",
320
- usage: NO_FORMS,
321
- module: "./install-harness.mjs",
322
- wraps: "module",
323
- how: "import",
324
- output: "envelope",
325
- writes: true,
326
- requiresBinding: false,
327
- mcp: Object.freeze([]),
328
- options: INSTALL_OPTS,
329
- run: runInstallVerb,
330
- }),
331
- Object.freeze({
332
- verb: "uninstall",
333
- summary: "Remove what install wrote, and only that.",
334
- usage: NO_FORMS,
335
- module: "./install-harness.mjs",
336
- wraps: "module",
337
- how: "import",
338
- output: "envelope",
339
- writes: true,
340
- requiresBinding: false,
341
- mcp: Object.freeze([]),
342
- options: UNINSTALL_OPTS,
343
- run: runInstallVerb,
344
- }),
345
- Object.freeze({
346
- verb: "upgrade",
347
- summary: "After an update, refresh what install wrote and leave the rest.",
348
- usage: NO_FORMS,
349
- module: "./install-harness.mjs",
350
- wraps: "module",
351
- how: "import",
352
- output: "envelope",
353
- writes: true,
354
- requiresBinding: false,
355
- mcp: Object.freeze([]),
356
- options: INSTALL_OPTS,
357
- run: runInstallVerb,
358
- }),
359
- Object.freeze({
360
- verb: "status",
361
- summary: "What is bound, what is in progress, and whether views are fresh.",
362
- usage: NO_FORMS,
363
- module: "./query.mjs",
364
- wraps: "new",
365
- how: "import",
366
- output: "envelope",
367
- writes: false,
368
- requiresBinding: false,
369
- mcp: Object.freeze(["status"]),
370
- options: READ_JSON,
371
- run: runRead("status"),
372
- }),
373
- Object.freeze({
374
- verb: "orientation",
375
- summary: "The SessionStart skeleton and the facts behind it.",
376
- usage: NO_FORMS,
377
- module: "./query.mjs",
378
- wraps: "module",
379
- how: "import",
380
- output: "envelope",
381
- writes: false,
382
- requiresBinding: true,
383
- mcp: Object.freeze(["orientation"]),
384
- options: READ_JSON,
385
- run: runRead("orientation"),
386
- }),
387
- Object.freeze({
388
- verb: "search",
389
- summary: "Find a phrase in the vault's artifacts, literal and bounded.",
390
- usage: Object.freeze(["<phrase…>"]),
391
- module: "./query.mjs",
392
- wraps: "new",
393
- how: "import",
394
- output: "envelope",
395
- writes: false,
396
- requiresBinding: true,
397
- mcp: Object.freeze(["search"]),
398
- options: [
399
- opt("kind", "<type>", "only artifacts of this kind", true),
400
- opt("status", "<status>", "only artifacts in this status"),
401
- opt("limit", "<n>", `at most n matches (default ${SEARCH_DEFAULT_LIMIT}, hard cap 100)`),
402
- opt("include-derived", false, "search the derived views too"),
403
- opt("case-sensitive", false, "match case"),
404
- JSON_OPT,
405
- ],
406
- run: runRead("search"),
407
- }),
408
- Object.freeze({
409
- verb: "show",
410
- summary: "One artifact's frontmatter; its body or a section on request.",
411
- usage: Object.freeze(["<path>"]),
412
- module: "./query.mjs",
413
- wraps: "module",
414
- how: "import",
415
- output: "envelope",
416
- writes: false,
417
- requiresBinding: true,
418
- mcp: Object.freeze(["get_artifact"]),
419
- options: [
420
- opt("body", false, "include the body"),
421
- opt("section", "<id>", "one section by its registry id (description, acceptance, …)"),
422
- JSON_OPT,
423
- ],
424
- run: runRead("show"),
425
- }),
426
- Object.freeze({
427
- verb: "graph",
428
- summary: "The live link graph by vault path.",
429
- usage: Object.freeze(["neighbors <path>", "lineage <path>"]),
430
- module: "./query.mjs",
431
- wraps: "new",
432
- how: "import",
433
- output: "envelope",
434
- writes: false,
435
- requiresBinding: true,
436
- mcp: Object.freeze(["neighbors", "lineage"]),
437
- options: [
438
- opt("kind", "<edge-kind>", "only edges of this kind (lineage: one of its four)", true),
439
- opt("direction", DIRECTIONS.join("|"), "neighbors: which edges"),
440
- opt("depth", "<n>", `lineage: how far (default ${LINEAGE_DEFAULT_DEPTH})`),
441
- opt("limit", "<n>", `neighbors: cap per direction (at most ${GRAPH_EDGE_CAP})`),
442
- JSON_OPT,
443
- ],
444
- run: runGraph,
445
- }),
446
- Object.freeze({
447
- verb: "codemap",
448
- summary: "The code an epic or artifact maps to, or a path's artifacts.",
449
- usage: Object.freeze(["--for <selector>"]),
450
- module: "./query.mjs",
451
- wraps: "new",
452
- how: "import",
453
- output: "envelope",
454
- writes: false,
455
- requiresBinding: true,
456
- mcp: Object.freeze(["code_refs"]),
457
- options: [
458
- opt("for", "<selector>", "an epic id, an artifact, or a repo path"),
459
- opt("reverse", false, "read the selector as a path even if it names an artifact"),
460
- JSON_OPT,
461
- ],
462
- run: runCodemap,
463
- }),
464
- Object.freeze({
465
- verb: "agents",
466
- summary: "The harness overlay's agents block: the model each agent runs.",
467
- usage: Object.freeze(["model <name>", "show", "configure"]),
468
- module: "./lib.mjs",
469
- wraps: "new",
470
- how: "import",
471
- output: "envelope",
472
- writes: true,
473
- requiresBinding: false,
474
- mcp: Object.freeze([]),
475
- options: [
476
- opt(
477
- "harness",
478
- "<id>",
479
- "configure: the overlay to write — and, non-interactively, the confirmation; there is no --yes",
480
- true,
481
- ),
482
- opt(
483
- "default",
484
- "<model>",
485
- "configure: agents.default.model (pins the clerk to sonnet unless --agent clerk=… says otherwise; an empty model clears it)",
486
- ),
487
- opt("agent", "<name>=<model>", "configure: agents.per_agent.<name>.model (an empty model removes the key)", true),
488
- opt("reset", false, "configure: empty the agents block first; --default and --agent given with it apply on top"),
489
- JSON_OPT,
490
- ],
491
- run: runAgents,
492
- }),
493
- Object.freeze({
494
- verb: "band",
495
- summary: "The band above the prompt: switch it, pin an epic, ask why.",
496
- usage: Object.freeze(["on", "off", "status", "epic <ID>"]),
497
- module: "./band.mjs",
498
- wraps: "new",
499
- how: "import",
500
- output: "envelope",
501
- writes: true,
502
- requiresBinding: true,
503
- mcp: Object.freeze([]),
504
- options: [JSON_OPT],
505
- run: runBand,
506
- }),
507
- Object.freeze({
508
- verb: "session",
509
- summary: "Refresh this session's current story from its file.",
510
- usage: Object.freeze(["refresh <path>"]),
511
- module: "./lib.mjs",
512
- wraps: "new",
513
- how: "import",
514
- output: "envelope",
515
- writes: true,
516
- requiresBinding: true,
517
- mcp: Object.freeze([]),
518
- options: [JSON_OPT],
519
- run: runSession,
520
- }),
521
- Object.freeze({
522
- verb: "bind",
523
- summary: "Bind this project to an existing vault; naming it confirms.",
524
- usage: Object.freeze(["<vault>"]),
525
- module: "./binding.mjs",
526
- wraps: "new",
527
- how: "import",
528
- output: "envelope",
529
- writes: true,
530
- requiresBinding: false,
531
- mcp: Object.freeze([]),
532
- options: [
533
- opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`),
534
- opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`),
535
- opt("rebind", false, "point an already bound project at another vault; every other setting is kept"),
536
- JSON_OPT,
537
- ],
538
- run: runBind(false),
539
- }),
540
- Object.freeze({
541
- verb: "init",
542
- summary: "Create a vault (folder, git repository, READMEs) and bind to it.",
543
- usage: Object.freeze(["<vault>"]),
544
- module: "./binding.mjs",
545
- wraps: "new",
546
- how: "import",
547
- output: "envelope",
548
- writes: true,
549
- requiresBinding: false,
550
- mcp: Object.freeze([]),
551
- options: [
552
- opt("layout", "<name>", `the layout (default ${DEFAULT_LAYOUT})`),
553
- opt("language", "<code>", `the template language (default ${DEFAULT_LANGUAGE})`),
554
- opt(
555
- "rebind",
556
- false,
557
- "an already bound project: create the new vault and point the project at it; every other setting is kept",
558
- ),
559
- JSON_OPT,
560
- ],
561
- run: runBind(true),
562
- }),
563
- Object.freeze({
564
- verb: "scaffold",
565
- summary: "Create the vault's missing folders and READMEs; rewrite nothing.",
566
- usage: NO_FORMS,
567
- module: "./scaffold.mjs",
568
- wraps: "new",
569
- how: "import",
570
- output: "envelope",
571
- writes: true,
572
- requiresBinding: true,
573
- mcp: Object.freeze([]),
574
- options: [
575
- opt("write", false, "create them (asks at a terminal; without one, --write is the confirmation)"),
576
- JSON_OPT,
577
- ],
578
- run: runScaffold,
579
- }),
580
- Object.freeze({
581
- verb: "mcp",
582
- summary: "Serve the read tools over MCP (stdio) for the project you name.",
583
- usage: NO_FORMS,
584
- module: "./mcp.mjs",
585
- wraps: "new",
586
- how: "import",
587
- output: "text",
588
- writes: false,
589
- requiresBinding: false,
590
- mcp: Object.freeze([]),
591
- options: [],
592
- run: runMcp,
593
- }),
594
- Object.freeze({
595
- verb: "version",
596
- summary: "Print the package version (also --version).",
597
- usage: NO_FORMS,
598
- module: null,
599
- wraps: "new",
600
- how: "import",
601
- output: "envelope",
602
- writes: false,
603
- requiresBinding: false,
604
- mcp: Object.freeze([]),
605
- options: [JSON_OPT],
606
- run: runVersion,
607
- }),
608
- ]);
609
-
610
- // Verbs the story names that land with a later slice — listed so
611
- // `projectstore <verb>` says where instead of "unknown verb". Empty since
612
- // A7: every verb the CLI story names has landed. The seam stays for the next
613
- // story that adds a verb in slices.
614
- export const PLANNED_VERBS = Object.freeze([]);
615
-
616
- // The verbs as a person meets them (presentation spec contract 10): starting,
617
- // reading the vault, keeping it consistent. Every verb not listed here is
618
- // ADVANCED, one line of names in table order — so a new row is never hidden
619
- // by this table, and init and scaffold, which the contract names in no group,
620
- // are there too. A group lists only the verbs this run's table has. With
621
- // setup, START is setup and uninstall, and install and bind are ADVANCED's
622
- // (contract 10; the setup story, rule 1).
623
- const HELP_GROUPS = [
624
- ["START", ["setup", "configure", "uninstall"]],
625
- ["VAULT", ["status", "search", "show", "graph", "codemap"]],
626
- ["CHECK AND REPAIR", ["doctor", "reconcile", "upgrade"]],
627
- ];
628
- // The column a group's summaries start at: two spaces, the verb in twelve,
629
- // one space. A summary fits 80 columns from there, so each is at most 65
630
- // characters, in ASCII (tests/presentation.test.mjs, criterion 5, holds every
631
- // row to it).
632
- const VERB_COLUMN = 12;
633
- export const SUMMARY_MAX = 80 - (2 + VERB_COLUMN + 1);
634
-
635
- // Examples per verb. They are what to run next, so they are the core's form
636
- // through a shell too (the story "User-facing text says where to run and
637
- // names no project path and no shell", rule 6): `{cmd}` is the core through
638
- // npx, and `{h}` the harness argument — ` --harness <id>` from the core, and
639
- // through a shell ` --harness <the shell's own harness>` (shellHarnessId),
640
- // never a session's identity. `{fixed}` is that argument through a shell
641
- // only: the shell inserts --harness for every verb that declares it
642
- // (packaging/shells.mjs, HARNESS_VERBS), so an example the core runs bare —
643
- // setup, which asks; agents show, which reads the session's overlay — names
644
- // the shell's harness through it and stays bare from the core. A `{core}`
645
- // example is the core's alone: a shell's help leaves it out, since it says
646
- // what the shell's own harness does not (every harness, the way back from a
647
- // switch). `{switch.surface}`,
648
- // `{switch.host}` and `{switch.doctor}` name the harness whose project can
649
- // switch channels — the one with a host plugin registration — from its
650
- // manifest: its surface key, its display name and its doctor form
651
- // (switchHarness, below); an example naming them is left out when no
652
- // manifest has one.
653
- const EXAMPLES = {
654
- setup: [
655
- "{cmd} setup{fixed}",
656
- "{cmd} setup{fixed} --dry-run # the questions and the plan; nothing is written or fetched",
657
- "{cmd} setup{h} --vault ~/vaults/my-project # without a terminal: --harness confirms, --vault names the vault",
658
- ],
659
- configure: [
660
- "{cmd} configure # at a terminal: the settings, then what to change",
661
- "{cmd} configure --set statusline=on",
662
- "{cmd} configure --set agents.critic=fable{h} # an agent's model, in that harness's overlay",
663
- "{cmd} configure --json # the settings, for scripts and agents",
664
- ],
665
- install: ["{cmd} install{h}", "{cmd} plan{h} # the same plan; nothing is written"],
666
- upgrade: [
667
- "{cmd}@<version> upgrade{h} # the version you name is the version that runs",
668
- "{cmd} upgrade{h} --verbose",
669
- ],
670
- uninstall: [
671
- "{core}{cmd} uninstall # every harness this project uses",
672
- "{cmd} uninstall{h}",
673
- "{cmd} uninstall{h} --surface statusline",
674
- "{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",
675
- ],
676
- plan: ["{cmd} plan{h}", "{cmd} plan{h} --json # one envelope, for scripts and agents"],
677
- status: ["{cmd} status", "{cmd} status --json"],
678
- orientation: ["{cmd} orientation --json"],
679
- search: ['{cmd} search "entry rule" --kind spec', "{cmd} search queue --status accepted --limit 5"],
680
- show: ["{cmd} show adr/README.md", "{cmd} show epics/PS-CORE/epic.md --section acceptance"],
681
- graph: ["{cmd} graph neighbors adr/README.md --direction out", "{cmd} graph lineage epics/PS-CORE/epic.md --depth 2"],
682
- codemap: ["{cmd} codemap --for PS-CORE", "{cmd} codemap --for scripts/lib.mjs --reverse"],
683
- doctor: ["{cmd} doctor", "{cmd} doctor --install --json"],
684
- reconcile: ["{cmd} reconcile # what would change; nothing is written", "{cmd} reconcile --write"],
685
- bind: ["{cmd} bind ~/vaults/my-project", "{cmd} bind ~/vaults/other --rebind"],
686
- init: ["{cmd} init ~/vaults/new-project --language ru"],
687
- scaffold: ["{cmd} scaffold # what would be created; nothing is written", "{cmd} scaffold --write"],
688
- agents: ["{cmd} agents show{fixed}", "{cmd} agents configure{h} --default opus --agent clerk=sonnet"],
689
- band: [
690
- "{cmd} band status",
691
- "{cmd} band off",
692
- "{cmd} band epic PS-CORE # a session with no epic of its own shows this one",
693
- ],
694
- session: ["{cmd} session refresh epics/PS-CORE/stories/story-x.md --json # after a write no hook saw"],
695
- mcp: ['{cmd} mcp --project "$PWD"'],
696
- };
697
-
698
- // How this run was invoked is invokedAs (harness.mjs), the one spelling of
699
- // the binary that ran: the help's first line, its Usage lines, the
700
- // fixed-harness line and the usage errors. What a person runs next is the
701
- // core's (CORE_NPX, packageCommand), as status's and bind's Next lines are.
702
-
703
- // The harness whose project can switch channels (the converge story's rule
704
- // 4): the one whose manifest declares a host plugin registration. Its surface
705
- // key, display name and doctor form, from the manifest — `invocation()` in
706
- // harness.mjs, never a literal; null when no manifest has one.
707
- function switchHarness() {
708
- for (const m of loadHarnesses().values()) {
709
- const row = Object.entries(m.surfaces || {}).find(
710
- ([k, s]) => !k.startsWith("_") && s?.format === "host-plugin-registration",
711
- );
712
- if (row)
713
- return {
714
- "switch.surface": row[0],
715
- "switch.host": m.display_name,
716
- "switch.doctor": invocation(m, "doctor", { args: "--fix" }),
717
- };
718
- }
719
- return null;
720
- }
721
-
722
- // An aligned options block: each option's form in a column at most 28 wide,
723
- // its description beside it — or, past that column, on the next line under
724
- // it. On a live terminal a description wraps with its column as the hanging
725
- // indent; anywhere else it stays one line, whole, for a reader that greps.
726
- function optionLines(options, c = term.PLAIN) {
727
- const rows = options.map((o) => [
728
- term.glyphText(c, `--${o.name}${o.arg ? " " + o.arg : ""}`),
729
- term.glyphText(c, `${o.summary}${o.multiple ? " (repeatable)" : ""}`),
730
- ]);
731
- const w = Math.min(28, Math.max(0, ...rows.map(([l]) => l.length)));
732
- const pad = " ".repeat(2 + w + 2);
733
- const text = (r) => term.wrap(r, c.live ? c.width : 0, pad);
734
- return rows.map(([l, r]) => (l.length > w ? ` ${l}\n${pad}${text(r)}` : ` ${l.padEnd(w)} ${text(r)}`));
735
- }
736
-
737
- const EXIT_CODES = "0 ok, 1 findings or a refusal, 2 usage, 3 not bound, 130 cancelled";
738
- // The facts that close a help screen (`Harnesses`, `Exit codes`): the key in
739
- // the explanation role, its value at one column for both.
740
- const FACT_KEY = "Exit codes".length;
741
- const fact = (c, key, value) =>
742
- `${term.painter(c)("explanation", key)}${" ".repeat(FACT_KEY - key.length + 2)}${value}`;
743
-
744
- // What the bin is, in the help's badge: one line with the name and version,
745
- // short enough that a prerelease version (0.30.0-rc.12) still fits 80 columns.
746
- const PURPOSE = "project memory for coding agents, in markdown";
747
-
748
- // What a verb's help says beneath its Usage when the summary has no room for
749
- // it: here, how mcp is told its project.
750
- const NOTES = {
751
- mcp: "It serves the project --project names, else PROJECTSTORE_PROJECT_DIR or the host session's project; never the directory it was started in.",
752
- };
753
-
754
- // The global options, as every verb takes them.
755
- const EVERY_VERB = Object.freeze([
756
- opt("project", "<dir>", "the project (default: the session's, else this directory)"),
757
- opt("json", false, "the result as one JSON envelope, for scripts and agents"),
758
- opt("version", false, "the package version"),
759
- ]);
760
-
761
- // Top-level help (presentation spec contract 10): the badge — name, version,
762
- // purpose; Usage; the named groups, each verb in the action role and its
763
- // summary on one line; ADVANCED, one line of the remaining verbs; the options
764
- // every verb takes; examples, each command alone on its line (contract 6);
765
- // the tips, whose plan command stands alone too; the harnesses and the exit
766
- // codes. `verbs` is the table this run answers for — VERBS, or a test's own.
767
- // `caps` is the writing stream's (term.mjs): painted at a terminal, plain on a
768
- // pipe, and every line of it fits 80 columns either way.
769
- export function usage(env = process.env, verbs = VERBS, { caps: c = term.PLAIN, version = packageVersion() } = {}) {
770
- const { shell, cmd } = invokedAs(env);
771
- const paint = term.painter(c);
772
- const width = c.live ? c.width : 0;
773
- const prose = (text) => " " + term.wrap(term.glyphText(c, text), width, " ");
774
- const lines = [
775
- term.badge(c, "projectstore", [version, PURPOSE]),
776
- "",
777
- term.heading(c, "Usage"),
778
- ...term.rows(c, [
779
- { target: `${cmd} <verb> [options]`, note: "run it inside your code project" },
780
- { target: `${cmd} <verb> --help`, note: "one verb's options and examples" },
781
- ]),
782
- ];
783
- const listed = new Set(HELP_GROUPS.flatMap(([, names]) => names));
784
- for (const [title, names] of HELP_GROUPS) {
785
- const rows = names.map((n) => verbs.find((v) => v.verb === n)).filter(Boolean);
786
- if (!rows.length) continue;
787
- lines.push("", term.heading(c, title));
788
- for (const v of rows) {
789
- const lead = ` ${paint("action", v.verb)}${" ".repeat(Math.max(0, VERB_COLUMN - v.verb.length))} `;
790
- lines.push(lead + term.wrap(term.glyphText(c, v.summary), width, " ".repeat(term.plain(lead).length)));
791
- }
792
- }
793
- // ADVANCED is one line of verbs, broken at 80 columns in every mode: with
794
- // install and bind among them (contract 10, once setup exists) it no longer
795
- // fits one row, and a pipe gets the same lines a terminal does.
796
- const advanced = verbs.filter((v) => !listed.has(v.verb));
797
- if (advanced.length)
798
- lines.push(
799
- "",
800
- term.heading(c, "ADVANCED"),
801
- " " + term.wrap(advanced.map((v) => paint("action", v.verb)).join(` ${term.icon(c, "dot")} `), 80, " "),
802
- );
803
- if (PLANNED_VERBS.length) lines.push("", `Planned: ${PLANNED_VERBS.map((v) => `${v.verb} (${v.lands})`).join(", ")}`);
804
- // A command is offered only for a verb this table has. The examples and the
805
- // tips' plan are what to run next: the core's form, through a shell with
806
- // the shell's own harness (rule 6 of the story "User-facing text says
807
- // where to run…"); setup names it only through a shell, as EXAMPLES'
808
- // `{fixed}` does.
809
- const h = ` --harness ${(shell && shellHarnessId(env)) || "<id>"}`;
810
- const has = (verb) => verbs.some((v) => v.verb === verb);
811
- const examples = [
812
- ["setup", `${CORE_NPX} setup${shell ? h : ""}`],
813
- ["status", `${CORE_NPX} status`],
814
- ["doctor", `${CORE_NPX} doctor`],
815
- ]
816
- .filter(([v]) => has(v))
817
- .map(([, x]) => x);
818
- lines.push("", term.heading(c, "Options"), ...optionLines(EVERY_VERB, c));
819
- if (examples.length) lines.push("", term.heading(c, "Examples"), ...term.commandBlock(c, examples));
820
- lines.push(
821
- "",
822
- term.heading(c, "Tips"),
823
- prose("setup, configure, install, upgrade and uninstall show a plan, then ask."),
824
- prose(
825
- `Without a terminal, ${shell ? "the verb is its own" : "--harness is the"} confirmation ${term.icon(c, "dash")} there is no --yes.`,
826
- ),
827
- prose("Unattended in a terminal: pass --json, set CI=1, or give stdin </dev/null."),
828
- prose("--verbose on those verbs adds every row's reasoning and the host's notes."),
829
- );
830
- if (has("plan"))
831
- lines.push(
832
- prose("plan prints the same plan and writes nothing:"),
833
- ...term.commandBlock(c, [`${CORE_NPX} plan${h}`]),
834
- );
835
- lines.push("", fact(c, "Harnesses", harnessIds().join(", ")), fact(c, "Exit codes", EXIT_CODES));
836
- return lines.join("\n");
837
- }
838
-
839
- // One verb: what it does, how to call it, every option, examples. The Usage
840
- // lines are the row's own forms, the same declaration that bounds its
841
- // arguments — never parsed out of the summary, so help and the parser cannot
842
- // disagree about what a verb takes. Its first line is the summary the
843
- // top-level help lists, after the command; `caps` as usage()'s.
844
- export function verbHelp(row, env = process.env, { caps: c = term.PLAIN } = {}) {
845
- const { shell, cmd } = invokedAs(env);
846
- const paint = term.painter(c);
847
- const fixed = Boolean(shell) && row.options.some((o) => o.name === "harness");
848
- const opts = row.options.filter((o) => !(fixed && o.name === "harness"));
849
- const prose = (text) => " " + term.wrap(term.glyphText(c, text), c.live ? c.width : 0, " ");
850
- const head = `${paint("emphasis", `${cmd} ${row.verb}`)} ${term.icon(c, "dash")} ${term.glyphText(c, row.summary)}`;
851
- const lines = [term.wrap(head, c.live ? c.width : 0, " "), "", term.heading(c, "Usage")];
852
- for (const f of row.usage.length ? row.usage : [""])
853
- lines.push(` ${cmd} ${row.verb}${f ? " " + term.glyphText(c, f) : ""}${opts.length ? " [options]" : ""}`);
854
- if (NOTES[row.verb]) lines.push("", prose(NOTES[row.verb]));
855
- if (opts.length) lines.push("", term.heading(c, "Options"), ...optionLines(opts, c));
856
- if (fixed)
857
- lines.push(
858
- "",
859
- term.wrap(
860
- row.verb === "configure"
861
- ? `${shell} names the harness itself: its overlay is the one agents.* keys write.`
862
- : `${shell} names the harness itself: without a terminal, the verb is its own confirmation.`,
863
- c.live ? c.width : 0,
864
- " ",
865
- ),
866
- );
867
- else if (row.options.some((o) => o.name === "harness")) lines.push("", fact(c, "Harnesses", harnessIds().join(", ")));
868
- const sw = switchHarness();
869
- const ex = (EXAMPLES[row.verb] || [])
870
- .filter((e) => !(fixed && e.startsWith("{core}")) && (sw || !e.includes("{switch.")))
871
- .map((e) => e.replace(/^\{core\}/, "").replace(/\{(switch\.\w+)\}/g, (_, k) => sw[k]));
872
- // What to run next is the core's form: through a shell, its own harness.
873
- const h = ` --harness ${(fixed && shellHarnessId(env)) || "<id>"}`;
874
- // Each example command stands alone on its line, after a blank one
875
- // (contract 6): nothing after it, so a paste takes exactly the command — a
876
- // trailing `# note` broke a paste into a shell that reads no comments. A
877
- // note leads its command, a blank line between them, as every other
878
- // introduced command does (DONE's tip, doctor's fix, bind's Next);
879
- // commands with no note share one block.
880
- const blocks = [];
881
- const render = (e) =>
882
- e
883
- .split("{cmd}")
884
- .join(CORE_NPX)
885
- .split("{h}")
886
- .join(h)
887
- .split("{fixed}")
888
- .join(fixed ? h : "");
889
- for (const [x, note] of ex.map((e) => render(e).split(" # "))) {
890
- const last = blocks.at(-1);
891
- if (!note && last && !last.note) last.cmds.push(x);
892
- else blocks.push({ note: note ? `${note[0].toUpperCase()}${note.slice(1)}:` : null, cmds: [x] });
893
- }
894
- if (blocks.length) lines.push("", term.heading(c, "Examples"));
895
- for (const b of blocks)
896
- lines.push(
897
- "",
898
- ...(b.note ? [prose(paint("explanation", b.note)), ""] : []),
899
- ...b.cmds.map((x) => ` ${paint("action", x)}`),
900
- );
901
- lines.push("", fact(c, "Exit codes", EXIT_CODES));
902
- return lines.join("\n");
903
- }
904
-
905
- // The nearest name within two edits — for a mistyped verb or option.
906
- export function nearest(word, names) {
907
- const d = (a, b) => {
908
- const m = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
909
- for (let j = 1; j <= b.length; j++) m[0][j] = j;
910
- for (let i = 1; i <= a.length; i++)
911
- for (let j = 1; j <= b.length; j++)
912
- 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));
913
- return m[a.length][b.length];
914
- };
915
- let best = null,
916
- score = 3;
917
- for (const n of names) {
918
- const k = n.startsWith(word) && word.length >= 3 ? 0 : d(word, n);
919
- if (k < score) {
920
- best = n;
921
- score = k;
922
- }
923
- }
924
- return best;
925
- }
926
-
927
- // ─── run ───────────────────────────────────────────────────────────────
928
-
929
- // Rule 1 of the per-verb parse: the verb is found before anything is parsed.
930
- // Before it only the globals' arity is known; after it, its row's too. Left
931
- // to right, to the first "--": a "--" before the verb makes the next token
932
- // the verb (`projectstore -- doctor`), and one after it leaves the rest to
933
- // the verb, so the MCP server's `-- <query>` is never read as a global. Only
934
- // the exact tokens set a flag: `--json=v` is a global with a bad value, which
935
- // the parse words (rule 6), not a verb's option. A bare --project takes the
936
- // next token whatever it is, as parseArgs does, and so does a bare option of
937
- // the verb's that takes a value: `reconcile --only --help` and the MCP
938
- // server's `--kind -h` hand the parse a value it refuses, never the help.
939
- // Any other option before the verb is misplaced (rule 3). A bare word right
940
- // after one is presumed to be its value when it is not a verb and some verb
941
- // gives that option a value — for the message only (`--harness codex plan`
942
- // reads back as `plan --harness codex`); nothing is parsed on that guess.
943
- function scanArgv(argv, verbs) {
944
- const scan = { verb: null, at: -1, json: false, help: false, version: false, misplaced: [] };
945
- const takesValue = (row, name) => row.options.some((o) => o.name === name && o.arg);
946
- let row = null;
947
- for (let i = 0; i < argv.length; i++) {
948
- const a = argv[i];
949
- if (a === "--") {
950
- if (scan.at === -1 && i + 1 < argv.length) {
951
- scan.verb = argv[i + 1];
952
- scan.at = i + 1;
953
- }
954
- break;
955
- }
956
- if (a.length > 1 && a.startsWith("-")) {
957
- if (/^-[hv]+$/.test(a)) {
958
- if (a.includes("h")) scan.help = true;
959
- if (a.includes("v")) scan.version = true;
960
- continue;
961
- }
962
- if (a === "--json") scan.json = true;
963
- if (a === "--help") scan.help = true;
964
- if (a === "--version") scan.version = true;
965
- const name = a.startsWith("--") ? a.slice(2).split("=")[0] : null;
966
- const inline = a.includes("=");
967
- if (!inline && (name === "project" || (row && takesValue(row, name)))) i++;
968
- else if (scan.at === -1 && !GLOBAL_OPTS.some((o) => o.name === name))
969
- scan.misplaced.push({ name, raw: a, at: i, inline, value: undefined });
970
- continue;
971
- }
972
- if (scan.at !== -1) continue;
973
- const prev = scan.misplaced[scan.misplaced.length - 1];
974
- if (
975
- !verbs.some((v) => v.verb === a) &&
976
- prev &&
977
- prev.at === i - 1 &&
978
- !prev.inline &&
979
- verbs.some((v) => takesValue(v, prev.name))
980
- ) {
981
- prev.value = a;
982
- continue;
983
- }
984
- scan.verb = a;
985
- scan.at = i;
986
- row = verbs.find((v) => v.verb === a) || null;
987
- }
988
- return scan;
989
- }
990
-
991
- // A row's options and the globals, in parser order: the later declaration
992
- // wins, so a row cannot retype a global. No row (no verb) is the globals alone.
993
- const optionsOf = (row) => [...(row ? row.options : []), ...GLOBAL_OPTS];
994
-
995
- // Rule 2: the row is the parser — arg is the type, multiple the repetition.
996
- function parserOptions(row) {
997
- const options = {};
998
- for (const o of optionsOf(row))
999
- options[o.name] = {
1000
- type: o.arg ? "string" : "boolean",
1001
- ...(o.multiple ? { multiple: true } : {}),
1002
- ...(SHORT[o.name] ? { short: SHORT[o.name] } : {}),
1003
- };
1004
- return options;
1005
- }
1006
-
1007
- // "install does not take --verbos — did you mean --verbose?" With a row the
1008
- // candidates are its options and the globals; without one (no verb, or a
1009
- // word that is none), every option the table declares, as 0.29.2 offered.
1010
- function unknownOption(row, raw, verbs) {
1011
- const names = (row ? optionsOf(row) : [...verbs.flatMap((v) => v.options), ...GLOBAL_OPTS]).map((o) => o.name);
1012
- const hint = nearest(raw.replace(/^-+/, ""), [...new Set(names)]);
1013
- return `${row ? `${row.verb} does not take` : "unknown option"} ${raw}${hint ? ` — did you mean --${hint}?` : ""}`;
1014
- }
1015
-
1016
- // Rule 6: Node's own wording explains "--" positionals; a person who typed
1017
- // --verbos wants the option they meant, and one who left --only bare wants
1018
- // its form. A bad value comes in three shapes — an inline value on a flag
1019
- // (`--json=v`), none at all (`--only` last), one that looks like an option
1020
- // (`--only --json`) — and each quotes the long name, with ` <value>` or a
1021
- // `-h, ` short form around it: the declaration words the answer. Node's text
1022
- // is the fallback when no name can be read.
1023
- function parseFailure(e, row, verbs) {
1024
- const raw = e.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION" ? /'(-{1,2}[^']+)'/.exec(e.message)?.[1] : null;
1025
- if (raw) return unknownOption(row, raw, verbs);
1026
- const name =
1027
- e.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE"
1028
- ? /'(?:-[^-], )?(--[^' ]+)(?: <value>)?'/.exec(e.message)?.[1]
1029
- : null;
1030
- const o = name ? optionsOf(row).findLast((x) => `--${x.name}` === name) : null;
1031
- if (!o) return e.message;
1032
- return o.arg ? `\`--${o.name}\` needs a value: \`--${o.name} ${o.arg}\`` : `\`--${o.name}\` takes no value`;
1033
- }
1034
-
1035
- // Rule 4: a usage form, word by word — a --name the form requires (the word
1036
- // after it is the option's value when it takes one, not an argument), a
1037
- // <placeholder> (`<phrase…>` takes the rest), or a literal sub-word. The
1038
- // bound is how many arguments the form holds.
1039
- function formOf(row, form) {
1040
- const f = { form, literals: [], required: [], placeholders: 0, variadic: false };
1041
- const words = form.split(" ");
1042
- for (let i = 0; i < words.length; i++) {
1043
- const w = words[i];
1044
- if (w.startsWith("--")) {
1045
- f.required.push(w.slice(2));
1046
- if (row.options.find((o) => o.name === w.slice(2))?.arg) i++;
1047
- } else if (w.startsWith("<")) {
1048
- f.placeholders++;
1049
- if (w.endsWith("…>")) f.variadic = true;
1050
- } else f.literals.push(w);
1051
- }
1052
- f.bound = f.variadic ? Infinity : f.literals.length + f.placeholders;
1053
- return f;
1054
- }
1055
-
1056
- // The form the arguments take: the first whose literal words lead them and
1057
- // whose required options were given. null when none does, and the verb's own
1058
- // message then says what it takes ("agents takes model <name>, show, or
1059
- // configure") — which is how `codemap PS-CORE` keeps its --for hint while
1060
- // `codemap --for X extra` names `extra`. A row with no forms binds nothing.
1061
- function matchForm(row, args, values) {
1062
- if (!row.usage.length) return { form: "", bound: 0 };
1063
- for (const form of row.usage) {
1064
- const f = formOf(row, form);
1065
- if (f.literals.every((w, i) => args[i] === w) && f.required.every((n) => values[n] !== undefined)) return f;
1066
- }
1067
- return null;
1068
- }
1069
-
1070
- // `verbs` is the table this run answers for: VERBS, or a test's own — the
1071
- // scan, the help, the verb lookup and every hint read it, never VERBS behind it.
1072
- export async function run(
1073
- argv,
1074
- {
1075
- verbs = VERBS,
1076
- env = process.env,
1077
- cwd = process.cwd(),
1078
- stdin = process.stdin,
1079
- stdout = process.stdout,
1080
- stderr = process.stderr,
1081
- ask = null,
1082
- } = {},
1083
- ) {
1084
- const scan = scanArgv(argv, verbs);
1085
- const row = verbs.find((v) => v.verb === scan.verb) || null;
1086
- // A failure before a verb runs still answers in the envelope under --json:
1087
- // a consumer (the MCP server, a script) always has something to parse. The
1088
- // verb is the scan's, whichever step failed (rule 7) — never the first
1089
- // bare word, which may be --project's value.
1090
- const fail = (message, code, { project = null, help = false } = {}) => {
1091
- if (scan.json)
1092
- stdout.write(JSON.stringify(envelope(scan.verb, project, false, { error: message, exit: code }), null, 2) + "\n");
1093
- stderr.write(message + "\n" + (help ? usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n" : ""));
1094
- return code;
1095
- };
1096
- // A usage error says where the options are: the verb's help, else the top
1097
- // (a word that is not a verb has no help to point at).
1098
- const refuse = (message) => fail(`${message}\nRun ${row ? `\`${row.verb} --help\`` : "--help"} for the options.`, 2);
1099
- // In-process reads resolve layouts and registries from THIS package, as the
1100
- // children already do through ownEnv — not from whichever copy the host
1101
- // session's variable points at.
1102
- pinPluginRoot(PACKAGE_ROOT);
1103
- // Rule 8: help and version answer first, whatever else the line holds, so
1104
- // `doctor extra --help` and `--harness codex plan --help` print the help.
1105
- if (scan.version) return runVersion({ values: { json: scan.json }, stdout });
1106
- // Help is painted at a terminal and plain on a pipe: the caps are stdout's.
1107
- if (scan.help) {
1108
- const caps = term.caps(stdout, env);
1109
- stdout.write((row ? verbHelp(row, env, { caps }) : usage(env, verbs, { caps })) + "\n");
1110
- return 0;
1111
- }
1112
- // With no verb to scope it, an option no verb declares is unknown before
1113
- // anything else is said, as in 0.29.2: `--bogus frobnicate` names --bogus,
1114
- // and `--verbos instal` offers --verbose.
1115
- const stray = row ? null : scan.misplaced.find((m) => !verbs.some((v) => v.options.some((o) => o.name === m.name)));
1116
- if (stray) return refuse(unknownOption(null, stray.raw.split("=")[0], verbs));
1117
- if (scan.verb === null) {
1118
- // No verb. A verb's option (`--harness codex`, `--verbose`) is the usage,
1119
- // as before — not "unknown verb: codex"; otherwise the globals alone are
1120
- // parsed, so a bad value of one (`--project` last) is named.
1121
- if (!scan.misplaced.length) {
1122
- try {
1123
- parseArgs({ args: argv, options: parserOptions(null), strict: true, allowPositionals: true });
1124
- } catch (e) {
1125
- return refuse(parseFailure(e, null, verbs));
1126
- }
1127
- }
1128
- stderr.write(usage(env, verbs, { caps: term.caps(stderr, env) }) + "\n");
1129
- return 2;
1130
- }
1131
- const verb = scan.verb;
1132
- if (!row) {
1133
- const planned = PLANNED_VERBS.find((v) => v.verb === verb);
1134
- const guess = planned
1135
- ? null
1136
- : nearest(
1137
- verb,
1138
- verbs.map((v) => v.verb),
1139
- );
1140
- if (guess) return fail(`unknown verb: ${verb} — did you mean ${guess}?\nRun --help for every verb.`, 2);
1141
- return fail(
1142
- planned ? `${verb} lands with roadmap ${planned.lands}; not in this release.` : `unknown verb: ${verb}`,
1143
- 2,
1144
- { help: true },
1145
- );
1146
- }
1147
- // Rule 3: only the globals come before the verb (git's model). A verb's
1148
- // own option there is shown in its place; one the verb does not declare is
1149
- // unknown, scoped to the verb. Accepting it would need its arity before the
1150
- // verb is known, which is what per-verb types make ambiguous.
1151
- if (scan.misplaced.length) {
1152
- const m = scan.misplaced[0];
1153
- const o = m.name === null ? null : row.options.find((x) => x.name === m.name);
1154
- if (!o) return refuse(unknownOption(row, m.raw.split("=")[0], verbs));
1155
- // Through a shell the harness is fixed (verbHelp's test: the row declares
1156
- // it): the order shown drops it, since the shell inserts it after the verb.
1157
- // The rest of the line follows, so the command shown is the one to run.
1158
- const { shell, cmd } = invokedAs(env);
1159
- const moved = shell && o.name === "harness" ? [] : [m.raw, ...(o.arg && m.value !== undefined ? [m.value] : [])];
1160
- return refuse(
1161
- `\`--${o.name}\` belongs after the verb: \`${[cmd, verb, ...moved, ...argv.slice(scan.at + 1)].join(" ")}\``,
1162
- );
1163
- }
1164
- // Rule 2: one strict parse with this row's options plus the globals, on the
1165
- // full argv so a token's index is the user's (rule 4's hints read them).
1166
- let parsed;
1167
- try {
1168
- parsed = parseArgs({ args: argv, options: parserOptions(row), strict: true, allowPositionals: true, tokens: true });
1169
- } catch (e) {
1170
- return refuse(parseFailure(e, row, verbs));
1171
- }
1172
- const { values, positionals, tokens } = parsed;
1173
- // Rule 4: an argument past the matched form is named, never dropped. The
1174
- // hint says how it became one: the flag before it takes no value, or a
1175
- // "--" made everything after it an argument.
1176
- const args = tokens.filter((t) => t.kind === "positional" && t.index !== scan.at);
1177
- const form = matchForm(
1178
- row,
1179
- args.map((t) => t.value),
1180
- values,
1181
- );
1182
- if (form && args.length > form.bound) {
1183
- const extra = args[form.bound];
1184
- const prev = tokens.find((t) => t.index === extra.index - 1);
1185
- const end = tokens.find((t) => t.kind === "option-terminator");
1186
- const why =
1187
- prev && prev.kind === "option" && prev.value === undefined
1188
- ? ` — \`--${prev.name}\` takes no value`
1189
- : end && extra.index > end.index
1190
- ? " — everything after `--` is an argument"
1191
- : "";
1192
- return refuse(
1193
- `${form.bound ? `\`${verb} ${form.form}\` takes no further argument` : `${verb} takes no argument`} \`${extra.value}\`${why}`,
1194
- );
1195
- }
1196
- const project = resolveProject({ project: values.project, env, cwd });
1197
- const cfg = readConfigAt(project);
1198
- if (row.requiresBinding && !cfg) return fail(notBoundText(project, env), 3, { project });
1199
- try {
1200
- return await row.run({
1201
- row,
1202
- values,
1203
- positionals: positionals.slice(1),
1204
- cfg,
1205
- project,
1206
- env,
1207
- cwd,
1208
- stdin,
1209
- stdout,
1210
- stderr,
1211
- ask,
1212
- });
1213
- } catch (e) {
1214
- // An internal failure is not "findings" (1) — it is 2, with an envelope
1215
- // when one was asked for.
1216
- const msg = e && e.message ? e.message : String(e);
1217
- if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: msg }), null, 2) + "\n");
1218
- stderr.write(`${verb} failed: ${msg}\n`);
1219
- return 2;
1220
- }
1221
- }
1222
-
1223
- // The environment a child or the installer runs in: the resolved project and
1224
- // THIS package as the plugin root, so the bin never answers for a sibling
1225
- // copy of the core.
1226
- function ownEnv(env, project) {
1227
- return childEnv(env, { projectRoot: project, pluginRoot: PACKAGE_ROOT });
1228
- }
70
+ import { dispatch, envelope, usageFail, ownEnv, nearest, notBoundText, PACKAGE_ROOT } from "./verbs.mjs";
71
+ import { READ_HANDLERS } from "./read-verbs.mjs";
72
+
73
+ // What this module exported before the table moved, from where it lives now.
74
+ export {
75
+ SCHEMA_VERSION,
76
+ PACKAGE_ROOT,
77
+ packageVersion,
78
+ envelope,
79
+ resolveProject,
80
+ opt,
81
+ VERBS,
82
+ PLANNED_VERBS,
83
+ SUMMARY_MAX,
84
+ usage,
85
+ verbHelp,
86
+ nearest,
87
+ notBoundText,
88
+ } from "./verbs.mjs";
89
+ export { doctorText } from "./read-verbs.mjs";
1229
90
 
1230
91
  // The one interactive question every write verb asks when nothing named the
1231
92
  // write. Streams and `ask` are parameters so it is testable without a tty.
@@ -1237,59 +98,16 @@ async function confirmWrite(question, { stdin, stdout, ask }) {
1237
98
  return answer !== null && /^y(es)?$/i.test(answer.trim());
1238
99
  }
1239
100
 
1240
- // ─── verbs ─────────────────────────────────────────────────────────────
1241
-
1242
- // The read verbs: one call into query.mjs, the result in the envelope or
1243
- // rendered. A usage error from the operation (a bad path, a missing query)
1244
- // is exit 2 with the message, never a stack trace. A renderer gets the
1245
- // stream's caps — painted at a terminal, plain on a pipe — and the env its
1246
- // command forms are named for; one that takes neither ignores them.
1247
- function emitRead({ verb, values, project, stdout, env }, op, result, ok = true) {
1248
- if (values.json) stdout.write(JSON.stringify(envelope(verb, project, ok, result), null, 2) + "\n");
1249
- else stdout.write(op.render(result, { caps: term.caps(stdout, env), env }));
1250
- return ok ? 0 : 1;
1251
- }
1252
-
1253
- function usageFail(e, { verb, values, project, stdout, stderr }) {
1254
- if (values.json) stdout.write(JSON.stringify(envelope(verb, project, false, { error: e.message }), null, 2) + "\n");
1255
- stderr.write(`${verb}: ${e.message}\n`);
1256
- return 2;
1257
- }
1258
-
1259
- function runRead(name) {
1260
- return async (ctx) => {
1261
- const { row, values, positionals, cfg, project } = ctx;
1262
- const op = READ_OPERATIONS[name];
1263
- try {
1264
- let result;
1265
- if (name === "status") result = op.fn(cfg, { project });
1266
- else if (name === "orientation") result = await op.fn(cfg);
1267
- else if (name === "search")
1268
- result = op.fn(cfg, positionals.join(" "), {
1269
- kinds: values.kind || null,
1270
- status: values.status ?? null,
1271
- limit: values.limit,
1272
- includeDerived: Boolean(values["include-derived"]),
1273
- caseSensitive: Boolean(values["case-sensitive"]),
1274
- });
1275
- else if (name === "show")
1276
- result = op.fn(cfg, positionals[0], { body: Boolean(values.body), section: values.section ?? null });
1277
- return emitRead({ verb: row.verb, ...ctx }, op, result);
1278
- } catch (e) {
1279
- if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
1280
- throw e;
1281
- }
1282
- };
1283
- }
101
+ // ─── The write verbs' handlers ─────────────────────────────────────────
1284
102
 
1285
103
  // The MCP server, imported lazily like the install family: no other verb
1286
- // carries the protocol code. A project must have been SUPPLIED — the flag,
1287
- // the neutral variable or the harness's declared directory; resolveProject's
1288
- // cwd fallback is exactly what the MCP ADR's decision 6 forbids here.
1289
- async function runMcp({ values, project, env, stdin, stdout, stderr }) {
1290
- const supplied = Boolean(values.project || env.PROJECTSTORE_PROJECT_DIR || projectRootDeclared(env));
1291
- const { serve } = await import("./mcp.mjs");
1292
- return serve({ project: supplied ? project : null, env, stdin, stdout, stderr });
104
+ // carries the protocol code. It serves the project the flag names, else the
105
+ // one the environment supplies, never this process's cwd — mcp.mjs's
106
+ // serveSupplied, the one path scripts/mcp-server.mjs (what .mcp.json runs)
107
+ // takes too.
108
+ async function runMcp({ values, env, cwd, stdin, stdout, stderr }) {
109
+ const { serveSupplied } = await import("./mcp.mjs");
110
+ return serveSupplied({ project: values.project ?? null, env, cwd, stdin, stdout, stderr });
1293
111
  }
1294
112
 
1295
113
  // git's user.name for a fresh config's default_author, from the project's
@@ -1664,140 +482,6 @@ async function runSession(ctx) {
1664
482
  return ok ? 0 : 1;
1665
483
  }
1666
484
 
1667
- async function runGraph(ctx) {
1668
- const { row, values, positionals, cfg } = ctx;
1669
- const sub = positionals[0];
1670
- const path = positionals[1];
1671
- if (!["neighbors", "lineage"].includes(sub))
1672
- return usageFail(Object.assign(new Error("graph takes neighbors <path> or lineage <path>"), { code: "USAGE" }), {
1673
- verb: row.verb,
1674
- ...ctx,
1675
- });
1676
- const op = READ_OPERATIONS[sub];
1677
- try {
1678
- // The row declares the union; each mode takes its own — an option the
1679
- // mode would ignore is a usage error, not a silent no-op.
1680
- const notFor = sub === "neighbors" ? ["depth"] : ["direction", "limit"];
1681
- for (const o of notFor)
1682
- if (values[o] !== undefined)
1683
- throw Object.assign(new Error(`--${o} is not an option of graph ${sub}`), { code: "USAGE" });
1684
- const result =
1685
- sub === "neighbors"
1686
- ? op.fn(cfg, path, { kinds: values.kind || null, direction: values.direction, limit: values.limit })
1687
- : op.fn(cfg, path, {
1688
- depth: values.depth,
1689
- kinds: values.kind && values.kind.length ? values.kind : LINEAGE_KINDS,
1690
- });
1691
- return emitRead({ verb: `graph ${sub}`, ...ctx }, op, result);
1692
- } catch (e) {
1693
- if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
1694
- throw e;
1695
- }
1696
- }
1697
-
1698
- async function runCodemap(ctx) {
1699
- const { row, values, cfg } = ctx;
1700
- const op = READ_OPERATIONS.codeRefs;
1701
- try {
1702
- if (!values.for)
1703
- throw Object.assign(new Error("codemap takes --for <selector>; regeneration is reconcile --only codemap"), {
1704
- code: "USAGE",
1705
- });
1706
- return emitRead({ verb: row.verb, ...ctx }, op, op.fn(cfg, values.for, { reverse: Boolean(values.reverse) }));
1707
- } catch (e) {
1708
- if (e && e.code === "USAGE") return usageFail(e, { verb: row.verb, ...ctx });
1709
- throw e;
1710
- }
1711
- }
1712
-
1713
- function runVersion({ values, stdout }) {
1714
- const version = packageVersion();
1715
- stdout.write(
1716
- values.json ? JSON.stringify(envelope("version", null, true, { version }), null, 2) + "\n" : version + "\n",
1717
- );
1718
- return 0;
1719
- }
1720
-
1721
- function spawnDoctor(project, env, flags) {
1722
- // A project that does not exist is still a project doctor can report on
1723
- // ("not bound") — so the cwd is only set when it can be.
1724
- return spawnSync(process.execPath, [script("doctor.mjs"), ...flags], {
1725
- encoding: "utf8",
1726
- cwd: existsSync(project) ? project : undefined,
1727
- env: ownEnv(env, project),
1728
- timeout: 60000,
1729
- maxBuffer: 1 << 24,
1730
- });
1731
- }
1732
-
1733
- // Spawned, never imported (MCP ADR decision 2): doctor's main writes to
1734
- // stdout and is not exported, and importing its checks would be a second
1735
- // doctor. One spawn, always --json: its findings decide the exit code
1736
- // (doctor.mjs sets none of its own), and the text a person reads is drawn
1737
- // from those same findings here, in this process, by doctor-report.mjs — a
1738
- // renderer that imports no check (the presentation spec, contract 11). So a
1739
- // terminal gets colour, which a piped child could not tell it had, and the
1740
- // report and the exit code can never come from two different runs.
1741
- async function runDoctor({ values, project, env, stdout, stderr }) {
1742
- const sections = [values.install && "--install", values.vault && "--vault"].filter(Boolean);
1743
- const r = spawnDoctor(project, env, ["--json", ...sections]);
1744
- const fail = (why) => {
1745
- if (values.json) stdout.write(JSON.stringify(envelope("doctor", project, false, { error: why }), null, 2) + "\n");
1746
- stderr.write(`doctor failed: ${why}\n`);
1747
- return 2;
1748
- };
1749
- if (r.error) return fail(r.error.message);
1750
- if (r.status !== 0 && !r.stdout) return fail((r.stderr || "").trim() || `exit ${r.status}`);
1751
- let findings;
1752
- try {
1753
- findings = JSON.parse(r.stdout);
1754
- } catch {
1755
- return fail("unparseable output");
1756
- }
1757
- const ok = !findings.some((f) => f.level === "issue");
1758
- if (values.json) stdout.write(JSON.stringify(envelope("doctor", project, ok, findings), null, 2) + "\n");
1759
- else {
1760
- // The sections as doctor.mjs derives them: no flag is both.
1761
- const groups = sections.length
1762
- ? [values.install && "install", values.vault && "vault"].filter(Boolean)
1763
- : ["install", "vault"];
1764
- stdout.write(
1765
- await doctorText(findings, groups, {
1766
- caps: term.caps(stdout, env),
1767
- verbose: Boolean(values.verbose),
1768
- project,
1769
- env: ownEnv(env, project),
1770
- stderr,
1771
- }),
1772
- );
1773
- }
1774
- return ok ? 0 : 1;
1775
- }
1776
-
1777
- // doctor's text from its findings. `env` is the child's environment
1778
- // (ownEnv), so every command form the report names is the one doctor itself
1779
- // would name. A renderer that cannot load, or throws, must not cost the
1780
- // reader the findings: they are printed one per line, `level check message
1781
- // — file`, and the Summary line still ends them (lib.mjs). `load` stands in
1782
- // for the import in a test.
1783
- export async function doctorText(
1784
- findings,
1785
- groups,
1786
- { caps, verbose = false, project = null, env = process.env, stderr = null } = {},
1787
- { load = () => import("./doctor-report.mjs") } = {},
1788
- ) {
1789
- try {
1790
- const { report } = await load();
1791
- return report(findings, groups, { caps, verbose, version: packageVersion(), project, env });
1792
- } catch (e) {
1793
- if (stderr)
1794
- stderr.write(
1795
- `doctor: the report could not be drawn (${e && e.message ? e.message : e}); its findings follow, one per line.\n`,
1796
- );
1797
- return doctorFallbackText(findings, { env });
1798
- }
1799
- }
1800
-
1801
485
  async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }) {
1802
486
  const write = Boolean(values.write);
1803
487
  const only = values.only ?? null;
@@ -1836,19 +520,6 @@ async function runReconcile({ values, project, env, stdin, stdout, stderr, ask }
1836
520
  return failed ? 1 : 0;
1837
521
  }
1838
522
 
1839
- // The not-bound refusal, for every verb that needs a binding and for configure.
1840
- // Each form alone on its line after the lead, the terminal's in the core's
1841
- // form, the directory in the lead (the story "User-facing text says where to
1842
- // run and names no project path and no shell", rules 1, 4 and 6).
1843
- export function notBoundText(project, env = process.env) {
1844
- return [
1845
- `${project} is not bound to a vault. Bind it in a session, or ${fromTerminalIn(project)}, where init creates the vault too:`,
1846
- commandForm("bind", { args: "<vault>", env }),
1847
- packageCommand([], "bind", { args: "<vault>" }),
1848
- packageCommand([], "init", { args: "<vault>" }),
1849
- ].join("\n");
1850
- }
1851
-
1852
523
  // configure (its spec): loaded on first use, like setup. The module returns a
1853
524
  // usage error, an unbound project, a refusal's message, or a result; the bin
1854
525
  // writes the line, the envelope and the exit code.
@@ -1963,3 +634,33 @@ async function runInstallVerb({ row, values, project, env, cwd, stdin, stdout, a
1963
634
  }
1964
635
  return ok ? 0 : 1;
1965
636
  }
637
+
638
+ // ─── run ───────────────────────────────────────────────────────────────
639
+
640
+ // Every verb's handler, keyed by verb: the read half from read-verbs.mjs,
641
+ // the rest here. Exported so a test can wrap one (setup's sandboxed home) in
642
+ // a table of its own.
643
+ export const HANDLERS = Object.freeze({
644
+ ...READ_HANDLERS,
645
+ setup: runSetupVerb,
646
+ configure: runConfigureVerb,
647
+ reconcile: runReconcile,
648
+ plan: runInstallVerb,
649
+ install: runInstallVerb,
650
+ uninstall: runInstallVerb,
651
+ upgrade: runInstallVerb,
652
+ agents: runAgents,
653
+ band: runBand,
654
+ session: runSession,
655
+ bind: runBind(false),
656
+ init: runBind(true),
657
+ scaffold: runScaffold,
658
+ mcp: runMcp,
659
+ });
660
+
661
+ // run(argv, opts) — the bin's one call: the parse over the one table, every
662
+ // verb served. `opts` as dispatch()'s (verbs.mjs): `verbs` is the table this
663
+ // run answers for, VERBS or a test's own, whose rows may carry their own run.
664
+ export function run(argv, opts = {}) {
665
+ return dispatch(argv, HANDLERS, opts);
666
+ }