plumbbob 0.13.0 → 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 +8 -6
- package/dist/cli-core.js +8 -3
- package/dist/lib/latch.js +23 -5
- package/dist/lib/notice.js +3 -2
- package/dist/lib/orient.js +307 -33
- package/dist/lib/sidecar.js +53 -0
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/build.js +73 -14
- package/dist/verbs/checkpoint.js +1 -0
- package/dist/verbs/handoff.js +86 -26
- package/dist/verbs/order.js +15 -11
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/turn.js +83 -52
- package/package.json +1 -1
- package/skills/build/SKILL.md +72 -33
- package/skills/harvest/SKILL.md +10 -1
- package/skills/order/SKILL.md +20 -10
- package/skills/plan/SKILL.md +51 -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 +1 -1
- package/templates/intent.md +23 -5
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.13.
|
|
4
|
+
"version": "0.13.1",
|
|
5
5
|
"description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
|
|
6
6
|
"author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
|
|
7
7
|
"homepage": "https://github.com/robmclarty/plumbbob#readme",
|
package/README.md
CHANGED
|
@@ -131,8 +131,8 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
131
131
|
|
|
132
132
|
1. **Plan.** Fire `/plumbbob:plan` and give it whatever you have: nothing (it interviews
|
|
133
133
|
you), a rough line (`/plumbbob:plan rate-limit POST /login, 5/min/IP, return 429`), or a
|
|
134
|
-
path to a spec file. Together you fill the build's `intent.md
|
|
135
|
-
yet.
|
|
134
|
+
path to a spec file. Together you fill the build's `intent.md`: the steps, and the
|
|
135
|
+
build ranges that say where the build stops for your call. No code is written yet.
|
|
136
136
|
2. **Build.** Fire `/plumbbob:build`. It implements the next undone step, runs the
|
|
137
137
|
heavy check gate, reviews its own diff against the plan, and stops. What it stops on
|
|
138
138
|
is one block with a fixed shape (the [turn anatomy](docs/presentation.md)): the CLI
|
|
@@ -198,7 +198,9 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
198
198
|
whole block again, since you have already read it; say `show the pause` to see the
|
|
199
199
|
block again. Direct it and it takes that as the fix, and nothing lands until you say
|
|
200
200
|
`looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
|
|
201
|
-
*is* the clock tick.
|
|
201
|
+
*is* the clock tick. When the plan cut build ranges, the line beneath Next Up names
|
|
202
|
+
one to type instead (`· range 2 of 2: /plumbbob:build 2-3`): it lands each step it
|
|
203
|
+
names and stops where the plan reserved a call for you. Whenever you lose the thread, `/plumbbob:status` shows where you
|
|
202
204
|
are and names the next move.
|
|
203
205
|
4. **Finish.** When the last step is done, `/plumbbob:finish` writes a report of what shipped
|
|
204
206
|
and why into the build's tracked folder, makes the final commit, and clears the slate
|
|
@@ -224,8 +226,8 @@ nothing else:
|
|
|
224
226
|
|
|
225
227
|
| Skill <img alt="" width="110" height="1"> | Does |
|
|
226
228
|
| ------------------------------------ | ------ |
|
|
227
|
-
| `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps
|
|
228
|
-
| `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3` self-approves
|
|
229
|
+
| `/plumbbob:plan` | plan the whole goal: open the session and author intent's Frame, Decisions, Constraints, **and all Steps**, with the build ranges that say where the build stops for you |
|
|
230
|
+
| `/plumbbob:build` | implement the next planned step, then verify it to the pause: `--auto` self-approves and chains to done; a range like `1-3`, or a list like `20,5-9`, self-approves exactly the steps it names, then stops |
|
|
229
231
|
| `/plumbbob:finish` | finish up: write the report, make the final commit, clear for a fresh goal |
|
|
230
232
|
|
|
231
233
|
**Plan-shaping moves**: optional, for when the plan needs work mid-flight:
|
|
@@ -233,7 +235,7 @@ nothing else:
|
|
|
233
235
|
| Skill <img alt="" width="110" height="1"> | Does |
|
|
234
236
|
| ------------------------------------ | ------ |
|
|
235
237
|
| `/plumbbob:step` | revise/sharpen the next step (empty input auto-syncs it to reality) |
|
|
236
|
-
| `/plumbbob:refine` | attack the frame for holes, or repair a drifted plan |
|
|
238
|
+
| `/plumbbob:refine` | attack the frame for holes, or repair a drifted plan (`ranges` re-cuts the build ranges) |
|
|
237
239
|
| `/plumbbob:spike` | throwaway worktree experiment for a fork the plan can't settle |
|
|
238
240
|
|
|
239
241
|
**Helpers**: orient, verify, recover, diagnose:
|
package/dist/cli-core.js
CHANGED
|
@@ -146,10 +146,15 @@ const VERBS = [
|
|
|
146
146
|
{
|
|
147
147
|
name: 'order',
|
|
148
148
|
description: 'set the build order: the undone steps in the sequence to build them',
|
|
149
|
-
synopsis: ['order <
|
|
150
|
-
args: [
|
|
149
|
+
synopsis: ['order <steps>', 'order --reset'],
|
|
150
|
+
args: [
|
|
151
|
+
{
|
|
152
|
+
name: '<steps>',
|
|
153
|
+
gloss: 'undone step numbers and spans (2-4) in the sequence to build them, space- or comma-separated (1, 6, 2-4, 7, 5); the rest follow in document order',
|
|
154
|
+
},
|
|
155
|
+
],
|
|
151
156
|
flags: [{ name: '--reset', gloss: 'drop the line, so the plan reads in document order again' }],
|
|
152
|
-
notes: 'Writes the one numeric line under `## Build order` in intent.md, which status, build, checkpoint, and handoff all read. Refuses (exit 1) with no session, no numbers, a number the plan lacks, a repeat, or a step already checkpointed.',
|
|
157
|
+
notes: 'Writes the one numeric line under `## Build order` in intent.md, which status, build, checkpoint, and handoff all read. A span is spelled out on the line. Refuses (exit 1) with no session, no numbers, a span that runs backward, a number the plan lacks, a repeat, or a step already checkpointed.',
|
|
153
158
|
},
|
|
154
159
|
{
|
|
155
160
|
name: 'spike',
|
package/dist/lib/latch.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import { readFileSync } from 'node:fs';
|
|
18
18
|
import { grantPath, tickPath, turnPath } from "./sidecar.js";
|
|
19
19
|
import { resolveBoolean } from "./settings.js";
|
|
20
|
+
import { hasStep, normalizeStepList, parseStepList } from "./steplist.js";
|
|
20
21
|
const ALLOW = { allow: true };
|
|
21
22
|
/**
|
|
22
23
|
* The refusal message is the affordance; it tells the model what the pause is,
|
|
@@ -51,12 +52,20 @@ const SETTINGS_AUTO_NOTE = `note: \`auto\` is set in settings but is no longer a
|
|
|
51
52
|
function ceilingMessage(ceiling) {
|
|
52
53
|
return `plumbbob: checkpoint refused — the range you granted ends at step ${ceiling} — pause here; run it again to continue.\n`;
|
|
53
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The refusal for a step a list grant does not name, naming the steps it does.
|
|
57
|
+
*/
|
|
58
|
+
function outsideMessage(step, steps) {
|
|
59
|
+
return `plumbbob: checkpoint refused — step ${step} is not in the range you granted (${normalizeStepList(steps)}) — pause here; run it again to continue.\n`;
|
|
60
|
+
}
|
|
54
61
|
/**
|
|
55
62
|
* The five-row matrix, in order, first hit wins.
|
|
56
63
|
*
|
|
57
|
-
* A
|
|
64
|
+
* A range grant speaks only to numbered steps: on a `--plan` checkpoint (step
|
|
58
65
|
* null) it neither allows nor refuses, and the decision falls through to the
|
|
59
|
-
* turn comparison.
|
|
66
|
+
* turn comparison. A `steps` grant lands exactly the steps the human typed and
|
|
67
|
+
* refuses any other, so no later edit to the plan or its build order can widen
|
|
68
|
+
* it; both kinds refuse with the `ceiling` reason, a step outside the range.
|
|
60
69
|
*/
|
|
61
70
|
export function evaluateLatch(input) {
|
|
62
71
|
if (input.isTTY)
|
|
@@ -65,6 +74,11 @@ export function evaluateLatch(input) {
|
|
|
65
74
|
return ALLOW; // 2: dormant ledger / hand-built diff
|
|
66
75
|
if (input.grant?.kind === 'auto')
|
|
67
76
|
return ALLOW; // 3: one-turn grant the human typed
|
|
77
|
+
if (input.grant?.kind === 'steps' && input.step !== null) {
|
|
78
|
+
if (hasStep(input.grant.steps, input.step))
|
|
79
|
+
return ALLOW;
|
|
80
|
+
return { allow: false, reason: 'ceiling', message: outsideMessage(input.step, input.grant.steps) };
|
|
81
|
+
}
|
|
68
82
|
if (input.grant?.kind === 'range' && input.step !== null) {
|
|
69
83
|
if (input.step <= input.grant.ceiling)
|
|
70
84
|
return ALLOW;
|
|
@@ -78,15 +92,19 @@ export function evaluateLatch(input) {
|
|
|
78
92
|
return { allow: false, reason: 'no-turn', message: input.settingsAuto ? base + SETTINGS_AUTO_NOTE : base };
|
|
79
93
|
}
|
|
80
94
|
/**
|
|
81
|
-
* Parse a GRANT file's content (`auto` | `range <M>`).
|
|
95
|
+
* Parse a GRANT file's content (`auto` | `steps <list>` | `range <M>`).
|
|
82
96
|
*
|
|
83
|
-
* Anything else (including a corrupted or hand-mangled file
|
|
84
|
-
* nothing: no grant, never an error.
|
|
97
|
+
* Anything else (including a corrupted or hand-mangled file, or a list that
|
|
98
|
+
* does not parse) contributes nothing: no grant, never an error.
|
|
85
99
|
*/
|
|
86
100
|
export function parseGrant(raw) {
|
|
87
101
|
const trimmed = raw.trim();
|
|
88
102
|
if (trimmed === 'auto')
|
|
89
103
|
return { kind: 'auto' };
|
|
104
|
+
const listed = /^steps (\S+)$/.exec(trimmed);
|
|
105
|
+
const steps = listed === null ? null : parseStepList(listed[1] ?? '');
|
|
106
|
+
if (steps !== null)
|
|
107
|
+
return { kind: 'steps', steps };
|
|
90
108
|
const range = /^range (\d+)$/.exec(trimmed);
|
|
91
109
|
if (range !== null)
|
|
92
110
|
return { kind: 'range', ceiling: Number(range[1]) };
|
package/dist/lib/notice.js
CHANGED
|
@@ -67,13 +67,14 @@ export function advisory(a) {
|
|
|
67
67
|
}
|
|
68
68
|
/**
|
|
69
69
|
* Assemble a transition's whole ending: the labeled lead line, the Verdict
|
|
70
|
-
* where one was measured, the advisories,
|
|
70
|
+
* where one was measured, the advisories, the Stop part where a range landed,
|
|
71
|
+
* and the pointer.
|
|
71
72
|
*
|
|
72
73
|
* The order is the type's field order, so a verb composes parts and never a
|
|
73
74
|
* sequence. A part with nothing in it vanishes rather than leaving a gap.
|
|
74
75
|
*/
|
|
75
76
|
export function ending(e) {
|
|
76
|
-
return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.pointer ?? null]);
|
|
77
|
+
return blocks([e.lead, e.verdict ?? null, ...(e.advisories ?? []), e.stop ?? null, e.pointer ?? null]);
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
79
80
|
* Stack a turn's parts: one blank line between each, one beneath the last.
|
package/dist/lib/orient.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// contents (no fs), and a malformed document degrades to fewer fields rather
|
|
5
5
|
// than throwing.
|
|
6
6
|
import { parseStepSeam, scopeDrift } from "./intent.js";
|
|
7
|
+
import { expandStepList, formatStepList, hasStep, normalizeStepList, parseStepList, readBuildArgs, } from "./steplist.js";
|
|
7
8
|
/**
|
|
8
9
|
* The lines of a named `## Section`, from its heading to the next `## ` (or
|
|
9
10
|
* EOF), with the offset of the first line so a caller can name an absolute
|
|
@@ -74,33 +75,35 @@ export function parseSteps(intent) {
|
|
|
74
75
|
return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
|
|
75
76
|
});
|
|
76
77
|
}
|
|
77
|
-
// A build-order line: step numbers and
|
|
78
|
-
//
|
|
79
|
-
|
|
78
|
+
// A build-order line: step numbers and spans (`2-4`) joined by commas, with or
|
|
79
|
+
// without a space after each, and nothing else. A trailing comma is tolerated,
|
|
80
|
+
// since a hand edit leaves one behind.
|
|
81
|
+
const BUILD_ORDER_LINE = /^\d+(?:-\d+)?(?:\s*,\s*\d+(?:-\d+)?)*\s*,?$/;
|
|
80
82
|
/**
|
|
81
83
|
* The build order declared under `## Build order`: the step numbers on the
|
|
82
|
-
* first line that is nothing but numbers and commas, in the sequence
|
|
84
|
+
* first line that is nothing but numbers, spans, and commas, in the sequence
|
|
85
|
+
* written, each span spelled out in place (`1, 6, 2-4` reads 1, 6, 2, 3, 4).
|
|
83
86
|
*
|
|
84
87
|
* The line is the plan's own statement of what to build next once the
|
|
85
88
|
* numbering stops carrying it (a refined plan appends a step 7 that has to
|
|
86
89
|
* land before step 5), and `plumbbob order` is the verb that writes it. The
|
|
87
90
|
* template's guidance, a comment, and an absent section all read as `[]`,
|
|
88
|
-
* which every picker takes to mean document order
|
|
89
|
-
*
|
|
90
|
-
* `recover` wants the raw read to
|
|
91
|
+
* which every picker takes to mean document order, and so does a line holding
|
|
92
|
+
* a span that runs backward. Numbers come back verbatim, repeats and unknowns
|
|
93
|
+
* included: `orderSteps` does the cleaning, and `recover` wants the raw read to
|
|
94
|
+
* say which number the plan lacks. A span stops at the first number past the
|
|
95
|
+
* plan, which it keeps for `recover` to name.
|
|
91
96
|
*/
|
|
92
97
|
export function parseBuildOrder(intent) {
|
|
93
98
|
const line = sectionLines(intent, '## Build order')
|
|
94
99
|
.lines.map((l) => l.trim())
|
|
95
100
|
.find((l) => BUILD_ORDER_LINE.test(l));
|
|
96
|
-
|
|
101
|
+
const list = line === undefined ? null : parseStepList(line.replace(/\s+/g, '').replace(/,$/, ''));
|
|
102
|
+
if (list === null) {
|
|
97
103
|
return [];
|
|
98
104
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.map((n) => n.trim())
|
|
102
|
-
.filter((n) => n.length > 0)
|
|
103
|
-
.map(Number);
|
|
105
|
+
const planned = new Set(parseSteps(intent).map((s) => s.n));
|
|
106
|
+
return expandStepList(list, (n) => planned.has(n));
|
|
104
107
|
}
|
|
105
108
|
/**
|
|
106
109
|
* The steps in build order: the numbers `order` lists first, in that sequence,
|
|
@@ -234,6 +237,246 @@ export function clearBuildOrder(intent) {
|
|
|
234
237
|
}
|
|
235
238
|
return lines.join('\n');
|
|
236
239
|
}
|
|
240
|
+
// A range's opener: the item's number, the command in backticks, a comma, and
|
|
241
|
+
// the stop's text from `**stop:**` to the end of the line.
|
|
242
|
+
const RANGE_OPENER = /^`([^`]*)`\s*,?\s*\*\*stop:\*\*\s*(.*)$/i;
|
|
243
|
+
// A `<...>` fill-in the template ships, still waiting for its words.
|
|
244
|
+
const FILL_IN = /<[^<>]+>/;
|
|
245
|
+
/**
|
|
246
|
+
* Parse the ranges under `## Build ranges`: one numbered item per range, the
|
|
247
|
+
* command in backticks, then `**stop:**` and the stop's text, then an optional
|
|
248
|
+
* `- model:` sub-line.
|
|
249
|
+
*
|
|
250
|
+
* The command's steps go through the turn hook's own reader, so the card can
|
|
251
|
+
* never suggest a command the hook reads another way. A paragraph before the
|
|
252
|
+
* list and any sub-line but the model are the reader's. An item still holding
|
|
253
|
+
* a `<...>` fill-in in its command or stop is scaffold, the template's
|
|
254
|
+
* placeholder waiting for words, and reads as neither a range nor a report; any
|
|
255
|
+
* other item that fails the grammar is reported by its number and never
|
|
256
|
+
* thrown. A plan without the section, a build's record from before ranges
|
|
257
|
+
* existed under another heading, and a section holding only the placeholder
|
|
258
|
+
* all read as no ranges.
|
|
259
|
+
*/
|
|
260
|
+
export function parseBuildRanges(intent) {
|
|
261
|
+
const { offset, lines } = sectionLines(intent, '## Build ranges');
|
|
262
|
+
const ranges = [];
|
|
263
|
+
const malformed = [];
|
|
264
|
+
const openers = lines.flatMap((l, idx) => {
|
|
265
|
+
const m = /^(\d+)\.\s+(.*)$/.exec(l);
|
|
266
|
+
return m === null ? [] : [{ item: Number(m[1]), text: m[2] ?? '', idx }];
|
|
267
|
+
});
|
|
268
|
+
openers.forEach((o, i) => {
|
|
269
|
+
const block = lines.slice(o.idx + 1, openers[i + 1]?.idx ?? lines.length);
|
|
270
|
+
const text = [o.text, ...wrappedLines(block)].join(' ').trim();
|
|
271
|
+
if (FILL_IN.test(text)) {
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const range = readRange(o.item, text, block, offset + o.idx + 1);
|
|
275
|
+
if (range === null) {
|
|
276
|
+
malformed.push(o.item);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
ranges.push(range);
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
return { ranges, malformed };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The lines an item's opener wraps onto: each line after it, trimmed, up to the
|
|
286
|
+
* first blank line, sub-line, or heading.
|
|
287
|
+
*/
|
|
288
|
+
function wrappedLines(block) {
|
|
289
|
+
const end = block.findIndex((l) => l.trim() === '' || /^\s*[-*+]\s/.test(l) || l.startsWith('#'));
|
|
290
|
+
return block.slice(0, end === -1 ? block.length : end).map((l) => l.trim());
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* One item read as a range, or null when it is not one: a command other than
|
|
294
|
+
* `/plumbbob:build` with steps and nothing after them, a bare command naming
|
|
295
|
+
* more than one step, or a stop with no text.
|
|
296
|
+
*/
|
|
297
|
+
function readRange(item, text, block, line) {
|
|
298
|
+
const m = RANGE_OPENER.exec(text);
|
|
299
|
+
const args = /^\/plumbbob:build\s+([\d,\s-]+)$/.exec((m?.[1] ?? '').trim())?.[1];
|
|
300
|
+
const stop = (m?.[2] ?? '').trim();
|
|
301
|
+
if (args === undefined || stop === '') {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
const read = readBuildArgs(args);
|
|
305
|
+
const single = read.steps?.length === 1 && read.steps[0]?.from === read.steps[0]?.to;
|
|
306
|
+
if (read.steps === null || (!read.range && !single)) {
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
const model = /^\s*-\s*model:\s*(.+)$/im.exec(block.join('\n'))?.[1]?.trim() ?? null;
|
|
310
|
+
return {
|
|
311
|
+
item,
|
|
312
|
+
command: (m?.[1] ?? '').trim(),
|
|
313
|
+
steps: read.steps,
|
|
314
|
+
bare: !read.range,
|
|
315
|
+
stop,
|
|
316
|
+
model: model === null || FILL_IN.test(model) ? null : model,
|
|
317
|
+
line,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Where the build stands in its ranges, read from `step`: the range holding
|
|
322
|
+
* it, the command that runs what is left of that range, the range its landing
|
|
323
|
+
* completed, and every range the plan has outgrown.
|
|
324
|
+
*
|
|
325
|
+
* Nothing is stored. A range is landed once every step it names is done, and
|
|
326
|
+
* what is left of it is its undone steps from `step` on, in build order, so a
|
|
327
|
+
* step pause names what is left once the step in flight lands. A landing
|
|
328
|
+
* range down to one step writes it `9-9`, which lands it, where a bare range
|
|
329
|
+
* stays bare. `ordered` is the plan's steps in build order, landed ones
|
|
330
|
+
* included. With no range that parses, no step sits outside one, so a fresh
|
|
331
|
+
* build's placeholder flags nothing.
|
|
332
|
+
*/
|
|
333
|
+
export function rangeStanding(plan, ordered, step) {
|
|
334
|
+
const drift = rangeDrift(plan, ordered);
|
|
335
|
+
const count = plan.ranges.length + plan.malformed.length;
|
|
336
|
+
const at = ordered.findIndex((s) => s.n === step);
|
|
337
|
+
const holding = plan.ranges.find((r) => step !== null && hasStep(r.steps, step));
|
|
338
|
+
if (holding === undefined) {
|
|
339
|
+
const outside = step !== null && plan.ranges.length > 0 && ordered[at]?.done === false;
|
|
340
|
+
return { count, holding: null, command: null, flag: outside ? noRangeWords([step]) : null, completed: null, drift };
|
|
341
|
+
}
|
|
342
|
+
const flag = drift.find((d) => d.item === holding.item && d.kind !== 'malformed')?.words ?? null;
|
|
343
|
+
const left = at === -1 ? [] : ordered.slice(at).filter((s) => !s.done && hasStep(holding.steps, s.n));
|
|
344
|
+
return {
|
|
345
|
+
count,
|
|
346
|
+
holding,
|
|
347
|
+
command: flag === null && left.length > 0 ? `/plumbbob:build ${leftExpression(holding, left)}` : null,
|
|
348
|
+
flag,
|
|
349
|
+
completed: ordered[at]?.done === true && rangeLanded(holding, ordered) ? holding : null,
|
|
350
|
+
drift,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/** Whether a range has landed: the plan holds a step it names, and every one it holds is done. */
|
|
354
|
+
function rangeLanded(range, ordered) {
|
|
355
|
+
const held = ordered.filter((s) => hasStep(range.steps, s.n));
|
|
356
|
+
return held.length > 0 && held.every((s) => s.done);
|
|
357
|
+
}
|
|
358
|
+
/**
|
|
359
|
+
* The ranges block the dashboard renders, and the move it suggests: the range
|
|
360
|
+
* holding the next undone step and the command that runs what is left of it.
|
|
361
|
+
*
|
|
362
|
+
* Null for a plan without ranges, so the block vanishes. The move is null when
|
|
363
|
+
* the next step sits in no range, its range is flagged, or the step is still
|
|
364
|
+
* unplanned, which leaves the dashboard's own step move in charge.
|
|
365
|
+
*/
|
|
366
|
+
function rangesView(intent, ordered, next) {
|
|
367
|
+
const plan = parseBuildRanges(intent);
|
|
368
|
+
const standing = rangeStanding(plan, ordered, next?.n ?? null);
|
|
369
|
+
if (standing.count === 0) {
|
|
370
|
+
return { view: null, move: null };
|
|
371
|
+
}
|
|
372
|
+
const own = (item) => standing.drift.filter((d) => d.item === item && d.kind !== 'malformed');
|
|
373
|
+
const rows = plan.ranges.map((range) => ({
|
|
374
|
+
range,
|
|
375
|
+
landed: rangeLanded(range, ordered),
|
|
376
|
+
next: range === standing.holding,
|
|
377
|
+
drift: own(range.item).map((d) => d.words),
|
|
378
|
+
}));
|
|
379
|
+
const view = { count: standing.count, rows, plan: standing.drift.filter((d) => d.item === null || d.kind === 'malformed').map((d) => d.words) };
|
|
380
|
+
const { holding, command } = standing;
|
|
381
|
+
const move = holding === null || command === null || next?.planned !== true ? null : `build range ${holding.item} of ${standing.count} — \`${command}\``;
|
|
382
|
+
return { view, move };
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* What is left of a range as the steps of a command: a landing range's last
|
|
386
|
+
* step written `9-9` so the command lands it, else the steps in build order
|
|
387
|
+
* with each climbing run folded to a span, a bare range's lone number included.
|
|
388
|
+
*/
|
|
389
|
+
function leftExpression(range, left) {
|
|
390
|
+
const ns = left.map((s) => s.n);
|
|
391
|
+
return !range.bare && ns.length === 1 ? `${ns[0]}-${ns[0]}` : formatStepList(ns);
|
|
392
|
+
}
|
|
393
|
+
/** The words for steps no range names: `no range names step 12`, or `steps 11, 12`. */
|
|
394
|
+
function noRangeWords(steps) {
|
|
395
|
+
return `no range names ${stepPhrase(steps.map(String)).words}`;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Every report on the ranges, in the order the dashboard lists them: each
|
|
399
|
+
* range's own (a step the plan lacks, a step another range also names, a step
|
|
400
|
+
* from outside it the build order puts among its own), then the undone steps
|
|
401
|
+
* no range names, then the items that do not parse.
|
|
402
|
+
*
|
|
403
|
+
* A step the build order puts inside a range, and a step no range names,
|
|
404
|
+
* count only while undone, since a landed step cannot cut a run short. The
|
|
405
|
+
* coverage check runs only once a real range parses.
|
|
406
|
+
*/
|
|
407
|
+
function rangeDrift(plan, ordered) {
|
|
408
|
+
const own = plan.ranges.flatMap((r) => [
|
|
409
|
+
...missingDrift(r, ordered),
|
|
410
|
+
...sharedDrift(r, plan.ranges, ordered),
|
|
411
|
+
...insideDrift(r, ordered),
|
|
412
|
+
]);
|
|
413
|
+
const outside = ordered.filter((s) => !s.done && !plan.ranges.some((r) => hasStep(r.steps, s.n))).map((s) => s.n);
|
|
414
|
+
const uncovered = plan.ranges.length > 0 && outside.length > 0 ? [{ kind: 'uncovered', item: null, words: noRangeWords(outside) }] : [];
|
|
415
|
+
const malformed = plan.malformed.map((item) => ({ kind: 'malformed', item, words: `item ${item} under Build ranges does not parse` }));
|
|
416
|
+
return [...own, ...uncovered, ...malformed];
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* A range naming steps the plan lacks, reported as the spans it names past
|
|
420
|
+
* the plan's steps, so a span of any width is read without being counted out.
|
|
421
|
+
*/
|
|
422
|
+
function missingDrift(range, ordered) {
|
|
423
|
+
const held = ordered.map((s) => s.n).sort((a, b) => a - b);
|
|
424
|
+
const gaps = [];
|
|
425
|
+
for (const span of range.steps) {
|
|
426
|
+
let from = span.from;
|
|
427
|
+
for (const n of held.filter((h) => h >= span.from && h <= span.to)) {
|
|
428
|
+
if (n > from) {
|
|
429
|
+
gaps.push({ from, to: n - 1 });
|
|
430
|
+
}
|
|
431
|
+
from = Math.max(from, n + 1);
|
|
432
|
+
}
|
|
433
|
+
if (from <= span.to) {
|
|
434
|
+
gaps.push({ from, to: span.to });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
if (gaps.length === 0) {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
const { words, be } = stepPhrase(normalizeStepList(gaps).split(','));
|
|
441
|
+
return [{ kind: 'missing', item: range.item, words: `${words} ${be} not in the plan` }];
|
|
442
|
+
}
|
|
443
|
+
/** A range sharing steps with another range, one report for each other range. */
|
|
444
|
+
function sharedDrift(range, ranges, ordered) {
|
|
445
|
+
const mine = ordered.filter((s) => hasStep(range.steps, s.n));
|
|
446
|
+
return ranges.flatMap((other) => {
|
|
447
|
+
const both = other === range ? [] : mine.filter((s) => hasStep(other.steps, s.n)).map((s) => String(s.n));
|
|
448
|
+
if (both.length === 0) {
|
|
449
|
+
return [];
|
|
450
|
+
}
|
|
451
|
+
const { words, be } = stepPhrase(both);
|
|
452
|
+
return [{ kind: 'shared', item: range.item, words: `${words} ${be} also in range ${other.item}` }];
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* A range the build order cuts short: an undone step it does not name, placed
|
|
457
|
+
* between two of its own, where a run of the range halts.
|
|
458
|
+
*
|
|
459
|
+
* An outside step ahead of the range's first step builds first and cuts
|
|
460
|
+
* nothing, and the card names its range before this one.
|
|
461
|
+
*/
|
|
462
|
+
function insideDrift(range, ordered) {
|
|
463
|
+
const first = ordered.findIndex((s) => hasStep(range.steps, s.n));
|
|
464
|
+
const last = ordered.findLastIndex((s) => hasStep(range.steps, s.n));
|
|
465
|
+
const inside = first === -1 ? [] : ordered.slice(first + 1, last).filter((s) => !s.done && !hasStep(range.steps, s.n));
|
|
466
|
+
if (inside.length === 0) {
|
|
467
|
+
return [];
|
|
468
|
+
}
|
|
469
|
+
const words = stepPhrase(inside.map((s) => String(s.n))).words;
|
|
470
|
+
return [{ kind: 'inside', item: range.item, words: `the build order puts ${words} inside it` }];
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Steps as a phrase, with the verb that agrees: `step 12` and `is`, or
|
|
474
|
+
* `steps 11, 12` and `are`. Each item is a number or a span as it is typed.
|
|
475
|
+
*/
|
|
476
|
+
function stepPhrase(items) {
|
|
477
|
+
const one = items.length === 1 && /^\d+$/.test(items[0] ?? '');
|
|
478
|
+
return { words: `${one ? 'step' : 'steps'} ${items.join(', ')}`, be: one ? 'is' : 'are' };
|
|
479
|
+
}
|
|
237
480
|
/**
|
|
238
481
|
* Flip step N's `[ ]` checkbox to `[x]` within the `## Steps` section.
|
|
239
482
|
*
|
|
@@ -365,27 +608,29 @@ export function lastLedgerSha(checkpoints) {
|
|
|
365
608
|
return last;
|
|
366
609
|
}
|
|
367
610
|
/**
|
|
368
|
-
* The
|
|
369
|
-
*
|
|
611
|
+
* The steps an explicit skill invocation asks for, out of the raw argument
|
|
612
|
+
* text `status --invoked` receives, read the way the turn hook reads a typed
|
|
613
|
+
* range.
|
|
370
614
|
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
* dashboard's own suggestion in charge. Null in
|
|
375
|
-
* never substitutes the invocation degrades to
|
|
615
|
+
* A step number names one step, and a range or a list names the steps it
|
|
616
|
+
* holds (`orient` starts it at the first undone one in build order); `--auto`
|
|
617
|
+
* alone, a range that does not parse, and free text parse to null, meaning "no
|
|
618
|
+
* explicit ask", which leaves the dashboard's own suggestion in charge. Null in
|
|
619
|
+
* means null out, so a host that never substitutes the invocation degrades to
|
|
620
|
+
* today's rendering.
|
|
376
621
|
*/
|
|
377
622
|
export function parseRequestedStep(raw) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
return null;
|
|
623
|
+
return raw === null ? null : readBuildArgs(raw.trim()).steps;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* The step a requested list starts at: its first undone step in build order,
|
|
627
|
+
* however the list was written; its first step in build order when every one
|
|
628
|
+
* has landed; or its first number written when the plan holds none of them, so
|
|
629
|
+
* the dashboard reports that number missing. Null for an empty list.
|
|
630
|
+
*/
|
|
631
|
+
export function rangeStart(ordered, list) {
|
|
632
|
+
const named = ordered.filter((s) => hasStep(list, s.n));
|
|
633
|
+
return (named.find((s) => !s.done) ?? named[0])?.n ?? list[0]?.from ?? null;
|
|
389
634
|
}
|
|
390
635
|
/**
|
|
391
636
|
* The single primary next move the dashboard suggests.
|
|
@@ -454,15 +699,22 @@ export function orient(input) {
|
|
|
454
699
|
const order = parseBuildOrder(input.intent);
|
|
455
700
|
const ordered = orderSteps(steps, order);
|
|
456
701
|
const parks = parseParkTotals(input.buildLog);
|
|
702
|
+
// The step an explicit ask points at: the number typed, or where a typed
|
|
703
|
+
// range starts in build order.
|
|
704
|
+
const requested = input.requested === null ? null : rangeStart(ordered, input.requested);
|
|
457
705
|
// The target the dashboard details: the explicitly requested step when it
|
|
458
706
|
// exists in the plan, else the next undone. A requested number that names no
|
|
459
707
|
// planned step carries nothing here; the next-move line reports the mismatch
|
|
460
708
|
// while the rest of the dashboard renders as usual.
|
|
461
|
-
const requestedStep =
|
|
709
|
+
const requestedStep = requested === null ? undefined : steps.find((s) => s.n === requested);
|
|
462
710
|
const nextUndone = ordered.find((s) => !s.done);
|
|
463
711
|
const target = requestedStep ?? nextUndone;
|
|
464
712
|
const seamParse = target === undefined ? null : parseStepSeam(input.intent, target.n);
|
|
465
713
|
const phase = input.spiking ? 'SPIKE' : input.inFlight !== null ? 'BUILD' : 'DESIGN';
|
|
714
|
+
// At the boundary with nothing asked for, the move names the range holding
|
|
715
|
+
// the next step; a spike, a step in flight, or a request keeps its own.
|
|
716
|
+
const ranges = rangesView(input.intent, ordered, nextUndone);
|
|
717
|
+
const atBoundary = !input.spiking && input.inFlight === null && requested === null;
|
|
466
718
|
return {
|
|
467
719
|
title: parseTitle(input.intent),
|
|
468
720
|
phase,
|
|
@@ -471,9 +723,10 @@ export function orient(input) {
|
|
|
471
723
|
parked: parks.open,
|
|
472
724
|
parkedTotal: parks.total,
|
|
473
725
|
openQuestions: parseOpenQuestions(input.intent),
|
|
474
|
-
next: nextMove(input.spiking, ordered, input.inFlight, parks.open,
|
|
726
|
+
next: (atBoundary ? ranges.move : null) ?? nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
|
|
475
727
|
nextUndone: nextUndone?.n ?? null,
|
|
476
728
|
buildOrder: remainingOrder(steps, order, null),
|
|
729
|
+
ranges: ranges.view,
|
|
477
730
|
requested: requestedStep?.n ?? null,
|
|
478
731
|
nextDoneWhen: target?.doneWhen ?? null,
|
|
479
732
|
nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
|
|
@@ -1024,6 +1277,7 @@ export function formatOrientation(o) {
|
|
|
1024
1277
|
return detail.length > 0 ? [head, ...detail].join('\n') : head;
|
|
1025
1278
|
});
|
|
1026
1279
|
const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
|
|
1280
|
+
const rangesBlock = o.ranges === null ? [] : [formatRanges(o.ranges), ''];
|
|
1027
1281
|
const cp = o.lastCheckpoint;
|
|
1028
1282
|
const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
|
|
1029
1283
|
// A neutral reconciliation note, only when there is something to reconcile:
|
|
@@ -1043,6 +1297,7 @@ export function formatOrientation(o) {
|
|
|
1043
1297
|
'',
|
|
1044
1298
|
stepsBlock,
|
|
1045
1299
|
'',
|
|
1300
|
+
...rangesBlock,
|
|
1046
1301
|
cpLine,
|
|
1047
1302
|
...receipts,
|
|
1048
1303
|
...orderRow,
|
|
@@ -1051,3 +1306,22 @@ export function formatOrientation(o) {
|
|
|
1051
1306
|
`next → ${o.next}`,
|
|
1052
1307
|
].join('\n');
|
|
1053
1308
|
}
|
|
1309
|
+
/**
|
|
1310
|
+
* The dashboard's ranges block, in the step list's own grammar: a count, then a
|
|
1311
|
+
* row per range with its command as the plan wrote it and its model, `✓` for
|
|
1312
|
+
* a range landed and `▸` with the `← next` tail for the one holding the next
|
|
1313
|
+
* step. An undone range carries its stop beneath it, since the stops ahead are
|
|
1314
|
+
* what the human steers by, and a landed one folds to its row. A range's drift
|
|
1315
|
+
* rides beneath it, and the plan's own rows close the block.
|
|
1316
|
+
*/
|
|
1317
|
+
function formatRanges(view) {
|
|
1318
|
+
const landed = view.rows.filter((r) => r.landed).length;
|
|
1319
|
+
const rows = view.rows.flatMap((r) => {
|
|
1320
|
+
const marker = r.landed ? '✓' : r.next ? '▸' : ' ';
|
|
1321
|
+
const model = r.range.model === null ? '' : ` · ${r.range.model}`;
|
|
1322
|
+
const head = ` ${marker} ${r.range.item} ${r.range.command}${model}${r.next ? ' ← next' : ''}`;
|
|
1323
|
+
const stop = r.landed ? [] : [` stop: ${r.range.stop}`];
|
|
1324
|
+
return [head, ...stop, ...r.drift.map((words) => ` ⚠ needs a re-cut: ${words}`)];
|
|
1325
|
+
});
|
|
1326
|
+
return [` ranges ${landed} of ${view.count} done`, ...rows, ...view.plan.map((words) => ` ⚠ ${words}`)].join('\n');
|
|
1327
|
+
}
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -473,6 +473,9 @@ export function clearSpike(root, slug) {
|
|
|
473
473
|
// `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
|
|
474
474
|
// and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
|
|
475
475
|
// refuse a land until a human turn has intervened since the step was entered.
|
|
476
|
+
// A background job's notice reaches the same hook and ticks neither; PROMPT,
|
|
477
|
+
// beside them, records the id of the last prompt the hook saw, so the hook can
|
|
478
|
+
// tell a notice folded into the running turn from one that starts its own.
|
|
476
479
|
// Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
|
|
477
480
|
// state are allowed. ---
|
|
478
481
|
/**
|
|
@@ -504,6 +507,41 @@ export function setGrant(root, grant) {
|
|
|
504
507
|
writeFileSync(grantPath(root), `${grant}\n`);
|
|
505
508
|
}
|
|
506
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* PROMPT: the id of the last prompt the turn hook saw, typed or not.
|
|
512
|
+
*/
|
|
513
|
+
export function promptPath(root) {
|
|
514
|
+
return join(root, DIRNAME, 'PROMPT');
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* The id of the last prompt the turn hook saw, or null when none is recorded.
|
|
518
|
+
*
|
|
519
|
+
* A notice the harness folds into a running turn arrives under that turn's id,
|
|
520
|
+
* where one that starts a turn of its own arrives under a fresh one, so the
|
|
521
|
+
* hook compares a notice's id against this one to tell the two apart.
|
|
522
|
+
*/
|
|
523
|
+
export function readPrompt(root) {
|
|
524
|
+
try {
|
|
525
|
+
const id = readFileSync(promptPath(root), 'utf8').trim();
|
|
526
|
+
return id === '' ? null : id;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Record the id of the prompt the hook just saw, or clear the record when the
|
|
534
|
+
* payload carried none, so a stale id can never make a later notice look as
|
|
535
|
+
* though it joined the running turn.
|
|
536
|
+
*/
|
|
537
|
+
export function notePrompt(root, id) {
|
|
538
|
+
if (id === null) {
|
|
539
|
+
rmSync(promptPath(root), { force: true });
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
writeFileSync(promptPath(root), `${id}\n`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
507
545
|
/**
|
|
508
546
|
* TICK: the per-build entry stamp: the TURN value recorded when work was
|
|
509
547
|
* entered (`build <n>` for a step, `start` for the plan), cleared when
|
|
@@ -612,6 +650,18 @@ export function writeRelay(root, relay) {
|
|
|
612
650
|
// A read-only tree or a directory in the way: the ending already printed.
|
|
613
651
|
}
|
|
614
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* Drop the recorded ending, so the `Stop` check has nothing to judge until a
|
|
655
|
+
* verb prints another.
|
|
656
|
+
*
|
|
657
|
+
* The turn hook calls this when a background job's notice starts a turn of its
|
|
658
|
+
* own. The ending was judged when its own turn ended, and a notice leaves TURN
|
|
659
|
+
* where it was, so without this the notice's turn would read as the one that
|
|
660
|
+
* printed the ending and be held open for a card the human has already read.
|
|
661
|
+
*/
|
|
662
|
+
export function clearRelay(root) {
|
|
663
|
+
rmSync(relayPath(root), { force: true });
|
|
664
|
+
}
|
|
615
665
|
/**
|
|
616
666
|
* Stamp the record with the turn the check held open over it, so the check
|
|
617
667
|
* holds at most once per turn. False when the stamp could not be written, and
|
|
@@ -739,6 +789,9 @@ export function excludeControl(root) {
|
|
|
739
789
|
// never writes, and never commits.
|
|
740
790
|
`${DIRNAME}/TURN`,
|
|
741
791
|
`${DIRNAME}/GRANT`,
|
|
792
|
+
// The id of the last prompt the turn hook saw, which tells a notice folded
|
|
793
|
+
// into the running turn from one that starts its own.
|
|
794
|
+
`${DIRNAME}/PROMPT`,
|
|
742
795
|
// The in-flight step's detail file: flat per-session control the model
|
|
743
796
|
// overwrites before every pause. checkpoint records it in the build-log's
|
|
744
797
|
// Log and then clears it, so its archive is the tracked ledger, never this
|