plumbbob 0.9.2 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.9.2",
4
+ "version": "0.9.3",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/dist/cli-core.js CHANGED
@@ -26,31 +26,260 @@ import { init } from "./verbs/init.js";
26
26
  import { doctor } from "./verbs/doctor.js";
27
27
  import { agent } from "./verbs/agent.js";
28
28
  import { turn } from "./verbs/turn.js";
29
- /** One help-table row per verb, in the order the table prints. */
29
+ /** Shared across every verb that resolves through the active-build cursor. */
30
+ const BUILD_FLAG = {
31
+ name: '--build',
32
+ value: '<slug>',
33
+ gloss: 'target a specific build instead of the active cursor',
34
+ };
35
+ /**
36
+ * The full CLI spec, in the order the help table prints.
37
+ *
38
+ * One table drives three things: the top-level verb table, per-verb `--help`,
39
+ * and the unknown-flag refusal in `run`. Keeping it as data rather than inline
40
+ * writes is the deliberate exception to the write-at-the-call-site convention —
41
+ * a synopsis has to be readable by the validator, not just printable.
42
+ */
30
43
  const VERBS = [
31
- { name: 'start', summary: 'scaffold .plumbbob/; open the session; record the baseline commit' },
32
- { name: 'status', summary: 'print the orientation dashboard, or NO ACTIVE SESSION' },
33
- { name: 'build', summary: 'build [<n>]: write the seam from step n, or the next undone step (in-flight, not a lock)' },
34
- { name: 'handoff', summary: 'print the standardized build hand-off block (pause or post-checkpoint); read-only' },
35
- { name: 'check', summary: 'run the heavy check and report; no state change (--bail --only a,b … narrow the checkride gate)' },
36
- { name: 'checkpoint', summary: 'checkpoint [<n>]: gate on green, commit/record SHA, mark step done (executor-agnostic)' },
37
- { name: 'revert', summary: 'revert [--to n]: git reset --hard to a checkpoint SHA' },
38
- { name: 'park', summary: 'park "<text>": append a raw line to the park list' },
39
- { name: 'spike', summary: 'spike "<slug>" | spike report "<slug>" | spike done: worktree experiment + report' },
40
- { name: 'use', summary: 'use <slug>: re-point the active-build cursor and resume that build' },
41
- { name: 'finish', summary: 'close-out: report + final commit (no gate), clear the control state, close the session' },
42
- { name: 'init', summary: 'link plumbbob into Claude Code as an in-place plugin (~/.claude/skills/plumbbob); --uninstall to undo' },
43
- { name: 'doctor', summary: 'diagnose the plugin link + detect a legacy flat sidecar; doctor --migrate moves it into builds/ (staged, not committed)' },
44
- { name: 'agent', summary: 'agent list | run <name> [--step N] [--mode before|build|after]: list or spawn a user-authored agent' },
45
- { name: 'turn', summary: 'turn: UserPromptSubmit hook machinery — tick the turn ledger from stdin (not a user verb)' },
44
+ {
45
+ name: 'start',
46
+ description: 'scaffold .plumbbob/; open the session; record the baseline commit',
47
+ synopsis: ['start "<title>" [--slug <name>] [--local] [--allow-dirty]'],
48
+ args: [{ name: '<title>', gloss: 'what you are building (quoted)' }],
49
+ flags: [
50
+ { name: '--slug', value: '<name>', gloss: 'use this slug verbatim instead of the derived YYYY-MM-DD-<title-slug>' },
51
+ { name: '--local', gloss: 'opt into the old fully-untracked flat layout' },
52
+ { name: '--allow-dirty', gloss: 'record the current HEAD as baseline despite a dirty tree' },
53
+ ],
54
+ notes: 'Refuses (exit 1) on an empty title, a slug collision, a non-git directory, a repo with no commits, an already-active session, or a dirty tree.',
55
+ },
56
+ {
57
+ name: 'status',
58
+ description: 'print the orientation dashboard, or NO ACTIVE SESSION',
59
+ synopsis: ['status [--build <slug>]'],
60
+ flags: [BUILD_FLAG],
61
+ notes: 'Read-only; always exits 0. With no cursor it lists the available builds.',
62
+ },
63
+ {
64
+ name: 'build',
65
+ description: 'write the seam from step n, or the next undone step (in-flight, not a lock)',
66
+ synopsis: ['build [<n>] [--build <slug>]'],
67
+ args: [{ name: '<n>', gloss: 'step to build (default: the next undone step)' }],
68
+ flags: [BUILD_FLAG],
69
+ notes: 'Refuses (exit 1) with no session, a non-numeric or < 1 step, or a seam it cannot parse.',
70
+ },
71
+ {
72
+ name: 'handoff',
73
+ description: 'print the standardized build hand-off block (pause or post-checkpoint); read-only',
74
+ synopsis: ['handoff [<n>] [--build <slug>]'],
75
+ args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
76
+ flags: [BUILD_FLAG],
77
+ notes: 'Read-only; refuses (exit 1) only with no session.',
78
+ },
79
+ {
80
+ name: 'check',
81
+ description: 'run the heavy check and report; no state change',
82
+ synopsis: ['check [--bail] [--changed] [--all] [--only a,b] [--skip a,b] [--include a,b]'],
83
+ flags: [
84
+ { name: '--bail', gloss: 'stop at the first red slot' },
85
+ { name: '--changed', gloss: 'limit the run to changed files' },
86
+ { name: '--all', gloss: 'run every slot' },
87
+ { name: '--only', value: '<a,b>', gloss: 'run only these slots' },
88
+ { name: '--skip', value: '<a,b>', gloss: 'run every slot except these' },
89
+ { name: '--include', value: '<a,b>', gloss: 'add these slots to the run' },
90
+ ],
91
+ notes: 'Exits 0 green, 1 red, 2 when the gate itself broke. The flags narrow a checkride run and are warned-and-ignored on the `check` settings-override path.',
92
+ },
93
+ {
94
+ name: 'checkpoint',
95
+ description: 'gate on green, commit/record SHA, mark step done (executor-agnostic)',
96
+ synopsis: ['checkpoint [<n>] [-m <msg>] [--body]', 'checkpoint --plan [--body]'],
97
+ args: [{ name: '<n>', gloss: 'step to checkpoint (else the in-flight STEP, else the first undone step)' }],
98
+ flags: [
99
+ { name: '--plan', gloss: "commit only the build's artifact folder as chore(<scope>): plan" },
100
+ { name: '-m', value: '<msg>', gloss: 'override the CLI-owned Conventional subject' },
101
+ { name: '--body', gloss: 'read the commit body from stdin (heredoc)' },
102
+ ],
103
+ notes: 'Refuses (exit 1) with no session, no resolvable step, or a red check.',
104
+ },
105
+ {
106
+ name: 'revert',
107
+ description: 'git reset --hard to a checkpoint SHA',
108
+ synopsis: ['revert [--to <n>] [--build <slug>]'],
109
+ flags: [
110
+ { name: '--to', value: '<n>', gloss: "revert to step n's checkpoint (default: the last, else the baseline)" },
111
+ BUILD_FLAG,
112
+ ],
113
+ notes: 'Refuses (exit 1) with no session, an invalid --to, or a step with no recorded checkpoint.',
114
+ },
115
+ {
116
+ name: 'park',
117
+ description: 'append a raw line to the park list',
118
+ synopsis: ['park "<text>"'],
119
+ args: [{ name: '<text>', gloss: 'the note to park (free text; quote it)' }],
120
+ tolerateUnknownFlags: true,
121
+ notes: 'Refuses (exit 1) with no session, empty text, or no `## Park list` section.',
122
+ },
123
+ {
124
+ name: 'spike',
125
+ description: 'worktree experiment + report for a genuine fork',
126
+ synopsis: ['spike "<slug>" [opt…] [--build <slug>]', 'spike report "<slug>"', 'spike done'],
127
+ args: [
128
+ { name: '<slug>', gloss: 'name of the experiment' },
129
+ { name: '[opt…]', gloss: 'the options to fork (default: a and b)' },
130
+ ],
131
+ flags: [BUILD_FLAG],
132
+ notes: 'Refuses (exit 1) with no session, a step already in flight, an empty slug, or a worktree path that already exists.',
133
+ },
134
+ {
135
+ name: 'use',
136
+ description: 're-point the active-build cursor and resume that build',
137
+ synopsis: ['use <slug>'],
138
+ args: [{ name: '<slug>', gloss: 'the build folder under .plumbbob/builds/ to resume' }],
139
+ notes: 'Refuses (exit 1) with an empty slug or a slug with no build folder; warns (but allows) leaving a step in flight.',
140
+ },
141
+ {
142
+ name: 'finish',
143
+ description: 'close-out: report + final commit (no gate), clear the control state, close the session',
144
+ synopsis: ['finish [--body] [--build <slug>]'],
145
+ flags: [{ name: '--body', gloss: 'read the commit body from stdin (heredoc)' }, BUILD_FLAG],
146
+ notes: 'Refuses (exit 1) only with no session. There is no refuse-without-report gate.',
147
+ },
148
+ {
149
+ name: 'init',
150
+ description: 'link plumbbob into Claude Code as an in-place plugin (~/.claude/skills/plumbbob)',
151
+ synopsis: ['init [--uninstall] [--force]'],
152
+ flags: [
153
+ { name: '--uninstall', gloss: 'drop the link' },
154
+ { name: '--force', gloss: 'override the marketplace double-install guard' },
155
+ ],
156
+ notes: 'Idempotent and global-only; it never writes settings.json. Restart Claude Code (or /reload-plugins) to activate.',
157
+ },
158
+ {
159
+ name: 'doctor',
160
+ description: 'diagnose the plugin link + check gate; detect a legacy flat sidecar',
161
+ synopsis: ['doctor [--migrate]'],
162
+ flags: [{ name: '--migrate', gloss: 'move a legacy flat sidecar into builds/ (staged, not committed)' }],
163
+ notes: 'Exits 0 when everything passes, 1 when a check fails or an un-migrated legacy sidecar is present.',
164
+ },
165
+ {
166
+ name: 'agent',
167
+ description: 'list or spawn a user-authored agent',
168
+ synopsis: [
169
+ 'agent list',
170
+ 'agent run <name> [--step N] [--mode before|build|after] [--agent <path>] [--build <slug>]',
171
+ 'agent run --mode before|build|after [--step N]',
172
+ ],
173
+ args: [
174
+ { name: 'list', gloss: 'print every resolvable agent — name, origin tier, slots, description' },
175
+ { name: 'run', gloss: 'spawn an agent through the doorway; no code path advances the loop' },
176
+ ],
177
+ flags: [
178
+ { name: '--step', value: '<n>', gloss: 'pick the step (else the in-flight STEP)' },
179
+ { name: '--mode', value: '<slot>', gloss: 'the slot to run in: before, build, or after' },
180
+ { name: '--agent', value: '<path>', gloss: 'point at an explicit agent directory (top of the ladder)' },
181
+ BUILD_FLAG,
182
+ ],
183
+ notes: 'Exits 0 on a clean run, 1 on a failed one. See docs/agents.md for the author contract.',
184
+ },
185
+ {
186
+ name: 'turn',
187
+ description: 'UserPromptSubmit hook machinery — tick the turn ledger from stdin (not a user verb)',
188
+ synopsis: ['turn'],
189
+ tolerateUnknownFlags: true,
190
+ notes: 'Reads the hook payload from stdin and always exits 0 — a broken hook must never wedge a prompt.',
191
+ },
46
192
  ];
