plumbbob 0.13.1 → 0.13.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.
@@ -0,0 +1,224 @@
1
+ // `plumbbob harvest`: record a harvest from the detail file. The skill decides
2
+ // and the CLI records (D104 (harvest-records)): the model writes one proposal
3
+ // per open park under the detail file's `## Harvest` section, the human
4
+ // settles them in one reply, and this verb flips each settled park in
5
+ // `build-log.md`, appends the dated Harvest lines, and, when the detail is a
6
+ // range's, writes the range's review beneath a dated Log line, the way
7
+ // `checkpoint` keeps a step's pause beneath its own. It commits nothing: the
8
+ // record rides the next step's checkpoint, or the finish.
9
+ //
10
+ // A stop's record is latched (D103 (stop-latched)). It refuses in the turn
11
+ // the stop card printed, since a run's long turn ends on proposals and that is
12
+ // where a model would carry straight on into recording its own; it refuses a
13
+ // range's detail no stop card showed, read off the digest the card's STOP
14
+ // stamp keeps; and it refuses a range that no longer reads as landed, telling
15
+ // a step landed and reopened from one that joined the range after it landed,
16
+ // which moves to a range still to come. A harvest outside a stop (`# Detail · Harvest`) needs no stamp. A proposal
17
+ // naming a handle the list lacks (`P9` with eight parked), or a class outside
18
+ // the three, refuses the whole record, so the card and the ledger never
19
+ // disagree about which parks are open; a park no proposal names stays open,
20
+ // and the record says nothing about it.
21
+ import { readFileSync, writeFileSync } from 'node:fs';
22
+ import { findRepoRoot } from "../lib/git.js";
23
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
24
+ import { appendHarvest, appendToSection, flipParks, harvestLines, logEntry, stopLogLine } from "../lib/buildlog.js";
25
+ import { checkStopLatch } from "../lib/latch.js";
26
+ import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOrderedSteps, stepShas } from "../lib/orient.js";
27
+ import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop, detailDigest, detailPath, hasSession, intentPath, readStop, } from "../lib/sidecar.js";
28
+ import { hasStep } from "../lib/steplist.js";
29
+ import { boundaryEnding, harvestProposals, parkList, parseDetailRange, stopRecord } from "./handoff.js";
30
+ // The three classes a park settles as, in the order the ending counts them.
31
+ const CLASSES = ['blocker', 'tangent', 'pivot'];
32
+ // Where a refusal about the proposals sends the model: the file it wrote them in.
33
+ const FIX_PROPOSALS = 'fix the Harvest proposals in .plumbbob/detail.md';
34
+ /**
35
+ * Record the harvest the detail file proposes, and the range's review when the
36
+ * detail is a range's.
37
+ *
38
+ * Exit 1 on any refusal: no session, a detail file that is no range's or
39
+ * harvest's, the latch, a range no stop card showed or no longer landed, and a
40
+ * proposal it cannot settle. Each refusal names the move that unblocks it.
41
+ */
42
+ export function harvest(cwd, _args) {
43
+ const root = findRepoRoot(cwd);
44
+ if (root === null || !hasSession(root)) {
45
+ process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
46
+ return 1;
47
+ }
48
+ const slug = activeBuild(root);
49
+ const detail = readOr(detailPath(root));
50
+ const item = parseDetailRange(detail);
51
+ if (item === null && !isHarvestDetail(detail)) {
52
+ process.stderr.write(nothingToRecord(detail));
53
+ return 1;
54
+ }
55
+ const intent = readOr(intentPath(root, slug));
56
+ const buildLog = readOr(buildLogPath(root, slug));
57
+ const parks = parkList(buildLog);
58
+ let stop = null;
59
+ if (item !== null) {
60
+ const found = stopRange(root, slug, intent, detail, item);
61
+ if (typeof found === 'string') {
62
+ process.stderr.write(found);
63
+ return 1;
64
+ }
65
+ stop = found;
66
+ }
67
+ const subject = stop === null ? 'harvest not recorded' : `range ${stop.item} of ${stop.count}'s stop not recorded`;
68
+ const settled = settle(parks, harvestProposals(detail));
69
+ if (typeof settled === 'string') {
70
+ process.stderr.write(notice({ fact: subject, detail: [settled], remedy: FIX_PROPOSALS }));
71
+ return 1;
72
+ }
73
+ if (stop === null && settled.length === 0) {
74
+ process.stderr.write(notice({ fact: 'harvest not recorded', detail: ['the detail file settles no open park'], remedy: FIX_PROPOSALS }));
75
+ return 1;
76
+ }
77
+ // The review is read before the flip, so its Harvested part shows each lean
78
+ // the human confirmed beside the park it settled.
79
+ const record = stop === null ? null : stopRecord(root, slug, detail, buildLog);
80
+ const date = new Date().toISOString().slice(0, 10);
81
+ const where = stop === null ? boundaryWords(root, slug) : `at range ${stop.item} of ${stop.count}'s stop`;
82
+ let updated = flipParks(buildLog, settled.map((p) => p.handle));
83
+ updated = updated === null ? null : appendHarvest(updated, harvestLines(date, where, settled));
84
+ if (stop !== null) {
85
+ updated = updated === null ? null : appendToSection(updated, 'Log', logEntry(stopLogLine(date, stop.item, stop.count, stop.stop), record));
86
+ }
87
+ if (updated === null) {
88
+ process.stderr.write(notice({ fact: `${subject}`, detail: ['build-log.md is missing a section it writes to'], remedy: 'restore its Park list, Harvest, and Log sections' }));
89
+ return 1;
90
+ }
91
+ writeFileSync(buildLogPath(root, slug), updated);
92
+ clearDetail(root);
93
+ clearStop(root, slug);
94
+ // Outside a stop the label names the act, `Recorded`, since `Harvested` is
95
+ // the stop card's part and one label over a part that proposes and an ending
96
+ // that records would mean two things (D106 (leans-harvested)).
97
+ const counted = classWords(settled);
98
+ const lead = stop === null
99
+ ? transition({ label: 'Recorded', fact: `${settled.length} parked idea${settled.length === 1 ? '' : 's'}`, detail: [counted] })
100
+ : transition({ label: 'Stop', fact: `Range ${stop.item} of ${stop.count} recorded`, detail: [counted] });
101
+ printEnding(root, {
102
+ tier: 'orientation',
103
+ render: 'harvest',
104
+ text: ending({ lead, pointer: boundaryEnding(root, slug, null).pointer }),
105
+ });
106
+ return 0;
107
+ }
108
+ /**
109
+ * The range a range's detail reviews, once the record may land: the plan
110
+ * holds the range, a stop card showed this detail, a human turn has read it,
111
+ * and every step of the range has landed. Otherwise the refusal, with the move
112
+ * that unblocks it.
113
+ *
114
+ * An undone step in the range reads two ways, told apart by the ledger. One
115
+ * with a `step N <sha>` line landed and was reopened, so it lands again and
116
+ * the card re-renders. One with none joined the range after the card, a
117
+ * blocker folded into the range that just landed, and `handoff --stop` would
118
+ * refuse too, since the last landing no longer completes the range; so the
119
+ * remedy moves the step to a range still to come and runs the record again,
120
+ * which holds in the same reply while the detail file stays as the card
121
+ * showed it.
122
+ */
123
+ function stopRange(root, slug, intent, detail, item) {
124
+ const plan = parseBuildRanges(intent);
125
+ const count = plan.ranges.length + plan.malformed.length;
126
+ const range = plan.ranges.find((r) => r.item === item);
127
+ const refuse = (why, remedy) => notice({ fact: `range ${item} of ${count}'s stop not recorded`, detail: [why], remedy });
128
+ if (range === undefined) {
129
+ return refuse(`the plan has no range ${item}`, `write the range's number in the header of .plumbbob/detail.md`);
130
+ }
131
+ const stamp = readStop(root, slug);
132
+ if (stamp === null || stamp.range !== item || stamp.detail !== detailDigest(detail)) {
133
+ return refuse('no stop card showed its detail', 'plumbbob handoff --stop, then end the turn on the card');
134
+ }
135
+ if (!checkStopLatch(root, stamp.turn)) {
136
+ return refuse("its card printed this turn, and no human turn has read it", "end the turn on the card; the human's reply is what records it");
137
+ }
138
+ const held = parseOrderedSteps(intent).filter((s) => hasStep(range.steps, s.n));
139
+ if (held.length === 0) {
140
+ return refuse('the plan holds none of its steps', 'land it, then plumbbob handoff --stop');
141
+ }
142
+ const open = held.find((s) => !s.done);
143
+ if (open !== undefined) {
144
+ // A step the ledger landed was reopened since, and lands again; one it never
145
+ // landed was folded into the range after the card, and moves out instead.
146
+ return stepShas(readOr(checkpointsPath(root, slug))).has(open.n)
147
+ ? refuse(`step ${open.n} is open again`, 'land it, then plumbbob handoff --stop')
148
+ : refuse(`step ${open.n} joined it after it landed`, `move step ${open.n} to a range still to come, then plumbbob harvest`);
149
+ }
150
+ return { item, count, stop: range.stop };
151
+ }
152
+ /**
153
+ * The open parks the proposals settle, in list order, or the words for the
154
+ * first proposal that cannot be recorded: one naming a handle the list lacks,
155
+ * or a class outside the three, each named by the handle the card prints. A park no proposal names stays open; a proposal
156
+ * for a park already harvested settles nothing; and the first proposal for a
157
+ * park is the one the card showed.
158
+ */
159
+ function settle(parks, proposals) {
160
+ for (const proposal of proposals) {
161
+ if (proposal.handle < 1 || proposal.handle > parks.length) {
162
+ return `P${proposal.handle} is not in the list; ${parks.length} parked`;
163
+ }
164
+ if (!CLASSES.includes(proposal.kind)) {
165
+ return `P${proposal.handle}'s class "${proposal.kind}" is not blocker, tangent, or pivot`;
166
+ }
167
+ }
168
+ return parks.flatMap((park) => {
169
+ const proposal = proposals.find((p) => p.handle === park.handle);
170
+ return park.open && proposal !== undefined ? [{ handle: park.handle, kind: proposal.kind, text: park.text, reason: proposal.reason }] : [];
171
+ });
172
+ }
173
+ /** The classes settled, counted in the ending's order: `1 blocker, 2 tangents`, or `nothing parked`. */
174
+ function classWords(settled) {
175
+ const words = CLASSES.flatMap((kind) => {
176
+ const n = settled.filter((p) => p.kind === kind).length;
177
+ return n === 0 ? [] : [`${n} ${kind}${n === 1 ? '' : 's'}`];
178
+ });
179
+ return words.length === 0 ? 'nothing parked' : words.join(', ');
180
+ }
181
+ /** Where a harvest outside a stop ran, for its dated line: the boundary of the step that landed last. */
182
+ function boundaryWords(root, slug) {
183
+ const last = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
184
+ return last === null ? 'before the first step landed' : `at the step ${last.n} boundary`;
185
+ }
186
+ /**
187
+ * Whether a detail file is a boundary harvest's wire, read off its
188
+ * `# Detail · Harvest` header.
189
+ */
190
+ export function isHarvestDetail(detail) {
191
+ const first = detail.trimStart().split('\n', 1)[0] ?? '';
192
+ return /^#\s+Detail\s*·\s*Harvest\s*$/.test(first);
193
+ }
194
+ /**
195
+ * The refusal for a detail file that is no range's and no harvest's: a step's
196
+ * is checkpoint's to record, and an empty one, or one with no header, records nothing.
197
+ */
198
+ function nothingToRecord(detail) {
199
+ const step = parseDetailStep(detail);
200
+ if (step !== null) {
201
+ return notice({
202
+ fact: 'nothing to record',
203
+ detail: [`the detail file is Step ${step}'s, not a range's or a harvest's`],
204
+ remedy: `plumbbob checkpoint ${step} records a step's detail`,
205
+ });
206
+ }
207
+ const why = detail.trim().length === 0 ? 'the detail file is empty' : 'the detail file names no range or harvest';
208
+ return notice({
209
+ fact: 'nothing to record',
210
+ detail: [why],
211
+ remedy: 'write the proposals in .plumbbob/detail.md under `# Detail · Harvest`',
212
+ });
213
+ }
214
+ /**
215
+ * Read a file as UTF-8, or '' when it is missing or unreadable.
216
+ */
217
+ function readOr(path) {
218
+ try {
219
+ return readFileSync(path, 'utf8');
220
+ }
221
+ catch {
222
+ return '';
223
+ }
224
+ }
@@ -25,7 +25,9 @@
25
25
  // has already landed. The once is counted in `RELAY` by the turn it held,
