plumbbob 0.13.3 → 0.13.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +24 -24
- package/dist/cli-core.js +6 -5
- package/dist/lib/notice.js +42 -7
- package/dist/lib/orient.js +379 -11
- package/dist/lib/sidecar.js +53 -31
- package/dist/verbs/checkpoint.js +12 -12
- package/dist/verbs/finish.js +7 -7
- package/dist/verbs/handoff.js +368 -302
- package/dist/verbs/harvest.js +20 -21
- package/dist/verbs/order.js +65 -21
- package/dist/verbs/relay.js +48 -6
- package/dist/verbs/turn.js +111 -31
- package/package.json +1 -1
- package/skills/build/SKILL.md +55 -51
- package/skills/finish/SKILL.md +10 -4
- package/skills/harvest/SKILL.md +43 -25
- package/skills/order/SKILL.md +19 -16
- package/skills/plan/SKILL.md +10 -9
- package/skills/refine/SKILL.md +17 -10
- package/skills/run/SKILL.md +34 -23
- package/skills/step/SKILL.md +28 -23
- package/skills/verify/SKILL.md +44 -39
package/dist/verbs/harvest.js
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
// A stop's record is latched (D103 (stop-latched)). It refuses in the turn
|
|
11
11
|
// the stop card printed, since a run's long turn ends on proposals and that is
|
|
12
12
|
// where a model would carry straight on into recording its own; it refuses a
|
|
13
|
-
// range
|
|
14
|
-
//
|
|
13
|
+
// range no stop card showed, read off the range the card's STOP stamp names,
|
|
14
|
+
// though not a detail file rewritten since, which is a lean the human changed
|
|
15
|
+
// in a reply (D110 (reply-after-the-card)); and it refuses a range that no
|
|
16
|
+
// longer reads as landed, telling
|
|
15
17
|
// a step landed and reopened from one that joined the range after it landed,
|
|
16
18
|
// which moves to a range still to come. A harvest outside a stop (`# Detail · Harvest`) needs no stamp. A proposal
|
|
17
19
|
// naming a handle the list lacks (`P9` with eight parked), or a class outside
|
|
@@ -24,9 +26,9 @@ import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
|
24
26
|
import { appendHarvest, appendToSection, flipParks, harvestLines, logEntry, stopLogLine } from "../lib/buildlog.js";
|
|
25
27
|
import { checkStopLatch } from "../lib/latch.js";
|
|
26
28
|
import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOrderedSteps, stepShas } from "../lib/orient.js";
|
|
27
|
-
import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop,
|
|
29
|
+
import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop, detailPath, hasSession, intentPath, readStop, } from "../lib/sidecar.js";
|
|
28
30
|
import { hasStep } from "../lib/steplist.js";
|
|
29
|
-
import { boundaryEnding, harvestProposals, parkList, parseDetailRange, stopRecord } from "./handoff.js";
|
|
31
|
+
import { boundaryEnding, harvestProposals, isHarvestDetail, parkList, parseDetailRange, stopRecord, } from "./handoff.js";
|
|
30
32
|
// The three classes a park settles as, in the order the ending counts them.
|
|
31
33
|
const CLASSES = ['blocker', 'tangent', 'pivot'];
|
|
32
34
|
// Where a refusal about the proposals sends the model: the file it wrote them in.
|
|
@@ -57,7 +59,7 @@ export function harvest(cwd, _args) {
|
|
|
57
59
|
const parks = parkList(buildLog);
|
|
58
60
|
let stop = null;
|
|
59
61
|
if (item !== null) {
|
|
60
|
-
const found = stopRange(root, slug, intent,
|
|
62
|
+
const found = stopRange(root, slug, intent, item);
|
|
61
63
|
if (typeof found === 'string') {
|
|
62
64
|
process.stderr.write(found);
|
|
63
65
|
return 1;
|
|
@@ -101,26 +103,31 @@ export function harvest(cwd, _args) {
|
|
|
101
103
|
printEnding(root, {
|
|
102
104
|
tier: 'orientation',
|
|
103
105
|
render: 'harvest',
|
|
104
|
-
text: ending({ lead,
|
|
106
|
+
text: ending({ lead, state: boundaryEnding(root, slug, null).state }),
|
|
105
107
|
});
|
|
106
108
|
return 0;
|
|
107
109
|
}
|
|
108
110
|
/**
|
|
109
111
|
* The range a range's detail reviews, once the record may land: the plan
|
|
110
|
-
* holds the range, a stop card
|
|
112
|
+
* holds the range, a stop card for it has shown, a human turn has read it,
|
|
111
113
|
* and every step of the range has landed. Otherwise the refusal, with the move
|
|
112
114
|
* that unblocks it.
|
|
113
115
|
*
|
|
116
|
+
* The card need not have shown this detail file byte for byte: a reply that
|
|
117
|
+
* changed a lean rewrote the file without printing the card again, and the
|
|
118
|
+
* change is the human's own (D110 (reply-after-the-card)).
|
|
119
|
+
*
|
|
114
120
|
* An undone step in the range reads two ways, told apart by the ledger. One
|
|
115
121
|
* with a `step N <sha>` line landed and was reopened, so it lands again and
|
|
116
|
-
* the card
|
|
122
|
+
* the card prints again on `--stop --full`, since the range it reviews has
|
|
123
|
+
* changed under it. One with none joined the range after the card, a
|
|
117
124
|
* blocker folded into the range that just landed, and `handoff --stop` would
|
|
118
125
|
* refuse too, since the last landing no longer completes the range; so the
|
|
119
126
|
* remedy moves the step to a range still to come and runs the record again,
|
|
120
127
|
* which holds in the same reply while the detail file stays as the card
|
|
121
128
|
* showed it.
|
|
122
129
|
*/
|
|
123
|
-
function stopRange(root, slug, intent,
|
|
130
|
+
function stopRange(root, slug, intent, item) {
|
|
124
131
|
const plan = parseBuildRanges(intent);
|
|
125
132
|
const count = plan.ranges.length + plan.malformed.length;
|
|
126
133
|
const range = plan.ranges.find((r) => r.item === item);
|
|
@@ -129,22 +136,22 @@ function stopRange(root, slug, intent, detail, item) {
|
|
|
129
136
|
return refuse(`the plan has no range ${item}`, `write the range's number in the header of .plumbbob/detail.md`);
|
|
130
137
|
}
|
|
131
138
|
const stamp = readStop(root, slug);
|
|
132
|
-
if (stamp === null || stamp.range !== item
|
|
133
|
-
return refuse('no stop card showed
|
|
139
|
+
if (stamp === null || stamp.range !== item) {
|
|
140
|
+
return refuse('no stop card showed it', 'plumbbob handoff --stop, then end the turn on the card');
|
|
134
141
|
}
|
|
135
142
|
if (!checkStopLatch(root, stamp.turn)) {
|
|
136
143
|
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
144
|
}
|
|
138
145
|
const held = parseOrderedSteps(intent).filter((s) => hasStep(range.steps, s.n));
|
|
139
146
|
if (held.length === 0) {
|
|
140
|
-
return refuse('the plan holds none of its steps', 'land it, then plumbbob handoff --stop');
|
|
147
|
+
return refuse('the plan holds none of its steps', 'land it, then plumbbob handoff --stop --full');
|
|
141
148
|
}
|
|
142
149
|
const open = held.find((s) => !s.done);
|
|
143
150
|
if (open !== undefined) {
|
|
144
151
|
// A step the ledger landed was reopened since, and lands again; one it never
|
|
145
152
|
// landed was folded into the range after the card, and moves out instead.
|
|
146
153
|
return stepShas(readOr(checkpointsPath(root, slug))).has(open.n)
|
|
147
|
-
? refuse(`step ${open.n} is open again`, 'land it, then plumbbob handoff --stop')
|
|
154
|
+
? refuse(`step ${open.n} is open again`, 'land it, then plumbbob handoff --stop --full')
|
|
148
155
|
: refuse(`step ${open.n} joined it after it landed`, `move step ${open.n} to a range still to come, then plumbbob harvest`);
|
|
149
156
|
}
|
|
150
157
|
return { item, count, stop: range.stop };
|
|
@@ -183,14 +190,6 @@ function boundaryWords(root, slug) {
|
|
|
183
190
|
const last = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
|
|
184
191
|
return last === null ? 'before the first step landed' : `at the step ${last.n} boundary`;
|
|
185
192
|
}
|
|
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
193
|
/**
|
|
195
194
|
* The refusal for a detail file that is no range's and no harvest's: a step's
|
|
196
195
|
* is checkpoint's to record, and an empty one, or one with no header, records nothing.
|
package/dist/verbs/order.js
CHANGED
|
@@ -7,15 +7,18 @@
|
|
|
7
7
|
// `checkpoint`, `handoff`), so the dashboard's `← next`, a bare
|
|
8
8
|
// `plumbbob build`, and the card's Next Up agree. The verb validates what a
|
|
9
9
|
// hand edit cannot: every number names an undone step, none twice. `--reset`
|
|
10
|
-
// drops the line, and the plan reads in document order again.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
10
|
+
// drops the line, and the plan reads in document order again. Either way the
|
|
11
|
+
// build ranges are re-cut to follow the order written, wherever the order
|
|
12
|
+
// settles which range a step is in, so a step placed next runs in the range
|
|
13
|
+
// it now builds with. It runs in any state, since re-sequencing what comes
|
|
14
|
+
// after the step in flight is the mid-step use, and the sidecar sits outside
|
|
15
|
+
// every seam.
|
|
13
16
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
14
17
|
import { findRepoRoot } from "../lib/git.js";
|
|
15
18
|
import { activeBuild, hasSession, intentPath } from "../lib/sidecar.js";
|
|
16
|
-
import { clearBuildOrder, orderSteps, parseSteps, setBuildOrder } from "../lib/orient.js";
|
|
17
|
-
import { expandStepList, parseStepList } from "../lib/steplist.js";
|
|
18
|
-
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
19
|
+
import { clearBuildOrder, orderSteps, parseBuildRanges, parseOrderedSteps, parseSteps, rangeStanding, recutPlan, setBuildOrder, } from "../lib/orient.js";
|
|
20
|
+
import { expandStepList, formatStepList, parseStepList } from "../lib/steplist.js";
|
|
21
|
+
import { advisory, blocks, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
19
22
|
import { driverPointer } from "./handoff.js";
|
|
20
23
|
/**
|
|
21
24
|
* Set the build order to the given step numbers and spans, the rest of the
|
|
@@ -36,15 +39,7 @@ export function order(cwd, args) {
|
|
|
36
39
|
const path = intentPath(root);
|
|
37
40
|
const intent = readFileSync(path, 'utf8');
|
|
38
41
|
if (args.includes('--reset')) {
|
|
39
|
-
|
|
40
|
-
printEnding(root, {
|
|
41
|
-
tier: 'driver',
|
|
42
|
-
render: 'order',
|
|
43
|
-
text: ending({
|
|
44
|
-
lead: transition({ label: 'Build order', fact: 'document order' }),
|
|
45
|
-
pointer: driverPointer(root, activeBuild(root)),
|
|
46
|
-
}),
|
|
47
|
-
});
|
|
42
|
+
writeOrder(root, path, clearBuildOrder(intent), 'document order');
|
|
48
43
|
return 0;
|
|
49
44
|
}
|
|
50
45
|
// Numbers and spans arrive space- or comma-separated, with or without a space
|
|
@@ -98,17 +93,66 @@ export function order(cwd, args) {
|
|
|
98
93
|
const full = orderSteps(steps, given)
|
|
99
94
|
.filter((s) => !s.done)
|
|
100
95
|
.map((s) => s.n);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
96
|
+
writeOrder(root, path, setBuildOrder(intent, full), full.join(', '));
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Write the plan under its new build order, its ranges re-cut to follow it,
|
|
101
|
+
* and print the ending: the order, the ranges the re-cut changed, any range it
|
|
102
|
+
* left for the human to re-cut, and the pointer.
|
|
103
|
+
*
|
|
104
|
+
* The pointer reads the plan back from disk, so it already follows the order
|
|
105
|
+
* just written: back at the step in flight, or forward from the boundary to
|
|
106
|
+
* the first step in the new sequence.
|
|
107
|
+
*/
|
|
108
|
+
function writeOrder(root, path, ordered, sequence) {
|
|
109
|
+
const { intent, recut } = recutPlan(ordered);
|
|
110
|
+
writeFileSync(path, intent);
|
|
111
|
+
const standing = rangeStanding(parseBuildRanges(intent), parseOrderedSteps(intent), null);
|
|
105
112
|
printEnding(root, {
|
|
106
113
|
tier: 'driver',
|
|
107
114
|
render: 'order',
|
|
108
115
|
text: ending({
|
|
109
|
-
lead: transition({ label: 'Build order', fact:
|
|
116
|
+
lead: blocks([transition({ label: 'Build order', fact: sequence }), recutLead(recut, standing.count)]),
|
|
117
|
+
advisories: rangeAdvisories(standing),
|
|
110
118
|
pointer: driverPointer(root, activeBuild(root)),
|
|
111
119
|
}),
|
|
112
120
|
});
|
|
113
|
-
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The lead naming each range the re-cut changed and the steps it names now,
|
|
124
|
+
* `range 2 of 3 re-cut (steps 10,4-6)`; null when it changed none.
|
|
125
|
+
*/
|
|
126
|
+
function recutLead(recut, count) {
|
|
127
|
+
const [only] = recut;
|
|
128
|
+
if (only === undefined) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
const steps = (cut) => `${cut.steps.length === 1 ? 'step' : 'steps'} ${formatStepList(cut.steps)}`;
|
|
132
|
+
const items = recut.map((cut) => cut.item).join(', ');
|
|
133
|
+
return transition({
|
|
134
|
+
label: 'Build ranges',
|
|
135
|
+
fact: `${recut.length === 1 ? 'range' : 'ranges'} ${items} of ${count} re-cut`,
|
|
136
|
+
detail: recut.length === 1 ? [steps(only)] : recut.map((cut) => `range ${cut.item}: ${steps(cut)}`),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Every range still flagged once the re-cut is written, in the words the
|
|
141
|
+
* dashboard flags it with, and beneath the last the move that re-cuts it by
|
|
142
|
+
* hand: the ties the order did not settle, a step no range can take, and the
|
|
143
|
+
* plan's other drift. Empty when every undone step sits in one stretch of one
|
|
144
|
+
* range.
|
|
145
|
+
*/
|
|
146
|
+
function rangeAdvisories(standing) {
|
|
147
|
+
const { count, drift } = standing;
|
|
148
|
+
const own = drift.filter((d) => d.item !== null && d.kind !== 'malformed');
|
|
149
|
+
const flagged = [...new Set(own.map((d) => d.item))].map((item) => ({
|
|
150
|
+
fact: `range ${item} of ${count} needs a re-cut`,
|
|
151
|
+
detail: own.filter((d) => d.item === item).map((d) => d.words),
|
|
152
|
+
}));
|
|
153
|
+
const lines = [...flagged, ...drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => ({ fact: d.words }))];
|
|
154
|
+
if (lines.length === 0) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
return [lines.map((l, i) => advisory(i === lines.length - 1 ? { ...l, remedy: '/plumbbob:refine ranges' } : l)).join('')];
|
|
114
158
|
}
|
package/dist/verbs/relay.js
CHANGED
|
@@ -66,7 +66,9 @@ export function relayCheck(cwd, raw) {
|
|
|
66
66
|
// A host too old to send the final message has nothing to check against.
|
|
67
67
|
if (payload === null || typeof payload.last_assistant_message !== 'string')
|
|
68
68
|
return null;
|
|
69
|
-
|
|
69
|
+
// The session the payload names, else the one the process stands in: the
|
|
70
|
+
// rule the turn hook ticks by, so the two always read the same session.
|
|
71
|
+
const root = (typeof payload.cwd === 'string' ? findSessionRoot(payload.cwd) : null) ?? findSessionRoot(cwd);
|
|
70
72
|
if (root === null)
|
|
71
73
|
return null;
|
|
72
74
|
const record = readRelay(root);
|
|
@@ -135,13 +137,24 @@ function sameParts(relayed, printed, counted) {
|
|
|
135
137
|
}
|
|
136
138
|
/**
|
|
137
139
|
* The facts the CLI measured, as one comparable string: the Readout table,
|
|
138
|
-
* whitespace aside, and the Verdict
|
|
140
|
+
* whitespace aside, and the Verdict's fact. Every other line of a block may be
|
|
139
141
|
* reworded or reordered without the relay counting as missed.
|
|
140
142
|
*/
|
|
141
143
|
function factsOf(parts) {
|
|
142
144
|
const readout = parts.find((p) => p.label === 'Readout');
|
|
143
145
|
const verdict = parts.find((p) => p.label === 'Verdict');
|
|
144
|
-
return `${flat(readout === undefined ? '' : tableOf(readout))}\n${flat(verdict
|
|
146
|
+
return `${flat(readout === undefined ? '' : tableOf(readout))}\n${flat(verdict === undefined ? '' : verdictOf(verdict))}`;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The Verdict's fact: the second cell of the state table's header row, where
|
|
150
|
+
* a card carries it (D111 (state-table)), or the text after the label on the
|
|
151
|
+
* line an older ending printed. Read as the fact, not the line, so a relay
|
|
152
|
+
* that changed the spacing around the table's pipes still carries the Verdict
|
|
153
|
+
* it printed.
|
|
154
|
+
*/
|
|
155
|
+
function verdictOf(part) {
|
|
156
|
+
const first = part.lines[0] ?? '';
|
|
157
|
+
return stateRowOf(first)?.value ?? first.replace(/^\*\*Verdict\*\*:\s*/, '');
|
|
145
158
|
}
|
|
146
159
|
/** The `text` fence inside the Readout part, rails included, or '' when it has none. */
|
|
147
160
|
function tableOf(part) {
|
|
@@ -180,8 +193,8 @@ function blockCount(part) {
|
|
|
180
193
|
}
|
|
181
194
|
/**
|
|
182
195
|
* An ending's labeled parts, in order: each opens on a line that starts with a
|
|
183
|
-
* bold label and a colon,
|
|
184
|
-
* the first label is not part of any.
|
|
196
|
+
* bold label and a colon, or on a row of the state table, outside a fence, and
|
|
197
|
+
* runs to the next. Text before the first label is not part of any.
|
|
185
198
|
*/
|
|
186
199
|
function partsOf(text) {
|
|
187
200
|
const parts = [];
|
|
@@ -196,7 +209,7 @@ function partsOf(text) {
|
|
|
196
209
|
parts.at(-1)?.lines.push(line);
|
|
197
210
|
continue;
|
|
198
211
|
}
|
|
199
|
-
const label = rail === null ?
|
|
212
|
+
const label = rail === null ? labelOf(line) : undefined;
|
|
200
213
|
if (label !== undefined) {
|
|
201
214
|
parts.push({ label, lines: [line] });
|
|
202
215
|
}
|
|
@@ -206,6 +219,35 @@ function partsOf(text) {
|
|
|
206
219
|
}
|
|
207
220
|
return parts;
|
|
208
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* The label a line opens a part on, or undefined when it opens none: a bold
|
|
224
|
+
* label and a colon at the line's start, or a state-table row, whose first
|
|
225
|
+
* cell is its label. Each row of the table is a part of its own, so the
|
|
226
|
+
* table's rows are read in order as the labeled lines were.
|
|
227
|
+
*/
|
|
228
|
+
function labelOf(line) {
|
|
229
|
+
return /^\*\*([^*\n]+)\*\*:(?:\s|$)/.exec(line)?.[1] ?? stateRowOf(line)?.label;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* A state-table row's label and fact, or null for any other line: a table row
|
|
233
|
+
* of two cells, its label with the bold stripped. The delimiter row and the
|
|
234
|
+
* lean table's four-cell rows are no state row, so they stay inside the part
|
|
235
|
+
* above them.
|
|
236
|
+
*/
|
|
237
|
+
function stateRowOf(line) {
|
|
238
|
+
const cells = tableCells(line);
|
|
239
|
+
const [label, value] = cells ?? [];
|
|
240
|
+
if (cells === null || cells.length !== 2 || label === undefined || value === undefined || /^:?-{3,}:?$/.test(label))
|
|
241
|
+
return null;
|
|
242
|
+
return { label: label.replace(/^\*\*(.*)\*\*$/, '$1').trim(), value };
|
|
243
|
+
}
|
|
244
|
+
/** The cells of a markdown table row, split on unescaped pipes, or null when the line is not one. */
|
|
245
|
+
function tableCells(line) {
|
|
246
|
+
const text = line.trim();
|
|
247
|
+
if (text.length < 2 || !text.startsWith('|') || !text.endsWith('|'))
|
|
248
|
+
return null;
|
|
249
|
+
return text.slice(1, -1).split(/(?<!\\)\|/).map((c) => c.trim());
|
|
250
|
+
}
|
|
209
251
|
/**
|
|
210
252
|
* The one-line reason the model acts on and the human reads in full: what the
|
|
211
253
|
* turn missed, and the command or block that puts it back.
|
package/dist/verbs/turn.js
CHANGED
|
@@ -13,28 +13,37 @@
|
|
|
13
13
|
// range the run is landing. The harness also sends a background job's
|
|
14
14
|
// completion notice through this event, as a prompt the human never typed, so
|
|
15
15
|
// a notice ticks nothing, keeps the grant and the mark, and clears only the
|
|
16
|
-
// ending `RELAY` holds from an earlier turn.
|
|
17
|
-
//
|
|
18
|
-
// session (post-compaction, or a scripted `-p` turn where no skill prose
|
|
19
|
-
// context) still knows
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
16
|
+
// ending `RELAY` holds from an earlier turn. A typed prompt also emits ONE
|
|
17
|
+
// `UserPromptSubmit` additionalContext line when there is a beat to keep, so a
|
|
18
|
+
// fresh session (post-compaction, or a scripted `-p` turn where no skill prose
|
|
19
|
+
// is in context) still knows it: mid-step, a tangent is parked, not chased;
|
|
20
|
+
// at a pause or a stop whose card has shown, the message is the human's reply,
|
|
21
|
+
// answered in the model's own words and closed on the one line `handoff`
|
|
22
|
+
// prints, never on the card again (D110 (reply-after-the-card)). Guidance
|
|
23
|
+
// only: it blocks nothing. It ALWAYS exits 0 and any failure degrades to no
|
|
24
|
+
// output, so neither the tick nor the emit can ever wedge prompting.
|
|
23
25
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
24
26
|
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";
|
|
27
|
+
import { activeBuild, clearRelay, findSessionRoot, inSpike, intentPath, notePrompt, readPause, readPrompt, readRun, setGrant, setRun, stepPath, turnPath, } from "../lib/sidecar.js";
|
|
26
28
|
import { hasStep, normalizeStepList, readBuildArgs } from "../lib/steplist.js";
|
|
29
|
+
import { waitingCard } from "./handoff.js";
|
|
27
30
|
/**
|
|
28
|
-
* The hook entry point: apply the prompt, then emit the
|
|
29
|
-
*
|
|
31
|
+
* The hook entry point: apply the prompt, then emit the nudge when it counted
|
|
32
|
+
* as a human turn.
|
|
30
33
|
*
|
|
34
|
+
* The session is the one the payload's `cwd` names when it names one, else
|
|
35
|
+
* the one the process stands in, the rule the `Stop` check follows too, so the
|
|
36
|
+
* turn that ticks and the turn that is judged are always the same session's.
|
|
31
37
|
* Always returns 0: a broken turn must never wedge a prompt, so any failure is
|
|
32
38
|
* swallowed and degrades to no output.
|
|
33
39
|
*/
|
|
34
40
|
export function turn(cwd, _args) {
|
|
35
41
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
42
|
+
const raw = readStdin();
|
|
43
|
+
const { cwd: named, prompt } = readPayload(raw);
|
|
44
|
+
const where = named !== null && findSessionRoot(named) !== null ? named : cwd;
|
|
45
|
+
if (applyTurn(where, raw)) {
|
|
46
|
+
const context = turnContext(where, prompt);
|
|
38
47
|
if (context !== null)
|
|
39
48
|
process.stdout.write(context);
|
|
40
49
|
}
|
|
@@ -45,32 +54,102 @@ export function turn(cwd, _args) {
|
|
|
45
54
|
return 0;
|
|
46
55
|
}
|
|
47
56
|
/**
|
|
48
|
-
* The `UserPromptSubmit` additionalContext payload
|
|
57
|
+
* The `UserPromptSubmit` additionalContext payload for the beat the session is
|
|
58
|
+
* on, or null when there is none to keep.
|
|
49
59
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
60
|
+
* Three beats, read off the control files. A step whose pause card has shown
|
|
61
|
+
* (STEP, and PAUSE naming that step's pause) is waiting for the human's call,
|
|
62
|
+
* so the message is the reply to it: answered or acted on in the model's own
|
|
63
|
+
* words, and closed on the one line `handoff` prints rather than the card
|
|
64
|
+
* again (D110 (reply-after-the-card)). A step in flight with no card yet is
|
|
65
|
+
* mid-build, where a tangent is a park and not an edit. A stop card waiting
|
|
66
|
+
* for its reply is the pause's beat at a stop. At a pause or a stop, a
|
|
67
|
+
* `prompt` asking to see the card (`show the pause`, the phrase the card's
|
|
68
|
+
* `expand` move teaches) gets the one instruction that matters for it: paste
|
|
69
|
+
* the `--full` output exactly as it printed, as the whole turn. Guidance only,
|
|
70
|
+
* since none of the build, run, or park prose may be in context after
|
|
71
|
+
* compaction or on a scripted turn. Exported because an in-process test cannot
|
|
72
|
+
* read the hook's real stdout.
|
|
58
73
|
*/
|
|
59
|
-
export function
|
|
74
|
+
export function turnContext(cwd, prompt = '') {
|
|
60
75
|
const root = findSessionRoot(cwd);
|
|
61
76
|
if (root === null)
|
|
62
77
|
return null;
|
|
78
|
+
const text = nudgeText(root, SHOW_REQUEST.test(prompt));
|
|
79
|
+
return text === null ? null : `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } })}\n`;
|
|
80
|
+
}
|
|
81
|
+
// A message asking to see the card again: `show the pause` and `show the stop`,
|
|
82
|
+
// the phrases the card's `expand` move teaches, and `show me the card` beside
|
|
83
|
+
// them. In a fresh session the model ran `--full` on that request and then
|
|
84
|
+
// retyped the card behind a line of its own, three runs in five of the
|
|
85
|
+
// 2026-10-27 eval, because nothing in its context said how to relay it.
|
|
86
|
+
const SHOW_REQUEST = /\bshow\s+(?:me\s+)?the\s+(?:pause|stop|card)\b/i;
|
|
87
|
+
/**
|
|
88
|
+
* The nudge's words for the beat the session is on, or null when it is on
|
|
89
|
+
* none: no step in flight and no stop card waiting. `showing` is a message
|
|
90
|
+
* that asks to see the card, which at a pause or a stop takes the show nudge.
|
|
91
|
+
*/
|
|
92
|
+
function nudgeText(root, showing) {
|
|
63
93
|
const step = inFlightStep(root);
|
|
64
|
-
if (step
|
|
94
|
+
if (step !== null) {
|
|
95
|
+
const shown = readPause(root);
|
|
96
|
+
if (shown?.kind !== 'pause' || shown.step !== step)
|
|
97
|
+
return parkNudge(step);
|
|
98
|
+
return showing ? showNudge(`step ${step}'s card`, 'plumbbob handoff --full') : pauseNudge(step);
|
|
99
|
+
}
|
|
100
|
+
const slug = activeBuild(root);
|
|
101
|
+
let intent = '';
|
|
102
|
+
try {
|
|
103
|
+
intent = readFileSync(intentPath(root, slug), 'utf8');
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
65
106
|
return null;
|
|
66
|
-
|
|
107
|
+
}
|
|
108
|
+
const card = waitingCard(root, slug, intent);
|
|
109
|
+
if (card === null)
|
|
110
|
+
return null;
|
|
111
|
+
return showing
|
|
112
|
+
? showNudge(`range ${card.range} of ${card.count}'s stop card`, 'plumbbob handoff --stop --full')
|
|
113
|
+
: stopNudge(card.range, card.count);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The nudge when the human asks to see a card again: the one command and how
|
|
117
|
+
* to relay what it prints, since the card is the whole turn and a retyped one
|
|
118
|
+
* is the miss the relay check holds.
|
|
119
|
+
*/
|
|
120
|
+
function showNudge(card, command) {
|
|
121
|
+
return (`plumbbob: the human asked to see ${card} again. ` +
|
|
122
|
+
`Run \`${command}\` and paste its output exactly as it printed, as the whole turn: ` +
|
|
123
|
+
`no line before it, nothing after it, and nothing in it retyped, reworded, or cut.`);
|
|
124
|
+
}
|
|
125
|
+
/** The mid-build nudge: a tangent is a park, not an edit, and the step lands on approval. */
|
|
126
|
+
function parkNudge(step) {
|
|
127
|
+
return (`plumbbob: step ${step} is in flight — a new idea or tangent is a park, not an edit: ` +
|
|
67
128
|
`capture it with \`plumbbob park "<one line>"\` and relay the block it prints whole, ` +
|
|
68
129
|
`with nothing written around it, since that block is the whole turn. ` +
|
|
69
130
|
`The one exception is a message that also asks something: answer the question first, ` +
|
|
70
131
|
`then relay the park's block after the answer and write nothing after it. ` +
|
|
71
132
|
`The block's pointer back at the step closes the turn either way, so run no \`plumbbob handoff\` after it. ` +
|
|
72
|
-
`The step stays open and lands when you run \`plumbbob checkpoint ${step}\` after approval
|
|
73
|
-
|
|
133
|
+
`The step stays open and lands when you run \`plumbbob checkpoint ${step}\` after approval.`);
|
|
134
|
+
}
|
|
135
|
+
/** The nudge at a pause whose card has shown: the message is the reply, and the card is not printed again. */
|
|
136
|
+
function pauseNudge(step) {
|
|
137
|
+
return (`plumbbob: step ${step}'s card has shown, and this message is the human's reply to it. ` +
|
|
138
|
+
`An approval lands the step: run \`plumbbob checkpoint ${step}\` and relay its ending. ` +
|
|
139
|
+
`Anything else is answered in your own words: answer the question, or make the change it asks for, ` +
|
|
140
|
+
`and when a change moves what the card said, write \`.plumbbob/detail.md\` again so the record matches what lands. ` +
|
|
141
|
+
`Print no card for it: run \`plumbbob handoff\` and end the turn on the one line it prints, with nothing after it. ` +
|
|
142
|
+
`Only when the human asks to see the card again, run \`plumbbob handoff --full\` and paste its output exactly as it printed, as the whole turn. ` +
|
|
143
|
+
`A tangent the human hands you is still a park: \`plumbbob park "<one line>"\`.`);
|
|
144
|
+
}
|
|
145
|
+
/** The nudge at a stop card waiting for its reply: the stop's version of the pause's. */
|
|
146
|
+
function stopNudge(range, count) {
|
|
147
|
+
return (`plumbbob: range ${range} of ${count}'s stop card has shown, and this message is the human's reply to it. ` +
|
|
148
|
+
`An approval records the stop: fold each confirmed blocker into the plan, then run \`plumbbob harvest\` and relay its ending. ` +
|
|
149
|
+
`Anything else is answered in your own words: answer the question, or make the change it asks for ` +
|
|
150
|
+
`(a lean is its line under the detail file's \`## Harvest\`). ` +
|
|
151
|
+
`Print no card for it: run \`plumbbob handoff --stop\` and end the turn on the one line it prints, with nothing after it. ` +
|
|
152
|
+
`Only when the human asks to see the card again, run \`plumbbob handoff --stop --full\` and paste its output exactly as it printed, as the whole turn.`);
|
|
74
153
|
}
|
|
75
154
|
/**
|
|
76
155
|
* The in-flight step number, or null when no STEP marker is present.
|
|
@@ -251,13 +330,13 @@ function readBuild(root) {
|
|
|
251
330
|
}
|
|
252
331
|
}
|
|
253
332
|
/**
|
|
254
|
-
* The `prompt` and `
|
|
255
|
-
* null when
|
|
333
|
+
* The `prompt`, `prompt_id`, and `cwd` fields of the UserPromptSubmit JSON, as
|
|
334
|
+
* '' and null when any is absent.
|
|
256
335
|
*
|
|
257
336
|
* Absent, unparseable, or non-string input contributes nothing rather than
|
|
258
337
|
* wedging the tool: the tick still lands (a human turn did occur), the empty
|
|
259
|
-
* prompt clears any GRANT,
|
|
260
|
-
* throws over bad stdin.
|
|
338
|
+
* prompt clears any GRANT, a missing id records none, and a missing `cwd`
|
|
339
|
+
* leaves the process's; the verb never throws over bad stdin.
|
|
261
340
|
*/
|
|
262
341
|
function readPayload(raw) {
|
|
263
342
|
try {
|
|
@@ -265,10 +344,11 @@ function readPayload(raw) {
|
|
|
265
344
|
return {
|
|
266
345
|
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : '',
|
|
267
346
|
promptId: typeof parsed.prompt_id === 'string' && parsed.prompt_id !== '' ? parsed.prompt_id : null,
|
|
347
|
+
cwd: typeof parsed.cwd === 'string' && parsed.cwd !== '' ? parsed.cwd : null,
|
|
268
348
|
};
|
|
269
349
|
}
|
|
270
350
|
catch {
|
|
271
|
-
return { prompt: '', promptId: null };
|
|
351
|
+
return { prompt: '', promptId: null, cwd: null };
|
|
272
352
|
}
|
|
273
353
|
}
|
|
274
354
|
/**
|
package/package.json
CHANGED