plumbbob 0.12.1 → 0.13.0
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 +12 -7
- package/dist/cli-core.js +14 -3
- package/dist/lib/git.js +20 -8
- package/dist/lib/notice.js +15 -1
- package/dist/lib/orient.js +43 -1
- package/dist/lib/sidecar.js +149 -3
- package/dist/verbs/abandon.js +19 -14
- package/dist/verbs/checkpoint.js +30 -21
- package/dist/verbs/doctor.js +23 -4
- package/dist/verbs/finish.js +21 -17
- package/dist/verbs/handoff.js +177 -58
- package/dist/verbs/order.js +17 -9
- package/dist/verbs/park.js +6 -2
- package/dist/verbs/relay.js +306 -0
- package/dist/verbs/revert.js +17 -11
- package/dist/verbs/spike.js +50 -38
- package/dist/verbs/start.js +13 -9
- package/dist/verbs/status.js +2 -2
- package/dist/verbs/turn.js +9 -3
- package/dist/verbs/use.js +21 -17
- package/hooks/hooks.json +7 -0
- package/package.json +1 -1
- package/skills/build/SKILL.md +35 -16
- package/skills/park/SKILL.md +7 -0
- package/skills/plan/SKILL.md +1 -0
- package/skills/verify/SKILL.md +34 -17
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
// `plumbbob relay`: the relay check. Wired to the harness's Stop event, it runs
|
|
2
|
+
// as the model ends a turn and asks one question: did the turn carry the ending
|
|
3
|
+
// plumbbob printed in it? Every ending a verb prints is recorded in `RELAY`
|
|
4
|
+
// with its tier and the `TURN` it printed in, and the tier picks the match. A
|
|
5
|
+
// decision ending (a pause card, its reminder, the plan card) has to close the
|
|
6
|
+
// turn, with an answer allowed above it; an orientation ending (a checkpoint's
|
|
7
|
+
// boundary, the plan commit, finish) has to appear somewhere in the turn, since
|
|
8
|
+
// an approving message can carry a question the model answers after it; a
|
|
9
|
+
// driver ending is never checked. A `RELAY` from an earlier turn is not this
|
|
10
|
+
// turn's to carry, so the check runs only while its turn is the current one.
|
|
11
|
+
//
|
|
12
|
+
// It reads a relay by its parts, not line for line: the labeled parts the
|
|
13
|
+
// printed block has, in its order, with no paragraph inside a part that the
|
|
14
|
+
// block does not have there (for a decision ending, after the last part too).
|
|
15
|
+
// Only the measured facts are compared exactly, the Readout
|
|
16
|
+
// table and the Verdict line, because those are what a laundered relay would
|
|
17
|
+
// change; a reworded Summary or Recommendation is the model's own prose, and a
|
|
18
|
+
// reordered Your Call move still offers every move.
|
|
19
|
+
//
|
|
20
|
+
// On a miss it holds the turn open once, through `additionalContext`, with a
|
|
21
|
+
// one-line reason the model acts on and the human reads under `Stop hook
|
|
22
|
+
// feedback`: a decision ending is reprinted by the command `RELAY` recorded,
|
|
23
|
+
// because the stored card goes stale once the model keeps working, and an
|
|
24
|
+
// orientation ending is pasted as it printed, since it reports a commit that
|
|
25
|
+
// has already landed. The once is counted in `RELAY` by the turn it held,
|
|
26
|
+
// never by `stop_hook_active`, which checkride's gate sets on every red Stop
|
|
27
|
+
// and which names no hook. It never sends `continue: false`, which would cancel
|
|
28
|
+
// a sibling's block in the same round.
|
|
29
|
+
//
|
|
30
|
+
// The final message comes from the payload's `last_assistant_message`, never
|
|
31
|
+
// from the transcript, which does not yet hold that message when Stop fires;
|
|
32
|
+
// the transcript supplies only the turn's earlier messages, which an
|
|
33
|
+
// orientation ending can sit in. It ALWAYS exits 0, and anything it cannot read
|
|
34
|
+
// (a payload without the field, a transcript it cannot scope) degrades to no
|
|
35
|
+
// output: a broken check must never wedge a turn's end.
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { findSessionRoot, holdRelay, readRelay, readTurn } from "../lib/sidecar.js";
|
|
38
|
+
/**
|
|
39
|
+
* The hook entry point: read the Stop payload, and hold the turn open once when
|
|
40
|
+
* it did not carry its ending.
|
|
41
|
+
*
|
|
42
|
+
* Always returns 0: any failure is swallowed and degrades to no output.
|
|
43
|
+
*/
|
|
44
|
+
export function relay(cwd, _args) {
|
|
45
|
+
try {
|
|
46
|
+
const output = relayCheck(cwd, readStdin());
|
|
47
|
+
if (output !== null)
|
|
48
|
+
process.stdout.write(output);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A broken check must never wedge a turn's end: swallow and exit 0.
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The hook's stdout for one Stop payload: the `additionalContext` that holds
|
|
57
|
+
* the turn open, or null when the turn carried its ending or there is nothing
|
|
58
|
+
* this check can judge.
|
|
59
|
+
*
|
|
60
|
+
* Exported because an in-process test cannot feed the hook's real stdin.
|
|
61
|
+
*/
|
|
62
|
+
export function relayCheck(cwd, raw) {
|
|
63
|
+
const payload = parsePayload(raw);
|
|
64
|
+
// A host too old to send the final message has nothing to check against.
|
|
65
|
+
if (payload === null || typeof payload.last_assistant_message !== 'string')
|
|
66
|
+
return null;
|
|
67
|
+
const root = findSessionRoot(typeof payload.cwd === 'string' ? payload.cwd : cwd);
|
|
68
|
+
if (root === null)
|
|
69
|
+
return null;
|
|
70
|
+
const record = readRelay(root);
|
|
71
|
+
const turn = readTurn(root);
|
|
72
|
+
if (record === null || turn === null || record.turn !== turn || record.held === turn)
|
|
73
|
+
return null;
|
|
74
|
+
const carried = carriedEnding(record, payload.last_assistant_message, payload);
|
|
75
|
+
// Whole, or not this check's to judge: let the turn end.
|
|
76
|
+
if (carried !== false)
|
|
77
|
+
return null;
|
|
78
|
+
// A hold that cannot be counted could repeat, so it is no hold at all.
|
|
79
|
+
if (!holdRelay(root, record))
|
|
80
|
+
return null;
|
|
81
|
+
return `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'Stop', additionalContext: reason(record) } })}\n`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether the turn carried the recorded ending: true when it did, false when
|
|
85
|
+
* it did not, and null when the tier is not checked or the turn's earlier text
|
|
86
|
+
* cannot be read to tell.
|
|
87
|
+
*/
|
|
88
|
+
function carriedEnding(record, last, payload) {
|
|
89
|
+
const printed = partsOf(record.text);
|
|
90
|
+
// A block with no labeled part is nothing this reader can match against.
|
|
91
|
+
if (printed.length === 0 || record.tier === 'driver')
|
|
92
|
+
return null;
|
|
93
|
+
if (record.tier === 'decision')
|
|
94
|
+
return closesOn(partsOf(last), printed);
|
|
95
|
+
if (appearsIn(partsOf(last), printed))
|
|
96
|
+
return true;
|
|
97
|
+
const earlier = turnText(payload);
|
|
98
|
+
return earlier === null ? null : appearsIn(partsOf(earlier), printed);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Whether a final message closes on the printed block: read from the last
|
|
102
|
+
* place it opens the block's first part, it has the same parts in the same
|
|
103
|
+
* order, the same measured facts, and no paragraph in any part, the last one
|
|
104
|
+
* included, that the block does not have there.
|
|
105
|
+
*/
|
|
106
|
+
function closesOn(relayed, printed) {
|
|
107
|
+
const first = printed[0]?.label;
|
|
108
|
+
const start = relayed.findLastIndex((p) => p.label === first);
|
|
109
|
+
return start !== -1 && sameParts(relayed.slice(start), printed, printed.length);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether the printed block appears anywhere in a stretch of text: some place
|
|
113
|
+
* that opens its first part runs on through the same parts in the same order,
|
|
114
|
+
* with the same measured facts and nothing written inside them. Text after the
|
|
115
|
+
* last part is the turn's to say, so that part's paragraphs are not counted.
|
|
116
|
+
*/
|
|
117
|
+
function appearsIn(relayed, printed) {
|
|
118
|
+
return relayed.some((p, i) => p.label === printed[0]?.label && sameParts(relayed.slice(i, i + printed.length), printed, printed.length - 1));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Whether two runs of parts carry the same labels in order and the same
|
|
122
|
+
* measured facts, with no more paragraphs than the printed block has in each
|
|
123
|
+
* of the first `counted` parts.
|
|
124
|
+
*/
|
|
125
|
+
function sameParts(relayed, printed, counted) {
|
|
126
|
+
if (relayed.length !== printed.length || relayed.some((p, i) => p.label !== printed[i]?.label))
|
|
127
|
+
return false;
|
|
128
|
+
const crowded = relayed.slice(0, counted).some((p, i) => blockCount(p) > blockCount(printed[i]));
|
|
129
|
+
return !crowded && factsOf(relayed) === factsOf(printed);
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* The facts the CLI measured, as one comparable string: the Readout table,
|
|
133
|
+
* whitespace aside, and the Verdict line. Every other line of a block may be
|
|
134
|
+
* reworded or reordered without the relay counting as missed.
|
|
135
|
+
*/
|
|
136
|
+
function factsOf(parts) {
|
|
137
|
+
const readout = parts.find((p) => p.label === 'Readout');
|
|
138
|
+
const verdict = parts.find((p) => p.label === 'Verdict');
|
|
139
|
+
return `${flat(readout === undefined ? '' : tableOf(readout))}\n${flat(verdict?.lines[0] ?? '')}`;
|
|
140
|
+
}
|
|
141
|
+
/** The `text` fence inside the Readout part, rails included, or '' when it has none. */
|
|
142
|
+
function tableOf(part) {
|
|
143
|
+
const open = part.lines.findIndex((l) => /^`{3,}text\s*$/.test(l));
|
|
144
|
+
if (open === -1)
|
|
145
|
+
return '';
|
|
146
|
+
const rail = /^`+/.exec(part.lines[open] ?? '')?.[0] ?? '```';
|
|
147
|
+
const close = part.lines.findIndex((l, i) => i > open && l.trim() === rail);
|
|
148
|
+
return part.lines.slice(open, close === -1 ? undefined : close + 1).join('\n');
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* How many blocks a part holds: runs of lines between blank lines, a fence
|
|
152
|
+
* counted whole, and the items of one list counted once even when a relay
|
|
153
|
+
* spaced them apart. A paragraph after a part's last block is the model
|
|
154
|
+
* talking past the ending.
|
|
155
|
+
*/
|
|
156
|
+
function blockCount(part) {
|
|
157
|
+
let count = 0;
|
|
158
|
+
let open = false;
|
|
159
|
+
let listing = false;
|
|
160
|
+
let inFence = false;
|
|
161
|
+
for (const line of part.lines) {
|
|
162
|
+
if (/^\s*`{3,}/.test(line))
|
|
163
|
+
inFence = !inFence;
|
|
164
|
+
if (line.trim().length === 0 && !inFence) {
|
|
165
|
+
open = false;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const item = /^\s*(?:[-*]|\d+\.)\s/.test(line);
|
|
169
|
+
if (!open && !(item && listing))
|
|
170
|
+
count += 1;
|
|
171
|
+
open = true;
|
|
172
|
+
listing = item;
|
|
173
|
+
}
|
|
174
|
+
return count;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* An ending's labeled parts, in order: each opens on a line that starts with a
|
|
178
|
+
* bold label and a colon, outside a fence, and runs to the next. Text before
|
|
179
|
+
* the first label is not part of any.
|
|
180
|
+
*/
|
|
181
|
+
function partsOf(text) {
|
|
182
|
+
const parts = [];
|
|
183
|
+
let rail = null;
|
|
184
|
+
for (const line of text.split('\n')) {
|
|
185
|
+
const fence = /^\s*(`{3,})/.exec(line)?.[1];
|
|
186
|
+
if (rail === null && fence !== undefined) {
|
|
187
|
+
rail = fence;
|
|
188
|
+
}
|
|
189
|
+
else if (rail !== null && fence !== undefined && fence.length >= rail.length && line.trim() === fence) {
|
|
190
|
+
rail = null;
|
|
191
|
+
parts.at(-1)?.lines.push(line);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const label = rail === null ? /^\*\*([^*\n]+)\*\*:(?:\s|$)/.exec(line)?.[1] : undefined;
|
|
195
|
+
if (label !== undefined) {
|
|
196
|
+
parts.push({ label, lines: [line] });
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
parts.at(-1)?.lines.push(line);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return parts;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The one-line reason the model acts on and the human reads in full: what the
|
|
206
|
+
* turn missed, and the command or block that puts it back.
|
|
207
|
+
*/
|
|
208
|
+
function reason(record) {
|
|
209
|
+
if (record.tier === 'decision') {
|
|
210
|
+
const command = record.command ?? 'plumbbob handoff --full';
|
|
211
|
+
return (`plumbbob: the turn did not close on the ${DECISION_NAMES[record.render] ?? 'pause'} it printed. ` +
|
|
212
|
+
`Run \`${command}\` and end the turn on its output, whole, with nothing after it.`);
|
|
213
|
+
}
|
|
214
|
+
const verb = ORIENTATION_VERBS[record.render] ?? 'plumbbob handoff';
|
|
215
|
+
return (`plumbbob: the turn did not relay the block \`${verb}\` printed. ` +
|
|
216
|
+
'Paste that block whole, exactly as it printed (it is stored below the header in `.plumbbob/RELAY`).');
|
|
217
|
+
}
|
|
218
|
+
// What a decision ending is called in the reason, by its render.
|
|
219
|
+
const DECISION_NAMES = {
|
|
220
|
+
card: 'pause card',
|
|
221
|
+
reminder: 'pause reminder',
|
|
222
|
+
plan: 'plan card',
|
|
223
|
+
};
|
|
224
|
+
// The command that printed an orientation ending, by its render, so the reason
|
|
225
|
+
// names the block the model already has in front of it.
|
|
226
|
+
const ORIENTATION_VERBS = {
|
|
227
|
+
checkpoint: 'plumbbob checkpoint',
|
|
228
|
+
'plan-commit': 'plumbbob checkpoint --plan',
|
|
229
|
+
finish: 'plumbbob finish',
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* The text of the turn's messages already on disk: every assistant text part
|
|
233
|
+
* after the human prompt the payload's `prompt_id` names.
|
|
234
|
+
*
|
|
235
|
+
* Null when the transcript cannot be read or that prompt is not in it: a turn
|
|
236
|
+
* the check cannot scope is a turn it does not judge. A line that does not
|
|
237
|
+
* parse is skipped, since the one being written as Stop fires is the final
|
|
238
|
+
* message, which the payload carries already.
|
|
239
|
+
*/
|
|
240
|
+
function turnText(payload) {
|
|
241
|
+
if (typeof payload.transcript_path !== 'string' || typeof payload.prompt_id !== 'string')
|
|
242
|
+
return null;
|
|
243
|
+
let lines;
|
|
244
|
+
try {
|
|
245
|
+
lines = readFileSync(payload.transcript_path, 'utf8').split('\n');
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const entries = lines.map(parseEntry).filter((e) => e !== null);
|
|
251
|
+
const start = entries.findIndex((e) => e.type === 'user' && e.promptId === payload.prompt_id && e.isMeta !== true);
|
|
252
|
+
if (start === -1)
|
|
253
|
+
return null;
|
|
254
|
+
return entries
|
|
255
|
+
.slice(start + 1)
|
|
256
|
+
.filter((e) => e.type === 'assistant')
|
|
257
|
+
.flatMap((e) => {
|
|
258
|
+
const content = e.message?.content;
|
|
259
|
+
return Array.isArray(content) ? content : [];
|
|
260
|
+
})
|
|
261
|
+
.flatMap((part) => (part?.type === 'text' && typeof part.text === 'string' ? [part.text] : []))
|
|
262
|
+
.join('\n');
|
|
263
|
+
}
|
|
264
|
+
/** One transcript line as an entry, or null when it is blank or does not parse. */
|
|
265
|
+
function parseEntry(line) {
|
|
266
|
+
if (line.trim().length === 0)
|
|
267
|
+
return null;
|
|
268
|
+
try {
|
|
269
|
+
const entry = JSON.parse(line);
|
|
270
|
+
return typeof entry === 'object' && entry !== null ? entry : null;
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** The Stop payload, or null when stdin is not a JSON object. */
|
|
277
|
+
function parsePayload(raw) {
|
|
278
|
+
try {
|
|
279
|
+
const parsed = JSON.parse(raw);
|
|
280
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null;
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Text with every run of whitespace collapsed to one space and the ends
|
|
288
|
+
* trimmed, so a measured fact still matches when a relay rewrapped it.
|
|
289
|
+
*/
|
|
290
|
+
function flat(text) {
|
|
291
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* The hook payload from stdin, or '' on a TTY or any read failure: a check
|
|
295
|
+
* that cannot read its payload judges nothing.
|
|
296
|
+
*/
|
|
297
|
+
function readStdin() {
|
|
298
|
+
if (process.stdin.isTTY === true)
|
|
299
|
+
return '';
|
|
300
|
+
try {
|
|
301
|
+
return readFileSync(0, 'utf8');
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return '';
|
|
305
|
+
}
|
|
306
|
+
}
|
package/dist/verbs/revert.js
CHANGED
|
@@ -20,10 +20,10 @@ import { tmpdir } from 'node:os';
|
|
|
20
20
|
import { join } from 'node:path';
|
|
21
21
|
import { fileURLToPath } from 'node:url';
|
|
22
22
|
import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
|
|
23
|
-
import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
|
|
23
|
+
import { bumpStepStat, checkpointsPath, clearPause, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
|
|
24
24
|
import { isArtifactPath, matchesSeam } from "../lib/intent.js";
|
|
25
25
|
import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
|
|
26
|
-
import { ending, notice, transition } from "../lib/notice.js";
|
|
26
|
+
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
27
27
|
import { driverPointer } from "./handoff.js";
|
|
28
28
|
/**
|
|
29
29
|
* Rewind to a recorded checkpoint and return the build to the boundary.
|
|
@@ -31,8 +31,9 @@ import { driverPointer } from "./handoff.js";
|
|
|
31
31
|
* Requires an active session (the `.plumbbob/STATE` sentinel). Resolves the
|
|
32
32
|
* target SHA from the checkpoints ledger (latest step, `--to n`, or the
|
|
33
33
|
* baseline), resets hard while preserving plumbbob-owned paths, removes
|
|
34
|
-
* untracked files inside the seam, clears the in-flight STEP/SEAM markers
|
|
35
|
-
* re-renders the build-log. Exit 1 with a hint on any
|
|
34
|
+
* untracked files inside the seam, clears the in-flight STEP/SEAM markers and
|
|
35
|
+
* the PAUSE stamp, and re-renders the build-log. Exit 1 with a hint on any
|
|
36
|
+
* refusal.
|
|
36
37
|
*/
|
|
37
38
|
export function revert(cwd, args) {
|
|
38
39
|
const root = findRepoRoot(cwd);
|
|
@@ -77,6 +78,7 @@ export function revert(cwd, args) {
|
|
|
77
78
|
}
|
|
78
79
|
rmSync(seamPath(root, slug), { force: true });
|
|
79
80
|
rmSync(stepPath(root, slug), { force: true });
|
|
81
|
+
clearPause(root, slug); // the pause it stamped is gone with the work
|
|
80
82
|
if (inFlight !== null) {
|
|
81
83
|
bumpStepStat(root, slug, inFlight, 'reverts');
|
|
82
84
|
}
|
|
@@ -87,14 +89,18 @@ export function revert(cwd, args) {
|
|
|
87
89
|
syncBuildLogState(root, slug, AT_BOUNDARY);
|
|
88
90
|
// A step exit: nothing landed, so no Verdict rides above the pointer, and with
|
|
89
91
|
// the markers cleared it aims forward from the boundary.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
92
|
+
printEnding(root, {
|
|
93
|
+
tier: 'driver',
|
|
94
|
+
render: 'revert',
|
|
95
|
+
text: ending({
|
|
96
|
+
lead: transition({
|
|
97
|
+
label: 'Reverted',
|
|
98
|
+
fact: `to ${sha.slice(0, 9)}`,
|
|
99
|
+
detail: ['park lines and intent edits preserved'],
|
|
100
|
+
}),
|
|
101
|
+
pointer: driverPointer(root, slug),
|
|
95
102
|
}),
|
|
96
|
-
|
|
97
|
-
}));
|
|
103
|
+
});
|
|
98
104
|
return 0;
|
|
99
105
|
}
|
|
100
106
|
/**
|
package/dist/verbs/spike.js
CHANGED
|
@@ -21,7 +21,7 @@ import { basename, dirname, join, relative } from 'node:path';
|
|
|
21
21
|
import { findRepoRoot } from "../lib/git.js";
|
|
22
22
|
import { buildFolder, clearSpike, hasSession, inSpike, listSpikeReports, markSpike, nextSpikeReportPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
23
23
|
import { readTemplate, stampTemplate } from "../lib/templates.js";
|
|
24
|
-
import { advisory, blocks, ending, notice, transition } from "../lib/notice.js";
|
|
24
|
+
import { advisory, blocks, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
25
25
|
import { driverPointer } from "./handoff.js";
|
|
26
26
|
// Worktree/branch names when the caller lists none: a fork defaults to two arms.
|
|
27
27
|
const DEFAULT_OPTIONS = ['a', 'b'];
|
|
@@ -69,15 +69,19 @@ function spikeReport(root, buildSlug, positionals) {
|
|
|
69
69
|
const inFlight = readInFlightStep(root, buildSlug);
|
|
70
70
|
const via = inFlight !== null ? `step ${inFlight}` : '/plumbbob:spike';
|
|
71
71
|
const path = scaffoldSpikeReport(root, buildSlug, slug, via);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
72
|
+
printEnding(root, {
|
|
73
|
+
tier: 'driver',
|
|
74
|
+
render: 'spike-report',
|
|
75
|
+
text: ending({
|
|
76
|
+
lead: transition({
|
|
77
|
+
label: 'Spike report',
|
|
78
|
+
fact: 'scaffolded',
|
|
79
|
+
detail: [relative(root, path)],
|
|
80
|
+
remedy: 'record Findings and the Verdict there, which is what closes a spike step',
|
|
81
|
+
}),
|
|
82
|
+
pointer: driverPointer(root, buildSlug),
|
|
78
83
|
}),
|
|
79
|
-
|
|
80
|
-
}));
|
|
84
|
+
});
|
|
81
85
|
return 0;
|
|
82
86
|
}
|
|
83
87
|
/**
|
|
@@ -158,22 +162,26 @@ function spikeStart(root, buildSlug, positionals) {
|
|
|
158
162
|
// The lead states the fact; the throwaway worktrees are a list, so they ride
|
|
159
163
|
// as a readout beneath it rather than crowding the line, and the report the
|
|
160
164
|
// spike scaffolded is a second landing under the same lead.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
165
|
+
printEnding(root, {
|
|
166
|
+
tier: 'driver',
|
|
167
|
+
render: 'spike-open',
|
|
168
|
+
text: ending({
|
|
169
|
+
lead: blocks([
|
|
170
|
+
transition({
|
|
171
|
+
label: 'Spike',
|
|
172
|
+
fact: 'opened',
|
|
173
|
+
detail: ['the main tree stays put', `${created.length} throwaway worktree${created.length === 1 ? '' : 's'}`],
|
|
174
|
+
}) + created.map((path) => ` ${path}`).join('\n'),
|
|
175
|
+
transition({
|
|
176
|
+
label: 'Spike report',
|
|
177
|
+
fact: 'scaffolded',
|
|
178
|
+
detail: [relative(root, report)],
|
|
179
|
+
remedy: 'record findings and the Verdict there, then run plumbbob spike done',
|
|
180
|
+
}),
|
|
181
|
+
]),
|
|
182
|
+
pointer: driverPointer(root, buildSlug),
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
177
185
|
return 0;
|
|
178
186
|
}
|
|
179
187
|
/**
|
|
@@ -203,19 +211,23 @@ function spikeDone(root, buildSlug) {
|
|
|
203
211
|
// closes. It follows the line it qualifies, and names the reports so the
|
|
204
212
|
// human knows where to write the call. The pointer, read once the marker is
|
|
205
213
|
// cleared, aims back at the step the spike interrupted, or forward.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
: [
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
214
|
+
printEnding(root, {
|
|
215
|
+
tier: 'driver',
|
|
216
|
+
render: 'spike-done',
|
|
217
|
+
text: ending({
|
|
218
|
+
lead: transition({ label: 'Spike', fact: 'closed', detail: ['worktrees and branches removed'] }),
|
|
219
|
+
advisories: unfilled.length === 0
|
|
220
|
+
? []
|
|
221
|
+
: [
|
|
222
|
+
advisory({
|
|
223
|
+
fact: 'no verdict recorded',
|
|
224
|
+
detail: unfilled,
|
|
225
|
+
remedy: 'record which option won, and why, now the worktrees are gone',
|
|
226
|
+
}),
|
|
227
|
+
],
|
|
228
|
+
pointer: driverPointer(root, buildSlug),
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
219
231
|
return 0;
|
|
220
232
|
}
|
|
221
233
|
/**
|
package/dist/verbs/start.js
CHANGED
|
@@ -10,7 +10,7 @@ import { readTemplate, stampTemplate } from "../lib/templates.js";
|
|
|
10
10
|
import { findRepoRoot, hasCommit, headSha, isDirty, isIgnored } from "../lib/git.js";
|
|
11
11
|
import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
|
|
12
12
|
import { settingsPath } from "../lib/settings.js";
|
|
13
|
-
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
13
|
+
import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
14
14
|
/**
|
|
15
15
|
* Human-readable echo of the default check gate, stamped into the templates'
|
|
16
16
|
* {{CHECK}} line.
|
|
@@ -177,15 +177,19 @@ export async function start(cwd, args) {
|
|
|
177
177
|
// No pointer: the plan skill owns the turn that follows, and there is no step
|
|
178
178
|
// yet to aim at, so the ending closes on the remedy line that says where to
|
|
179
179
|
// frame the build.
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
printEnding(root, {
|
|
181
|
+
tier: 'driver',
|
|
182
|
+
render: 'start',
|
|
183
|
+
text: ending({
|
|
184
|
+
lead: transition({
|
|
185
|
+
label: 'Session',
|
|
186
|
+
fact: `started "${title}"`,
|
|
187
|
+
detail: [`baseline ${sha.slice(0, 9)}`],
|
|
188
|
+
remedy: `frame and decide in ${intentLocation}, then build a step`,
|
|
189
|
+
}),
|
|
190
|
+
advisories,
|
|
186
191
|
}),
|
|
187
|
-
|
|
188
|
-
}));
|
|
192
|
+
});
|
|
189
193
|
return 0;
|
|
190
194
|
}
|
|
191
195
|
/**
|
package/dist/verbs/status.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
|
|
7
7
|
import { readFileSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
|
-
import { commitsSince, findRepoRoot } from "../lib/git.js";
|
|
9
|
+
import { PLAN_COMMIT_MARKER, commitsSince, findRepoRoot } from "../lib/git.js";
|
|
10
10
|
import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
|
|
11
11
|
import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
12
12
|
import { formatOrientation, lastLedgerSha, orient, parseRequestedStep } from "../lib/orient.js";
|
|
@@ -80,7 +80,7 @@ export function status(cwd, args = []) {
|
|
|
80
80
|
inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
|
|
81
81
|
spiking: inSpike(root, slug),
|
|
82
82
|
requested: parseRequestedStep(invoked),
|
|
83
|
-
outOfBand: anchor === null ? 0 : commitsSince(root, anchor),
|
|
83
|
+
outOfBand: anchor === null ? 0 : commitsSince(root, anchor, PLAN_COMMIT_MARKER),
|
|
84
84
|
});
|
|
85
85
|
const lines = [formatOrientation(orientation), ...harnessSection(root, slug)];
|
|
86
86
|
process.stdout.write(`${lines.join('\n')}\n`);
|
package/dist/verbs/turn.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
// could forge would be no grant). When a step is in flight it also emits ONE
|
|
11
11
|
// `UserPromptSubmit` additionalContext line so a fresh session (post-compaction,
|
|
12
12
|
// or a scripted `-p` turn where no skill prose is in context) still knows the
|
|
13
|
-
// beat: a tangent is parked, not chased
|
|
13
|
+
// beat: a tangent is parked, not chased, and a message that also asks something
|
|
14
|
+
// is answered before the park's block. Guidance only: it blocks nothing. It
|
|
14
15
|
// ALWAYS exits 0 and any failure degrades to no output, so neither the tick nor
|
|
15
16
|
// the emit can ever wedge prompting.
|
|
16
17
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
@@ -39,8 +40,10 @@ export function turn(cwd, _args) {
|
|
|
39
40
|
* A step is in flight when a STEP marker (the flat control file recording the
|
|
40
41
|
* step between `build` and `checkpoint`) is present. Guidance only: it reminds a
|
|
41
42
|
* fresh session that a tangent is a park (not an edit), that the park's own
|
|
42
|
-
* block is the whole turn
|
|
43
|
-
*
|
|
43
|
+
* block is the whole turn when the message is only a tangent, that a message
|
|
44
|
+
* which also asks something is answered first with the block after the answer,
|
|
45
|
+
* and how the step lands, since neither the build nor the park prose may be in
|
|
46
|
+
* context after compaction or on a scripted turn.
|
|
44
47
|
* Exported because an in-process test cannot read the hook's real stdout.
|
|
45
48
|
*/
|
|
46
49
|
export function stepInFlightContext(cwd) {
|
|
@@ -53,6 +56,9 @@ export function stepInFlightContext(cwd) {
|
|
|
53
56
|
const text = `plumbbob: step ${step} is in flight — a new idea or tangent is a park, not an edit: ` +
|
|
54
57
|
`capture it with \`plumbbob park "<one line>"\` and relay the block it prints whole, ` +
|
|
55
58
|
`with nothing written around it, since that block is the whole turn. ` +
|
|
59
|
+
`The one exception is a message that also asks something: answer the question first, ` +
|
|
60
|
+
`then relay the park's block after the answer and write nothing after it. ` +
|
|
61
|
+
`The block's pointer back at the step closes the turn either way, so run no \`plumbbob handoff\` after it. ` +
|
|
56
62
|
`The step stays open and lands when you run \`plumbbob checkpoint ${step}\` after approval.`;
|
|
57
63
|
return `${JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } })}\n`;
|
|
58
64
|
}
|
package/dist/verbs/use.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { findRepoRoot } from "../lib/git.js";
|
|
13
13
|
import { activeBuild, hasSession, intentPath, listBuilds, setActiveBuild, stepPath } from "../lib/sidecar.js";
|
|
14
|
-
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
14
|
+
import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
15
15
|
import { driverPointer } from "./handoff.js";
|
|
16
16
|
/**
|
|
17
17
|
* Switch the active-build cursor to the named build.
|
|
@@ -43,22 +43,26 @@ export function use(cwd, args) {
|
|
|
43
43
|
// The lead line, then the advisory it qualifies, then the pointer into the
|
|
44
44
|
// build just switched to: one fixed order for every ending, so the relay never
|
|
45
45
|
// has to guess which line leads.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
printEnding(root, {
|
|
47
|
+
tier: 'driver',
|
|
48
|
+
render: 'use',
|
|
49
|
+
text: ending({
|
|
50
|
+
lead: transition({
|
|
51
|
+
label: 'Active build',
|
|
52
|
+
fact: target,
|
|
53
|
+
detail: existsSync(stepPath(root, target)) ? ['a step is in flight'] : [],
|
|
54
|
+
}),
|
|
55
|
+
advisories: left === null
|
|
56
|
+
? []
|
|
57
|
+
: [
|
|
58
|
+
advisory({
|
|
59
|
+
fact: `build "${left}" has a step in flight`,
|
|
60
|
+
detail: ['its in-flight state is preserved'],
|
|
61
|
+
remedy: `plumbbob use ${left} to pick it back up`,
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
pointer: driverPointer(root, target),
|
|
51
65
|
}),
|
|
52
|
-
|
|
53
|
-
? []
|
|
54
|
-
: [
|
|
55
|
-
advisory({
|
|
56
|
-
fact: `build "${left}" has a step in flight`,
|
|
57
|
-
detail: ['its in-flight state is preserved'],
|
|
58
|
-
remedy: `plumbbob use ${left} to pick it back up`,
|
|
59
|
-
}),
|
|
60
|
-
],
|
|
61
|
-
pointer: driverPointer(root, target),
|
|
62
|
-
}));
|
|
66
|
+
});
|
|
63
67
|
return 0;
|
|
64
68
|
}
|
package/hooks/hooks.json
CHANGED
package/package.json
CHANGED