plumbbob 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +75 -54
- package/dist/cli-core.js +29 -9
- package/dist/lib/buildlog.js +77 -0
- package/dist/lib/git.js +53 -0
- package/dist/lib/latch.js +46 -5
- package/dist/lib/notice.js +3 -2
- package/dist/lib/orient.js +878 -61
- package/dist/lib/sidecar.js +164 -3
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/build.js +108 -14
- package/dist/verbs/checkpoint.js +41 -13
- package/dist/verbs/handoff.js +508 -42
- package/dist/verbs/harvest.js +224 -0
- package/dist/verbs/order.js +15 -11
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/relay.js +15 -2
- package/dist/verbs/status.js +64 -17
- package/dist/verbs/turn.js +170 -58
- package/package.json +1 -1
- package/skills/build/SKILL.md +85 -34
- package/skills/finish/SKILL.md +18 -4
- package/skills/harvest/SKILL.md +54 -11
- package/skills/order/SKILL.md +20 -10
- package/skills/plan/SKILL.md +77 -17
- package/skills/refine/SKILL.md +24 -3
- package/skills/run/SKILL.md +256 -0
- package/skills/status/SKILL.md +10 -3
- package/skills/step/SKILL.md +25 -3
- package/skills/verify/SKILL.md +6 -2
- package/templates/intent.md +30 -8
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// `plumbbob harvest`: record a harvest from the detail file. The skill decides
|
|
2
|
+
// and the CLI records (D104 (harvest-records)): the model writes one proposal
|
|
3
|
+
// per open park under the detail file's `## Harvest` section, the human
|
|
4
|
+
// settles them in one reply, and this verb flips each settled park in
|
|
5
|
+
// `build-log.md`, appends the dated Harvest lines, and, when the detail is a
|
|
6
|
+
// range's, writes the range's review beneath a dated Log line, the way
|
|
7
|
+
// `checkpoint` keeps a step's pause beneath its own. It commits nothing: the
|
|
8
|
+
// record rides the next step's checkpoint, or the finish.
|
|
9
|
+
//
|
|
10
|
+
// A stop's record is latched (D103 (stop-latched)). It refuses in the turn
|
|
11
|
+
// the stop card printed, since a run's long turn ends on proposals and that is
|
|
12
|
+
// where a model would carry straight on into recording its own; it refuses a
|
|
13
|
+
// range's detail no stop card showed, read off the digest the card's STOP
|
|
14
|
+
// stamp keeps; and it refuses a range that no longer reads as landed, telling
|
|
15
|
+
// a step landed and reopened from one that joined the range after it landed,
|
|
16
|
+
// which moves to a range still to come. A harvest outside a stop (`# Detail · Harvest`) needs no stamp. A proposal
|
|
17
|
+
// naming a handle the list lacks (`P9` with eight parked), or a class outside
|
|
18
|
+
// the three, refuses the whole record, so the card and the ledger never
|
|
19
|
+
// disagree about which parks are open; a park no proposal names stays open,
|
|
20
|
+
// and the record says nothing about it.
|
|
21
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
22
|
+
import { findRepoRoot } from "../lib/git.js";
|
|
23
|
+
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
24
|
+
import { appendHarvest, appendToSection, flipParks, harvestLines, logEntry, stopLogLine } from "../lib/buildlog.js";
|
|
25
|
+
import { checkStopLatch } from "../lib/latch.js";
|
|
26
|
+
import { parseBuildRanges, parseDetailStep, parseLastCheckpoint, parseOrderedSteps, stepShas } from "../lib/orient.js";
|
|
27
|
+
import { activeBuild, buildLogPath, checkpointsPath, clearDetail, clearStop, detailDigest, detailPath, hasSession, intentPath, readStop, } from "../lib/sidecar.js";
|
|
28
|
+
import { hasStep } from "../lib/steplist.js";
|
|
29
|
+
import { boundaryEnding, harvestProposals, parkList, parseDetailRange, stopRecord } from "./handoff.js";
|
|
30
|
+
// The three classes a park settles as, in the order the ending counts them.
|
|
31
|
+
const CLASSES = ['blocker', 'tangent', 'pivot'];
|
|
32
|
+
// Where a refusal about the proposals sends the model: the file it wrote them in.
|
|
33
|
+
const FIX_PROPOSALS = 'fix the Harvest proposals in .plumbbob/detail.md';
|
|
34
|
+
/**
|
|
35
|
+
* Record the harvest the detail file proposes, and the range's review when the
|
|
36
|
+
* detail is a range's.
|
|
37
|
+
*
|
|
38
|
+
* Exit 1 on any refusal: no session, a detail file that is no range's or
|
|
39
|
+
* harvest's, the latch, a range no stop card showed or no longer landed, and a
|
|
40
|
+
* proposal it cannot settle. Each refusal names the move that unblocks it.
|
|
41
|
+
*/
|
|
42
|
+
export function harvest(cwd, _args) {
|
|
43
|
+
const root = findRepoRoot(cwd);
|
|
44
|
+
if (root === null || !hasSession(root)) {
|
|
45
|
+
process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
const slug = activeBuild(root);
|
|
49
|
+
const detail = readOr(detailPath(root));
|
|
50
|
+
const item = parseDetailRange(detail);
|
|
51
|
+
if (item === null && !isHarvestDetail(detail)) {
|
|
52
|
+
process.stderr.write(nothingToRecord(detail));
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
const intent = readOr(intentPath(root, slug));
|
|
56
|
+
const buildLog = readOr(buildLogPath(root, slug));
|
|
57
|
+
const parks = parkList(buildLog);
|
|
58
|
+
let stop = null;
|
|
59
|
+
if (item !== null) {
|
|
60
|
+
const found = stopRange(root, slug, intent, detail, item);
|
|
61
|
+
if (typeof found === 'string') {
|
|
62
|
+
process.stderr.write(found);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
stop = found;
|
|
66
|
+
}
|
|
67
|
+
const subject = stop === null ? 'harvest not recorded' : `range ${stop.item} of ${stop.count}'s stop not recorded`;
|
|
68
|
+
const settled = settle(parks, harvestProposals(detail));
|
|
69
|
+
if (typeof settled === 'string') {
|
|
70
|
+
process.stderr.write(notice({ fact: subject, detail: [settled], remedy: FIX_PROPOSALS }));
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
if (stop === null && settled.length === 0) {
|
|
74
|
+
process.stderr.write(notice({ fact: 'harvest not recorded', detail: ['the detail file settles no open park'], remedy: FIX_PROPOSALS }));
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
// The review is read before the flip, so its Harvested part shows each lean
|
|
78
|
+
// the human confirmed beside the park it settled.
|
|
79
|
+
const record = stop === null ? null : stopRecord(root, slug, detail, buildLog);
|
|
80
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
81
|
+
const where = stop === null ? boundaryWords(root, slug) : `at range ${stop.item} of ${stop.count}'s stop`;
|
|
82
|
+
let updated = flipParks(buildLog, settled.map((p) => p.handle));
|
|
83
|
+
updated = updated === null ? null : appendHarvest(updated, harvestLines(date, where, settled));
|
|
84
|
+
if (stop !== null) {
|
|
85
|
+
updated = updated === null ? null : appendToSection(updated, 'Log', logEntry(stopLogLine(date, stop.item, stop.count, stop.stop), record));
|
|
86
|
+
}
|
|
87
|
+
if (updated === null) {
|
|
88
|
+
process.stderr.write(notice({ fact: `${subject}`, detail: ['build-log.md is missing a section it writes to'], remedy: 'restore its Park list, Harvest, and Log sections' }));
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
writeFileSync(buildLogPath(root, slug), updated);
|
|
92
|
+
clearDetail(root);
|
|
93
|
+
clearStop(root, slug);
|
|
94
|
+
// Outside a stop the label names the act, `Recorded`, since `Harvested` is
|
|
95
|
+
// the stop card's part and one label over a part that proposes and an ending
|
|
96
|
+
// that records would mean two things (D106 (leans-harvested)).
|
|
97
|
+
const counted = classWords(settled);
|
|
98
|
+
const lead = stop === null
|
|
99
|
+
? transition({ label: 'Recorded', fact: `${settled.length} parked idea${settled.length === 1 ? '' : 's'}`, detail: [counted] })
|
|
100
|
+
: transition({ label: 'Stop', fact: `Range ${stop.item} of ${stop.count} recorded`, detail: [counted] });
|
|
101
|
+
printEnding(root, {
|
|
102
|
+
tier: 'orientation',
|
|
103
|
+
render: 'harvest',
|
|
104
|
+
text: ending({ lead, pointer: boundaryEnding(root, slug, null).pointer }),
|
|
105
|
+
});
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The range a range's detail reviews, once the record may land: the plan
|
|
110
|
+
* holds the range, a stop card showed this detail, a human turn has read it,
|
|
111
|
+
* and every step of the range has landed. Otherwise the refusal, with the move
|
|
112
|
+
* that unblocks it.
|
|
113
|
+
*
|
|
114
|
+
* An undone step in the range reads two ways, told apart by the ledger. One
|
|
115
|
+
* with a `step N <sha>` line landed and was reopened, so it lands again and
|
|
116
|
+
* the card re-renders. One with none joined the range after the card, a
|
|
117
|
+
* blocker folded into the range that just landed, and `handoff --stop` would
|
|
118
|
+
* refuse too, since the last landing no longer completes the range; so the
|
|
119
|
+
* remedy moves the step to a range still to come and runs the record again,
|
|
120
|
+
* which holds in the same reply while the detail file stays as the card
|
|
121
|
+
* showed it.
|
|
122
|
+
*/
|
|
123
|
+
function stopRange(root, slug, intent, detail, item) {
|
|
124
|
+
const plan = parseBuildRanges(intent);
|
|
125
|
+
const count = plan.ranges.length + plan.malformed.length;
|
|
126
|
+
const range = plan.ranges.find((r) => r.item === item);
|
|
127
|
+
const refuse = (why, remedy) => notice({ fact: `range ${item} of ${count}'s stop not recorded`, detail: [why], remedy });
|
|
128
|
+
if (range === undefined) {
|
|
129
|
+
return refuse(`the plan has no range ${item}`, `write the range's number in the header of .plumbbob/detail.md`);
|
|
130
|
+
}
|
|
131
|
+
const stamp = readStop(root, slug);
|
|
132
|
+
if (stamp === null || stamp.range !== item || stamp.detail !== detailDigest(detail)) {
|
|
133
|
+
return refuse('no stop card showed its detail', 'plumbbob handoff --stop, then end the turn on the card');
|
|
134
|
+
}
|
|
135
|
+
if (!checkStopLatch(root, stamp.turn)) {
|
|
136
|
+
return refuse("its card printed this turn, and no human turn has read it", "end the turn on the card; the human's reply is what records it");
|
|
137
|
+
}
|
|
138
|
+
const held = parseOrderedSteps(intent).filter((s) => hasStep(range.steps, s.n));
|
|
139
|
+
if (held.length === 0) {
|
|
140
|
+
return refuse('the plan holds none of its steps', 'land it, then plumbbob handoff --stop');
|
|
141
|
+
}
|
|
142
|
+
const open = held.find((s) => !s.done);
|
|
143
|
+
if (open !== undefined) {
|
|
144
|
+
// A step the ledger landed was reopened since, and lands again; one it never
|
|
145
|
+
// landed was folded into the range after the card, and moves out instead.
|
|
146
|
+
return stepShas(readOr(checkpointsPath(root, slug))).has(open.n)
|
|
147
|
+
? refuse(`step ${open.n} is open again`, 'land it, then plumbbob handoff --stop')
|
|
148
|
+
: refuse(`step ${open.n} joined it after it landed`, `move step ${open.n} to a range still to come, then plumbbob harvest`);
|
|
149
|
+
}
|
|
150
|
+
return { item, count, stop: range.stop };
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The open parks the proposals settle, in list order, or the words for the
|
|
154
|
+
* first proposal that cannot be recorded: one naming a handle the list lacks,
|
|
155
|
+
* or a class outside the three, each named by the handle the card prints. A park no proposal names stays open; a proposal
|
|
156
|
+
* for a park already harvested settles nothing; and the first proposal for a
|
|
157
|
+
* park is the one the card showed.
|
|
158
|
+
*/
|
|
159
|
+
function settle(parks, proposals) {
|
|
160
|
+
for (const proposal of proposals) {
|
|
161
|
+
if (proposal.handle < 1 || proposal.handle > parks.length) {
|
|
162
|
+
return `P${proposal.handle} is not in the list; ${parks.length} parked`;
|
|
163
|
+
}
|
|
164
|
+
if (!CLASSES.includes(proposal.kind)) {
|
|
165
|
+
return `P${proposal.handle}'s class "${proposal.kind}" is not blocker, tangent, or pivot`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return parks.flatMap((park) => {
|
|
169
|
+
const proposal = proposals.find((p) => p.handle === park.handle);
|
|
170
|
+
return park.open && proposal !== undefined ? [{ handle: park.handle, kind: proposal.kind, text: park.text, reason: proposal.reason }] : [];
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
/** The classes settled, counted in the ending's order: `1 blocker, 2 tangents`, or `nothing parked`. */
|
|
174
|
+
function classWords(settled) {
|
|
175
|
+
const words = CLASSES.flatMap((kind) => {
|
|
176
|
+
const n = settled.filter((p) => p.kind === kind).length;
|
|
177
|
+
return n === 0 ? [] : [`${n} ${kind}${n === 1 ? '' : 's'}`];
|
|
178
|
+
});
|
|
179
|
+
return words.length === 0 ? 'nothing parked' : words.join(', ');
|
|
180
|
+
}
|
|
181
|
+
/** Where a harvest outside a stop ran, for its dated line: the boundary of the step that landed last. */
|
|
182
|
+
function boundaryWords(root, slug) {
|
|
183
|
+
const last = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
|
|
184
|
+
return last === null ? 'before the first step landed' : `at the step ${last.n} boundary`;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Whether a detail file is a boundary harvest's wire, read off its
|
|
188
|
+
* `# Detail · Harvest` header.
|
|
189
|
+
*/
|
|
190
|
+
export function isHarvestDetail(detail) {
|
|
191
|
+
const first = detail.trimStart().split('\n', 1)[0] ?? '';
|
|
192
|
+
return /^#\s+Detail\s*·\s*Harvest\s*$/.test(first);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The refusal for a detail file that is no range's and no harvest's: a step's
|
|
196
|
+
* is checkpoint's to record, and an empty one, or one with no header, records nothing.
|
|
197
|
+
*/
|
|
198
|
+
function nothingToRecord(detail) {
|
|
199
|
+
const step = parseDetailStep(detail);
|
|
200
|
+
if (step !== null) {
|
|
201
|
+
return notice({
|
|
202
|
+
fact: 'nothing to record',
|
|
203
|
+
detail: [`the detail file is Step ${step}'s, not a range's or a harvest's`],
|
|
204
|
+
remedy: `plumbbob checkpoint ${step} records a step's detail`,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
const why = detail.trim().length === 0 ? 'the detail file is empty' : 'the detail file names no range or harvest';
|
|
208
|
+
return notice({
|
|
209
|
+
fact: 'nothing to record',
|
|
210
|
+
detail: [why],
|
|
211
|
+
remedy: 'write the proposals in .plumbbob/detail.md under `# Detail · Harvest`',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Read a file as UTF-8, or '' when it is missing or unreadable.
|
|
216
|
+
*/
|
|
217
|
+
function readOr(path) {
|
|
218
|
+
try {
|
|
219
|
+
return readFileSync(path, 'utf8');
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return '';
|
|
223
|
+
}
|
|
224
|
+
}
|
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 { expandStepList, parseStepList } from "../lib/steplist.js";
|
|
17
18
|
import { ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
18
19
|
import { driverPointer } from "./handoff.js";
|
|
19
20
|
/**
|
|
20
|
-
* Set the build order to the given step numbers, the rest of the
|
|
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);
|
|
@@ -45,8 +47,9 @@ export function order(cwd, args) {
|
|
|
45
47
|
});
|
|
46
48
|
return 0;
|
|
47
49
|
}
|
|
48
|
-
// Numbers arrive space- or comma-separated
|
|
49
|
-
//
|
|
50
|
+
// Numbers and spans arrive space- or comma-separated, with or without a space
|
|
51
|
+
// after a comma: `order 7 8 5`, `order 7,8,5`, and `order 1, 6, 2-4, 7, 5`
|
|
52
|
+
// all read, and a span names its steps in place, since the line is a sequence.
|
|
50
53
|
const tokens = args
|
|
51
54
|
.filter((a) => !a.startsWith('--'))
|
|
52
55
|
.flatMap((a) => a.split(','))
|
|
@@ -60,14 +63,15 @@ export function order(cwd, args) {
|
|
|
60
63
|
}));
|
|
61
64
|
return 1;
|
|
62
65
|
}
|
|
63
|
-
const
|
|
66
|
+
const lists = tokens.map(parseStepList);
|
|
67
|
+
const stray = tokens.find((_, i) => lists[i] === null);
|
|
64
68
|
if (stray !== undefined) {
|
|
65
|
-
process.stderr.write(notice({ fact: `\`${stray}\` is not a step number`, remedy: 'plumbbob order 7
|
|
69
|
+
process.stderr.write(notice({ fact: `\`${stray}\` is not a step number or span`, remedy: 'plumbbob order 1, 6, 2-4, 7, 5' }));
|
|
66
70
|
return 1;
|
|
67
71
|
}
|
|
68
|
-
const given = tokens.map(Number);
|
|
69
72
|
const steps = parseSteps(intent);
|
|
70
73
|
const planned = steps.map((s) => s.n);
|
|
74
|
+
const given = expandStepList(lists.flatMap((list) => list ?? []), (n) => planned.includes(n));
|
|
71
75
|
const unknown = given.find((n) => !planned.includes(n));
|
|
72
76
|
if (unknown !== undefined) {
|
|
73
77
|
process.stderr.write(notice({
|
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
|
*
|
package/dist/verbs/relay.js
CHANGED
|
@@ -25,7 +25,9 @@
|
|
|
25
25
|
// has already landed. The once is counted in `RELAY` by the turn it held,
|
|
26
26
|
// never by `stop_hook_active`, which checkride's gate sets on every red Stop
|
|
27
27
|
// and which names no hook. It never sends `continue: false`, which would cancel
|
|
28
|
-
// a sibling's block in the same round.
|
|
28
|
+
// a sibling's block in the same round. A run's boundary that still owes its
|
|
29
|
+
// stop card is a miss however whole the boundary was, since the card is the
|
|
30
|
+
// run's ending, and its reason names `plumbbob handoff --stop` as the retry.
|
|
29
31
|
//
|
|
30
32
|
// The final message comes from the payload's `last_assistant_message`, never
|
|
31
33
|
// from the transcript, which does not yet hold that message when Stop fires;
|
|
@@ -71,7 +73,10 @@ export function relayCheck(cwd, raw) {
|
|
|
71
73
|
const turn = readTurn(root);
|
|
72
74
|
if (record === null || turn === null || record.turn !== turn || record.held === turn)
|
|
73
75
|
return null;
|
|
74
|
-
|
|
76
|
+
// A run's boundary that still owes its stop card is held whatever the turn
|
|
77
|
+
// relayed: the card is the run's ending, and `handoff --stop` overwrites
|
|
78
|
+
// this record once it prints.
|
|
79
|
+
const carried = record.owes !== undefined ? false : carriedEnding(record, payload.last_assistant_message, payload);
|
|
75
80
|
// Whole, or not this check's to judge: let the turn end.
|
|
76
81
|
if (carried !== false)
|
|
77
82
|
return null;
|
|
@@ -206,6 +211,11 @@ function partsOf(text) {
|
|
|
206
211
|
* turn missed, and the command or block that puts it back.
|
|
207
212
|
*/
|
|
208
213
|
function reason(record) {
|
|
214
|
+
if (record.owes !== undefined) {
|
|
215
|
+
const { range, count, step } = record.owes;
|
|
216
|
+
return (`plumbbob: the turn ended on Step ${step}'s boundary, and range ${range} of ${count}'s stop card is still owed. ` +
|
|
217
|
+
"Write the range's detail file, run `plumbbob handoff --stop`, and end the turn on its output, whole, with nothing after it.");
|
|
218
|
+
}
|
|
209
219
|
if (record.tier === 'decision') {
|
|
210
220
|
const command = record.command ?? 'plumbbob handoff --full';
|
|
211
221
|
return (`plumbbob: the turn did not close on the ${DECISION_NAMES[record.render] ?? 'pause'} it printed. ` +
|
|
@@ -219,6 +229,8 @@ function reason(record) {
|
|
|
219
229
|
const DECISION_NAMES = {
|
|
220
230
|
card: 'pause card',
|
|
221
231
|
reminder: 'pause reminder',
|
|
232
|
+
stop: 'stop card',
|
|
233
|
+
'stop-reminder': 'stop reminder',
|
|
222
234
|
plan: 'plan card',
|
|
223
235
|
};
|
|
224
236
|
// The command that printed an orientation ending, by its render, so the reason
|
|
@@ -226,6 +238,7 @@ const DECISION_NAMES = {
|
|
|
226
238
|
const ORIENTATION_VERBS = {
|
|
227
239
|
checkpoint: 'plumbbob checkpoint',
|
|
228
240
|
'plan-commit': 'plumbbob checkpoint --plan',
|
|
241
|
+
harvest: 'plumbbob harvest',
|
|
229
242
|
finish: 'plumbbob finish',
|
|
230
243
|
};
|
|
231
244
|
/**
|
package/dist/verbs/status.js
CHANGED
|
@@ -3,13 +3,19 @@
|
|
|
3
3
|
// the in-flight STEP marker) into a where-am-I view that suggests one primary
|
|
4
4
|
// next move while printing the full picture, so you can always override.
|
|
5
5
|
// Read-only, always exits 0. Skills pre-inject this output to gate their own
|
|
6
|
-
// behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
|
|
6
|
+
// behavior, so the `NO ACTIVE SESSION` sentinel is kept exact. A stop card
|
|
7
|
+
// still waiting for its reply marks its range and takes the next move. With
|
|
8
|
+
// `--run`, it prints the run a `/plumbbob:run` invocation names above the
|
|
9
|
+
// dashboard, and names the waiting card again beneath the run's grant.
|
|
7
10
|
import { readFileSync } from 'node:fs';
|
|
8
11
|
import { join } from 'node:path';
|
|
9
12
|
import { PLAN_COMMIT_MARKER, commitsSince, findRepoRoot } from "../lib/git.js";
|
|
10
13
|
import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
|
|
11
14
|
import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
12
|
-
import {
|
|
15
|
+
import { readGrant } from "../lib/latch.js";
|
|
16
|
+
import { formatOrientation, formatRunDashboard, lastLedgerSha, orient, parseRequestedStep, resolveRunArgs, } from "../lib/orient.js";
|
|
17
|
+
import { normalizeStepList } from "../lib/steplist.js";
|
|
18
|
+
import { waitingCard } from "./handoff.js";
|
|
13
19
|
/**
|
|
14
20
|
* Read a file as utf8, or '' when it is missing or unreadable.
|
|
15
21
|
*/
|
|
@@ -22,20 +28,32 @@ function readOr(path) {
|
|
|
22
28
|
}
|
|
23
29
|
}
|
|
24
30
|
/**
|
|
25
|
-
* Split
|
|
26
|
-
* flag parsing.
|
|
31
|
+
* Split a flag carrying a skill invocation's raw text (`--invoked` or `--run`)
|
|
32
|
+
* out of the argument list, ahead of any other flag parsing.
|
|
27
33
|
*
|
|
28
34
|
* The value is the verbatim text of a skill invocation (the `$ARGUMENTS` the
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
35
|
+
* skill passes through), so it can contain anything, including tokens that
|
|
36
|
+
* look like this CLI's own flags; extracting it first keeps such a value from
|
|
37
|
+
* confusing `resolveBuild`. An absent flag reads as null; a flag with no value
|
|
38
|
+
* reads as `missing`, which `--invoked` takes as no explicit ask and `--run`
|
|
39
|
+
* as a run with nothing after the verb.
|
|
33
40
|
*/
|
|
34
|
-
function
|
|
35
|
-
const i = args.indexOf(
|
|
41
|
+
function takeRaw(args, flag, missing) {
|
|
42
|
+
const i = args.indexOf(flag);
|
|
36
43
|
if (i === -1)
|
|
37
|
-
return {
|
|
38
|
-
return {
|
|
44
|
+
return { raw: null, rest: args };
|
|
45
|
+
return { raw: args[i + 1] ?? missing, rest: [...args.slice(0, i), ...args.slice(i + 2)] };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A grant as the GRANT file holds it in its normal form, or null for none:
|
|
49
|
+
* the text the run header holds against the grant a run would mint.
|
|
50
|
+
*/
|
|
51
|
+
function grantText(grant) {
|
|
52
|
+
if (grant === null)
|
|
53
|
+
return null;
|
|
54
|
+
if (grant.kind === 'auto')
|
|
55
|
+
return 'auto';
|
|
56
|
+
return grant.kind === 'steps' ? `steps ${normalizeStepList(grant.steps)}` : `range ${grant.ceiling}`;
|
|
39
57
|
}
|
|
40
58
|
/**
|
|
41
59
|
* Print the orientation dashboard for the active build, plus its harness bindings.
|
|
@@ -46,6 +64,10 @@ function takeInvoked(args) {
|
|
|
46
64
|
* `/plumbbob:build` skill passes its own invocation through), an explicit step
|
|
47
65
|
* number in the raw text repoints the dashboard's marker, detail, and next
|
|
48
66
|
* move at that step, so the injected state never contradicts the human's ask.
|
|
67
|
+
* With `--run "<raw args>"` (how the `/plumbbob:run` skill passes its own),
|
|
68
|
+
* the dashboard prints beneath a header naming the run the text resolves to
|
|
69
|
+
* and the grant the turn hook minted, or carries the refusal in its pointer
|
|
70
|
+
* when the run names nothing it can walk.
|
|
49
71
|
*/
|
|
50
72
|
export function status(cwd, args = []) {
|
|
51
73
|
const root = findRepoRoot(cwd);
|
|
@@ -53,7 +75,8 @@ export function status(cwd, args = []) {
|
|
|
53
75
|
process.stdout.write('NO ACTIVE SESSION\n');
|
|
54
76
|
return 0;
|
|
55
77
|
}
|
|
56
|
-
const {
|
|
78
|
+
const { raw: run, rest: afterRun } = takeRaw(args, '--run', '');
|
|
79
|
+
const { raw: invoked, rest } = takeRaw(afterRun, '--invoked', null);
|
|
57
80
|
// A session with builds but no resolvable cursor (finish cleared it, or the repo
|
|
58
81
|
// holds several builds and none is active) has no single dashboard to show, so
|
|
59
82
|
// list the builds and point at `use` instead of rendering a broken, empty one.
|
|
@@ -73,19 +96,43 @@ export function status(cwd, args = []) {
|
|
|
73
96
|
// checkpoint still surfaces. No ledger yet ⇒ nothing to reconcile against ⇒ 0.
|
|
74
97
|
const checkpoints = readOr(checkpointsPath(root, slug));
|
|
75
98
|
const anchor = lastLedgerSha(checkpoints);
|
|
76
|
-
const
|
|
77
|
-
|
|
99
|
+
const intent = readOr(intentPath(root, slug));
|
|
100
|
+
// The stop card still waiting, read once: the dashboard marks its range and
|
|
101
|
+
// names it as the move, and the run header's waiting line names it too.
|
|
102
|
+
const card = waitingCard(root, slug, intent);
|
|
103
|
+
const input = {
|
|
104
|
+
intent,
|
|
78
105
|
buildLog: readOr(buildLogPath(root, slug)),
|
|
79
106
|
checkpoints,
|
|
80
107
|
inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
|
|
81
108
|
spiking: inSpike(root, slug),
|
|
82
109
|
requested: parseRequestedStep(invoked),
|
|
83
110
|
outOfBand: anchor === null ? 0 : commitsSince(root, anchor, PLAN_COMMIT_MARKER),
|
|
84
|
-
|
|
85
|
-
|
|
111
|
+
waiting: card?.range ?? null,
|
|
112
|
+
};
|
|
113
|
+
const dashboard = run === null
|
|
114
|
+
? formatOrientation(orient(input))
|
|
115
|
+
: withWaiting(formatRunDashboard(input, resolveRunArgs(run, input), grantText(readGrant(root))), card);
|
|
116
|
+
const lines = [dashboard, ...harnessSection(root, slug)];
|
|
86
117
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
87
118
|
return 0;
|
|
88
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* The run dashboard with the waiting line beneath the run header's grant line,
|
|
122
|
+
* the header's fourth line, naming the stop card still waiting for its reply
|
|
123
|
+
* (`range 3 of 5's stop card, recorded before the run`). With no card waiting,
|
|
124
|
+
* or no header (a run that refuses has no grant line to ride), the dashboard
|
|
125
|
+
* is returned as it is.
|
|
126
|
+
*/
|
|
127
|
+
function withWaiting(dashboard, card) {
|
|
128
|
+
const lines = dashboard.split('\n');
|
|
129
|
+
const grant = lines.findIndex((l) => l.startsWith(' grant: '));
|
|
130
|
+
if (card === null || grant === -1) {
|
|
131
|
+
return dashboard;
|
|
132
|
+
}
|
|
133
|
+
lines.splice(grant + 1, 0, ` waiting: range ${card.range} of ${card.count}'s stop card, recorded before the run`);
|
|
134
|
+
return lines.join('\n');
|
|
135
|
+
}
|
|
89
136
|
/**
|
|
90
137
|
* The active build's harness bindings: the agents bound to each slot, per
|
|
91
138
|
* `defaults` and per step.
|