plumbbob 0.10.0 → 0.10.2

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.10.0",
4
+ "version": "0.10.2",
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/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  <a href="https://robmclarty.github.io/plumbbob/api.html">API reference</a>
11
11
  </p>
12
12
 
13
- PlumbBob is a Claude Code plugin (thirteen skills and a small CLI) that runs
13
+ PlumbBob is a Claude Code plugin (fourteen skills and a small CLI) that runs
14
14
  LLM-assisted coding as a loop you control. You and the model settle the plan in a file
15
15
  *before* any code; then the model builds one small step at a time and **stops after
16
16
  each one**, waiting for you to read the diff and approve it before anything is
@@ -145,7 +145,7 @@ You drive the whole loop from your IDE with `/plumbbob:*` skills: no step number
145
145
  remember, no raw CLI to type. Every skill is `disable-model-invocation`, so *you* fire
146
146
  every move, and `/plumbbob:status` always names your next one. (Claude Code namespaces
147
147
  them under the plugin, so the command is `/plumbbob:plan`. The bare `/plan` reaches it
148
- too, but only where nothing else owns that name, and four of the thirteen share one with
148
+ too, but only where nothing else owns that name, and four of the fourteen share one with
149
149
  a Claude Code built-in, so these docs write the full form.)
150
150
 
151
151
  **The happy path**: the three moves every session makes; many sessions need
@@ -172,6 +172,7 @@ nothing else:
172
172
  | `/plumbbob:status` | orient: where you are, the next step's done-when and seam, and the next move |
173
173
  | `/plumbbob:verify` | the tick, standalone: check → self-review → validate → **PAUSE** → checkpoint, for a diff `/plumbbob:build` didn't write |
174
174
  | `/plumbbob:revert` | rewind: `git reset --hard` to a recorded checkpoint |
175
+ | `/plumbbob:abandon` | drop the in-flight step, keep its work in the tree; the step stays planned |
175
176
  | `/plumbbob:recover` | reconcile the session's own state when the dashboard looks wrong |
176
177
  | `/plumbbob:doctor` | check the install from inside a session |
177
178
 
@@ -182,7 +183,7 @@ nothing else:
182
183
  | `/plumbbob:park` | capture a mid-build idea without chasing it |
183
184
  | `/plumbbob:harvest` | triage parked ideas between steps (blocker / tangent / pivot) |
184
185
 
185
- All thirteen, with inputs and effects, are in
186
+ All fourteen, with inputs and effects, are in
186
187
  [`docs/skills-reference.md`](docs/skills-reference.md).
187
188
 
188
189
  Under the skills ships a lean `plumbbob` CLI (the mechanical verbs the
@@ -240,7 +241,7 @@ modes, spec kits, native checkpoints), and that convergence is evidence *for* th
240
241
  premise. PlumbBob is deliberately thin against that future: the artifacts are
241
242
  plain markdown and ordinary git commits in your own repo, the CLI is lean
242
243
  (node builtins plus one deliberate dependency), and nothing locks you in. The
243
- investment is small (thirteen
244
+ investment is small (fourteen
244
245
  skills over a plain-markdown sidecar, learnable in an afternoon), and what you're
245
246
  actually learning is the method: decisions before code, one verified step at a
246
247
  time, capture instead of chase. That transfers to whatever tool wins. If
@@ -289,7 +290,7 @@ Each doc answers one question (in rough reading order for a new user):
289
290
  - *Show me the artifacts it leaves behind.* → [`examples/`](examples/): that same session's finished build folder, file by file.
290
291
  - *Should I / can I / what about…?* → [`docs/faq.md`](docs/faq.md): the adoption questions, answered straight.
291
292
  - *What is each method for?* → [`docs/techniques.md`](docs/techniques.md): steps, seams, the pause, park/harvest, spikes.
292
- - *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md): all thirteen skills: inputs, effects, when to reach for each.
293
+ - *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md): all fourteen skills: inputs, effects, when to reach for each.
293
294
  - *How do I plug in my own agent?* → [`docs/agents.md`](docs/agents.md): the subprocess envelope, the manifest, `harness.json`, and working examples (including a local-model reviewer via Ollama).
294
295
  - *How do I get a local model reviewing my steps?* → [`docs/local-model-review.md`](docs/local-model-review.md): the ollama-reviewer example walked end to end, install to every-pause review.
295
296
  - *How do I install it, exactly?* → [`docs/install.md`](docs/install.md): the full guide and the agent-neutral roadmap.
package/dist/cli-core.js CHANGED
@@ -19,6 +19,7 @@ import { handoff } from "./verbs/handoff.js";
19
19
  import { check } from "./verbs/check.js";
20
20
  import { checkpoint } from "./verbs/checkpoint.js";