47
193
  /**
48
194
  * Render the verb table for `plumbbob help`.
49
195
  */
50
196
  export function formatHelp() {
51
197
  const width = Math.max(...VERBS.map((v) => v.name.length));
52
- const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.summary}`);
53
- return ['plumbbob — attention-first build process', '', 'Usage: plumbbob <verb> [args]', '', 'Verbs:', ...rows, ''].join('\n');
198
+ const rows = VERBS.map((v) => ` ${v.name.padEnd(width)} ${v.description}`);
199
+ return [
200
+ 'plumbbob — attention-first build process',
201
+ '',
202
+ 'Usage: plumbbob <verb> [args]',
203
+ '',
204
+ 'Verbs:',
205
+ ...rows,
206
+ '',
207
+ "Run 'plumbbob <verb> --help' for a verb's arguments and flags.",
208
+ '',
209
+ ].join('\n');
210
+ }
211
+ /**
212
+ * Render the synopsis, argument, and flag block for `plumbbob <verb> --help`,
213
+ * or null when the name is not a verb.
214
+ *
215
+ * Arguments and flags share one aligned column so the reader scans a single
216
+ * list; a flag that takes a value prints as `--only <a,b>` so the spelling in
217
+ * the help is the spelling you type.
218
+ */
219
+ export function formatVerbHelp(name) {
220
+ const spec = verbSpec(name);
221
+ if (spec === null)
222
+ return null;
223
+ const entries = [
224
+ ...(spec.args ?? []).map((a) => ({ label: a.name, gloss: a.gloss })),
225
+ ...(spec.flags ?? []).map((f) => ({
226
+ label: f.value === undefined ? f.name : `${f.name} ${f.value}`,
227
+ gloss: f.gloss,
228
+ })),
229
+ ];
230
+ const width = Math.max(0, ...entries.map((e) => e.label.length));
231
+ const rows = entries.map((e) => ` ${e.label.padEnd(width)} ${e.gloss}`);
232
+ return [
233
+ ...spec.synopsis.map((s) => `plumbbob ${s}`),
234
+ '',
235
+ spec.description,
236
+ ...(rows.length > 0 ? ['', ...rows] : []),
237
+ ...(spec.notes === undefined ? [] : ['', spec.notes]),
238
+ '',
239
+ `See: docs/cli-reference.md#${spec.name}`,
240
+ '',
241
+ ].join('\n');
242
+ }
243
+ /**
244
+ * Look up a verb's spec by name, or null when the name is not a verb.
245
+ */
246
+ export function verbSpec(name) {
247
+ return VERBS.find((v) => v.name === name) ?? null;
248
+ }
249
+ /**
250
+ * Every verb name, in table order — the contract tests iterate this.
251
+ */
252
+ export function verbNames() {
253
+ return VERBS.map((v) => v.name);
254
+ }
255
+ /**
256
+ * Walk a verb's argv against its spec: did it ask for help, and what did it
257
+ * spell wrong?
258
+ *
259
+ * Value-consuming flags swallow their next token, so a value that happens to
260
+ * look like a flag (`checkpoint -m "--help"`) is read as the value it is. Bare
261
+ * `--help`/`-h` are recognized for every verb without being declared.
262
+ */
263
+ function scanFlags(spec, args) {
264
+ const known = new Map((spec.flags ?? []).map((f) => [f.name, f]));
265
+ const unknown = [];
266
+ let help = false;
267
+ for (let i = 0; i < args.length; i++) {
268
+ const arg = args[i];
269
+ const flag = known.get(arg);
270
+ if (flag !== undefined) {
271
+ if (flag.value !== undefined)
272
+ i++;
273
+ continue;
274
+ }
275
+ if (arg === '--help' || arg === '-h') {
276
+ help = true;
277
+ }
278
+ else if (arg.startsWith('-') && arg.length > 1) {
279
+ unknown.push(arg);
280
+ }
281
+ }
282
+ return { help, unknown };
54
283
  }
