plumbbob 0.12.1 → 0.13.1
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 +20 -13
- package/dist/cli-core.js +22 -6
- package/dist/lib/git.js +20 -8
- package/dist/lib/latch.js +23 -5
- package/dist/lib/notice.js +18 -3
- package/dist/lib/orient.js +350 -34
- package/dist/lib/sidecar.js +202 -3
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/abandon.js +19 -14
- package/dist/verbs/build.js +73 -14
- package/dist/verbs/checkpoint.js +31 -21
- package/dist/verbs/doctor.js +23 -4
- package/dist/verbs/finish.js +21 -17
- package/dist/verbs/handoff.js +259 -80
- package/dist/verbs/order.js +32 -20
- package/dist/verbs/park.js +6 -2
- package/dist/verbs/recover.js +31 -1
- 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 +90 -53
- package/dist/verbs/use.js +21 -17
- package/hooks/hooks.json +7 -0
- package/package.json +1 -1
- package/skills/build/SKILL.md +107 -49
- package/skills/harvest/SKILL.md +10 -1
- package/skills/order/SKILL.md +20 -10
- package/skills/park/SKILL.md +7 -0
- package/skills/plan/SKILL.md +52 -7
- package/skills/refine/SKILL.md +21 -3
- package/skills/status/SKILL.md +8 -3
- package/skills/step/SKILL.md +16 -3
- package/skills/verify/SKILL.md +35 -18
- package/templates/intent.md +23 -5
package/dist/verbs/order.js
CHANGED
|
@@ -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 {
|
|
17
|
+
import { expandStepList, parseStepList } from "../lib/steplist.js";
|
|
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
|
|
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,
|
|
24
|
-
* intent.md sees every undone step and not only the ones
|
|
25
|
-
* Refuses (exit 1) with no session, no numbers, a token that is
|
|
26
|
-
* a number the plan lacks, a repeated number, or a
|
|
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);
|
|
@@ -35,14 +37,19 @@ export function order(cwd, args) {
|
|
|
35
37
|
const intent = readFileSync(path, 'utf8');
|
|
36
38
|
if (args.includes('--reset')) {
|
|
37
39
|
writeFileSync(path, clearBuildOrder(intent));
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
48
|
return 0;
|
|
43
49
|
}
|
|
44
|
-
// Numbers arrive space- or comma-separated
|
|
45
|
-
//
|
|
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.
|
|
46
53
|
const tokens = args
|
|
47
54
|
.filter((a) => !a.startsWith('--'))
|
|
48
55
|
.flatMap((a) => a.split(','))
|
|
@@ -56,14 +63,15 @@ export function order(cwd, args) {
|
|
|
56
63
|
}));
|
|
57
64
|
return 1;
|
|
58
65
|
}
|
|
59
|
-
const
|
|
66
|
+
const lists = tokens.map(parseStepList);
|
|
67
|
+
const stray = tokens.find((_, i) => lists[i] === null);
|
|
60
68
|
if (stray !== undefined) {
|
|
61
|
-
process.stderr.write(notice({ fact: `\`${stray}\` is not a step number`, remedy: 'plumbbob order 7
|
|
69
|
+
process.stderr.write(notice({ fact: `\`${stray}\` is not a step number or span`, remedy: 'plumbbob order 1, 6, 2-4, 7, 5' }));
|
|
62
70
|
return 1;
|
|
63
71
|
}
|
|
64
|
-
const given = tokens.map(Number);
|
|
65
72
|
const steps = parseSteps(intent);
|
|
66
73
|
const planned = steps.map((s) => s.n);
|
|
74
|
+
const given = expandStepList(lists.flatMap((list) => list ?? []), (n) => planned.includes(n));
|
|
67
75
|
const unknown = given.find((n) => !planned.includes(n));
|
|
68
76
|
if (unknown !== undefined) {
|
|
69
77
|
process.stderr.write(notice({
|
|
@@ -94,9 +102,13 @@ export function order(cwd, args) {
|
|
|
94
102
|
// The pointer reads the plan back from disk, so it already follows the
|
|
95
103
|
// order just written: back at the step in flight, or forward from the
|
|
96
104
|
// boundary to the first step in the new sequence.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
105
|
+
printEnding(root, {
|
|
106
|
+
tier: 'driver',
|
|
107
|
+
render: 'order',
|
|
108
|
+
text: ending({
|
|
109
|
+
lead: transition({ label: 'Build order', fact: full.join(', ') }),
|
|
110
|
+
pointer: driverPointer(root, activeBuild(root)),
|
|
111
|
+
}),
|
|
112
|
+
});
|
|
101
113
|
return 0;
|
|
102
114
|
}
|
package/dist/verbs/park.js
CHANGED
|
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
11
11
|
import { findRepoRoot } from "../lib/git.js";
|
|
12
12
|
import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
|
|
13
13
|
import { appendToSection } from "../lib/buildlog.js";
|
|
14
|
-
import { ending, notice, transition } from "../lib/notice.js";
|
|
14
|
+
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
15
15
|
import { driverPointer } from "./handoff.js";
|
|
16
16
|
/**
|
|
17
17
|
* Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
|
|
@@ -43,6 +43,10 @@ export function park(cwd, args) {
|
|
|
43
43
|
writeFileSync(path, updated);
|
|
44
44
|
// A park interrupts a step without ending it, so the ending is the capture
|
|
45
45
|
// and the pointer back at what was interrupted.
|
|
46
|
-
|
|
46
|
+
printEnding(root, {
|
|
47
|
+
tier: 'driver',
|
|
48
|
+
render: 'park',
|
|
49
|
+
text: ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }),
|
|
50
|
+
});
|
|
47
51
|
return 0;
|
|
48
52
|
}
|
package/dist/verbs/recover.js
CHANGED
|
@@ -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
|
*
|
|
@@ -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
|
/**
|