26
26
  // never by `stop_hook_active`, which checkride's gate sets on every red Stop
27
27
  // and which names no hook. It never sends `continue: false`, which would cancel
28
- // a sibling's block in the same round.
28
+ // a sibling's block in the same round. A run's boundary that still owes its
29
+ // stop card is a miss however whole the boundary was, since the card is the
30
+ // run's ending, and its reason names `plumbbob handoff --stop` as the retry.
29
31
  //
30
32
  // The final message comes from the payload's `last_assistant_message`, never
31
33
  // from the transcript, which does not yet hold that message when Stop fires;
@@ -71,7 +73,10 @@ export function relayCheck(cwd, raw) {
71
73
  const turn = readTurn(root);
72
74
  if (record === null || turn === null || record.turn !== turn || record.held === turn)
73
75
  return null;
74
- const carried = carriedEnding(record, payload.last_assistant_message, payload);
76
+ // A run's boundary that still owes its stop card is held whatever the turn
77
+ // relayed: the card is the run's ending, and `handoff --stop` overwrites
78
+ // this record once it prints.
79
+ const carried = record.owes !== undefined ? false : carriedEnding(record, payload.last_assistant_message, payload);
75
80
  // Whole, or not this check's to judge: let the turn end.
76
81
  if (carried !== false)
77
82
  return null;
@@ -206,6 +211,11 @@ function partsOf(text) {
206
211
  * turn missed, and the command or block that puts it back.
207
212
  */
208
213
  function reason(record) {
214
+ if (record.owes !== undefined) {
215
+ const { range, count, step } = record.owes;
216
+ return (`plumbbob: the turn ended on Step ${step}'s boundary, and range ${range} of ${count}'s stop card is still owed. ` +
217
+ "Write the range's detail file, run `plumbbob handoff --stop`, and end the turn on its output, whole, with nothing after it.");
218
+ }
209
219
  if (record.tier === 'decision') {
210
220
  const command = record.command ?? 'plumbbob handoff --full';
211
221
  return (`plumbbob: the turn did not close on the ${DECISION_NAMES[record.render] ?? 'pause'} it printed. ` +
@@ -219,6 +229,8 @@ function reason(record) {
219
229
  const DECISION_NAMES = {
220
230
  card: 'pause card',
221
231
  reminder: 'pause reminder',
232
+ stop: 'stop card',
233
+ 'stop-reminder': 'stop reminder',
222
234
  plan: 'plan card',
223
235
  };
224
236
  // The command that printed an orientation ending, by its render, so the reason
@@ -226,6 +238,7 @@ const DECISION_NAMES = {
226
238
  const ORIENTATION_VERBS = {
227
239
  checkpoint: 'plumbbob checkpoint',
228
240
  'plan-commit': 'plumbbob checkpoint --plan',
241
+ harvest: 'plumbbob harvest',
229
242
  finish: 'plumbbob finish',
230
243
  };
231
244
  /**
@@ -3,13 +3,19 @@
3
3
  // the in-flight STEP marker) into a where-am-I view that suggests one primary
4
4
  // next move while printing the full picture, so you can always override.
5
5
  // Read-only, always exits 0. Skills pre-inject this output to gate their own
6
- // behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
6
+ // behavior, so the `NO ACTIVE SESSION` sentinel is kept exact. A stop card
7
+ // still waiting for its reply marks its range and takes the next move. With
8
+ // `--run`, it prints the run a `/plumbbob:run` invocation names above the
9
+ // dashboard, and names the waiting card again beneath the run's grant.
7
10
  import { readFileSync } from 'node:fs';
8
11
  import { join } from 'node:path';
9
12
  import { PLAN_COMMIT_MARKER, commitsSince, findRepoRoot } from "../lib/git.js";
10
13
  import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
11
14
  import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
12
- import { formatOrientation, lastLedgerSha, orient, parseRequestedStep } from "../lib/orient.js";
15
+ import { readGrant } from "../lib/latch.js";
16
+ import { formatOrientation, formatRunDashboard, lastLedgerSha, orient, parseRequestedStep, resolveRunArgs, } from "../lib/orient.js";
17
+ import { normalizeStepList } from "../lib/steplist.js";
18
+ import { waitingCard } from "./handoff.js";
13
19
  /**
14
20
  * Read a file as utf8, or '' when it is missing or unreadable.
15
21
  */
@@ -22,20 +28,32 @@ function readOr(path) {
22
28
  }
23
29
  }
24
30
  /**
25
- * Split `--invoked "<raw args>"` out of the argument list, ahead of any other
26
- * flag parsing.
31
+ * Split a flag carrying a skill invocation's raw text (`--invoked` or `--run`)
32
+ * out of the argument list, ahead of any other flag parsing.
27
33
  *
28
34
  * 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.
35
+ * skill passes through), so it can contain anything, including tokens that
36
+ * look like this CLI's own flags; extracting it first keeps such a value from
37
+ * confusing `resolveBuild`. An absent flag reads as null; a flag with no value
38
+ * reads as `missing`, which `--invoked` takes as no explicit ask and `--run`
39
+ * as a run with nothing after the verb.
33
40
  */
34
- function takeInvoked(args) {
35
- const i = args.indexOf('--invoked');
41
+ function takeRaw(args, flag, missing) {
42
+ const i = args.indexOf(flag);
36
43
  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)] };
44
+ return { raw: null, rest: args };
45
+ return { raw: args[i + 1] ?? missing, rest: [...args.slice(0, i), ...args.slice(i + 2)] };
46
+ }
47
+ /**
48
+ * A grant as the GRANT file holds it in its normal form, or null for none:
49
+ * the text the run header holds against the grant a run would mint.
50
+ */
51
+ function grantText(grant) {
52
+ if (grant === null)
53
+ return null;
54
+ if (grant.kind === 'auto')
55
+ return 'auto';
56
+ return grant.kind === 'steps' ? `steps ${normalizeStepList(grant.steps)}` : `range ${grant.ceiling}`;
39
57
  }
40
58
  /**
41
59
  * Print the orientation dashboard for the active build, plus its harness bindings.
@@ -46,6 +64,10 @@ function takeInvoked(args) {
46
64
  * `/plumbbob:build` skill passes its own invocation through), an explicit step
47
65
  * number in the raw text repoints the dashboard's marker, detail, and next
48
66
  * move at that step, so the injected state never contradicts the human's ask.
67
+ * With `--run "<raw args>"` (how the `/plumbbob:run` skill passes its own),
68
+ * the dashboard prints beneath a header naming the run the text resolves to
69
+ * and the grant the turn hook minted, or carries the refusal in its pointer
70
+ * when the run names nothing it can walk.
49
71
  */
50
72
  export function status(cwd, args = []) {
51
73
  const root = findRepoRoot(cwd);
@@ -53,7 +75,8 @@ export function status(cwd, args = []) {
53
75
  process.stdout.write('NO ACTIVE SESSION\n');
54
76
  return 0;
55
77
  }
56
- const { invoked, rest } = takeInvoked(args);
78
+ const { raw: run, rest: afterRun } = takeRaw(args, '--run', '');
79
+ const { raw: invoked, rest } = takeRaw(afterRun, '--invoked', null);
57
80
  // A session with builds but no resolvable cursor (finish cleared it, or the repo
58
81
  // holds several builds and none is active) has no single dashboard to show, so
59
82
  // list the builds and point at `use` instead of rendering a broken, empty one.
@@ -73,19 +96,43 @@ export function status(cwd, args = []) {
73
96
  // checkpoint still surfaces. No ledger yet ⇒ nothing to reconcile against ⇒ 0.
74
97
  const checkpoints = readOr(checkpointsPath(root, slug));
75
98
  const anchor = lastLedgerSha(checkpoints);
76
- const orientation = orient({
77
- intent: readOr(intentPath(root, slug)),
99
+ const intent = readOr(intentPath(root, slug));
100
+ // The stop card still waiting, read once: the dashboard marks its range and
101
+ // names it as the move, and the run header's waiting line names it too.
102
+ const card = waitingCard(root, slug, intent);
103
+ const input = {
104
+ intent,
78
105
  buildLog: readOr(buildLogPath(root, slug)),
79
106
  checkpoints,
80
107
  inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
81
108
  spiking: inSpike(root, slug),
82
109
  requested: parseRequestedStep(invoked),
83
110
  outOfBand: anchor === null ? 0 : commitsSince(root, anchor, PLAN_COMMIT_MARKER),
84
- });
85
- const lines = [formatOrientation(orientation), ...harnessSection(root, slug)];
111
+ waiting: card?.range ?? null,
112
+ };
113
+ const dashboard = run === null
114
+ ? formatOrientation(orient(input))
115
+ : withWaiting(formatRunDashboard(input, resolveRunArgs(run, input), grantText(readGrant(root))), card);
116
+ const lines = [dashboard, ...harnessSection(root, slug)];
86
117
  process.stdout.write(`${lines.join('\n')}\n`);
87
118
  return 0;
88
119
  }
120
+ /**
121
+ * The run dashboard with the waiting line beneath the run header's grant line,
122
+ * the header's fourth line, naming the stop card still waiting for its reply
123
+ * (`range 3 of 5's stop card, recorded before the run`). With no card waiting,
124
+ * or no header (a run that refuses has no grant line to ride), the dashboard
125
+ * is returned as it is.
126
+ */
127
+ function withWaiting(dashboard, card) {
128
+ const lines = dashboard.split('\n');
129
+ const grant = lines.findIndex((l) => l.startsWith(' grant: '));
130
+ if (card === null || grant === -1) {
131
+ return dashboard;
132
+ }
133
+ lines.splice(grant + 1, 0, ` waiting: range ${card.range} of ${card.count}'s stop card, recorded before the run`);
134
+ return lines.join('\n');
135
+ }
89
136
  /**
90
137
  * The active build's harness bindings: the agents bound to each slot, per
91
138
  * `defaults` and per step.
@@ -1,25 +1,29 @@
1
1
  // `plumbbob turn`: the turn-ledger hook. Wired to the harness's
2
- // UserPromptSubmit event, it runs once per prompt and maintains two of the
3
- // sidecar's git-excluded control files (flat files under `.plumbbob/`): it ticks
4
- // `TURN`, the monotonic count of human turns the checkpoint latch compares
5
- // against a step's entry stamp to know a human turn intervened since the step
6
- // began, and it rewrites `GRANT` from the literal prompt, minting a one-turn
7
- // self-approval only when the human typed `/build --auto` or a step range
8
- // (`N-M`, or a list such as `20,5-9`), clearing it otherwise (those strings can only come from a human
9
- // keystroke, because `build` is disable-model-invocation: a grant the model
10
- // could forge would be no grant). The harness also sends a background job's
2
+ // UserPromptSubmit event, it runs once per prompt and maintains the sidecar's
3
+ // git-excluded turn ledger (flat files under `.plumbbob/`): it ticks `TURN`,
4
+ // the monotonic count of human turns the checkpoint latch compares against a
5
+ // step's entry stamp to know a human turn intervened since the step began, and
6
+ // it rewrites `GRANT` from the literal prompt, minting a one-turn
7
+ // self-approval only when the human typed `/build --auto`, a step range (`N-M`,
8
+ // or a list such as `20,5-9`), or `/plumbbob:run`, clearing it otherwise (those
9
+ // strings can only come from a human keystroke, because `build` and `run` are
10
+ // disable-model-invocation: a grant the model could forge would be no grant).
11
+ // A run's grant is the steps left of the range the plan names, read from the
12
+ // active build's `intent.md` at the keystroke, and `RUN` beside it marks the
13
+ // range the run is landing. The harness also sends a background job's
11
14
  // completion notice through this event, as a prompt the human never typed, so
12
- // a notice ticks nothing, keeps the grant, and clears only the ending `RELAY`
13
- // holds from an earlier turn. When a step is in flight a typed prompt also
14
- // emits ONE `UserPromptSubmit` additionalContext line so a fresh session
15
- // (post-compaction, or a scripted `-p` turn where no skill prose is in context)
16
- // still knows the beat: a tangent is parked, not chased, and a message that
17
- // also asks something is answered before the park's block. Guidance only: it
18
- // blocks nothing. It ALWAYS exits 0 and any failure degrades to no output, so
19
- // neither the tick nor the emit can ever wedge prompting.
15
+ // a notice ticks nothing, keeps the grant and the mark, and clears only the
16
+ // ending `RELAY` holds from an earlier turn. When a step is in flight a typed
17
+ // prompt also emits ONE `UserPromptSubmit` additionalContext line so a fresh
18
+ // session (post-compaction, or a scripted `-p` turn where no skill prose is in
19
+ // context) still knows the beat: a tangent is parked, not chased, and a
20
+ // message that also asks something is answered before the park's block.
21
+ // Guidance only: it blocks nothing. It ALWAYS exits 0 and any failure degrades
22
+ // to no output, so neither the tick nor the emit can ever wedge prompting.
20
23
  import { readFileSync, writeFileSync } from 'node:fs';
21
- import { clearRelay, findSessionRoot, notePrompt, readPrompt, setGrant, stepPath, turnPath, } from "../lib/sidecar.js";
22
- import { normalizeStepList, readBuildArgs } from "../lib/steplist.js";
24
+ import { parseBuildRanges, readRunArgs, resolveRun } from "../lib/orient.js";
25
+ import { clearRelay, findSessionRoot, inSpike, intentPath, notePrompt, readPrompt, readRun, setGrant, setRun, stepPath, turnPath, } from "../lib/sidecar.js";
26
+ import { hasStep, normalizeStepList, readBuildArgs } from "../lib/steplist.js";
23
27
  /**
24
28
  * The hook entry point: apply the prompt, then emit the in-flight-step nudge
25
29
  * when it counted as a human turn.
@@ -87,10 +91,10 @@ function inFlightStep(root) {
87
91
  * The testable core: apply one prompt given the raw hook stdin, and say
88
92
  * whether it counted as a human turn.
89
93
  *
90
- * A typed prompt ticks TURN and rewrites GRANT. A notice touches neither,
91
- * because the latch reads TURN passing a step's entry stamp as its one sign
92
- * that a human answered, and a grant the human typed has to outlive a job
93
- * that reports back partway through the range. A notice that starts a turn of
94
+ * A typed prompt ticks TURN and rewrites GRANT and the RUN mark. A notice
95
+ * touches none of them, because the latch reads TURN passing a step's entry
96
+ * stamp as its one sign that a human answered, and a grant the human typed has
97
+ * to outlive a job that reports back partway through the range. A notice that starts a turn of
94
98
  * its own clears RELAY, whose ending was judged when its own turn ended; one
95
99
  * the harness folds into the running turn arrives under that turn's prompt id
96
100
  * and leaves RELAY alone, since the ending the turn just printed has yet to be
@@ -112,7 +116,9 @@ export function applyTurn(cwd, raw) {
112
116
  }
113
117
  else {
114
118
  writeFileSync(turnPath(root), `${readTurn(root) + 1}\n`);
115
- setGrant(root, grantFromPrompt(prompt));
119
+ const minted = mintSafely(root, prompt);
120
+ setGrant(root, minted.grant);
121
+ setRun(root, minted.run);
116
122
  }
117
123
  // Last, so a record that cannot be written never costs the tick or the grant.
118
124
  notePrompt(root, promptId);
@@ -133,6 +139,12 @@ export function applyTurn(cwd, raw) {
133
139
  export function isNotice(prompt) {
134
140
  return prompt.startsWith('<task-notification>') || prompt.startsWith('<agent-message ');
135
141
  }
142
+ // A build invocation, bare or namespaced, starting a token.
143
+ const BUILD_INVOCATION = /(?<![\w/.~-])\/(?:plumbbob:)?build\b/;
144
+ // A run invocation: only the namespaced verb, since a bare `/run` reaches
145
+ // Claude Code's own skill, starting a token and ending at a space, a line's
146
+ // end, or sentence punctuation.
147
+ const RUN_INVOCATION = /(?<![\w/.~-])\/plumbbob:run(?=[\s.;:!?,]|$)/;
136
148
  /**
137
149
  * Derive the GRANT content from the human's prompt, or null to clear it.
138
150
  *
@@ -160,15 +172,84 @@ export function isNotice(prompt) {
160
172
  * argument the human typed.
161
173
  */
162
174
  export function grantFromPrompt(prompt) {
163
- const invocation = /(?<![\w/.~-])\/(?:plumbbob:)?build\b/.exec(prompt);
175
+ const invocation = BUILD_INVOCATION.exec(prompt);
164
176
  if (invocation === null)
165
177
  return null;
166
- const line = prompt.slice(invocation.index + invocation[0].length).split('\n', 1)[0] ?? '';
167
- const args = readBuildArgs(line);
178
+ const args = readBuildArgs(argumentLine(prompt, invocation));
168
179
  if (args.range)
169
180
  return args.steps === null ? null : `steps ${normalizeStepList(args.steps)}`;
170
181
  return args.auto ? 'auto' : null;
171
182
  }
183
+ /**
184
+ * The text an invocation's arguments are read from: the rest of its line.
185
+ */
186
+ function argumentLine(prompt, invocation) {
187
+ return prompt.slice(invocation.index + invocation[0].length).split('\n', 1)[0] ?? '';
188
+ }
189
+ /**
190
+ * The GRANT one typed prompt mints and the RUN mark it leaves, given the
191
+ * active build as the hook read it (null when it could not) and the range the
192
+ * RUN mark names now.
193
+ *
194
+ * Whichever invocation comes first in the prompt is the one the human fired.
195
+ * A run mints `steps <what is left>` of the range the plan names at the
196
+ * keystroke, and marks that range, so a later edit to the ranges or the build
197
+ * order leaves both as they were. A bare range's run mints nothing and marks
198
+ * nothing, since it builds its step to the pause, and so does a run that names
199
+ * nothing it can walk, a span or a list of ranges, and a plan the hook could
200
+ * not read. Any other prompt mints what `grantFromPrompt` reads, and keeps the
201
+ * mark only while a step of the marked range waits at its pause, so a halt
202
+ * inside the run approved with `looks good` still lands under it.
203
+ */
204
+ export function mintFromPrompt(prompt, build, marked) {
205
+ const run = RUN_INVOCATION.exec(prompt);
206
+ const first = BUILD_INVOCATION.exec(prompt);
207
+ if (run === null || (first !== null && first.index < run.index)) {
208
+ return { grant: grantFromPrompt(prompt), run: heldMark(build, marked) };
209
+ }
210
+ const args = readRunArgs(argumentLine(prompt, run));
211
+ const resolved = args === null || build === null ? null : resolveRun({ ...build, item: args.item });
212
+ if (resolved?.ok !== true || resolved.grant === null)
213
+ return { grant: null, run: null };
214
+ return { grant: resolved.grant, run: resolved.range.item };
215
+ }
216
+ /**
217
+ * The RUN mark a prompt that is not a run leaves: the marked range while a
218
+ * step it names is in flight, so waiting at its pause, else none.
219
+ */
220
+ function heldMark(build, marked) {
221
+ if (build === null || marked === null || build.inFlight === null)
222
+ return null;
223
+ const step = build.inFlight;
224
+ const range = parseBuildRanges(build.intent).ranges.find((r) => r.item === marked);
225
+ return range !== undefined && hasStep(range.steps, step) ? marked : null;
226
+ }
227
+ /**
228
+ * What a typed prompt mints, read against the active build, degrading to no
229
+ * grant and no mark on any failure, so a broken read can never leave the last
230
+ * turn's grant standing.
231
+ */
232
+ function mintSafely(root, prompt) {
233
+ try {
234
+ return mintFromPrompt(prompt, readBuild(root), readRun(root));
235
+ }
236
+ catch {
237
+ return { grant: null, run: null };
238
+ }
239
+ }
240
+ /**
241
+ * The active build as a run reads it: the plan's text, the step in flight,
242
+ * and whether a spike is open; null when the plan cannot be read, which mints
243
+ * no run.
244
+ */
245
+ function readBuild(root) {
246
+ try {
247
+ return { intent: readFileSync(intentPath(root), 'utf8'), inFlight: inFlightStep(root), spiking: inSpike(root) };
248
+ }
249
+ catch {
250
+ return null;
251
+ }
252
+ }
172
253
  /**
173
254
  * The `prompt` and `prompt_id` fields of the UserPromptSubmit JSON, as '' and
174
255
  * null when either is absent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Attention-first build process: 15 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -44,7 +44,9 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
44
44
  step is checkpointed.
45
45
  2. **Enter the step.** Run `plumbbob build <n>`, or bare `plumbbob build` to enter the
46
46
  next undone step (records the in-flight STEP + SEAM so `/plumbbob:status` shows the step in
47
- flight; the seam is awareness, not a lock).
47
+ flight; the seam is awareness, not a lock). When it refuses with `no step entered` because
48
+ a stop card is waiting for its reply, relay the refusal whole and end the turn: the card's
49
+ reply is the human's, and no step is built until it records.
48
50
  - **Spike-as-step.** If the step's title opens with `spike:` / `Spike:`, the increment
49
51
  *is* the experiment: resolving a fork the plan couldn't settle on paper. After
50
52
  entering it, run `plumbbob spike report "<slug>"` (the CLI scaffolds a
@@ -332,6 +334,13 @@ comma), and it grants exactly the steps it names: the checkpoint refuses any oth
332
334
  ([D91 (step-list-grants)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d91)).
333
335
  The range *is* the opt-in; you do not also pass `--auto`.
334
336
 
337
+ **A run hands you a range the plan names, walked the same way.** `/plumbbob:run` (the run
338
+ skill) reads this section for its walk: its grant is the `steps` list the turn hook minted
339
+ from the plan's `## Build ranges` when the human typed the run, in the normal form a typed
340
+ list mints, so the latch, `plumbbob build`'s notice at the top step, and every halt below
341
+ read it exactly as they read a typed range
342
+ ([D96 (plan-names-the-grant)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d96)).
343
+
335
344
  Run it as the `--auto` loop, walked in build order. **Start at the range's first undone step
336
345
  in build order, however it is written** (`5-9,20` under an order that puts 20 first starts at
337
346
  20), and after each landing take the next undone step in build order. Build it if the range
@@ -364,13 +373,16 @@ out, or the next step in build order is one the range does not name),
364
373
  the whole stretch lists step 4 too (`/plumbbob:build 1-4`, or `/plumbbob:build 1,4,2,3`).
365
374
  - **At a range's stop, harvest before the next range.** No harvest runs between the steps
366
375
  inside a range, so the parks pile up, and a blocker among them would otherwise ride into
367
- the next one. When the human asks what comes next at a stop with parks open (the note's
368
- `parked 2 of 7 open`), the move is `/plumbbob:harvest`, then the range the card names.
376
+ the next one. A run's range ends on the stop card, which lists every open park with a
377
+ proposed class, and the reply that settles the card records them (the run skill's
378
+ section on the reply at a stop). A range landed here, by a typed range, ends on the plain
379
+ boundary instead; when the human asks what comes next there with parks open (the note's
380
+ `parked 2 of 7 open`), the move is `/plumbbob:harvest`, then the run the card names.
369
381
  - **After an early stop, settle the step, then rerun what is left.** A halt on trouble
370
382
  inside a range ends at that step's pause, and the grant ends with the turn. Once the
371
- human approves and the step lands, the note beneath Next Up names what is left of the
372
- range (`· range 3 of 5: /plumbbob:build 8-9`), and that is the command the human types
373
- to carry on.
383
+ human approves and the step lands, the note beneath Next Up names the run that lands
384
+ what is left of the range (`· range 3 of 5: /plumbbob:run (steps 8-9)`), and that is what
385
+ the human types to carry on; `/plumbbob:build 8-9` lands the same steps by hand.
374
386
 
375
387
  ## The hard contracts
376
388