plumbbob 0.10.3 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli-core.js +8 -4
- package/dist/lib/agents.js +2 -2
- package/dist/lib/git.js +60 -5
- package/dist/lib/notice.js +140 -0
- package/dist/lib/orient.js +437 -8
- package/dist/lib/sidecar.js +28 -0
- package/dist/verbs/abandon.js +25 -8
- package/dist/verbs/agent.js +63 -31
- package/dist/verbs/build.js +25 -9
- package/dist/verbs/check.js +8 -3
- package/dist/verbs/checkpoint.js +96 -23
- package/dist/verbs/finish.js +33 -9
- package/dist/verbs/handoff.js +432 -63
- package/dist/verbs/park.js +11 -5
- package/dist/verbs/revert.js +16 -5
- package/dist/verbs/spike.js +61 -22
- package/dist/verbs/start.js +56 -17
- package/dist/verbs/use.js +26 -16
- package/package.json +1 -1
- package/skills/abandon/SKILL.md +2 -1
- package/skills/build/SKILL.md +116 -35
- package/skills/finish/SKILL.md +19 -8
- package/skills/park/SKILL.md +15 -1
- package/skills/plan/SKILL.md +14 -1
- package/skills/recover/SKILL.md +3 -2
- package/skills/revert/SKILL.md +2 -1
- package/skills/spike/SKILL.md +3 -2
- package/skills/status/SKILL.md +13 -5
- package/skills/verify/SKILL.md +127 -31
package/dist/lib/orient.js
CHANGED
|
@@ -3,21 +3,23 @@
|
|
|
3
3
|
// one suggested next move. Pure and best-effort by design: it takes raw file
|
|
4
4
|
// contents (no fs), and a malformed document degrades to fewer fields rather
|
|
5
5
|
// than throwing.
|
|
6
|
-
import { parseStepSeam } from "./intent.js";
|
|
6
|
+
import { parseStepSeam, scopeDrift } from "./intent.js";
|
|
7
7
|
/**
|
|
8
|
-
* The lines of a named `## Section`, from its heading to the next `## ` (or
|
|
8
|
+
* The lines of a named `## Section`, from its heading to the next `## ` (or
|
|
9
|
+
* EOF), with the offset of the first line so a caller can name an absolute
|
|
10
|
+
* line number in the document rather than one relative to the section.
|
|
9
11
|
*/
|
|
10
12
|
function sectionLines(content, heading) {
|
|
11
13
|
const lines = content.split('\n');
|
|
12
14
|
const start = lines.findIndex((l) => l.trim() === heading);
|
|
13
15
|
if (start === -1) {
|
|
14
|
-
return [];
|
|
16
|
+
return { offset: 0, lines: [] };
|
|
15
17
|
}
|
|
16
18
|
let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
|
|
17
19
|
if (end === -1) {
|
|
18
20
|
end = lines.length;
|
|
19
21
|
}
|
|
20
|
-
return lines.slice(start + 1, end);
|
|
22
|
+
return { offset: start + 1, lines: lines.slice(start + 1, end) };
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
25
|
* The build title: the first `# ` heading in intent.md, or null when absent.
|
|
@@ -42,7 +44,7 @@ export function parseTitle(intent) {
|
|
|
42
44
|
* narrative roadmap prose lives in its own section and never lands here.
|
|
43
45
|
*/
|
|
44
46
|
export function parseSteps(intent) {
|
|
45
|
-
const lines = sectionLines(intent, '## Steps');
|
|
47
|
+
const { offset, lines } = sectionLines(intent, '## Steps');
|
|
46
48
|
const starts = [];
|
|
47
49
|
lines.forEach((line, idx) => {
|
|
48
50
|
const m = /^(\d+)\.\s+\[([ xX])\]\s+(.*)$/.exec(line);
|
|
@@ -65,7 +67,7 @@ export function parseSteps(intent) {
|
|
|
65
67
|
const doneWhen = dw ? (dw[1] ?? '').trim() : null;
|
|
66
68
|
const md = /^\s*-\s*model:\s*(.+)$/im.exec(block);
|
|
67
69
|
const model = md ? (md[1] ?? '').trim() : null;
|
|
68
|
-
return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model };
|
|
70
|
+
return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
/**
|
|
@@ -110,7 +112,7 @@ export function markStepDone(intent, n) {
|
|
|
110
112
|
*/
|
|
111
113
|
export function parseOpenQuestions(intent) {
|
|
112
114
|
const opener = /^-\s+(?:<a id="[^"]*"><\/a>\s*)?\*{0,2}Q\d+(?: \([^)]+\))?\*{0,2}:\s*(.*)$/;
|
|
113
|
-
return sectionLines(intent, '## Open questions').filter((l) => {
|
|
115
|
+
return sectionLines(intent, '## Open questions').lines.filter((l) => {
|
|
114
116
|
const body = opener.exec(l.trim())?.[1];
|
|
115
117
|
return body !== undefined && !/\bresolved\b/i.test(l) && !/^<[^>]*>/.test(body);
|
|
116
118
|
}).length;
|
|
@@ -124,7 +126,18 @@ export function parseOpenQuestions(intent) {
|
|
|
124
126
|
* never match.
|
|
125
127
|
*/
|
|
126
128
|
export function parseParked(buildLog) {
|
|
127
|
-
return sectionLines(buildLog, '## Park list').filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
|
|
129
|
+
return sectionLines(buildLog, '## Park list').lines.filter((l) => /^-\s+\[ \]\s+\S/.test(l.trim())).length;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Count the constraints declared under `## Constraints`: the section's
|
|
133
|
+
* top-level list items, in whatever anchored or glossed form they are written.
|
|
134
|
+
*
|
|
135
|
+
* This is the readout's denominator for the constraints row. A constraint
|
|
136
|
+
* always applies (that is what makes it one), so the count is the CLI's to read
|
|
137
|
+
* rather than the model's to attest.
|
|
138
|
+
*/
|
|
139
|
+
export function parseConstraintCount(intent) {
|
|
140
|
+
return sectionLines(intent, '## Constraints').lines.filter((l) => /^-\s+\S/.test(l)).length;
|
|
128
141
|
}
|
|
129
142
|
/**
|
|
130
143
|
* The last `step N <sha>` line in the checkpoints ledger, or null when no step
|
|
@@ -267,6 +280,422 @@ export function orient(input) {
|
|
|
267
280
|
outOfBand: input.outOfBand,
|
|
268
281
|
};
|
|
269
282
|
}
|
|
283
|
+
/** The recap's five measuring rows, in the fixed order the fold walks them. */
|
|
284
|
+
const RECAP_ROW_NAMES = ['check', 'done-when', 'decisions', 'constraints', 'seam'];
|
|
285
|
+
/**
|
|
286
|
+
* Classify one recap row's value by its leading word, per the closed vocabulary
|
|
287
|
+
* in docs/presentation.md's row table. Null when the value opens with none of
|
|
288
|
+
* the words the row's kind allows (an unparseable row is simply absent from
|
|
289
|
+
* the fold, the same as a row that never rode the recap at all).
|
|
290
|
+
*/
|
|
291
|
+
function classifyRecapRow(name, value) {
|
|
292
|
+
const v = value.trim();
|
|
293
|
+
const test = (re, verdict) => {
|
|
294
|
+
const m = re.exec(v);
|
|
295
|
+
return m === null ? null : { verdict, word: m[0] };
|
|
296
|
+
};
|
|
297
|
+
switch (name) {
|
|
298
|
+
case 'check':
|
|
299
|
+
return test(/^green\b/, 'true') ?? test(/^red\b/, 'failing') ?? test(/^error\b/, 'failing');
|
|
300
|
+
case 'done-when':
|
|
301
|
+
return test(/^met\b/, 'true') ?? test(/^not met\b/, 'failing') ?? test(/^drift\b/, 'drift');
|
|
302
|
+
case 'decisions':
|
|
303
|
+
return (test(/^honored\b/, 'true') ??
|
|
304
|
+
test(/^\d+ of \d+ honored\b/, 'true') ??
|
|
305
|
+
test(/^none exercised\b/, 'true') ??
|
|
306
|
+
test(/^bent\b/, 'failing') ??
|
|
307
|
+
test(/^drift\b/, 'drift'));
|
|
308
|
+
case 'constraints':
|
|
309
|
+
return (test(/^all honored\b/, 'true') ??
|
|
310
|
+
test(/^\d+ of \d+ honored\b/, 'true') ??
|
|
311
|
+
test(/^bent\b/, 'failing') ??
|
|
312
|
+
test(/^drift\b/, 'drift'));
|
|
313
|
+
case 'seam':
|
|
314
|
+
return test(/^held\b/, 'true') ?? test(/^strayed\b/, 'failing') ?? test(/^drift\b/, 'drift');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Parse the fenced recap the model writes into `.plumbbob/detail.md`: the
|
|
319
|
+
* `── recap · step N of M ──` header, the (subset of) five measuring rows
|
|
320
|
+
* present, and the free-text diff row. Null when no header is found.
|
|
321
|
+
*
|
|
322
|
+
* A row absent from the block is simply missing from `rows`: a vanished row
|
|
323
|
+
* the fold treats exactly like one that never applied, never like a failure.
|
|
324
|
+
* An indented `- ` or `→ ` line attaches to the row above it, which is how a
|
|
325
|
+
* row carrying two or more items, or a pointer at its evidence, survives the
|
|
326
|
+
* round trip through the file.
|
|
327
|
+
*/
|
|
328
|
+
export function parseRecap(detail) {
|
|
329
|
+
const lines = detail.split('\n');
|
|
330
|
+
const headerIdx = lines.findIndex((l) => /^──\s*recap\s*·\s*step\s+\d+\s+of\s+\d+\s*──$/.test(l.trim()));
|
|
331
|
+
if (headerIdx === -1) {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
const header = /step\s+(\d+)\s+of\s+(\d+)/.exec(lines[headerIdx] ?? '');
|
|
335
|
+
if (header === null) {
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
const rows = {};
|
|
339
|
+
let diff = null;
|
|
340
|
+
let last = null;
|
|
341
|
+
for (let i = headerIdx + 1; i < lines.length; i++) {
|
|
342
|
+
const line = lines[i] ?? '';
|
|
343
|
+
if (line.trim().length === 0) {
|
|
344
|
+
break; // the fence is one contiguous block; a blank line ends it
|
|
345
|
+
}
|
|
346
|
+
const carried = /^\s+([-→])\s+(\S.*)$/.exec(line);
|
|
347
|
+
const open = last === null ? undefined : rows[last];
|
|
348
|
+
if (carried !== null) {
|
|
349
|
+
if (last !== null && open !== undefined) {
|
|
350
|
+
const item = (carried[2] ?? '').trim();
|
|
351
|
+
rows[last] = carried[1] === '→' ? { ...open, pointer: item } : { ...open, items: [...(open.items ?? []), item] };
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const m = /^(\S[\w-]*)\s+(.+)$/.exec(line);
|
|
356
|
+
if (m === null) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
const label = m[1] ?? '';
|
|
360
|
+
const value = (m[2] ?? '').trim();
|
|
361
|
+
last = null;
|
|
362
|
+
if (label === 'diff') {
|
|
363
|
+
diff = value;
|
|
364
|
+
}
|
|
365
|
+
else if (RECAP_ROW_NAMES.includes(label)) {
|
|
366
|
+
const classified = classifyRecapRow(label, value);
|
|
367
|
+
if (classified !== null) {
|
|
368
|
+
rows[label] = { ...classified, evidence: value };
|
|
369
|
+
last = label;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { step: Number(header[1]), total: Number(header[2]), rows, diff };
|
|
374
|
+
}
|
|
375
|
+
const PLUMB = { glyph: '●', state: 'Plumb' };
|
|
376
|
+
const A_HAIR_OFF = { glyph: '◐', state: 'A hair off' };
|
|
377
|
+
const OUT_OF_PLUMB = { glyph: '○', state: 'Out of plumb' };
|
|
378
|
+
const NOT_STANDING = { glyph: '✗', state: 'Not standing' };
|
|
379
|
+
/**
|
|
380
|
+
* Fold the recap's measuring rows worst-of with the step's accrued stats:
|
|
381
|
+
* drift beats a live failure beats an advisory beats plumb, and each rung
|
|
382
|
+
* names the one component that earned it. The advisory rung runs in a fixed
|
|
383
|
+
* order, the seam stray first.
|
|
384
|
+
*/
|
|
385
|
+
export function foldVerdict(rows, stats) {
|
|
386
|
+
for (const name of RECAP_ROW_NAMES) {
|
|
387
|
+
if (rows[name]?.verdict === 'drift') {
|
|
388
|
+
return { ladder: NOT_STANDING, worst: `${name} drifted` };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
for (const name of RECAP_ROW_NAMES) {
|
|
392
|
+
const row = rows[name];
|
|
393
|
+
if (row !== undefined && row.verdict === 'failing') {
|
|
394
|
+
return { ladder: OUT_OF_PLUMB, worst: `${name} ${row.word}` };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (stats.driftWarnings > 0) {
|
|
398
|
+
// First of the advisory rung, and uncounted: the stat bumps once per
|
|
399
|
+
// checkpoint rather than once per stray path, and this is the one advisory
|
|
400
|
+
// that asks a question of the plan rather than of the work.
|
|
401
|
+
return { ladder: A_HAIR_OFF, worst: 'staged outside the seam' };
|
|
402
|
+
}
|
|
403
|
+
if (stats.redChecks > 0) {
|
|
404
|
+
return { ladder: A_HAIR_OFF, worst: `${stats.redChecks} red run${stats.redChecks === 1 ? '' : 's'} before green` };
|
|
405
|
+
}
|
|
406
|
+
if (stats.reverts > 0) {
|
|
407
|
+
return { ladder: A_HAIR_OFF, worst: `${stats.reverts} revert${stats.reverts === 1 ? '' : 's'} on this step` };
|
|
408
|
+
}
|
|
409
|
+
if (stats.outOfBand > 0) {
|
|
410
|
+
return { ladder: A_HAIR_OFF, worst: `${stats.outOfBand} commit${stats.outOfBand === 1 ? '' : 's'} outside the ledger` };
|
|
411
|
+
}
|
|
412
|
+
return { ladder: PLUMB, worst: null };
|
|
413
|
+
}
|
|
414
|
+
// --- The assembled readout: the CLI-computed rows and the fence they ride in
|
|
415
|
+
// (docs/presentation.md). `handoff` measures (the check summary, the numstat,
|
|
416
|
+
// the seam file, the stats receipt) and this section turns each measurement
|
|
417
|
+
// into its row, every value cut to the 67 columns the 13-character label pad
|
|
418
|
+
// leaves inside the 80-column fence. ---
|
|
419
|
+
/** The label pad: readout values start at column 14, so a value gets 67 of the 80. */
|
|
420
|
+
const RECAP_VALUE_BUDGET = 67;
|
|
421
|
+
/** The continuation indent: a row's `- ` items and its `→ ` pointer align under the value column. */
|
|
422
|
+
const RECAP_INDENT = ' '.repeat(13);
|
|
423
|
+
/** Where a red check row points when no single slot owns the failure. */
|
|
424
|
+
const CHECK_SUMMARY = '.check/summary.json';
|
|
425
|
+
/**
|
|
426
|
+
* The measured check row from the last run's summary: the verdict word and a
|
|
427
|
+
* count that sizes the gate. A narrowed run records its deselected slots as
|
|
428
|
+
* skipped entries and the row names them behind the separator, degrading to
|
|
429
|
+
* their count when the names overflow the value budget.
|
|
430
|
+
*
|
|
431
|
+
* Red expands where green collapses: one failing slot is named in the row and
|
|
432
|
+
* its raw output is the `→` pointer, and two or more collapse to a count with
|
|
433
|
+
* one `- ` line per slot.
|
|
434
|
+
*/
|
|
435
|
+
export function summaryCheckRow(summary) {
|
|
436
|
+
const ran = summary.checks.filter((c) => c.skipped !== true);
|
|
437
|
+
const skipped = summary.checks.filter((c) => c.skipped === true).map((c) => c.name);
|
|
438
|
+
const named = skipped.length === 0 ? '' : ` · without ${skipped.join(', ')}`;
|
|
439
|
+
const counted = skipped.length === 0 ? '' : ` · without ${skipped.length} others`;
|
|
440
|
+
const fit = (long, short) => (long.length <= RECAP_VALUE_BUDGET ? long : short);
|
|
441
|
+
if (summary.ok) {
|
|
442
|
+
const size = `${ran.length} of ${summary.checks.length} checks`;
|
|
443
|
+
return { verdict: 'true', word: 'green', evidence: fit(`green: ${size}${named}`, `green: ${size}${counted}`) };
|
|
444
|
+
}
|
|
445
|
+
const failing = ran.filter((c) => !c.ok);
|
|
446
|
+
const first = failing[0];
|
|
447
|
+
if (failing.length > 1) {
|
|
448
|
+
return {
|
|
449
|
+
verdict: 'failing',
|
|
450
|
+
word: 'red',
|
|
451
|
+
evidence: `red: ${failing.length} of ${ran.length} checks failing`,
|
|
452
|
+
items: failing.map((c) => c.name),
|
|
453
|
+
pointer: CHECK_SUMMARY,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
if (first !== undefined) {
|
|
457
|
+
return {
|
|
458
|
+
verdict: 'failing',
|
|
459
|
+
word: 'red',
|
|
460
|
+
evidence: fit(`red: ${first.name} failing${named}`, `red: ${first.name} failing`),
|
|
461
|
+
pointer: first.output_file == null ? CHECK_SUMMARY : `.check/${first.output_file}`,
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
return { verdict: 'failing', word: 'red', evidence: fit(`red${named}`, 'red'), pointer: CHECK_SUMMARY };
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* The measured seam row: the changed work-plane paths against the step's seam
|
|
468
|
+
* tokens. Null when there is no seam to measure against or nothing changed
|
|
469
|
+
* (the row then vanishes, or the model's attested row stands).
|
|
470
|
+
*
|
|
471
|
+
* Green sizes the declared seam: how many of its tokens the diff actually
|
|
472
|
+
* touched, and that nothing landed outside them. Red states the size of the
|
|
473
|
+
* problem and lets the paths themselves be the evidence: one stray is the `→`
|
|
474
|
+
* pointer, and several break onto `- ` lines. `paths` are work-plane changes,
|
|
475
|
+
* artifacts already filtered by the caller.
|
|
476
|
+
*/
|
|
477
|
+
export function seamRowFromDiff(paths, seam) {
|
|
478
|
+
if (seam.length === 0 || paths.length === 0) {
|
|
479
|
+
return null;
|
|
480
|
+
}
|
|
481
|
+
const outside = scopeDrift(paths, seam);
|
|
482
|
+
if (outside.length === 0) {
|
|
483
|
+
const touched = seam.filter((token) => paths.some((p) => scopeDrift([p], [token]).length === 0)).length;
|
|
484
|
+
return { verdict: 'true', word: 'held', evidence: `held: ${touched} of ${seam.length} declared, no strays` };
|
|
485
|
+
}
|
|
486
|
+
const evidence = `strayed: ${outside.length} path${outside.length === 1 ? '' : 's'} outside the seam`;
|
|
487
|
+
const only = outside[0];
|
|
488
|
+
return outside.length === 1 && only !== undefined
|
|
489
|
+
? { verdict: 'failing', word: 'strayed', evidence, pointer: only }
|
|
490
|
+
: { verdict: 'failing', word: 'strayed', evidence, items: outside };
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Sum numstat entries into the diff row's three counts.
|
|
494
|
+
*/
|
|
495
|
+
export function countDiff(entries) {
|
|
496
|
+
return {
|
|
497
|
+
added: entries.reduce((sum, e) => sum + e.added, 0),
|
|
498
|
+
removed: entries.reduce((sum, e) => sum + e.removed, 0),
|
|
499
|
+
files: entries.length,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* The diff row's value: `+<added> -<removed> across <N> files`, plus the
|
|
504
|
+
* `inline below` pointer when the patch rides the turn. Null when nothing
|
|
505
|
+
* changed: information only, and no change is no information.
|
|
506
|
+
*/
|
|
507
|
+
export function diffRowValue(counts, inline) {
|
|
508
|
+
if (counts.files === 0) {
|
|
509
|
+
return null;
|
|
510
|
+
}
|
|
511
|
+
const files = `${counts.files} file${counts.files === 1 ? '' : 's'}`;
|
|
512
|
+
return `+${counts.added} -${counts.removed} across ${files}${inline ? ' · inline below' : ''}`;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The `spent` row's value: what the step has consumed, read from what
|
|
516
|
+
* stats.json and the turn ledger already hold. Null when nothing is countable
|
|
517
|
+
* yet, which vanishes the row on a fresh session rather than printing zeroes.
|
|
518
|
+
*
|
|
519
|
+
* Tokens and cost stay out: the transcript is host-specific and a price table
|
|
520
|
+
* goes stale, while every fact here is on disk already.
|
|
521
|
+
*/
|
|
522
|
+
export function spentRowValue(input) {
|
|
523
|
+
const parts = [];
|
|
524
|
+
const elapsed = elapsedSpent(input);
|
|
525
|
+
if (elapsed !== null) {
|
|
526
|
+
parts.push(elapsed);
|
|
527
|
+
}
|
|
528
|
+
if (input.turns !== null && input.turns > 0) {
|
|
529
|
+
parts.push(`${input.turns} turn${input.turns === 1 ? '' : 's'}`);
|
|
530
|
+
}
|
|
531
|
+
if (input.gateMs !== null) {
|
|
532
|
+
parts.push(`${Math.round(input.gateMs / 1000)}s gate`);
|
|
533
|
+
}
|
|
534
|
+
if (input.redChecks > 0) {
|
|
535
|
+
parts.push(`${input.redChecks} red run${input.redChecks === 1 ? '' : 's'}`);
|
|
536
|
+
}
|
|
537
|
+
else if (input.gateMs !== null) {
|
|
538
|
+
parts.push('green first run');
|
|
539
|
+
}
|
|
540
|
+
if (input.driftWarnings > 0) {
|
|
541
|
+
parts.push(`${input.driftWarnings} drift warning${input.driftWarnings === 1 ? '' : 's'}`);
|
|
542
|
+
}
|
|
543
|
+
return parts.length === 0 ? null : parts.join(' · ');
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* The elapsed clause: the step's `startedAt` to its `landedAt` at the
|
|
547
|
+
* boundary, or to now while it is still open. Null when no stamp parses, or
|
|
548
|
+
* the span runs backwards (a hand-edited receipt), since a negative age is
|
|
549
|
+
* worse than a missing one.
|
|
550
|
+
*/
|
|
551
|
+
function elapsedSpent(input) {
|
|
552
|
+
const started = input.startedAt === undefined ? Number.NaN : Date.parse(input.startedAt);
|
|
553
|
+
if (!Number.isFinite(started)) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
const landed = input.landedAt === undefined ? Number.NaN : Date.parse(input.landedAt);
|
|
557
|
+
const ms = (Number.isFinite(landed) ? landed : input.now) - started;
|
|
558
|
+
if (ms < 0) {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
if (ms < 60_000) {
|
|
562
|
+
return `${Math.round(ms / 1000)}s`;
|
|
563
|
+
}
|
|
564
|
+
const minutes = Math.round(ms / 60_000);
|
|
565
|
+
return minutes < 120 ? `${minutes} min` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Collapse a green row to the count that sizes its universe; a failing row
|
|
569
|
+
* comes back untouched, since red is where a row expands to name its offender.
|
|
570
|
+
*
|
|
571
|
+
* `done-when` collapses to its bare word (the Summary above is its evidence),
|
|
572
|
+
* `decisions` to the tags the model listed, and `constraints` to the number
|
|
573
|
+
* declared in intent.md, which the CLI counts rather than trusting the model's
|
|
574
|
+
* arithmetic. The measured rows arrive collapsed already.
|
|
575
|
+
*/
|
|
576
|
+
function collapseRow(name, row, constraints) {
|
|
577
|
+
if (row.verdict !== 'true') {
|
|
578
|
+
return row;
|
|
579
|
+
}
|
|
580
|
+
switch (name) {
|
|
581
|
+
case 'done-when':
|
|
582
|
+
return { ...row, evidence: row.word };
|
|
583
|
+
case 'decisions': {
|
|
584
|
+
const tags = new Set(row.evidence.match(/\b[A-Z]\d+\b/g) ?? []);
|
|
585
|
+
return tags.size === 0 ? row : { ...row, evidence: `${tags.size} of ${tags.size} honored` };
|
|
586
|
+
}
|
|
587
|
+
case 'constraints':
|
|
588
|
+
return constraints === 0 ? row : { ...row, evidence: `${constraints} of ${constraints} honored` };
|
|
589
|
+
default:
|
|
590
|
+
return row;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* The readout fence's inner lines: the measuring rows present, in their fixed
|
|
595
|
+
* order, then `diff` and `spent`, labels padded so every value starts at
|
|
596
|
+
* column 14. A row's `- ` items and its `→ ` pointer follow it on indented
|
|
597
|
+
* continuation lines.
|
|
598
|
+
*
|
|
599
|
+
* The step's identity is not in here: the `**Readout**:` label above the fence
|
|
600
|
+
* carries it, so it renders once. Empty when no row survived, so the caller can
|
|
601
|
+
* drop the label and the fence together rather than emit an empty instrument.
|
|
602
|
+
*/
|
|
603
|
+
export function recapLines(rows, extras) {
|
|
604
|
+
const body = [];
|
|
605
|
+
for (const name of RECAP_ROW_NAMES) {
|
|
606
|
+
const row = rows[name];
|
|
607
|
+
if (row === undefined) {
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
const shown = collapseRow(name, row, extras.constraints);
|
|
611
|
+
body.push(`${name.padEnd(13)}${shown.evidence}`);
|
|
612
|
+
for (const item of shown.items ?? []) {
|
|
613
|
+
body.push(`${RECAP_INDENT}- ${item}`);
|
|
614
|
+
}
|
|
615
|
+
if (shown.pointer !== undefined) {
|
|
616
|
+
body.push(`${RECAP_INDENT}→ ${shown.pointer}`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (extras.diff !== null) {
|
|
620
|
+
body.push(`${'diff'.padEnd(13)}${extras.diff}`);
|
|
621
|
+
}
|
|
622
|
+
if (extras.spent !== null) {
|
|
623
|
+
body.push(`${'spent'.padEnd(13)}${extras.spent}`);
|
|
624
|
+
}
|
|
625
|
+
return body;
|
|
626
|
+
}
|
|
627
|
+
// --- The detail file's prose: the Summary that opens a decision turn and the
|
|
628
|
+
// recommendation that closes it (docs/presentation.md). The model writes both
|
|
629
|
+
// into `.plumbbob/detail.md` and handoff renders them, so the whole turn is one
|
|
630
|
+
// relayed block and the model authors nothing in the chat. ---
|
|
631
|
+
/**
|
|
632
|
+
* A named section's prose from `.plumbbob/detail.md`, unwrapped: the lines
|
|
633
|
+
* inside a paragraph join into one and blank lines keep their paragraph
|
|
634
|
+
* breaks. Null when the section is absent or holds nothing.
|
|
635
|
+
*
|
|
636
|
+
* The turn's prose flows at the renderer's width, so whatever column the
|
|
637
|
+
* detail file happened to be hard-wrapped to never reaches the turn. The
|
|
638
|
+
* headings are matched case-insensitively: a file written the older lowercase
|
|
639
|
+
* way still parses.
|
|
640
|
+
*/
|
|
641
|
+
function sectionProse(detail, heading) {
|
|
642
|
+
const lines = detail.split('\n');
|
|
643
|
+
const start = lines.findIndex((l) => heading.test(l.trim()));
|
|
644
|
+
if (start === -1) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
|
|
648
|
+
if (end === -1) {
|
|
649
|
+
end = lines.length;
|
|
650
|
+
}
|
|
651
|
+
const text = lines
|
|
652
|
+
.slice(start + 1, end)
|
|
653
|
+
.join('\n')
|
|
654
|
+
.trim();
|
|
655
|
+
if (text.length === 0) {
|
|
656
|
+
return null;
|
|
657
|
+
}
|
|
658
|
+
return text
|
|
659
|
+
.split(/\n{2,}/)
|
|
660
|
+
.map((paragraph) => paragraph
|
|
661
|
+
.split('\n')
|
|
662
|
+
.map((l) => l.trim())
|
|
663
|
+
.join(' '))
|
|
664
|
+
.join('\n\n');
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* The `## Summary` section of `.plumbbob/detail.md` and the numbered detail
|
|
668
|
+
* sections beneath it: the lead prose, then the title of every `## <n>`
|
|
669
|
+
* heading in the order the model wrote them. Null when the file carries no
|
|
670
|
+
* lead, which vanishes the block rather than labeling nothing.
|
|
671
|
+
*
|
|
672
|
+
* The titles pass through as the markdown the model wrote; they are the
|
|
673
|
+
* highlights, and their numbers are the handles `expand` binds to, so each
|
|
674
|
+
* renders under its own number rather than being renumbered by position.
|
|
675
|
+
*/
|
|
676
|
+
export function parseSummary(detail) {
|
|
677
|
+
const lead = sectionProse(detail, /^##\s+summary\s*$/i);
|
|
678
|
+
if (lead === null) {
|
|
679
|
+
return null;
|
|
680
|
+
}
|
|
681
|
+
const highlights = [];
|
|
682
|
+
for (const line of detail.split('\n')) {
|
|
683
|
+
const m = /^##\s+(\d+)\s+(\S.*?)\s*$/.exec(line);
|
|
684
|
+
if (m !== null) {
|
|
685
|
+
highlights.push({ n: Number(m[1]), title: m[2] ?? '' });
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
return { lead, highlights };
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* The `## Recommendation` section of `.plumbbob/detail.md`: the one or two
|
|
692
|
+
* plain sentences a decision turn ends on, or null when the model wrote none.
|
|
693
|
+
* `handoff` emits it after the card, unfenced; the eye lands on the last text,
|
|
694
|
+
* and the last text should say which move the model would take.
|
|
695
|
+
*/
|
|
696
|
+
export function parseRecommendation(detail) {
|
|
697
|
+
return sectionProse(detail, /^##\s+recommendation\s*$/i);
|
|
698
|
+
}
|
|
270
699
|
/**
|
|
271
700
|
* Render an Orientation as the plain-text dashboard `status` prints.
|
|
272
701
|
*/
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -319,6 +319,29 @@ export function appendHandoff(root, slug, entry) {
|
|
|
319
319
|
export function clearHandoff(root, slug) {
|
|
320
320
|
rmSync(handoffPath(root, slug), { force: true });
|
|
321
321
|
}
|
|
322
|
+
// --- The detail plane: `.plumbbob/detail.md`, one flat per-session file holding
|
|
323
|
+
// the in-flight step's full detail. The model overwrites it before every pause
|
|
324
|
+
// (the recap first, then numbered sections matching the highlights); checkpoint
|
|
325
|
+
// folds it into the commit body and then clears it, so the durable archive is
|
|
326
|
+
// the commit and no stale detail piles up. Flat like STATE/TURN, not per-build:
|
|
327
|
+
// a session drives one build at a time, and a past step's expansion reads the
|
|
328
|
+
// commit, never this file. ---
|
|
329
|
+
/**
|
|
330
|
+
* detail.md: the in-flight step's full detail, at the flat sidecar root.
|
|
331
|
+
*/
|
|
332
|
+
export function detailPath(root) {
|
|
333
|
+
return join(root, DIRNAME, 'detail.md');
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Clear detail.md once checkpoint has folded it into the commit body; absent is
|
|
337
|
+
* a no-op.
|
|
338
|
+
*
|
|
339
|
+
* The rmSync lives here with the sidecar's other deletions, the same home
|
|
340
|
+
* clearHandoff and clearTick keep.
|
|
341
|
+
*/
|
|
342
|
+
export function clearDetail(root) {
|
|
343
|
+
rmSync(detailPath(root), { force: true });
|
|
344
|
+
}
|
|
322
345
|
/**
|
|
323
346
|
* stats.json: the per-build stats receipt, beside checkpoints.
|
|
324
347
|
*/
|
|
@@ -576,6 +599,11 @@ export function excludeControl(root) {
|
|
|
576
599
|
// never writes, and never commits.
|
|
577
600
|
`${DIRNAME}/TURN`,
|
|
578
601
|
`${DIRNAME}/GRANT`,
|
|
602
|
+
// The in-flight step's detail file: flat per-session control the model
|
|
603
|
+
// overwrites before every pause. checkpoint folds it into the commit body
|
|
604
|
+
// and then clears it, so its archive is the commit, never a tracked path;
|
|
605
|
+
// excluding it keeps stageAll from ever sweeping it into a step commit.
|
|
606
|
+
`${DIRNAME}/detail.md`,
|
|
579
607
|
`${DIRNAME}/builds/*/STEP`,
|
|
580
608
|
`${DIRNAME}/builds/*/SEAM`,
|
|
581
609
|
`${DIRNAME}/builds/*/SPIKE`,
|
package/dist/verbs/abandon.js
CHANGED
|
@@ -19,6 +19,8 @@ import { bumpStepStat, buildLogPath, clearHandoff, clearSeam, clearStep, clearTi
|
|
|
19
19
|
import { checkLatch } from "../lib/latch.js";
|
|
20
20
|
import { abandonLogLine, appendToSection } from "../lib/buildlog.js";
|
|
21
21
|
import { parseSteps } from "../lib/orient.js";
|
|
22
|
+
import { ending, notice, transition } from "../lib/notice.js";
|
|
23
|
+
import { driverPointer } from "./handoff.js";
|
|
22
24
|
/**
|
|
23
25
|
* Drop the in-flight step while keeping its work: latch, clear the markers,
|
|
24
26
|
* record the event, and return to the boundary.
|
|
@@ -29,13 +31,13 @@ import { parseSteps } from "../lib/orient.js";
|
|
|
29
31
|
export function abandon(cwd, args) {
|
|
30
32
|
const root = findRepoRoot(cwd);
|
|
31
33
|
if (root === null || !hasSession(root)) {
|
|
32
|
-
process.stderr.write(
|
|
34
|
+
process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
|
|
33
35
|
return 1;
|
|
34
36
|
}
|
|
35
37
|
const { build: slug } = resolveBuild(root, args);
|
|
36
38
|
const step = readInFlightStep(root, slug);
|
|
37
39
|
if (step === null) {
|
|
38
|
-
process.stderr.write(
|
|
40
|
+
process.stderr.write(notice({ fact: 'no step in flight to abandon', detail: ['abandon drops an in-flight step'] }));
|
|
39
41
|
return 1;
|
|
40
42
|
}
|
|
41
43
|
// The same latch checkpoint runs, keyed on this step: a step exit is a boundary
|
|
@@ -55,7 +57,16 @@ export function abandon(cwd, args) {
|
|
|
55
57
|
clearHandoff(root, slug);
|
|
56
58
|
bumpStepStat(root, slug, step, 'abandons');
|
|
57
59
|
logAbandon(root, slug, step);
|
|
58
|
-
|
|
60
|
+
// A step exit: with the step back at `[ ]` and its markers gone, the pointer
|
|
61
|
+
// aims forward from the boundary, at the very step just dropped.
|
|
62
|
+
process.stdout.write(ending({
|
|
63
|
+
lead: transition({
|
|
64
|
+
label: 'Abandoned',
|
|
65
|
+
fact: `Step ${step}`,
|
|
66
|
+
detail: ['work kept in the tree', 'the step stays planned'],
|
|
67
|
+
}),
|
|
68
|
+
pointer: driverPointer(root, slug),
|
|
69
|
+
}));
|
|
59
70
|
return 0;
|
|
60
71
|
}
|
|
61
72
|
/**
|
|
@@ -68,12 +79,18 @@ export function abandon(cwd, args) {
|
|
|
68
79
|
*/
|
|
69
80
|
function abandonPause(reason, step) {
|
|
70
81
|
if (reason === 'ceiling') {
|
|
71
|
-
return
|
|
82
|
+
return notice({
|
|
83
|
+
fact: 'abandon refused',
|
|
84
|
+
detail: [`step ${step} is past the range you granted`],
|
|
85
|
+
remedy: 'pause here, then run it again to continue',
|
|
86
|
+
});
|
|
72
87
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
human'
|
|
88
|
+
// The fact rides the register; the paragraph under it stays, because the
|
|
89
|
+
// pause affordance is an explanation, not a one-liner, and it mirrors the
|
|
90
|
+
// one the checkpoint latch prints.
|
|
91
|
+
return `${notice({ fact: 'abandon refused', detail: ['no human turn since this step began'] })}A step exit crosses the same boundary as a checkpoint, so abandon honors the same pause: end the
|
|
92
|
+
turn, and the human's approval on their next turn is what lets it land. (An explicit
|
|
93
|
+
\`/plumbbob:build --auto\` or a step range in the human's own prompt is the only self-approval.)
|
|
77
94
|
`;
|
|
78
95
|
}
|
|
79
96
|
/**
|