plumbbob 0.12.1 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +20 -13
- package/dist/cli-core.js +22 -6
- package/dist/lib/git.js +20 -8
- package/dist/lib/latch.js +23 -5
- package/dist/lib/notice.js +18 -3
- package/dist/lib/orient.js +350 -34
- package/dist/lib/sidecar.js +202 -3
- package/dist/lib/steplist.js +128 -0
- package/dist/verbs/abandon.js +19 -14
- package/dist/verbs/build.js +73 -14
- package/dist/verbs/checkpoint.js +31 -21
- package/dist/verbs/doctor.js +23 -4
- package/dist/verbs/finish.js +21 -17
- package/dist/verbs/handoff.js +259 -80
- package/dist/verbs/order.js +32 -20
- package/dist/verbs/park.js +6 -2
- package/dist/verbs/recover.js +31 -1
- package/dist/verbs/relay.js +306 -0
- package/dist/verbs/revert.js +17 -11
- package/dist/verbs/spike.js +50 -38
- package/dist/verbs/start.js +13 -9
- package/dist/verbs/status.js +2 -2
- package/dist/verbs/turn.js +90 -53
- package/dist/verbs/use.js +21 -17
- package/hooks/hooks.json +7 -0
- package/package.json +1 -1
- package/skills/build/SKILL.md +107 -49
- package/skills/harvest/SKILL.md +10 -1
- package/skills/order/SKILL.md +20 -10
- package/skills/park/SKILL.md +7 -0
- package/skills/plan/SKILL.md +52 -7
- package/skills/refine/SKILL.md +21 -3
- package/skills/status/SKILL.md +8 -3
- package/skills/step/SKILL.md +16 -3
- package/skills/verify/SKILL.md +35 -18
- package/templates/intent.md +23 -5
package/dist/verbs/doctor.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
//
|
|
3
3
|
// 1. The global plugin link (read-only). After `plumbbob init`, plumbbob lives as a
|
|
4
4
|
// symlink at ~/.claude/skills/plumbbob pointing at the installed package; Claude
|
|
5
|
-
// Code loads it in place as a plugin (skills `/plumbbob:*`, the
|
|
5
|
+
// Code loads it in place as a plugin (skills `/plumbbob:*`, the hooks from
|
|
6
6
|
// hooks.json). doctor verifies the link resolves to a package carrying the manifest,
|
|
7
|
-
// the skills, and the
|
|
7
|
+
// the skills, and the hooks, and names the fix for anything missing. The failure
|
|
8
8
|
// class it exists for is SILENT (a `/plumbbob:status` that opens an empty dashboard
|
|
9
9
|
// because the plugin never linked).
|
|
10
10
|
//
|
|
@@ -80,7 +80,7 @@ function linkedPackage(link) {
|
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
82
|
/**
|
|
83
|
-
* The link-install checks: manifest, skill set, and
|
|
83
|
+
* The link-install checks: manifest, skill set, and hooks, each with its fix.
|
|
84
84
|
*
|
|
85
85
|
* `shipped` is the canonical skill list read from the package itself; an installed
|
|
86
86
|
* count below it means a stale or partial link, and every fix is the same re-link.
|
|
@@ -96,10 +96,29 @@ function buildChecks(link, pkg, shipped) {
|
|
|
96
96
|
? { ok: true, label: `skills present (${installed.length}) — load as /plumbbob:*` }
|
|
97
97
|
: { ok: false, label: `skills incomplete (${installed.length}/${shipped.length})`, fix: 're-link: plumbbob init' },
|
|
98
98
|
existsSync(join(pkg, 'hooks', 'hooks.json'))
|
|
99
|
-
? { ok: true, label: '
|
|
99
|
+
? { ok: true, label: hooksLabel(wiredEvents(join(pkg, 'hooks', 'hooks.json'))) }
|
|
100
100
|
: { ok: false, label: 'hook missing (hooks/hooks.json)', fix: 're-link: plumbbob init' },
|
|
101
101
|
];
|
|
102
102
|
}
|
|
103
|
+
/**
|
|
104
|
+
* The hooks line's label: every event the package wires, so a link that
|
|
105
|
+
* predates a hook reads as one at a glance. A file whose events cannot be read
|
|
106
|
+
* still counts as present, the way the line read before it named any.
|
|
107
|
+
*/
|
|
108
|
+
function hooksLabel(events) {
|
|
109
|
+
const named = events.length === 0 ? '' : `: ${events.join(', ')}`;
|
|
110
|
+
return `hooks present (hooks/hooks.json${named}; auto-registers)`;
|
|
111
|
+
}
|
|
112
|
+
/** The events a hooks.json wires, in file order, or none when it does not parse. */
|
|
113
|
+
function wiredEvents(path) {
|
|
114
|
+
try {
|
|
115
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
116
|
+
return typeof parsed.hooks === 'object' && parsed.hooks !== null ? Object.keys(parsed.hooks) : [];
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
103
122
|
/**
|
|
104
123
|
* The plugin-link diagnostic: how plumbbob is installed into Claude Code, as checks.
|
|
105
124
|
*
|
package/dist/verbs/finish.js
CHANGED
|
@@ -17,7 +17,7 @@ import { readCommitBody } from "../lib/commitbody.js";
|
|
|
17
17
|
import { buildScope, checkpointsPath, clearTick, hasSession, intentPath, readStats, refreshExcludes, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
|
|
18
18
|
import { conventionalSubject, withMarker } from "../lib/commitmsg.js";
|
|
19
19
|
import { parseBuildScope } from "../lib/intent.js";
|
|
20
|
-
import { advisory, ending, notice, transition } from "../lib/notice.js";
|
|
20
|
+
import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
|
|
21
21
|
// The pointer finish leaves the turn on. Every other one is `handoff`'s, but a
|
|
22
22
|
// finished session has no state left to render from, and past a closed build
|
|
23
23
|
// the only move is framing the next goal.
|
|
@@ -84,23 +84,27 @@ export function finish(cwd, args = []) {
|
|
|
84
84
|
// the turn's last text. finish renders that pointer itself: it just cleared
|
|
85
85
|
// the session `handoff` would read one from.
|
|
86
86
|
const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
87
|
+
printEnding(root, {
|
|
88
|
+
tier: 'orientation',
|
|
89
|
+
render: 'finish',
|
|
90
|
+
text: ending({
|
|
91
|
+
lead: transition({
|
|
92
|
+
label: 'Session',
|
|
93
|
+
fact: 'finished',
|
|
94
|
+
detail: [sha.slice(0, 9), `${where} rides your branch into the PR`],
|
|
95
|
+
}),
|
|
96
|
+
advisories: reported
|
|
97
|
+
? []
|
|
98
|
+
: [
|
|
99
|
+
advisory({
|
|
100
|
+
fact: 'no report.md found',
|
|
101
|
+
detail: ['finished without one', 'no gate here by design'],
|
|
102
|
+
remedy: '/plumbbob:finish normally writes the report first',
|
|
103
|
+
}),
|
|
104
|
+
],
|
|
105
|
+
pointer: NEXT_UP,
|
|
92
106
|
}),
|
|
93
|
-
|
|
94
|
-
? []
|
|
95
|
-
: [
|
|
96
|
-
advisory({
|
|
97
|
-
fact: 'no report.md found',
|
|
98
|
-
detail: ['finished without one', 'no gate here by design'],
|
|
99
|
-
remedy: '/plumbbob:finish normally writes the report first',
|
|
100
|
-
}),
|
|
101
|
-
],
|
|
102
|
-
pointer: NEXT_UP,
|
|
103
|
-
}));
|
|
107
|
+
});
|
|
104
108
|
return 0;
|
|
105
109
|
}
|
|
106
110
|
/**
|
package/dist/verbs/handoff.js
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
// `plumbbob handoff`: emit the CLI-owned ending of a turn as one contiguous
|
|
2
|
-
// block (docs/presentation.md).
|
|
2
|
+
// block (docs/presentation.md). Changes no state but three: at a pause it
|
|
3
|
+
// leaves the detail file with a blank line around each heading, at a step pause
|
|
4
|
+
// it stamps what the card showed in `PAUSE`, and every render it prints is
|
|
5
|
+
// recorded in `RELAY` for the check at the turn's end. Every part outside
|
|
3
6
|
// the readout fence is a bold label, a colon, and text that wraps, one blank
|
|
4
7
|
// line between blocks and no fence but the readout's own. It derives the moment
|
|
5
|
-
// from the session: a step in flight ⇒ the decision-tier ending (the
|
|
6
|
-
// Readout and its fence, the inline diff when
|
|
7
|
-
// the Verdict, Next Up, Your Call, and the
|
|
8
|
+
// from the session: a step in flight ⇒ the decision-tier ending (the Now line
|
|
9
|
+
// naming that step, the labeled Readout and its fence, the inline diff when
|
|
10
|
+
// the change is 20 lines or fewer, the Verdict, Next Up, Your Call, and the
|
|
11
|
+
// Recommendation last); a landed step
|
|
8
12
|
// with none in flight ⇒ the orientation-tier ending (Verdict and Next Up); a
|
|
9
13
|
// fresh session with nothing yet measured ⇒ the forward pointer alone.
|
|
10
14
|
//
|
|
15
|
+
// A step pause renders its card once (D87 (pause-once)). While the stamp still
|
|
16
|
+
// matches, a later render is the one-line reminder, the driver pointer back at
|
|
17
|
+
// the step; the card comes back when the pause changes or on `--full`.
|
|
18
|
+
//
|
|
11
19
|
// The whole decision turn is rendered here, its opening block included: the
|
|
12
20
|
// Summary lead and the numbered highlights come out of `.plumbbob/detail.md`
|
|
13
21
|
// as the markdown the model wrote, with the `(details: …)` bracket appended so
|
|
@@ -29,22 +37,18 @@
|
|
|
29
37
|
// the step names closing the spike, a step exit (a revert, an abandon, a spike
|
|
30
38
|
// closed at the boundary) finds nothing in flight and points forward, with no
|
|
31
39
|
// Verdict either way, since nothing landed.
|
|
32
|
-
import {
|
|
40
|
+
import { createHash } from 'node:crypto';
|
|
41
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
33
42
|
import { join, relative } from 'node:path';
|
|
34
|
-
import { commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
|
|
35
|
-
import { blocks } from "../lib/notice.js";
|
|
43
|
+
import { PLAN_COMMIT_MARKER, commitsSince, diffNumstat, diffPatch, findRepoRoot } from "../lib/git.js";
|
|
44
|
+
import { blocks, printEnding, transition } from "../lib/notice.js";
|
|
36
45
|
import { isArtifactPath, parseStepSeam } from "../lib/intent.js";
|
|
37
|
-
import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
|
|
38
|
-
import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, parkedPhrase, parseBuildOrder, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
46
|
+
import { buildLogPath, checkpointsPath, detailPath, hasSession, inSpike, intentPath, pausePath, readStats, readTurn, resolveBuild, seamPath, stepPath, tickPath, } from "../lib/sidecar.js";
|
|
47
|
+
import { buildOrderPhrase, countDiff, diffRowValue, foldVerdict, lastLedgerSha, normalizeDetailHeadings, parkedPhrase, parseBuildOrder, parseBuildRanges, parseConstraintCount, parseLastCheckpoint, parseOrderedSteps, parseParkTotals, parseRecap, parseRecommendation, parseSections, parseSteps, parseSummary, rangeStanding, recapLines, remainingOrder, seamRowFromDiff, spentRowValue, summaryCheckRow, } from "../lib/orient.js";
|
|
39
48
|
// The inline-diff threshold (docs/presentation.md): at 20 changed lines or
|
|
40
49
|
// fewer the patch rides the turn; at 21 it stays in the working tree behind
|
|
41
50
|
// the counted diff row.
|
|
42
51
|
const INLINE_DIFF_MAX = 20;
|
|
43
|
-
// plumbbob's own plan commits carry this marker on a line of their body. A
|
|
44
|
-
// `chore(plan)` harvest lands between nearly every step, so counting them as
|
|
45
|
-
// out-of-band would trip the advisory rung on routine housekeeping and stop
|
|
46
|
-
// meaning anything within three steps.
|
|
47
|
-
const PLAN_COMMIT_MARKER = '^plumbbob plan$';
|
|
48
52
|
// The seam rule, and the one tier that still has a seam to mark: at the plan
|
|
49
53
|
// pause the model presents the framed plan above the relay, so the rule keeps
|
|
50
54
|
// a label from reading as the tail of what precedes it, and a run of blank
|
|
@@ -58,11 +62,14 @@ const SEAM_RULE = '\n---';
|
|
|
58
62
|
*
|
|
59
63
|
* Requires an active session (the STATE sentinel under `.plumbbob/`). The step
|
|
60
64
|
* tiers are derived, not passed: a step in flight yields the full
|
|
61
|
-
* decision-tier ending (the Readout and its fence, the inline
|
|
62
|
-
* enough, the Verdict, Next Up, Your Call, and the
|
|
65
|
+
* decision-tier ending (the Now line, the Readout and its fence, the inline
|
|
66
|
+
* diff when small enough, the Verdict, Next Up, Your Call, and the
|
|
67
|
+
* Recommendation), and so does
|
|
63
68
|
* a named step that has not landed when nothing is in flight (a hand-built diff
|
|
64
|
-
* under verify)
|
|
65
|
-
*
|
|
69
|
+
* under verify). That card renders once: a later render of the same pause, with
|
|
70
|
+
* nothing in it changed, is the one-line reminder, and `--full` prints the card
|
|
71
|
+
* whatever the stamp says. A landed step yields the orientation-tier ending
|
|
72
|
+
* (Verdict and Next Up, no Your Call),
|
|
66
73
|
* and a fresh session with nothing to report yields only
|
|
67
74
|
* the forward pointer. The two endings no session state can distinguish are
|
|
68
75
|
* named by a flag: `--plan` is the plan pause (a decision turn about the plan,
|
|
@@ -80,13 +87,16 @@ export function handoff(cwd, args = []) {
|
|
|
80
87
|
process.stderr.write('plumbbob: handoff: --plan and --driver name different tiers; pass one.\n');
|
|
81
88
|
return 1;
|
|
82
89
|
}
|
|
90
|
+
// A build named by flag rides any command that reprints this ending, since
|
|
91
|
+
// the cursor may point elsewhere.
|
|
92
|
+
const named = args.includes('--build') ? slug : null;
|
|
83
93
|
const intent = readOr(intentPath(root, slug));
|
|
84
94
|
// In build order, so the pointer names the step a bare `plumbbob build`
|
|
85
95
|
// would enter; the build-log feeds the park count beneath it.
|
|
86
96
|
const steps = parseOrderedSteps(intent);
|
|
87
97
|
const buildLog = readOr(buildLogPath(root, slug));
|
|
88
98
|
const inFlight = readMarker(stepPath(root, slug));
|
|
89
|
-
const
|
|
99
|
+
const rawDetail = readOr(detailPath(root));
|
|
90
100
|
// Where a pointer sends the reader to read the step itself: the intent file
|
|
91
101
|
// as the human would type it, relative to the repo root.
|
|
92
102
|
const where = relative(root, intentPath(root, slug));
|
|
@@ -95,12 +105,14 @@ export function handoff(cwd, args = []) {
|
|
|
95
105
|
// renders; the pointer and the moves both aim at the first undone step. A
|
|
96
106
|
// decision turn still ends on the model's recommendation when it wrote one.
|
|
97
107
|
const first = steps.find((s) => !s.done);
|
|
108
|
+
const detail = settleDetail(root, rawDetail);
|
|
98
109
|
const plan = [
|
|
99
110
|
SEAM_RULE,
|
|
100
|
-
nextUpBlock(
|
|
111
|
+
nextUpBlock(intent, buildLog, first, null, where),
|
|
101
112
|
planCallBlock(first),
|
|
102
113
|
];
|
|
103
|
-
|
|
114
|
+
const command = reprintCommand('--plan', undefined, named);
|
|
115
|
+
return emit(root, { tier: 'decision', render: 'plan', command }, withRecommendation(plan, parseRecommendation(detail)));
|
|
104
116
|
}
|
|
105
117
|
const explicit = rest.find((a) => /^\d+$/.test(a));
|
|
106
118
|
const lastDone = parseLastCheckpoint(readOr(checkpointsPath(root, slug)));
|
|
@@ -120,11 +132,13 @@ export function handoff(cwd, args = []) {
|
|
|
120
132
|
// (a step exit: a revert, an abandon, a spike closed at the boundary) the
|
|
121
133
|
// ordinary forward pointer stands, and no Verdict rides above it: nothing
|
|
122
134
|
// landed to measure.
|
|
123
|
-
return emit([driverPointer(root, slug)]);
|
|
135
|
+
return emit(root, { tier: 'driver', render: 'driver' }, [driverPointer(root, slug)]);
|
|
124
136
|
}
|
|
125
137
|
if (current === null) {
|
|
126
138
|
// Nothing measured yet (a fresh session): no Verdict, just the forward pointer.
|
|
127
|
-
return emit(
|
|
139
|
+
return emit(root, { tier: 'orientation', render: 'pointer' }, [
|
|
140
|
+
nextUpBlock(intent, buildLog, nextUp, current, where),
|
|
141
|
+
]);
|
|
128
142
|
}
|
|
129
143
|
// A step is pending, and its hand-off is the pause, while it is in flight.
|
|
130
144
|
// With nothing in flight, a step the turn names that has not landed is
|
|
@@ -133,16 +147,19 @@ export function handoff(cwd, args = []) {
|
|
|
133
147
|
// which pause to render.
|
|
134
148
|
const pending = inFlight !== null || (explicit !== undefined && steps.some((s) => s.n === current && !s.done));
|
|
135
149
|
if (!pending) {
|
|
136
|
-
// The boundary: the orientation-tier ending, the state word
|
|
137
|
-
//
|
|
138
|
-
// checkpoint printed for itself,
|
|
150
|
+
// The boundary: the orientation-tier ending, the state word, the Stop part
|
|
151
|
+
// where a range just landed, and the forward pointer, no decision pending
|
|
152
|
+
// and so no Your Call. The same parts the checkpoint printed for itself,
|
|
153
|
+
// from the same call.
|
|
139
154
|
const landed = boundaryEnding(root, slug, current);
|
|
140
|
-
return emit([landed.verdict, landed.pointer]);
|
|
155
|
+
return emit(root, { tier: 'orientation', render: 'boundary' }, [landed.verdict, landed.stop, landed.pointer]);
|
|
141
156
|
}
|
|
142
157
|
// The pause: the whole turn as one contiguous block, each part a labeled line
|
|
143
158
|
// with one blank line between, and the only fence the readout's own (plus the
|
|
144
|
-
// inline diff when the change is small enough). It opens on the
|
|
145
|
-
// model wrote into the detail file, so there
|
|
159
|
+
// inline diff when the change is small enough). It opens on the step in
|
|
160
|
+
// flight and then the Summary the model wrote into the detail file, so there
|
|
161
|
+
// is no seam left to rule off.
|
|
162
|
+
const detail = settleDetail(root, rawDetail);
|
|
146
163
|
const { rows, work, summary, checkGreen } = measured(root, slug, intent, detail, current);
|
|
147
164
|
const { ladder, worst } = foldVerdict(rows, accruedStats(root, slug, current));
|
|
148
165
|
const verdict = verdictLine(ladder, worst);
|
|
@@ -150,44 +167,153 @@ export function handoff(cwd, args = []) {
|
|
|
150
167
|
const changed = counts.added + counts.removed;
|
|
151
168
|
// The patch comes first so `· inline below` is a promise the fence keeps: a
|
|
152
169
|
// small change git can still print nothing for (a vanished path) falls back
|
|
153
|
-
// to the bare counts rather than pointing at a fence that never arrives.
|
|
154
|
-
|
|
170
|
+
// to the bare counts rather than pointing at a fence that never arrives. The
|
|
171
|
+
// stamp hashes it at any size, so it is read whenever there is a change.
|
|
172
|
+
const fullPatch = changed > 0 ? diffPatch(root, work) : '';
|
|
173
|
+
const patch = changed <= INLINE_DIFF_MAX ? fullPatch : '';
|
|
155
174
|
const inline = patch.length > 0;
|
|
175
|
+
const diffRow = diffRowValue(counts, inline);
|
|
176
|
+
const stamp = pauseStamp(current, detail, measuredLines(rows, diffRow), fullPatch);
|
|
177
|
+
if (!rest.includes('--full') && readOr(pausePath(root, slug)).trim() === stamp) {
|
|
178
|
+
// The pause the human already read, unchanged: the answer above this line
|
|
179
|
+
// is what they asked for, and the card would push it off the screen.
|
|
180
|
+
const command = reprintCommand(null, explicit, named);
|
|
181
|
+
return emit(root, { tier: 'decision', render: 'reminder', command }, [
|
|
182
|
+
`**Next Up**: Back to ${stepPhrase(current, steps)}`,
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
writePause(root, slug, stamp);
|
|
156
186
|
const readout = recapLines(rows, {
|
|
157
|
-
diff:
|
|
187
|
+
diff: diffRow,
|
|
158
188
|
spent: spentRowValue(spentInputs(root, slug, current, summary)),
|
|
159
189
|
constraints: parseConstraintCount(intent),
|
|
160
190
|
});
|
|
161
|
-
const parts = [
|
|
191
|
+
const parts = [
|
|
192
|
+
`**Now**: ${stepPhrase(current, steps)}`,
|
|
193
|
+
summaryBlock(parseSummary(detail), relative(root, detailPath(root))),
|
|
194
|
+
];
|
|
162
195
|
if (readout.length > 0) {
|
|
163
|
-
parts.push([
|
|
196
|
+
parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
|
|
164
197
|
}
|
|
165
198
|
if (inline) {
|
|
166
199
|
parts.push(fence('diff', patch.split('\n')).join('\n'));
|
|
167
200
|
}
|
|
168
|
-
parts.push(verdict, nextUpBlock(
|
|
169
|
-
|
|
201
|
+
parts.push(verdict, nextUpBlock(intent, buildLog, nextUp, current, where), yourCallBlock(current, checkGreen));
|
|
202
|
+
const command = reprintCommand('--full', explicit, named);
|
|
203
|
+
return emit(root, { tier: 'decision', render: 'card', command }, withRecommendation(parts, parseRecommendation(detail)));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* The detail file's text with a blank line around each heading, written back to
|
|
207
|
+
* disk when that changed anything, so the file the gate lints before
|
|
208
|
+
* `checkpoint` clears it is clean whatever the model typed.
|
|
209
|
+
*
|
|
210
|
+
* The render goes out on the normalized text either way. A failed write costs
|
|
211
|
+
* only the lint guard: stdout and the exit code are the same as if it had
|
|
212
|
+
* landed.
|
|
213
|
+
*/
|
|
214
|
+
function settleDetail(root, raw) {
|
|
215
|
+
const clean = normalizeDetailHeadings(raw);
|
|
216
|
+
if (clean !== raw) {
|
|
217
|
+
try {
|
|
218
|
+
writeFileSync(detailPath(root), clean);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Read-only tree or a vanished directory: the pause still renders.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return clean;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* The stamp of a step pause: its number and a hash of everything on the card a
|
|
228
|
+
* later turn could change, so the reminder prints only while the pause is the
|
|
229
|
+
* one the human already read.
|
|
230
|
+
*
|
|
231
|
+
* Hashed: the detail file as normalized, the measured check and seam rows and
|
|
232
|
+
* the diff row as rendered, and the patch the diff row counts, so an edit that
|
|
233
|
+
* keeps the counts still reads as a touched file. Never `spent`: it counts
|
|
234
|
+
* turns, so it moves on every turn and would make every render look new.
|
|
235
|
+
*/
|
|
236
|
+
function pauseStamp(step, detail, rendered, patch) {
|
|
237
|
+
const hash = createHash('sha256');
|
|
238
|
+
for (const part of [detail, ...rendered, patch]) {
|
|
239
|
+
hash.update(part).update('\0');
|
|
240
|
+
}
|
|
241
|
+
return `${step} ${hash.digest('hex')}`;
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The readout rows the CLI measured, as the card renders them: `check`,
|
|
245
|
+
* `seam`, and `diff`. The judgment rows ride the detail file, which the stamp
|
|
246
|
+
* hashes whole, and `spent` stays out of it.
|
|
247
|
+
*/
|
|
248
|
+
function measuredLines(rows, diff) {
|
|
249
|
+
const measuredRows = {};
|
|
250
|
+
if (rows.check !== undefined) {
|
|
251
|
+
measuredRows.check = rows.check;
|
|
252
|
+
}
|
|
253
|
+
if (rows.seam !== undefined) {
|
|
254
|
+
measuredRows.seam = rows.seam;
|
|
255
|
+
}
|
|
256
|
+
return recapLines(measuredRows, { diff, spent: null, constraints: 0 });
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Record the stamp of the card just printed. A failed write costs one more
|
|
260
|
+
* full card on a later turn, never this turn's output or its exit code.
|
|
261
|
+
*/
|
|
262
|
+
function writePause(root, slug, stamp) {
|
|
263
|
+
try {
|
|
264
|
+
writeFileSync(pausePath(root, slug), `${stamp}\n`);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// A read-only tree or a vanished build folder: the card still renders.
|
|
268
|
+
}
|
|
170
269
|
}
|
|
171
270
|
/**
|
|
172
|
-
* The
|
|
173
|
-
* everything measurable right now,
|
|
271
|
+
* The CLI-rendered parts of a boundary ending: the Verdict folded from
|
|
272
|
+
* everything measurable right now, the Stop part when the landing finished a
|
|
273
|
+
* range, and the pointer at the step to come.
|
|
174
274
|
*
|
|
175
275
|
* `step` is the step that just landed, or null at a plan commit, where nothing
|
|
176
|
-
* was measured and the Verdict
|
|
177
|
-
* its own lead line, so a boundary reads identically
|
|
178
|
-
* emitted it or a later `handoff` did; the paths a stray
|
|
179
|
-
* one part that does not survive, and the commit holds
|
|
276
|
+
* was measured and the Verdict and the Stop part both vanish. A transition verb
|
|
277
|
+
* prints these beneath its own lead line, so a boundary reads identically
|
|
278
|
+
* whether `checkpoint` emitted it or a later `handoff` did; the paths a stray
|
|
279
|
+
* advisory named are the one part that does not survive, and the commit holds
|
|
280
|
+
* those.
|
|
180
281
|
*/
|
|
181
282
|
export function boundaryEnding(root, slug, step) {
|
|
182
283
|
const intent = readOr(intentPath(root, slug));
|
|
183
284
|
const steps = parseOrderedSteps(intent);
|
|
184
285
|
const nextUp = steps.find((s) => !s.done && s.n !== step);
|
|
185
|
-
const
|
|
286
|
+
const where = relative(root, intentPath(root, slug));
|
|
186
287
|
return {
|
|
187
288
|
verdict: step === null ? null : landedVerdict(root, slug, intent, step),
|
|
188
|
-
|
|
289
|
+
stop: stopLine(intent, steps, step, where),
|
|
290
|
+
pointer: nextUpBlock(intent, readOr(buildLogPath(root, slug)), nextUp, step, where),
|
|
189
291
|
};
|
|
190
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* The Stop part: why the build stops at this boundary, when the step that
|
|
295
|
+
* landed was the last undone step of a range that lands its steps. One labeled
|
|
296
|
+
* line, the range over the count, a plain hyphen, the stop as the plan wrote
|
|
297
|
+
* it, and a bracket pointing at the item, where its note can be read.
|
|
298
|
+
*
|
|
299
|
+
* Null for every other boundary: a landing that leaves the range open, a bare
|
|
300
|
+
* range (its stop was its own pause), a plan without ranges, and the plan
|
|
301
|
+
* commit, where nothing landed.
|
|
302
|
+
*/
|
|
303
|
+
function stopLine(intent, steps, step, where) {
|
|
304
|
+
if (step === null) {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
const { count, completed } = rangeStanding(parseBuildRanges(intent), steps, step);
|
|
308
|
+
if (completed === null || completed.bare) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
return transition({
|
|
312
|
+
label: 'Stop',
|
|
313
|
+
fact: `Range ${completed.item} of ${count} landed - ${completed.stop}`,
|
|
314
|
+
detail: [`details: \`${where}:${completed.line}\``],
|
|
315
|
+
});
|
|
316
|
+
}
|
|
191
317
|
/**
|
|
192
318
|
* The step's record for the build-log's Log: the pause as the human approved
|
|
193
319
|
* it, with the full story behind each highlight beneath. The Summary and its
|
|
@@ -195,6 +321,9 @@ export function boundaryEnding(root, slug, step) {
|
|
|
195
321
|
* numbered sections' bodies, blank-line separated and unindented; the caller
|
|
196
322
|
* nests it under the Log's dated line.
|
|
197
323
|
*
|
|
324
|
+
* The record carries no Now line, and its Readout label stays bare like the
|
|
325
|
+
* card's: the dated Log line above it already names the step.
|
|
326
|
+
*
|
|
198
327
|
* Measured, not recalled: the check, seam, and diff rows are read the same way
|
|
199
328
|
* the pause read them. Both measures run against HEAD, so staging no longer
|
|
200
329
|
* moves the counts and the caller's build-then-stage order is a nicety rather
|
|
@@ -224,7 +353,7 @@ export function stepRecord(root, slug, step, detail) {
|
|
|
224
353
|
parts.push(recordSummary(summary));
|
|
225
354
|
}
|
|
226
355
|
if (readout.length > 0) {
|
|
227
|
-
parts.push([
|
|
356
|
+
parts.push([READOUT_LABEL, '', ...fence('text', readout)].join('\n'));
|
|
228
357
|
}
|
|
229
358
|
parts.push(verdictLine(ladder, worst));
|
|
230
359
|
return [...withRecommendation(parts, recommendation), ...sectionBlocks(sections)].join('\n\n');
|
|
@@ -275,7 +404,7 @@ export function driverPointer(root, slug) {
|
|
|
275
404
|
// driver line itself stays one line, with no note beneath it.
|
|
276
405
|
const steps = parseOrderedSteps(intent);
|
|
277
406
|
const inFlight = readMarker(stepPath(root, slug));
|
|
278
|
-
const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight
|
|
407
|
+
const driver = inSpike(root, slug) ? spikeNextUpLine(inFlight) : driverNextUpLine(steps, inFlight);
|
|
279
408
|
// The forward fallback skips the step just landed as well as the one open, so
|
|
280
409
|
// a checkpoint whose `[x]` flip failed still points on rather than back at
|
|
281
410
|
// work already recorded.
|
|
@@ -315,22 +444,33 @@ function measured(root, slug, intent, detail, step) {
|
|
|
315
444
|
return { rows, work, summary, checkGreen: measuredCheck?.verdict === 'true' };
|
|
316
445
|
}
|
|
317
446
|
/**
|
|
318
|
-
*
|
|
447
|
+
* Print an ending, record it for the relay check, and return handoff's exit
|
|
448
|
+
* code.
|
|
319
449
|
*
|
|
320
450
|
* The parts are stacked by the same assembly a transition verb prints its own
|
|
321
451
|
* ending through, so the block reads the same whichever command emitted it.
|
|
322
452
|
*/
|
|
323
|
-
function emit(parts) {
|
|
324
|
-
|
|
453
|
+
function emit(root, kind, parts) {
|
|
454
|
+
printEnding(root, { ...kind, text: blocks(parts) });
|
|
325
455
|
return 0;
|
|
326
456
|
}
|
|
457
|
+
/**
|
|
458
|
+
* The command that prints a decision ending again, for the retry the relay
|
|
459
|
+
* check asks for: handoff in the form that printed it, with the step a verify
|
|
460
|
+
* pause was named by and the build a flag named, so the retry lands on the
|
|
461
|
+
* same pause with nothing in flight and the cursor elsewhere.
|
|
462
|
+
*/
|
|
463
|
+
function reprintCommand(form, step, build) {
|
|
464
|
+
const parts = ['plumbbob handoff', form, step, build === null ? null : `--build ${build}`];
|
|
465
|
+
return parts.filter((p) => p !== null && p !== undefined).join(' ');
|
|
466
|
+
}
|
|
327
467
|
/**
|
|
328
468
|
* The Verdict line from an already-folded result: the ladder rung, and the
|
|
329
469
|
* worst component in a trailing parenthetical when one exists.
|
|
330
470
|
*
|
|
331
|
-
* No step segment rides here. The identity renders once per turn: the
|
|
332
|
-
*
|
|
333
|
-
* left saying the one thing only it says.
|
|
471
|
+
* No step segment rides here. The identity renders once per turn: the Now line
|
|
472
|
+
* names the step in flight with its count, Next Up names the step to come with
|
|
473
|
+
* its own, and the Verdict is left saying the one thing only it says.
|
|
334
474
|
*/
|
|
335
475
|
function verdictLine(ladder, worst) {
|
|
336
476
|
const state = `**Verdict**: ${ladder.glyph} ${ladder.state}`;
|
|
@@ -356,13 +496,26 @@ function summaryBlock(summary, where) {
|
|
|
356
496
|
}
|
|
357
497
|
return [lead, '', ...summary.highlights.map((h) => `${h.n}. ${h.title}`)].join('\n');
|
|
358
498
|
}
|
|
499
|
+
// The Readout's label, bare on the card and in the record alike: the Now line
|
|
500
|
+
// at the top of the card names the step the fence measures, and a label that
|
|
501
|
+
// named it too would put the step on the card twice.
|
|
502
|
+
const READOUT_LABEL = '**Readout**:';
|
|
359
503
|
/**
|
|
360
|
-
*
|
|
504
|
+
* A step named the way every pointer names it: the number, the count out of
|
|
505
|
+
* the whole plan, and the title behind a plain hyphen.
|
|
506
|
+
*
|
|
507
|
+
* The count is the plan's own, step number over total, so the Now line and
|
|
508
|
+
* Next Up agree under a re-sequenced build order. It is dropped only where it
|
|
509
|
+
* would lie: a step the plan no longer holds is its number alone, since
|
|
510
|
+
* "Step 9 of 3" would be worse than no count.
|
|
361
511
|
*/
|
|
362
|
-
function
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
512
|
+
function stepPhrase(n, steps) {
|
|
513
|
+
const step = steps.find((s) => s.n === n);
|
|
514
|
+
if (step === undefined) {
|
|
515
|
+
return `Step ${n}`;
|
|
516
|
+
}
|
|
517
|
+
const title = step.title.length > 0 ? ` - ${step.title}` : '';
|
|
518
|
+
return `Step ${n} of ${steps.length}${title}`;
|
|
366
519
|
}
|
|
367
520
|
/**
|
|
368
521
|
* What the `spent` row is rendered from: the step's own stamps and counters
|
|
@@ -467,16 +620,18 @@ function readCheckSummary(root) {
|
|
|
467
620
|
*
|
|
468
621
|
* Nobody types "needs work", so the block names what really happens instead:
|
|
469
622
|
* a message that asks is an expand (nothing changes), and a message that
|
|
470
|
-
* directs is the fix.
|
|
471
|
-
*
|
|
472
|
-
*
|
|
623
|
+
* directs is the fix. The expand move also names the way back to the card,
|
|
624
|
+
* since an expand turn ends on the reminder and the reminder states no move.
|
|
625
|
+
* `revert` comes last, because a destructive move is named rather than
|
|
626
|
+
* discovered. `looks good` renders only while the measured check is green;
|
|
627
|
+
* offering a move that would refuse teaches a false ceremony.
|
|
473
628
|
*/
|
|
474
629
|
function yourCallBlock(step, checkGreen) {
|
|
475
630
|
const moves = [];
|
|
476
631
|
if (checkGreen) {
|
|
477
632
|
moves.push(callLine('`looks good`', `I checkpoint Step ${step}; back to the boundary`));
|
|
478
633
|
}
|
|
479
|
-
moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes'));
|
|
634
|
+
moves.push(callLine('`expand`, or any question', 'I show more of what is there; nothing changes, and `show the pause` brings all of this back'));
|
|
480
635
|
moves.push(callLine('anything that reads as direction', 'I take it as what to change; nothing lands until you approve'));
|
|
481
636
|
moves.push(callLine('`revert`', 'I wind the work back to the last checkpoint'));
|
|
482
637
|
return ['**Your Call**:', '', ...moves].join('\n');
|
|
@@ -511,16 +666,8 @@ function planCallBlock(first) {
|
|
|
511
666
|
* step is already being built, so the `/model` call is behind us. Null when no
|
|
512
667
|
* step is open, which is the caller's cue to fall back to the forward pointer.
|
|
513
668
|
*/
|
|
514
|
-
function driverNextUpLine(steps, inFlight
|
|
515
|
-
|
|
516
|
-
return null;
|
|
517
|
-
}
|
|
518
|
-
const open = steps.find((s) => s.n === inFlight);
|
|
519
|
-
const title = open !== undefined && open.title.length > 0 ? ` - ${open.title}` : '';
|
|
520
|
-
// The progress count rides every tier's pointer, but only where the plan
|
|
521
|
-
// actually holds the step: "step 9 of 3" would be worse than no count.
|
|
522
|
-
const progress = open === undefined ? '' : ` of ${total}`;
|
|
523
|
-
return `**Next Up**: Back to Step ${inFlight}${progress}${title}`;
|
|
669
|
+
function driverNextUpLine(steps, inFlight) {
|
|
670
|
+
return inFlight === null ? null : `**Next Up**: Back to ${stepPhrase(inFlight, steps)}`;
|
|
524
671
|
}
|
|
525
672
|
/**
|
|
526
673
|
* The driver pointer while a spike is open: the spike outranks the step it
|
|
@@ -539,10 +686,11 @@ function spikeNextUpLine(inFlight) {
|
|
|
539
686
|
// count. The line is a reminder; the dashboard holds the whole sequence.
|
|
540
687
|
const ORDER_CAP = 5;
|
|
541
688
|
/**
|
|
542
|
-
* The line beneath the forward pointer: the
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
689
|
+
* The line beneath the forward pointer: the range segment, when the plan
|
|
690
|
+
* declares build ranges; the build order from the step the pointer names, when
|
|
691
|
+
* `## Build order` re-sequences the plan away from its numbering; and the park
|
|
692
|
+
* count, when anything has been parked. '' when none says anything, so a plan
|
|
693
|
+
* with no ranges, nobody re-sequenced, and an empty park list renders the
|
|
546
694
|
* pointer alone, as before.
|
|
547
695
|
*
|
|
548
696
|
* Indented two spaces, because an indented line continues the part above it
|
|
@@ -551,9 +699,10 @@ const ORDER_CAP = 5;
|
|
|
551
699
|
* `Nothing planned`: an unharvested list at the end of the plan is the one
|
|
552
700
|
* reminder that matters there.
|
|
553
701
|
*/
|
|
554
|
-
function nextUpNote(intent, buildLog, exclude) {
|
|
702
|
+
function nextUpNote(intent, buildLog, exclude, range) {
|
|
555
703
|
const parks = parseParkTotals(buildLog);
|
|
556
704
|
const segments = [
|
|
705
|
+
range,
|
|
557
706
|
buildOrderPhrase(remainingOrder(parseSteps(intent), parseBuildOrder(intent), exclude), ORDER_CAP),
|
|
558
707
|
parks.total > 0 ? parkedPhrase(parks) : '',
|
|
559
708
|
].filter((s) => s.length > 0);
|
|
@@ -562,11 +711,41 @@ function nextUpNote(intent, buildLog, exclude) {
|
|
|
562
711
|
/**
|
|
563
712
|
* The forward pointer with its note beneath, as one part: `blocks` keeps a
|
|
564
713
|
* part's own newlines, so the note rides flush under the line it qualifies.
|
|
714
|
+
*
|
|
715
|
+
* `exclude` is the step the turn is about: the step in flight at a pause, the
|
|
716
|
+
* step just landed at a boundary, null at the plan pause and the plan commit.
|
|
717
|
+
* While the note suggests a range, the bracket bolds the range's model, since
|
|
718
|
+
* the run is the range and that is the `/model` the human picks before typing
|
|
719
|
+
* it.
|
|
565
720
|
*/
|
|
566
|
-
function nextUpBlock(nextUp,
|
|
567
|
-
const
|
|
721
|
+
function nextUpBlock(intent, buildLog, nextUp, exclude, where) {
|
|
722
|
+
const steps = parseOrderedSteps(intent);
|
|
723
|
+
const range = rangeSegment(intent, steps, nextUp, exclude);
|
|
724
|
+
const line = nextUpLine(nextUp, steps.length, where, range.model ?? nextUp?.model ?? null);
|
|
725
|
+
const note = nextUpNote(intent, buildLog, exclude, range.segment);
|
|
568
726
|
return note.length === 0 ? line : `${line}\n${note}`;
|
|
569
727
|
}
|
|
728
|
+
/**
|
|
729
|
+
* The range segment that leads the note, and the model the pointer's bracket
|
|
730
|
+
* bolds while it suggests a range: `range 3 of 5: /plumbbob:build 6-9` with
|
|
731
|
+
* the range's model, the re-cut words for a flagged range, the words for a step
|
|
732
|
+
* no range names, or '' for a plan without ranges.
|
|
733
|
+
*
|
|
734
|
+
* The step the turn is about reads as landed, so a step pause names what is
|
|
735
|
+
* left of the range once the step in flight lands. A flagged range, and a
|
|
736
|
+
* range with no `- model:` line, leave the step's model in the bracket.
|
|
737
|
+
*/
|
|
738
|
+
function rangeSegment(intent, steps, nextUp, exclude) {
|
|
739
|
+
const landed = steps.map((s) => (s.n === exclude ? { ...s, done: true } : s));
|
|
740
|
+
const { count, holding, command, flag } = rangeStanding(parseBuildRanges(intent), landed, nextUp?.n ?? null);
|
|
741
|
+
if (holding !== null && command !== null) {
|
|
742
|
+
return { segment: `range ${holding.item} of ${count}: ${command}`, model: holding.model };
|
|
743
|
+
}
|
|
744
|
+
if (flag === null) {
|
|
745
|
+
return { segment: '', model: null };
|
|
746
|
+
}
|
|
747
|
+
return { segment: holding === null ? flag : `range ${holding.item} of ${count} needs a re-cut (${flag})`, model: null };
|
|
748
|
+
}
|
|
570
749
|
/**
|
|
571
750
|
* The forward pointer: the next undone step with the progress count, its
|
|
572
751
|
* title, and a closing bracket carrying its advisory `- model:` recommendation
|
|
@@ -579,12 +758,12 @@ function nextUpBlock(nextUp, total, where, note) {
|
|
|
579
758
|
* reads as a path in a PR diff. The line alone is the driver tier's pointer;
|
|
580
759
|
* every other tier rides `nextUpNote` beneath it, through `nextUpBlock`.
|
|
581
760
|
*/
|
|
582
|
-
function nextUpLine(nextUp, total, where) {
|
|
761
|
+
function nextUpLine(nextUp, total, where, model = nextUp?.model ?? null) {
|
|
583
762
|
if (nextUp === undefined) {
|
|
584
763
|
return '**Next Up**: Nothing planned - /plumbbob:step or /plumbbob:finish';
|
|
585
764
|
}
|
|
586
765
|
const title = nextUp.title.length > 0 ? ` - ${nextUp.title}` : '';
|
|
587
|
-
const token = modelToken(
|
|
766
|
+
const token = modelToken(model);
|
|
588
767
|
const bracket = token === null ? [] : [`model: **${capitalize(token)}**`];
|
|
589
768
|
bracket.push(`details: \`${where}:${nextUp.line}\``);
|
|
590
769
|
return `**Next Up**: Step ${nextUp.n} of ${total}${title} (${bracket.join(', ')})`;
|