21
21
  import { revert } from "./verbs/revert.js";
22
+ import { abandon } from "./verbs/abandon.js";
22
23
  import { spike } from "./verbs/spike.js";
23
24
  import { use } from "./verbs/use.js";
24
25
  import { finish } from "./verbs/finish.js";
@@ -57,8 +58,15 @@ const VERBS = [
57
58
  {
58
59
  name: 'status',
59
60
  description: 'print the orientation dashboard, or NO ACTIVE SESSION',
60
- synopsis: ['status [--build <slug>]'],
61
- flags: [BUILD_FLAG],
61
+ synopsis: ['status [--build <slug>] [--invoked "<args>"]'],
62
+ flags: [
63
+ BUILD_FLAG,
64
+ {
65
+ name: '--invoked',
66
+ value: '"<args>"',
67
+ gloss: 'the raw skill invocation; an explicit step number in it repoints the dashboard at that step',
68
+ },
69
+ ],
62
70
  notes: 'Read-only; always exits 0. With no cursor it lists the available builds.',
63
71
  },
64
72
  {
@@ -113,6 +121,13 @@ const VERBS = [
113
121
  ],
114
122
  notes: 'Refuses (exit 1) with no session, an invalid --to, or a step with no recorded checkpoint.',
115
123
  },
124
+ {
125
+ name: 'abandon',
126
+ description: 'drop the in-flight step but keep its work in the tree',
127
+ synopsis: ['abandon [--build <slug>]'],
128
+ flags: [BUILD_FLAG],
129
+ notes: 'Refuses (exit 1) with no session, no step in flight, or a latched boundary (no human turn since the step began). Clears STEP/SEAM/TICK/handoff and logs the abandon; never touches the working tree, git, or the intent checkbox.',
130
+ },
116
131
  {
117
132
  name: 'park',
118
133
  description: 'append a raw line to the park list',
@@ -331,6 +346,8 @@ async function dispatch(verb, cwd, rest) {
331
346
  return checkpoint(cwd, rest);
332
347
  case 'revert':
333
348
  return revert(cwd, rest);
349
+ case 'abandon':
350
+ return abandon(cwd, rest);
334
351
  case 'spike':
335
352
  return spike(cwd, rest);
336
353
  case 'use':
@@ -102,3 +102,13 @@ export function checkpointLogLine(date, step, sha, title, stats = null) {
102
102
  const titled = title !== null && title.length > 0 ? `${head} — ${title}` : head;
103
103
  return stats !== null && stats.length > 0 ? `${titled} (${stats})` : titled;
104
104
  }
105
+ /**
106
+ * The Log line `abandon` writes for a dropped-but-kept step: the first
107
+ * non-checkpoint event the log records. Dated, names the step (its title when
108
+ * intent.md still carries it), and states plainly that the work stayed in the
109
+ * tree, so the narrative never has a step go quiet mid-flight.
110
+ */
111
+ export function abandonLogLine(date, step, title) {
112
+ const head = `- ${date} — step ${step} abandoned · work kept in tree`;
113
+ return title !== null && title.length > 0 ? `${head} — ${title}` : head;
114
+ }
@@ -159,21 +159,70 @@ export function lastLedgerSha(checkpoints) {
159
159
  }
160
160
  return last;
161
161
  }
162
+ /**
163
+ * The step number an explicit skill invocation asks for, out of the raw
164
+ * argument text `status --invoked` receives.
165
+ *
166
+ * The first whitespace-separated token shaped like `22` or `22-24` names the
167
+ * step (a range asks to start at its first number); flags such as `--auto` and
168
+ * anything else parse to null, meaning "no explicit ask", which leaves the
169
+ * dashboard's own suggestion in charge. Null in means null out, so a host that
170
+ * never substitutes the invocation degrades to today's rendering.
171
+ */
172
+ export function parseRequestedStep(raw) {
173
+ if (raw === null) {
174
+ return null;
175
+ }
176
+ for (const token of raw.trim().split(/\s+/)) {
177
+ const m = /^(\d+)(?:-\d+)?$/.exec(token);
178
+ if (m) {
179
+ const n = Number(m[1]);
180
+ return n >= 1 ? n : null;
181
+ }
182
+ }
183
+ return null;
184
+ }
162
185
  /**
163
186
  * The single primary next move the dashboard suggests.
164
187
  *
165
188
  * It suggests; the dashboard prints the full list + counts so the human can
166
- * always override. The phase is derived: a spike in progress and an in-flight
167
- * step each have one obvious next move; otherwise you are at the boundary and
168
- * the move follows from the steps.
189
+ * always override. An explicitly requested step outranks the derived
190
+ * suggestion: the request IS the human's override, so the move names that step
191
+ * (noting anything it skips or collides with) rather than arguing for the
192
+ * default. Otherwise the phase decides: a spike in progress and an in-flight
193
+ * step each have one obvious next move; else you are at the boundary and the
194
+ * move follows from the steps.
169
195
  */
170
- function nextMove(spiking, steps, inFlight, parked) {
196
+ function nextMove(spiking, steps, inFlight, parked, requested) {
197
+ const target = requested === null ? undefined : steps.find((s) => s.n === requested);
198
+ if (requested !== null && target === undefined) {
199
+ return `step ${requested} is not in the plan (${steps.length} step${steps.length === 1 ? '' : 's'} planned) — report the mismatch rather than guess`;
200
+ }
171
201
  if (spiking) {
172
- return 'close the spike — `plumbbob spike done`';
202
+ const tail = target === undefined ? '' : `; then build step ${target.n} (explicitly requested)`;
203
+ return `close the spike — \`plumbbob spike done\`${tail}`;
173
204
  }
174
205
  if (inFlight !== null) {
206
+ if (target !== undefined && target.n !== inFlight) {
207
+ return `build step ${target.n} — explicitly requested (step ${inFlight} is still in flight: \`/plumbbob:verify\` it or \`/plumbbob:abandon\` it first)`;
208
+ }
175
209
  return `finish step ${inFlight} — \`/plumbbob:verify\` (or keep editing, then \`/plumbbob:verify\`)`;
176
210
  }
211
+ if (target !== undefined) {
212
+ if (target.done) {
213
+ return `build step ${target.n} — explicitly requested (already checkpointed)`;
214
+ }
215
+ const skipped = steps.filter((s) => !s.done && s.n < target.n).length;
216
+ const notes = [];
217
+ if (skipped > 0) {
218
+ notes.push(`skips ${skipped} undone step${skipped === 1 ? '' : 's'}`);
219
+ }
220
+ if (!target.planned) {
221
+ notes.push('still unplanned: `/plumbbob:step` it first');
222
+ }
223
+ const suffix = notes.length > 0 ? ` (${notes.join('; ')})` : '';
224
+ return `build step ${target.n} — explicitly requested${suffix}`;
225
+ }
177
226
  // At the boundary (DESIGN): the move follows from the steps.
178
227
  const nextUndone = steps.find((s) => !s.done);
179
228
  if (nextUndone === undefined) {
@@ -195,8 +244,13 @@ function nextMove(spiking, steps, inFlight, parked) {
195
244
  export function orient(input) {
196
245
  const steps = parseSteps(input.intent);
197
246
  const parked = parseParked(input.buildLog);
198
- const nextUndone = steps.find((s) => !s.done);
199
- const seamParse = nextUndone === undefined ? null : parseStepSeam(input.intent, nextUndone.n);
247
+ // The target the dashboard details: the explicitly requested step when it
248
+ // exists in the plan, else the next undone. A requested number that names no
249
+ // planned step carries nothing here; the next-move line reports the mismatch
250
+ // while the rest of the dashboard renders as usual.
251
+ const requestedStep = input.requested === null ? undefined : steps.find((s) => s.n === input.requested);
252
+ const target = requestedStep ?? steps.find((s) => !s.done);
253
+ const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
200
254
  const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
201
255
  return {
202
256
  title: parseTitle(input.intent),
@@ -205,10 +259,11 @@ export function orient(input) {
205
259
  lastCheckpoint: parseLastCheckpoint(input.checkpoints),
206
260
  parked,
207
261
  openQuestions: parseOpenQuestions(input.intent),
208
- next: nextMove(input.spiking, steps, input.inFlight, parked),
209
- nextDoneWhen: nextUndone?.doneWhen ?? null,
262
+ next: nextMove(input.spiking, steps, input.inFlight, parked, input.requested),
263
+ requested: requestedStep?.n ?? null,
264
+ nextDoneWhen: target?.doneWhen ?? null,
210
265
  nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
211
- nextModel: nextUndone?.model ?? null,
266
+ nextModel: target?.model ?? null,
212
267
  outOfBand: input.outOfBand,
213
268
  };
214
269
  }
@@ -218,14 +273,19 @@ export function orient(input) {
218
273
  export function formatOrientation(o) {
219
274
  const doneCount = o.steps.filter((s) => s.done).length;
220
275
  const nextUndone = o.steps.find((s) => !s.done);
276
+ // One arrow, always: an explicitly requested step takes the marker and the
277
+ // detail rows, so the injected state never argues with the invocation. With
278
+ // no (valid) request the next undone step keeps them, as ever.
279
+ const requestedStep = o.requested === null ? undefined : o.steps.find((s) => s.n === o.requested);
280
+ const marked = requestedStep ?? nextUndone;
221
281
  const stepLines = o.steps.map((s) => {
222
- const marker = s.done ? '✓' : s === nextUndone ? '▸' : ' ';
223
- const tail = s === nextUndone ? ' ← next' : '';
282
+ const marker = s.done ? '✓' : s === marked ? '▸' : ' ';
283
+ const tail = s === marked ? (requestedStep === undefined ? ' ← next' : ' ← requested') : '';
224
284
  const head = ` ${marker} ${s.n} ${s.title}${tail}`;
225
- if (s !== nextUndone) {
285
+ if (s !== marked) {
226
286
  return head;
227
287
  }
228
- // Surface the next step's detail so the human can review it (and `/plumbbob:step`-
288
+ // Surface the target step's detail so the human can review it (and `/plumbbob:step`-
229
289
  // revise) before building. Only what's present: a rough step shows neither.
230
290
  const detail = [];
231
291
  if (o.nextDoneWhen !== null) {
@@ -191,12 +191,28 @@ export function spikePath(root, slug) {
191
191
  export function seamPath(root, slug) {
192
192
  return join(artifactDir(root, slug), 'SEAM');
193
193
  }
194
+ /**
195
+ * Remove the SEAM marker: `abandon` drops the in-flight step's edit grant, the
196
+ * same clear `checkpoint` and `revert` do inline. Homed here so a verb not
197
+ * sanctioned to delete on its own can still return to the boundary; absent is a
198
+ * no-op.
199
+ */
200
+ export function clearSeam(root, slug) {
201
+ rmSync(seamPath(root, slug), { force: true });
202
+ }
194
203
  /**
195
204
  * STEP: the number of the step in flight, a bare number in a flat file.
196
205
  */
197
206
  export function stepPath(root, slug) {
198
207
  return join(artifactDir(root, slug), 'STEP');
199
208
  }
209
+ /**
210
+ * Remove the STEP marker alongside SEAM, dropping the build back to the DESIGN
211
+ * boundary; absent is a no-op.
212
+ */
213
+ export function clearStep(root, slug) {
214
+ rmSync(stepPath(root, slug), { force: true });
215
+ }
200
216
  /**
201
217
  * The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
202
218
  * commits, one line each.
@@ -0,0 +1,122 @@
1
+ // `plumbbob abandon`: the third exit from an in-flight step. Checkpoint lands
2
+ // the step and revert destroys the work; abandon drops the attempt but keeps the
3
+ // working-tree diff exactly where it is. Its only writes are the in-flight
4
+ // control markers it clears (STEP, SEAM, TICK, and the step-scoped handoff
5
+ // ledger), a build-log line, and the stats receipt: it touches neither the
6
+ // working tree nor git nor the intent checkbox, so the step stays planned and
7
+ // re-buildable with its diff still in the tree for the human to keep, rework, or
8
+ // commit by hand.
9
+ //
10
+ // A step exit is a boundary crossing, so abandon honors the same approval latch
11
+ // as checkpoint. That is not decoration: abandon clears the entry stamp the
12
+ // latch reads, so an unlatched abandon followed by a same-turn checkpoint would
13
+ // hand that checkpoint an unlatched land, the same side door the settings-auto
14
+ // case closed. Every deletion routes through the sidecar helpers that own
15
+ // control-state clears; abandon itself never calls the raw fs deleters.
16
+ import { readFileSync, writeFileSync } from 'node:fs';
17
+ import { findRepoRoot } from "../lib/git.js";
18
+ import { bumpStepStat, buildLogPath, clearHandoff, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
19
+ import { checkLatch } from "../lib/latch.js";
20
+ import { abandonLogLine, appendToSection } from "../lib/buildlog.js";
21
+ import { parseSteps } from "../lib/orient.js";
22
+ /**
23
+ * Drop the in-flight step while keeping its work: latch, clear the markers,
24
+ * record the event, and return to the boundary.
25
+ *
26
+ * Exit 1 on any refusal: no session, no step in flight, or a latched boundary
27
+ * (no human turn since the step began). Each refusal says what unblocks it.
28
+ */
29
+ export function abandon(cwd, args) {
30
+ const root = findRepoRoot(cwd);
31
+ if (root === null || !hasSession(root)) {
32
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
33
+ return 1;
34
+ }
35
+ const { build: slug } = resolveBuild(root, args);
36
+ const step = readInFlightStep(root, slug);
37
+ if (step === null) {
38
+ process.stderr.write('plumbbob: no step in flight to abandon. abandon drops an in-flight step; from the boundary there is nothing to drop.\n');
39
+ return 1;
40
+ }
41
+ // The same latch checkpoint runs, keyed on this step: a step exit is a boundary
42
+ // crossing, and abandon clears the entry stamp, so an unlatched abandon would
43
+ // hand a same-turn checkpoint an unlatched land. Refusal is the pause, not an
44
+ // error: exit 1 and hand the turn back.
45
+ const latch = checkLatch(root, step);
46
+ if (!latch.allow) {
47
+ process.stderr.write(abandonPause(latch.reason, step));
48
+ return 1;
49
+ }
50
+ // Keep the work: clear only the in-flight control markers, never the tree,
51
+ // git, or the intent checkbox. The step stays [ ] and re-buildable.
52
+ clearStep(root, slug);
53
+ clearSeam(root, slug);
54
+ clearTick(root, slug);
55
+ clearHandoff(root, slug);
56
+ bumpStepStat(root, slug, step, 'abandons');
57
+ logAbandon(root, slug, step);
58
+ process.stdout.write(`plumbbob: step ${step} abandoned — work kept in the tree, back at the boundary. The step stays planned.\n`);
59
+ return 0;
60
+ }
61
+ /**
62
+ * The abandon-worded pause affordance.
63
+ *
64
+ * abandon runs checkpoint's latch, but the pause it hits is its own: the wording
65
+ * names the verb the human ran and the one self-approval that lands it, rather
66
+ * than sending them back to `checkpoint`. A `ceiling` reason (a range grant the
67
+ * step overran) keeps the range wording.
68
+ */
69
+ function abandonPause(reason, step) {
70
+ if (reason === 'ceiling') {
71
+ return `plumbbob: abandon refused — step ${step} is past the range you granted; pause here, then run it again to continue.\n`;
72
+ }
73
+ return `plumbbob: abandon refused — no human turn since this step began. A step exit crosses the same
74
+ boundary as a checkpoint, so abandon honors the same pause: end the turn, and the human's approval on
75
+ their next turn is what lets it land. (An explicit \`/plumbbob:build --auto\` or a step range in the
76
+ human's own prompt is the only self-approval.)
77
+ `;
78
+ }
79
+ /**
80
+ * The in-flight STEP number from the marker, or null when none is in flight.
81
+ */
82
+ function readInFlightStep(root, slug) {
83
+ try {
84
+ const raw = readFileSync(stepPath(root, slug), 'utf8').trim();
85
+ return /^\d+$/.test(raw) ? Number(raw) : null;
86
+ }
87
+ catch {
88
+ return null;
89
+ }
90
+ }
91
+ /**
92
+ * Append the abandon line to the build-log's `## Log`, the first non-checkpoint
93
+ * event the log records.
94
+ *
95
+ * Best-effort like every build-log write: a missing or odd log never fails an
96
+ * abandon, whose real record is the cleared markers and the stats bump.
97
+ */
98
+ function logAbandon(root, slug, step) {
99
+ try {
100
+ const path = buildLogPath(root, slug);
101
+ const date = new Date().toISOString().slice(0, 10);
102
+ const line = abandonLogLine(date, step, titleForStep(root, slug, step));
103
+ const updated = appendToSection(readFileSync(path, 'utf8'), 'Log', line);
104
+ if (updated !== null) {
105
+ writeFileSync(path, updated);
106
+ }
107
+ }
108
+ catch {
109
+ // best-effort ledger; never fail an abandon over the build-log.
110
+ }
111
+ }
112
+ /**
113
+ * The step's title from intent.md, or null when absent or unreadable.
114
+ */
115
+ function titleForStep(root, slug, step) {
116
+ try {
117
+ return parseSteps(readFileSync(intentPath(root, slug), 'utf8')).find((s) => s.n === step)?.title ?? null;
118
+ }
119
+ catch {
120
+ return null;
121
+ }
122
+ }
@@ -40,13 +40,14 @@ export function build(cwd, args) {
40
40
  return 1;
41
41
  }
42
42
  const intent = readFileSync(intentPath(root, slug), 'utf8');
43
+ const steps = parseSteps(intent);
43
44
  // No argument ⇒ enter the next undone step in intent.md (the same idiom
44
45
  // `checkpoint` uses), so a bare `plumbbob build` advances the loop without the
45
46
  // skill re-deriving the step in prose. Every step checkpointed ⇒ a `/plumbbob:step`
46
47
  // nudge, not a silent no-op.
47
48
  let step;
48
49
  if (raw === undefined) {
49
- const nextUndone = parseSteps(intent).find((s) => !s.done);
50
+ const nextUndone = steps.find((s) => !s.done);
50
51
  if (nextUndone === undefined) {
51
52
  process.stderr.write('plumbbob: no undone step to build — every planned step is checkpointed. `/plumbbob:step` to add an increment, or `/plumbbob:finish`.\n');
52
53
  return 1;
@@ -73,9 +74,17 @@ export function build(cwd, args) {
73
74
  // The build-log's top half is CLI-owned so it never lies: show this step in
74
75
  // flight: Current step plus the ☐/☑ mirror, re-rendered from intent.md.
75
76
  // Best-effort: a missing or hand-edited build-log never blocks the build.
76
- const title = parseSteps(intent).find((s) => s.n === step)?.title ?? null;
77
+ const title = steps.find((s) => s.n === step)?.title ?? null;
77
78
  syncBuildLogState(root, slug, stepLabel(step, title));
78
- const picked = raw === undefined ? ' (next undone)' : '';
79
+ // An explicit jump past undone work says so out loud: the entered step and
80
+ // the count it skips read back to the human (and to the transcript), so a
81
+ // deliberate `build 22` and a confused one look different on the page.
82
+ const skipped = raw === undefined ? 0 : steps.filter((s) => !s.done && s.n < step).length;
83
+ const picked = raw === undefined
84
+ ? ' (next undone)'
85
+ : skipped > 0
86
+ ? ` (explicitly requested; skips ${skipped} undone step${skipped === 1 ? '' : 's'})`
87
+ : '';
79
88
  process.stdout.write(`plumbbob: building step ${step}${picked}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
80
89
  return 0;
81
90
  }
@@ -252,8 +252,11 @@ function narrowExcludes(root) {
252
252
  /**
253
253
  * Perform the migration and return a human-readable list of what moved.
254
254
  *
255
- * STAGES the result but never commits: the human owns that commit. Call only when
256
- * `inspectLegacy` reported a legacy layout; doctor prints the returned actions.
255
+ * STAGES the result but never commits: the human owns that commit. In a repo
256
+ * that gitignores the sidecar the stage is skipped (stagePath's record-only
257
+ * guard) and the report says so: the files move but stay untracked. Call only
258
+ * when `inspectLegacy` reported a legacy layout; doctor prints the returned
259
+ * actions.
257
260
  */
258
261
  export function migrateSidecar(root) {
259
262
  const dir = sidecarDir(root);
@@ -297,8 +300,10 @@ export function migrateSidecar(root) {
297
300
  }
298
301
  rmSync(archiveDir, { recursive: true, force: true });
299
302
  narrowExcludes(root);
300
- stagePath(root, '.plumbbob');
301
- actions.push('staged the move (builds/ + settings.json) — commit it yourself');
303
+ const staged = stagePath(root, '.plumbbob');
304
+ actions.push(staged
305
+ ? 'staged the move (builds/ + settings.json) — commit it yourself'
306
+ : 'moved but not staged (the sidecar is gitignored) — the files stay untracked');
302
307
  return actions;
303
308
  }
304
309
  /**
@@ -159,7 +159,7 @@ function phaseFindings(root, slug, stepInFlight) {
159
159
  name: 'step marker',
160
160
  ok: false,
161
161
  detail: `step ${marker} is in flight but the plan has no step ${marker} (planned: ${planned.join(', ')})`,
162
- hint: 'the plan was rewritten under the step — sharpen it with /plumbbob:step, or plumbbob revert to drop the step',
162
+ hint: 'the plan was rewritten under the step — sharpen it with /plumbbob:step, plumbbob revert to drop the step and its work, or plumbbob abandon to drop the step but keep the work',
163
163
  });
164
164
  }
165
165
  }
@@ -104,9 +104,9 @@ function readInFlightStep(root, buildSlug) {
104
104
  * Open a spike: one throwaway worktree + `spike/<slug>-<opt>` branch per option.
105
105
  *
106
106
  * Refuses when a spike is already open or a step is in flight: a spike is a
107
- * deliberate fork from a settled boundary, so the current step must checkpoint
108
- * or revert first. Marks the SPIKE control file and scaffolds the report before
109
- * returning.
107
+ * deliberate fork from a settled boundary, so the current step must exit first
108
+ * (checkpoint, abandon, or revert). Marks the SPIKE control file and scaffolds
109
+ * the report before returning.
110
110
  */
111
111
  function spikeStart(root, buildSlug, positionals) {
112
112
  if (inSpike(root, buildSlug)) {
@@ -115,7 +115,7 @@ function spikeStart(root, buildSlug, positionals) {
115
115
  }
116
116
  if (existsSync(stepPath(root, buildSlug))) {
117
117
  process.stderr.write('plumbbob: spike starts from a settled boundary, but a step is in flight. ' +
118
- 'A spike is a deliberate fork — checkpoint or revert the current step first.\n');
118
+ 'A spike is a deliberate fork — checkpoint, abandon, or revert the current step first.\n');
119
119
  return 1;
120
120
  }
121
121
  const slug = sanitize(positionals[0] ?? '');
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { commitsSince, findRepoRoot } from "../lib/git.js";
10
10
  import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
11
11
  import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
12
- import { formatOrientation, lastLedgerSha, orient } from "../lib/orient.js";
12
+ import { formatOrientation, lastLedgerSha, orient, parseRequestedStep } from "../lib/orient.js";
13
13
  /**
14
14
  * Read a file as utf8, or '' when it is missing or unreadable.
15
15
  */
@@ -21,12 +21,31 @@ function readOr(path) {
21
21
  return '';
22
22
  }
23
23
  }
24
+ /**
25
+ * Split `--invoked "<raw args>"` out of the argument list, ahead of any other
26
+ * flag parsing.
27
+ *
28
+ * The value is the verbatim text of a skill invocation (the `$ARGUMENTS` the
29
+ * `/plumbbob:build` skill passes through), so it can contain anything,
30
+ * including tokens that look like this CLI's own flags; extracting it first
31
+ * keeps such a value from confusing `resolveBuild`. A missing flag, or one
32
+ * with no value, reads as null: no explicit ask.
33
+ */
34
+ function takeInvoked(args) {
35
+ const i = args.indexOf('--invoked');
36
+ if (i === -1)
37
+ return { invoked: null, rest: args };
38
+ return { invoked: args[i + 1] ?? null, rest: [...args.slice(0, i), ...args.slice(i + 2)] };
39
+ }
24
40
  /**
25
41
  * Print the orientation dashboard for the active build, plus its harness bindings.
26
42
  *
27
43
  * No session prints the exact `NO ACTIVE SESSION` sentinel; a session with
28
44
  * builds but no active one lists them and points at `use`. Always exits 0:
29
- * status is a report, never a gate.
45
+ * status is a report, never a gate. With `--invoked "<raw args>"` (how the
46
+ * `/plumbbob:build` skill passes its own invocation through), an explicit step
47
+ * number in the raw text repoints the dashboard's marker, detail, and next
48
+ * move at that step, so the injected state never contradicts the human's ask.
30
49
  */
31
50
  export function status(cwd, args = []) {
32
51
  const root = findRepoRoot(cwd);
@@ -34,10 +53,11 @@ export function status(cwd, args = []) {
34
53
  process.stdout.write('NO ACTIVE SESSION\n');
35
54
  return 0;
36
55
  }
56
+ const { invoked, rest } = takeInvoked(args);
37
57
  // A session with builds but no resolvable cursor (finish cleared it, or the repo
38
58
  // holds several builds and none is active) has no single dashboard to show, so
39
59
  // list the builds and point at `use` instead of rendering a broken, empty one.
40
- const { build: slug } = resolveBuild(root, args);
60
+ const { build: slug } = resolveBuild(root, rest);
41
61
  if (slug === null) {
42
62
  const builds = listBuilds(root);
43
63
  if (builds.length > 0) {
@@ -59,6 +79,7 @@ export function status(cwd, args = []) {
59
79
  checkpoints,
60
80
  inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
61
81
  spiking: inSpike(root, slug),
82
+ requested: parseRequestedStep(invoked),
62
83
  outOfBand: anchor === null ? 0 : commitsSince(root, anchor),
63
84
  });
64
85
  const lines = [formatOrientation(orientation), ...harnessSection(root, slug)];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.10.0",
4
- "description": "Attention-first build process: 12 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
3
+ "version": "0.10.2",
4
+ "description": "Attention-first build process: 14 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
7
7
  "claude",
@@ -42,7 +42,8 @@
42
42
  "test": "vitest run",
43
43
  "eval:baseline": "pnpm build && node test/evals/run.ts --sweep baseline",
44
44
  "eval:latched": "pnpm build && node test/evals/run.ts --sweep latched",
45
- "eval:report": "node test/evals/run.ts --report"
45
+ "eval:report": "node test/evals/run.ts --report",
46
+ "size:prose": "node scripts/count-prose.ts"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@ast-grep/cli": "0.45.1",
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: abandon
3
+ description: "Human-triggered driver for `plumbbob abandon`: drop the in-flight step while keeping its work in the tree, and return to the boundary. The step stays planned."
4
+ disable-model-invocation: true
5
+ model: haiku
6
+ allowed-tools: Bash(plumbbob status:*), Bash(plumbbob abandon:*)
7
+ ---
8
+
9
+ # PlumbBob: abandon the in-flight step (driver)
10
+
11
+ Current session state (injected when this skill runs): !`plumbbob status 2>/dev/null || echo "plumbbob CLI not on PATH in this session. Marketplace install: confirm the plugin is enabled in /plugin, then /reload-plugins. Skills-dir/global install: npm i -g plumbbob && plumbbob init."`
12
+
13
+ This is a **driver skill**: a chat-side trigger for the mechanical `plumbbob abandon` verb, so the whole workflow runs from the agent window instead of a terminal. It is `disable-model-invocation: true`: only the human fires it. It carries **no Edit and no Write tool**; its only action is to shell the verb and report the verb's output verbatim, including any refusal. The CLI is the source of truth: never retry a refused transition, and never edit a file to work around one.
14
+
15
+ Abandon is the third exit from an in-flight step. `/plumbbob:verify` lands the step and `/plumbbob:revert` destroys its work; abandon drops the attempt and keeps the working-tree diff exactly where it is. The step keeps its `[ ]` in the plan, re-buildable later, and the diff stays yours to keep, rework, or commit by hand.
16
+
17
+ ## What it does
18
+
19
+ 1. Run `plumbbob abandon` via Bash. It clears the in-flight markers (`STEP`, `SEAM`, `TICK`, and the step's agent handoff ledger), appends an abandon line to the build log, and touches neither the working tree nor git.
20
+ 2. Report the verb's output verbatim: the step it dropped, or any refusal. A step exit crosses the same boundary as a checkpoint, so abandon honors the same approval latch; a "no human turn since this step began" refusal is the pause working, not an error. End the turn, and the human's next turn is what lets a re-fired abandon land.
@@ -8,7 +8,14 @@ allowed-tools: Read, Edit, Write, Bash(plumbbob status:*), Bash(plumbbob build:*
8
8
 
9
9
  # PlumbBob: build a step (the default engine)
10
10
 
11
- Current session state (injected when this skill runs): !`plumbbob status 2>/dev/null || echo "plumbbob CLI not on PATH in this session. Marketplace install: confirm the plugin is enabled in /plugin, then /reload-plugins. Skills-dir/global install: npm i -g plumbbob && plumbbob init."`
11
+ Current session state (injected when this skill runs): !`plumbbob status --invoked "$ARGUMENTS" 2>/dev/null || echo "plumbbob CLI not on PATH in this session. Marketplace install: confirm the plugin is enabled in /plugin, then /reload-plugins. Skills-dir/global install: npm i -g plumbbob && plumbbob init."`
12
+
13
+ **The invocation outranks the injected state.** The dashboard above is computed from
14
+ the plan; the arguments you were invoked with are the human's explicit ask. When they
15
+ name a step, build that step, whatever any `next →` line suggests. The CLI already
16
+ repoints the dashboard at an explicit step number when it can see one (`--invoked`),
17
+ so a surviving mismatch means the invocation text never reached it, and the typed
18
+ number still wins.
12
19
 
13
20
  This is the **bundled executor**: the default engine, not the only one. It is
14
21
  **swappable**: you can implement any step by hand, in a vibe session, or with
@@ -25,4 +25,4 @@ Reach for it when the dashboard and reality disagree: `status` shows a build you
25
25
 
26
26
  - **Spike leftovers are reported, never removed.** Those worktrees sit outside the repo and may hold the only copy of what a spike learned, so the verb prints the exact `git worktree remove` / `git branch -D` commands and stops. Relay them; do not run them yourself.
27
27
  - **A contradictory phase is yours to settle.** A spike and a step both marked in flight, or a step number the plan no longer contains, need a decision about which one is real; `--fix` deliberately leaves them alone.
28
- - **It is not a rewind.** Recovering reconciles bookkeeping; it never restores lost work. Discarding a half-done step is `/plumbbob:revert`, and that is a different, destructive move the human asks for by name.
28
+ - **It is not a rewind, and not an exit.** Recovering reconciles bookkeeping; it never restores lost work and never drops a step. Discarding a half-done step is `/plumbbob:revert` (destructive), and dropping one while keeping its work in the tree is `/plumbbob:abandon`; both are different moves the human asks for by name.
@@ -16,5 +16,5 @@ This is a **driver skill**: a chat-side trigger for the mechanical `plumbbob rev
16
16
  ## What it does
17
17
 
18
18
  1. Read an optional target step from the way you were invoked (for example `/plumbbob:revert --to 2` → step `2`). With no target, revert goes to the last done-checkpoint.
19
- 2. Run `plumbbob revert` (or `plumbbob revert --to <n>`) via Bash. This is a `git reset --hard`; it discards the current in-progress step. Run it exactly as the human asked; do not add or drop the `--to` on your own.
19
+ 2. Run `plumbbob revert` (or `plumbbob revert --to <n>`) via Bash. This is a `git reset --hard`; it discards the current in-progress step *and its work*. If the human wants to drop the step but keep the working-tree diff, that is `/plumbbob:abandon`, not revert. Run it exactly as the human asked; do not add or drop the `--to` on your own.
20
20
  3. Report the verb's output verbatim: which checkpoint it reset to, or any refusal.