plumbbob 0.13.0 → 0.13.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.
@@ -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
+ }
@@ -14,16 +14,18 @@ import { readFileSync, writeFileSync } from 'node:fs';
14
14
  import { findRepoRoot } from "../lib/git.js";
15
15
  import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
16
16
  import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
17
+ import { expandStepList, parseStepList } from "../lib/steplist.js";
17
18
  import { ending, notice, printEnding, transition } from "../lib/notice.js";
18
19
  import { driverPointer } from "./handoff.js";
19
20
  /**
20
- * Set the build order to the given step numbers, the rest of the undone steps
21
- * following in document order, or clear it with `--reset`.
21
+ * Set the build order to the given step numbers and spans, the rest of the
22
+ * undone steps following in document order, or clear it with `--reset`.
22
23
  *
23
- * The line written is the whole remaining sequence, so a cold reader of
24
- * intent.md sees every undone step and not only the ones re-sequenced.
25
- * Refuses (exit 1) with no session, no numbers, a token that is not a number,
26
- * a number the plan lacks, a repeated number, or a step already checkpointed.
24
+ * The line written is the whole remaining sequence, each span spelled out, so a
25
+ * cold reader of intent.md sees every undone step and not only the ones
26
+ * re-sequenced. Refuses (exit 1) with no session, no numbers, a token that is
27
+ * neither a number nor a span, a number the plan lacks, a repeated number, or a
28
+ * step already checkpointed.
27
29
  */
28
30
  export function order(cwd, args) {
29
31
  const root = findRepoRoot(cwd);
@@ -45,8 +47,9 @@ export function order(cwd, args) {
45
47
  });
46
48
  return 0;
47
49
  }
48
- // Numbers arrive space- or comma-separated: `order 7 8 5` and `order 7,8,5`
49
- // read the same, since the line they write is the comma form.
50
+ // Numbers and spans arrive space- or comma-separated, with or without a space
51
+ // after a comma: `order 7 8 5`, `order 7,8,5`, and `order 1, 6, 2-4, 7, 5`
52
+ // all read, and a span names its steps in place, since the line is a sequence.
50
53
  const tokens = args
51
54
  .filter((a) => !a.startsWith('--'))
52
55
  .flatMap((a) => a.split(','))
@@ -60,14 +63,15 @@ export function order(cwd, args) {
60
63
  }));
61
64
  return 1;
62
65
  }
63
- const stray = tokens.find((t) => !/^\d+$/.test(t));
66
+ const lists = tokens.map(parseStepList);
67
+ const stray = tokens.find((_, i) => lists[i] === null);
64
68
  if (stray !== undefined) {
65
- process.stderr.write(notice({ fact: `\`${stray}\` is not a step number`, remedy: 'plumbbob order 7 8 5' }));
69
+ process.stderr.write(notice({ fact: `\`${stray}\` is not a step number or span`, remedy: 'plumbbob order 1, 6, 2-4, 7, 5' }));
66
70
  return 1;
67
71
  }
68
- const given = tokens.map(Number);
69
72
  const steps = parseSteps(intent);
70
73
  const planned = steps.map((s) => s.n);
74
+ const given = expandStepList(lists.flatMap((list) => list ?? []), (n) => planned.includes(n));
71
75
  const unknown = given.find((n) => !planned.includes(n));
72
76
  if (unknown !== undefined) {
73
77
  process.stderr.write(notice({
@@ -18,7 +18,7 @@
18
18
  // deleting them is a gamble the human should take by hand.
19
19
  import { existsSync, readFileSync } from 'node:fs';
20
20
  import { findRepoRoot } from "../lib/git.js";
21
- import { parseBuildOrder, parseSteps } from "../lib/orient.js";
21
+ import { parseBuildOrder, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding } from "../lib/orient.js";
22
22
  import { activeBuild, checkpointsPath, clearHandoff, clearTick, grantPath, handoffPath, hasSession, intentPath, inSpike, listBuilds, readTurn, setActiveBuild, setGrant, stepPath, tickPath, } from "../lib/sidecar.js";
23
23
  import { spikeBranches, spikeWorktrees } from "./spike.js";
24
24
  /**
@@ -94,6 +94,7 @@ function sessionFindings(root) {
94
94
  const stepInFlight = existsSync(stepPath(root, slug));
95
95
  findings.push(...phaseFindings(root, slug, stepInFlight));
96
96
  findings.push(...orderFindings(root, slug));
97
+ findings.push(...rangeFindings(root, slug));
97
98
  findings.push(...leftoverFindings(root, slug, stepInFlight));
98
99
  return findings;
99
100
  }
@@ -126,6 +127,35 @@ function orderFindings(root, slug) {
126
127
  },
127
128
  ];
128
129
  }
130
+ /**
131
+ * A build range naming a step the plan does not hold.
132
+ *
133
+ * The section under `## Build ranges` is hand-editable, and a refine can drop
134
+ * a step a range names; the card then suggests no command for that range, and
135
+ * the dashboard says why. Reported beside the build order's own finding, never
136
+ * repaired: where the stop belongs now is the human's call. Silent while the
137
+ * plan declares no ranges.
138
+ */
139
+ function rangeFindings(root, slug) {
140
+ const intent = readOr(intentPath(root, slug));
141
+ const plan = parseBuildRanges(intent);
142
+ if (plan.ranges.length === 0) {
143
+ return [];
144
+ }
145
+ const missing = rangeStanding(plan, parseOrderedSteps(intent), null).drift.filter((d) => d.kind === 'missing');
146
+ if (missing.length === 0) {
147
+ const count = `${plan.ranges.length} range${plan.ranges.length === 1 ? '' : 's'}`;
148
+ return [{ name: 'build ranges', ok: true, detail: `every step they name is planned (${count})` }];
149
+ }
150
+ return [
151
+ {
152
+ name: 'build ranges',
153
+ ok: false,
154
+ detail: missing.map((d) => `range ${d.item} names a step the plan lacks: ${d.words}`).join('; '),
155
+ hint: 're-cut the range under ## Build ranges by hand, or with /plumbbob:refine',
156
+ },
157
+ ];
158
+ }
129
159
  /**
130
160
  * The cursor names a build that is not there.
131
161
  *
@@ -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.