55
284
  /**
56
285
  * The package version, or 'unknown'.
@@ -116,21 +345,49 @@ async function dispatch(verb, cwd, rest) {
116
345
  /**
117
346
  * The CLI entrypoint minus the exit: parse argv, dispatch, return the exit code.
118
347
  *
119
- * `help` and `version` short-circuit before dispatch; any thrown error becomes
120
- * a one-line stderr report and exit 1, so the bin entry only ever exits with
121
- * the code returned here.
348
+ * `help` and `version` short-circuit before dispatch, as do per-verb `--help`
349
+ * and the unknown-flag refusal. Screening flags *here* rather than in each verb
350
+ * is what makes it safe by construction: a mutating verb can no longer be
351
+ * reached by an argv it does not understand, which is how `checkpoint --help`
352
+ * used to commit a real checkpoint. Any thrown error becomes a one-line stderr
353
+ * report and exit 1, so the bin entry only ever exits with the code returned
354
+ * here.
122
355
  */
123
356
  export async function run(argv) {
124
357
  const verb = argv[0] ?? 'help';
125
358
  const rest = argv.slice(1);
126
359
  if (verb === 'help' || verb === '--help' || verb === '-h') {
127
- process.stdout.write(`${formatHelp()}\n`);
360
+ // `plumbbob help <verb>` is the same page as `plumbbob <verb> --help` —
361
+ // both spellings get typed, so both work.
362
+ const topic = rest.find((a) => !a.startsWith('-'));
363
+ if (topic === undefined) {
364
+ process.stdout.write(`${formatHelp()}\n`);
365
+ return 0;
366
+ }
367
+ const help = formatVerbHelp(topic);
368
+ if (help === null) {
369
+ process.stderr.write(`plumbbob: unknown verb '${topic}'. Run 'plumbbob help' for the verb table.\n`);
370
+ return 1;
371
+ }
372
+ process.stdout.write(`${help}\n`);
128
373
  return 0;
129
374
  }
130
375
  if (verb === 'version' || verb === '--version' || verb === '-v') {
131
376
  process.stdout.write(`plumbbob ${readVersion()}\n`);
132
377
  return 0;
133
378
  }
379
+ const spec = verbSpec(verb);
380
+ if (spec !== null) {
381
+ const { help, unknown } = scanFlags(spec, rest);
382
+ if (help) {
383
+ process.stdout.write(`${formatVerbHelp(verb)}\n`);
384
+ return 0;
385
+ }
386
+ if (unknown.length > 0 && spec.tolerateUnknownFlags !== true) {
387
+ process.stderr.write(`plumbbob: ${verb}: unknown flag '${unknown[0]}'. Run 'plumbbob ${verb} --help' for the flags.\n`);
388
+ return 1;
389
+ }
390
+ }
134
391
  try {
135
392
  return await dispatch(verb, process.cwd(), rest);
136
393
  }
@@ -215,8 +215,9 @@ function degrade(ambient, hard, soft) {
215
215
  * agent name and the value flags.
216
216
  *
217
217
  * A value flag missing its value, or `--step` given a non-number, is a loud
218
- * error (returned as a string) rather than a silent default. Unknown `--flags`
219
- * are ignored — the point is the named agent.
218
+ * error (returned as a string) rather than a silent default. An undeclared
219
+ * `--flag` never arrives here — `run` screens argv against the verb spec in
220
+ * cli-core.ts and refuses before dispatch.
220
221
  */
221
222
  function parseRunArgs(args) {
222
223
  const positionals = [];
@@ -32,8 +32,12 @@ export async function check(cwd, args = []) {
32
32
  /**
33
33
  * Map argv onto checkride's flags: bare booleans plus comma-separated slot lists.
34
34
  *
35
- * Unknown args are ignored rather than refused — running the gate is the point,
36
- * not flag hygiene.
35
+ * Only the declared flags are read here; an undeclared one never reaches this
36
+ * function, because `run` screens argv against the verb spec in cli-core.ts and
37
+ * refuses before dispatch. This used to tolerate unknown args on the grounds
38
+ * that running the gate mattered more than flag hygiene — but the same tolerance
39
+ * on the mutating verbs let `checkpoint --help` commit, so the screen is now
40
+ * central and uniform.
37
41
  */
38
42
  function parseFlags(args) {
39
43
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Attention-first build process: 12 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",