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.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- 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.
|
|
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 {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 {
|
|
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(
|
|
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(
|
|
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 = [
|
|
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: "
|
|
99
|
-
|
|
100
|
-
|
|
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",
|
|
105
|
-
|
|
106
|
-
|
|
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",
|
|
111
|
-
|
|
112
|
-
|
|
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",
|
|
116
|
-
|
|
117
|
-
|
|
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",
|
|
121
|
-
|
|
122
|
-
|
|
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",
|
|
126
|
-
|
|
127
|
-
|
|
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",
|
|
131
|
-
|
|
132
|
-
|
|
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",
|
|
136
|
-
|
|
137
|
-
|
|
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",
|
|
141
|
-
|
|
142
|
-
|
|
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",
|
|
147
|
-
|
|
148
|
-
|
|
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",
|
|
153
|
-
|
|
154
|
-
|
|
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",
|
|
159
|
-
|
|
160
|
-
|
|
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",
|
|
165
|
-
|
|
166
|
-
|
|
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: "
|
|
171
|
-
|
|
172
|
-
|
|
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",
|
|
177
|
-
|
|
178
|
-
|
|
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: "
|
|
183
|
-
|
|
184
|
-
|
|
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",
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
200
|
-
//
|
|
201
|
-
// new row is never hidden
|
|
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
|
-
["
|
|
204
|
-
["
|
|
205
|
-
["
|
|
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.
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
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: [
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
233
|
-
|
|
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
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
747
|
+
term.badge(c, "projectstore", [version, PURPOSE]),
|
|
263
748
|
"",
|
|
264
|
-
"Usage",
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
269
|
-
const
|
|
270
|
-
|
|
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)
|
|
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
|
-
|
|
282
|
-
"
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
"
|
|
287
|
-
"
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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++)
|
|
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,
|
|
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) {
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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")
|
|
460
|
-
|
|
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
|
|
494
|
-
//
|
|
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)
|
|
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, {
|
|
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,
|
|
508
|
-
else
|
|
509
|
-
|
|
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)
|
|
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))
|
|
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) => {
|
|
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(
|
|
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(
|
|
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 = [
|
|
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)
|
|
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(
|
|
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)
|
|
563
|
-
|
|
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 = {
|
|
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]))
|
|
574
|
-
|
|
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) {
|
|
594
|
-
|
|
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 =
|
|
601
|
-
|
|
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())
|
|
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)
|
|
607
|
-
|
|
608
|
-
|
|
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) {
|
|
612
|
-
|
|
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(
|
|
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))
|
|
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)
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
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)
|
|
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(
|
|
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], {
|
|
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
|
|
664
|
-
// not exported, and importing
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
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 {
|
|
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
|
-
|
|
685
|
-
|
|
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] `, {
|
|
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)
|
|
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) {
|
|
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)
|
|
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
|
-
|
|
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 = {
|
|
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)
|
|
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",
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
stdout.write(
|
|
741
|
-
|
|
742
|
-
|
|
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
|
}
|