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/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 : [],
|
|
@@ -903,6 +1156,45 @@ export function parseSummary(detail) {
|
|
|
903
1156
|
}
|
|
904
1157
|
return { lead, highlights };
|
|
905
1158
|
}
|
|
1159
|
+
/**
|
|
1160
|
+
* `.plumbbob/detail.md` with a blank line around every heading, and everything
|
|
1161
|
+
* else as written.
|
|
1162
|
+
*
|
|
1163
|
+
* A model copying the detail template can run a heading straight into its body
|
|
1164
|
+
* or its predecessor, and markdownlint's MD022 fails that file wherever
|
|
1165
|
+
* `.plumbbob/` is linted, which turns a downstream checkpoint red. Heading lines
|
|
1166
|
+
* inside a fenced block are content, not headings, so a fence is left whole.
|
|
1167
|
+
* Pure and idempotent: text that already has its blank lines comes back byte for
|
|
1168
|
+
* byte, so a caller can compare the result to the input to learn whether
|
|
1169
|
+
* anything needed fixing.
|
|
1170
|
+
*/
|
|
1171
|
+
export function normalizeDetailHeadings(detail) {
|
|
1172
|
+
const out = [];
|
|
1173
|
+
let fence = null;
|
|
1174
|
+
let afterHeading = false;
|
|
1175
|
+
for (const line of detail.split('\n')) {
|
|
1176
|
+
if (fence !== null) {
|
|
1177
|
+
const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
|
|
1178
|
+
if (close !== undefined && close.startsWith(fence.char) && close.length >= fence.length) {
|
|
1179
|
+
fence = null;
|
|
1180
|
+
}
|
|
1181
|
+
out.push(line);
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
const heading = /^ {0,3}#{1,6}(\s|$)/.test(line);
|
|
1185
|
+
const prior = out[out.length - 1];
|
|
1186
|
+
if ((heading || afterHeading) && line.trim() !== '' && prior !== undefined && prior.trim() !== '') {
|
|
1187
|
+
out.push('');
|
|
1188
|
+
}
|
|
1189
|
+
const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
1190
|
+
if (open !== undefined) {
|
|
1191
|
+
fence = { char: open.charAt(0), length: open.length };
|
|
1192
|
+
}
|
|
1193
|
+
out.push(line);
|
|
1194
|
+
afterHeading = heading;
|
|
1195
|
+
}
|
|
1196
|
+
return out.join('\n');
|
|
1197
|
+
}
|
|
906
1198
|
/**
|
|
907
1199
|
* The `## <n>` sections of `.plumbbob/detail.md` with their bodies: the full
|
|
908
1200
|
* story behind each highlight, in the order the model wrote them. The body is
|
|
@@ -985,11 +1277,15 @@ export function formatOrientation(o) {
|
|
|
985
1277
|
return detail.length > 0 ? [head, ...detail].join('\n') : head;
|
|
986
1278
|
});
|
|
987
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), ''];
|
|
988
1281
|
const cp = o.lastCheckpoint;
|
|
989
1282
|
const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
|
|
990
1283
|
// A neutral reconciliation note, only when there is something to reconcile:
|
|
991
1284
|
// commits landed since the last checkpoint that plumbbob's ledger didn't
|
|
992
|
-
// record.
|
|
1285
|
+
// record. A commit a plumbbob verb makes starts the range, and a hand-made
|
|
1286
|
+
// plan commit drops out on its `plumbbob plan` body marker, so the count is
|
|
1287
|
+
// the same one `handoff` folds into its Verdict. Informational: the human
|
|
1288
|
+
// commits freely, so this never gates.
|
|
993
1289
|
const receipts = o.outOfBand > 0
|
|
994
1290
|
? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
|
|
995
1291
|
: [];
|
|
@@ -1001,6 +1297,7 @@ export function formatOrientation(o) {
|
|
|
1001
1297
|
'',
|
|
1002
1298
|
stepsBlock,
|
|
1003
1299
|
'',
|
|
1300
|
+
...rangesBlock,
|
|
1004
1301
|
cpLine,
|
|
1005
1302
|
...receipts,
|
|
1006
1303
|
...orderRow,
|
|
@@ -1009,3 +1306,22 @@ export function formatOrientation(o) {
|
|
|
1009
1306
|
`next → ${o.next}`,
|
|
1010
1307
|
].join('\n');
|
|
1011
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
|
+
}
|