plumbbob 0.12.0 → 0.13.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.
Files changed (61) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +15 -10
  3. package/dist/cli-core.js +25 -3
  4. package/dist/lib/git.js +20 -8
  5. package/dist/lib/notice.js +15 -1
  6. package/dist/lib/orient.js +266 -19
  7. package/dist/lib/sidecar.js +149 -3
  8. package/dist/verbs/abandon.js +19 -14
  9. package/dist/verbs/build.js +45 -22
  10. package/dist/verbs/checkpoint.js +34 -25
  11. package/dist/verbs/doctor.js +23 -4
  12. package/dist/verbs/finish.js +21 -17
  13. package/dist/verbs/handoff.js +235 -65
  14. package/dist/verbs/order.js +110 -0
  15. package/dist/verbs/park.js +6 -2
  16. package/dist/verbs/recover.js +31 -1
  17. package/dist/verbs/relay.js +306 -0
  18. package/dist/verbs/revert.js +17 -11
  19. package/dist/verbs/spike.js +50 -38
  20. package/dist/verbs/start.js +13 -9
  21. package/dist/verbs/status.js +2 -2
  22. package/dist/verbs/turn.js +14 -7
  23. package/dist/verbs/use.js +21 -17
  24. package/hooks/hooks.json +7 -0
  25. package/node_modules/checkride/.claude-plugin/plugin.json +1 -1
  26. package/node_modules/checkride/CHANGELOG.md +117 -0
  27. package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
  28. package/node_modules/checkride/dist/adapters.js +14 -10
  29. package/node_modules/checkride/dist/adapters.js.map +1 -1
  30. package/node_modules/checkride/dist/baseline/fallow.d.ts +4 -3
  31. package/node_modules/checkride/dist/baseline/fallow.d.ts.map +1 -1
  32. package/node_modules/checkride/dist/baseline/fallow.js +15 -8
  33. package/node_modules/checkride/dist/baseline/fallow.js.map +1 -1
  34. package/node_modules/checkride/dist/gate.d.ts.map +1 -1
  35. package/node_modules/checkride/dist/gate.js +62 -44
  36. package/node_modules/checkride/dist/gate.js.map +1 -1
  37. package/node_modules/checkride/dist/init.js +3 -3
  38. package/node_modules/checkride/dist/init.js.map +1 -1
  39. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
  40. package/node_modules/checkride/dist/orchestrator.js +53 -8
  41. package/node_modules/checkride/dist/orchestrator.js.map +1 -1
  42. package/node_modules/checkride/dist/pack.js +3 -3
  43. package/node_modules/checkride/dist/security.d.ts +4 -2
  44. package/node_modules/checkride/dist/security.d.ts.map +1 -1
  45. package/node_modules/checkride/dist/security.js +42 -8
  46. package/node_modules/checkride/dist/security.js.map +1 -1
  47. package/node_modules/checkride/dist/triage/render.js +1 -1
  48. package/node_modules/checkride/dist/triage/render.js.map +1 -1
  49. package/node_modules/checkride/package.json +10 -10
  50. package/node_modules/checkride/skills/check/SKILL.md +4 -3
  51. package/node_modules/checkride/templates/shared/cspell.json +3 -24
  52. package/package.json +3 -3
  53. package/skills/build/SKILL.md +47 -20
  54. package/skills/harvest/SKILL.md +4 -1
  55. package/skills/order/SKILL.md +50 -0
  56. package/skills/park/SKILL.md +7 -0
  57. package/skills/plan/SKILL.md +4 -1
  58. package/skills/refine/SKILL.md +6 -2
  59. package/skills/step/SKILL.md +8 -3
  60. package/skills/verify/SKILL.md +42 -22
  61. package/templates/intent.md +9 -0
@@ -213,6 +213,21 @@ export function stepPath(root, slug) {
213
213
  export function clearStep(root, slug) {
214
214
  rmSync(stepPath(root, slug), { force: true });
215
215
  }
216
+ /**
217
+ * PAUSE: the stamp of the step pause `handoff` last rendered whole, the step
218
+ * number and a hash of what the card showed. While a later render matches it,
219
+ * handoff prints the one-line reminder instead of the card.
220
+ */
221
+ export function pausePath(root, slug) {
222
+ return join(artifactDir(root, slug), 'PAUSE');
223
+ }
224
+ /**
225
+ * Remove the PAUSE stamp: a checkpoint, a revert, or an abandon ends the pause
226
+ * it recorded, so the next pause renders whole. Absent is a no-op.
227
+ */
228
+ export function clearPause(root, slug) {
229
+ rmSync(pausePath(root, slug), { force: true });
230
+ }
216
231
  /**
217
232
  * The build's `checkpoints` ledger: the recorded SHAs of landed plan and step
218
233
  * commits, one line each.
@@ -536,6 +551,131 @@ export function readTurn(root) {
536
551
  return null;
537
552
  }
538
553
  }
554
+ // --- The relay record: `.plumbbob/RELAY`, the ending the CLI printed last, so a
555
+ // Stop hook can check that the turn closed on it. The verb that prints an
556
+ // ending records it here: it knows what it printed, where a hook scraping
557
+ // stdout would have to guess which command ran. Flat like TURN, because the
558
+ // turn it is checked against is the session's, not a build's. A later ending
559
+ // overwrites an earlier one, so a turn is judged by the last thing it printed. ---
560
+ // The tiers of docs/presentation.md, which decide how a turn has to carry an
561
+ // ending: a decision ending closes the turn, an orientation ending appears in
562
+ // it, and a driver ending is recorded only so that the last ending wins.
563
+ const RELAY_TIERS = ['decision', 'orientation', 'driver'];
564
+ // Which ending printed: handoff's six forms first, then one per transition. It
565
+ // is what the check's reason names when the ending went missing.
566
+ const RELAY_RENDERS = [
567
+ 'card',
568
+ 'reminder',
569
+ 'plan',
570
+ 'boundary',
571
+ 'pointer',
572
+ 'driver',
573
+ 'checkpoint',
574
+ 'plan-commit',
575
+ 'park',
576
+ 'revert',
577
+ 'abandon',
578
+ 'spike-open',
579
+ 'spike-report',
580
+ 'spike-done',
581
+ 'use',
582
+ 'order',
583
+ 'finish',
584
+ 'start',
585
+ ];
586
+ /**
587
+ * RELAY: the ending the CLI printed last, at the flat sidecar root.
588
+ */
589
+ export function relayPath(root) {
590
+ return join(root, DIRNAME, 'RELAY');
591
+ }
592
+ /**
593
+ * Record an ending in RELAY, stamped with the current TURN: a header of
594
+ * `key value` lines, a blank line, then the text exactly as it printed.
595
+ *
596
+ * Written only while TURN holds a count, the same dormancy `stampTick` keeps: a
597
+ * host with no turn hook has no turn to check the ending against. Best-effort
598
+ * by contract, since the ending has already reached stdout and a failed record
599
+ * must not change it or the exit code.
600
+ */
601
+ export function writeRelay(root, relay) {
602
+ const turn = readTurn(root);
603
+ if (turn === null)
604
+ return;
605
+ // A hold the check already spent on this turn survives the ending the retry
606
+ // reprints, or a turn whose retry missed again would be held a second time.
607
+ const held = readRelay(root)?.held === turn ? { held: turn } : {};
608
+ try {
609
+ writeFileSync(relayPath(root), formatRelay({ ...relay, turn, ...held }));
610
+ }
611
+ catch {
612
+ // A read-only tree or a directory in the way: the ending already printed.
613
+ }
614
+ }
615
+ /**
616
+ * Stamp the record with the turn the check held open over it, so the check
617
+ * holds at most once per turn. False when the stamp could not be written, and
618
+ * the check then holds nothing, since a hold it cannot count could repeat.
619
+ */
620
+ export function holdRelay(root, record) {
621
+ try {
622
+ writeFileSync(relayPath(root), formatRelay({ ...record, held: record.turn }));
623
+ return true;
624
+ }
625
+ catch {
626
+ return false;
627
+ }
628
+ }
629
+ /** A record as RELAY stores it: the header's `key value` lines, a blank line, then the text. */
630
+ function formatRelay(record) {
631
+ const header = [`turn ${record.turn}`, `tier ${record.tier}`, `render ${record.render}`];
632
+ if (record.command !== undefined)
633
+ header.push(`command ${record.command}`);
634
+ if (record.held !== undefined)
635
+ header.push(`held ${record.held}`);
636
+ return `${header.join('\n')}\n\n${record.text}`;
637
+ }
638
+ /**
639
+ * The recorded ending, or null when RELAY is absent or its header does not
640
+ * parse: a record the check cannot read is no record.
641
+ */
642
+ export function readRelay(root) {
643
+ let raw;
644
+ try {
645
+ raw = readFileSync(relayPath(root), 'utf8');
646
+ }
647
+ catch {
648
+ return null;
649
+ }
650
+ const split = raw.indexOf('\n\n');
651
+ if (split === -1)
652
+ return null;
653
+ const fields = new Map();
654
+ for (const line of raw.slice(0, split).split('\n')) {
655
+ const space = line.indexOf(' ');
656
+ if (space > 0)
657
+ fields.set(line.slice(0, space), line.slice(space + 1));
658
+ }
659
+ const turn = count(fields.get('turn'));
660
+ const tier = RELAY_TIERS.find((t) => t === fields.get('tier'));
661
+ const render = RELAY_RENDERS.find((r) => r === fields.get('render'));
662
+ if (turn === null || tier === undefined || render === undefined)
663
+ return null;
664
+ const command = fields.get('command');
665
+ const held = count(fields.get('held'));
666
+ return {
667
+ turn,
668
+ tier,
669
+ render,
670
+ ...(command === undefined ? {} : { command }),
671
+ ...(held === null ? {} : { held }),
672
+ text: raw.slice(split + 2),
673
+ };
674
+ }
675
+ /** A header field read as a bare count, or null when it is absent or anything else. */
676
+ function count(field) {
677
+ return field !== undefined && /^\d+$/.test(field) ? Number(field) : null;
678
+ }
539
679
  /**
540
680
  * Walk up from `cwd` to the nearest ancestor with an active session (a
541
681
  * `.plumbbob/STATE` file), or null when there is none.
@@ -600,16 +740,22 @@ export function excludeControl(root) {
600
740
  `${DIRNAME}/TURN`,
601
741
  `${DIRNAME}/GRANT`,
602
742
  // 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.
743
+ // overwrites before every pause. checkpoint records it in the build-log's
744
+ // Log and then clears it, so its archive is the tracked ledger, never this
745
+ // path; excluding it keeps stageAll from ever sweeping it into a step commit.
606
746
  `${DIRNAME}/detail.md`,
747
+ // The ending the CLI printed last, for the Stop check: per-worktree
748
+ // control, rewritten by every ending, and never a step commit's to carry.
749
+ `${DIRNAME}/RELAY`,
607
750
  `${DIRNAME}/builds/*/STEP`,
608
751
  `${DIRNAME}/builds/*/SEAM`,
609
752
  `${DIRNAME}/builds/*/SPIKE`,
610
753
  // The entry stamp is in-flight control like STEP/SEAM: checkpoint's
611
754
  // stageAll must never sweep it into a step commit.
612
755
  `${DIRNAME}/builds/*/TICK`,
756
+ // The pause stamp is in-flight control too: what handoff last showed, so
757
+ // a repeat prints the reminder. It must never ride a step commit.
758
+ `${DIRNAME}/builds/*/PAUSE`,
613
759
  // The agent-run handoff ledger is step-scoped in-flight state, not a
614
760
  // tracked artifact: it must never ride a step commit into the PR.
615
761
  `${DIRNAME}/builds/*/handoff.json`,
@@ -1,11 +1,11 @@
1
1
  // `plumbbob abandon`: the third exit from an in-flight step. Checkpoint lands
2
2
  // the step and revert destroys the work; abandon drops the attempt but keeps the
3
3
  // working-tree diff exactly where it is. Its only writes are the in-flight
4
- // control markers it clears (STEP, SEAM, TICK, and the step-scoped handoff
5
- // ledger), a build-log line, and the stats receipt: it touches neither the
6
- // working tree nor git nor the intent checkbox, so the step stays planned and
7
- // re-buildable with its diff still in the tree for the human to keep, rework, or
8
- // commit by hand.
4
+ // control markers it clears (STEP, SEAM, TICK, the PAUSE stamp, and the
5
+ // step-scoped handoff ledger), a build-log line, and the stats receipt: it
6
+ // touches neither the working tree nor git nor the intent checkbox, so the step
7
+ // stays planned and re-buildable with its diff still in the tree for the human
8
+ // to keep, rework, or commit by hand.
9
9
  //
10
10
  // A step exit is a boundary crossing, so abandon honors the same approval latch
11
11
  // as checkpoint. That is not decoration: abandon clears the entry stamp the
@@ -15,11 +15,11 @@
15
15
  // control-state clears; abandon itself never calls the raw fs deleters.
16
16
  import { readFileSync, writeFileSync } from 'node:fs';
17
17
  import { findRepoRoot } from "../lib/git.js";
18
- import { bumpStepStat, buildLogPath, clearHandoff, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
18
+ import { bumpStepStat, buildLogPath, clearHandoff, clearPause, clearSeam, clearStep, clearTick, hasSession, intentPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
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";
22
+ import { ending, notice, printEnding, transition } from "../lib/notice.js";
23
23
  import { driverPointer } from "./handoff.js";
24
24
  /**
25
25
  * Drop the in-flight step while keeping its work: latch, clear the markers,
@@ -55,18 +55,23 @@ export function abandon(cwd, args) {
55
55
  clearSeam(root, slug);
56
56
  clearTick(root, slug);
57
57
  clearHandoff(root, slug);
58
+ clearPause(root, slug);
58
59
  bumpStepStat(root, slug, step, 'abandons');
59
60
  logAbandon(root, slug, step);
60
61
  // A step exit: with the step back at `[ ]` and its markers gone, the pointer
61
62
  // 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'],
63
+ printEnding(root, {
64
+ tier: 'driver',
65
+ render: 'abandon',
66
+ text: ending({
67
+ lead: transition({
68
+ label: 'Abandoned',
69
+ fact: `Step ${step}`,
70
+ detail: ['work kept in the tree', 'the step stays planned'],
71
+ }),
72
+ pointer: driverPointer(root, slug),
67
73
  }),
68
- pointer: driverPointer(root, slug),
69
- }));
74
+ });
70
75
  return 0;
71
76
  }
72
77
  /**
@@ -8,7 +8,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
8
8
  import { findRepoRoot } from "../lib/git.js";
9
9
  import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, stepPath } from "../lib/sidecar.js";
10
10
  import { parseStepSeam } from "../lib/intent.js";
11
- import { parseSteps } from "../lib/orient.js";
11
+ import { parseOrderedSteps, skippedBefore } from "../lib/orient.js";
12
12
  import { stepLabel, syncBuildLogState } from "../lib/buildlogsync.js";
13
13
  import { readGrant } from "../lib/latch.js";
14
14
  import { notice } from "../lib/notice.js";
@@ -18,8 +18,8 @@ import { notice } from "../lib/notice.js";
18
18
  * Refuses a missing session, a malformed step argument, an `N-M` range (a
19
19
  * `/plumbbob:build` skill feature, not a CLI one), and a seam that fails to parse.
20
20
  * On entry it stamps the turn ledger and the step's start time, re-renders the
21
- * build-log's Current step line, and says so when the step is the top of a
22
- * range the human typed.
21
+ * build-log's Current step line, and names the step when it is the last one a
22
+ * self-approval grant reaches.
23
23
  */
24
24
  export function build(cwd, args) {
25
25
  const root = findRepoRoot(cwd);
@@ -48,7 +48,9 @@ export function build(cwd, args) {
48
48
  return 1;
49
49
  }
50
50
  const intent = readFileSync(intentPath(root, slug), 'utf8');
51
- const steps = parseSteps(intent);
51
+ // In build order: the sequence `## Build order` declares when a refined plan
52
+ // has one, else the numbering, so this pick and the dashboard's agree.
53
+ const steps = parseOrderedSteps(intent);
52
54
  // No argument ⇒ enter the next undone step in intent.md (the same idiom
53
55
  // `checkpoint` uses), so a bare `plumbbob build` advances the loop without the
54
56
  // skill re-deriving the step in prose. Every step checkpointed ⇒ a `/plumbbob:step`
@@ -89,30 +91,20 @@ export function build(cwd, args) {
89
91
  const title = steps.find((s) => s.n === step)?.title ?? null;
90
92
  syncBuildLogState(root, slug, stepLabel(step, title));
91
93
  // An explicit jump past undone work says so out loud: the entered step and
92
- // the count it skips read back to the human (and to the transcript), so a
93
- // deliberate `build 22` and a confused one look different on the page.
94
- const skipped = raw === undefined ? 0 : steps.filter((s) => !s.done && s.n < step).length;
94
+ // the count it skips (the undone steps ahead of it in build order) read back
95
+ // to the human (and to the transcript), so a deliberate `build 22` and a
96
+ // confused one look different on the page.
97
+ const skipped = raw === undefined ? 0 : skippedBefore(steps, step).length;
95
98
  const picked = raw === undefined
96
99
  ? ['next undone']
97
100
  : skipped > 0
98
101
  ? ['explicitly requested', `skips ${skipped} undone step${skipped === 1 ? '' : 's'}`]
99
102
  : [];
100
- // The top step of a typed range lands like every step under it, and the turn
101
- // ends on its checkpoint. Saying so as the step is entered puts that fact in
102
- // front of the model before it chooses between landing the step and holding
103
- // it at a pause, which is the choice a range kept getting wrong.
104
- const grant = readGrant(root);
105
- const top = grant?.kind === 'range' && grant.ceiling === step
106
- ? notice({
107
- fact: `step ${step} is the top of the range you granted`,
108
- remedy: 'land it on green, then stop at the boundary',
109
- })
110
- : '';
111
- // Two lines (three at a range's top), one colon each, then the seam as a
112
- // plain readout beneath the notice that frames it: the paths are a list, and
113
- // a list is not a one-liner.
103
+ // Two lines (three on the last step a grant reaches), one colon each, then
104
+ // the seam as a plain readout beneath the notice that frames it: the paths
105
+ // are a list, and a list is not a one-liner.
114
106
  process.stdout.write(notice({ fact: `building step ${step}`, detail: picked }) +
115
- top +
107
+ lastGrantedNotice(readGrant(root), steps, step) +
116
108
  notice({
117
109
  fact: 'the seam is orientation, not a lock',
118
110
  detail: [`${parsed.seam.length} path${parsed.seam.length === 1 ? '' : 's'}`],
@@ -120,3 +112,34 @@ export function build(cwd, args) {
120
112
  `${parsed.seam.map((path) => ` ${path}`).join('\n')}\n`);
121
113
  return 0;
122
114
  }
115
+ /**
116
+ * The line naming the last step a self-approval grant reaches this turn, or ''
117
+ * for any other step and when no grant is live.
118
+ *
119
+ * That step lands like every step before it, and then the turn ends on its
120
+ * checkpoint: a clean halt. Saying so as the step is entered puts the fact in
121
+ * front of the model before it chooses between landing the step and holding it
122
+ * at a pause, which is the choice a clean halt kept getting wrong. A range
123
+ * reaches its top, or the last undone step under its top when the plan ends
124
+ * first, or when the build order puts a number past the top ahead of the rest
125
+ * (a range names plan numbers, and the skill halts before one past its top);
126
+ * `--auto` reaches the plan's last undone step.
127
+ */
128
+ function lastGrantedNotice(grant, steps, step) {
129
+ if (grant === null) {
130
+ return '';
131
+ }
132
+ const remedy = 'land it on green, then stop at the boundary';
133
+ if (grant.kind === 'range') {
134
+ if (step === grant.ceiling) {
135
+ return notice({ fact: `step ${step} is the top of the range you granted`, remedy });
136
+ }
137
+ const after = steps.slice(steps.findIndex((s) => s.n === step) + 1).find((s) => !s.done);
138
+ const more = after !== undefined && after.n <= grant.ceiling;
139
+ return step < grant.ceiling && !more
140
+ ? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
141
+ : '';
142
+ }
143
+ const more = steps.some((s) => !s.done && s.n !== step);
144
+ return more ? '' : notice({ fact: `step ${step} is the last undone step in the plan`, remedy });
145
+ }
@@ -3,7 +3,7 @@
3
3
  // `/plumbbob:build` run, your own hands, a vibe session, or another harness all
4
4
  // checkpoint identically. It does NOT require a STEP marker (the flat control
5
5
  // file recording the step in flight): the step is whatever you pass, else the
6
- // in-flight STEP, else the next undone step in intent.md. It evaluates the
6
+ // in-flight STEP, else the next undone step in intent.md's build order. It evaluates the
7
7
  // approval latch, gates on a green check, commits any pending work (or records
8
8
  // the existing HEAD when the tree is already clean: the human's commit skill
9
9
  // may have committed first), records the SHA, flips the intent checkbox to
@@ -12,16 +12,16 @@
12
12
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
13
  import { relative } from 'node:path';
14
14
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
15
- import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
15
+ import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearPause, clearTick, detailPath, hasSession, intentPath, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
16
16
  import { runCheck } from "../lib/check.js";
17
17
  import { readCommitBody } from "../lib/commitbody.js";
18
18
  import { checkLatch } from "../lib/latch.js";
19
- import { markStepDone, parseDetailStep, parseSteps } from "../lib/orient.js";
19
+ import { markStepDone, parseDetailStep, parseOrderedSteps, parseSteps } from "../lib/orient.js";
20
20
  import { parseBuildScope, parseStepSeam, scopeDrift } from "../lib/intent.js";
21
21
  import { conventionalSubject, subjectFromTitle, withMarker } from "../lib/commitmsg.js";
22
22
  import { appendToSection, checkpointLogLine, logEntry, planLogLine } from "../lib/buildlog.js";
23
23
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
24
- import { advisory, ending, notice, transition } from "../lib/notice.js";
24
+ import { advisory, ending, notice, printEnding, transition } from "../lib/notice.js";
25
25
  import { boundaryEnding, planRecord, stepRecord } from "./handoff.js";
26
26
  /**
27
27
  * Land a step: latch, check gate, commit, record, return to the boundary.
@@ -116,17 +116,22 @@ export async function checkpoint(cwd, args) {
116
116
  rmSync(stepPath(root), { force: true });
117
117
  clearHandoff(root); // the agent-run handoff ledger is step-scoped; clear it with the markers.
118
118
  clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
119
+ clearPause(root); // the pause it stamped is over; the next step's pause renders whole.
119
120
  // The whole ending, one block: the lead line, the Verdict folded over what
120
121
  // just landed, the advisories this checkpoint alone can state, and the
121
122
  // pointer. Read after the flip and the ledger append, so the fold and the
122
123
  // pointer both see the boundary the human is standing at.
123
124
  const landed = boundaryEnding(root, activeBuild(root), step);
124
- process.stdout.write(ending({
125
- lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
126
- verdict: landed.verdict,
127
- advisories: [drift, flipNotice],
128
- pointer: landed.pointer,
129
- }));
125
+ printEnding(root, {
126
+ tier: 'orientation',
127
+ render: 'checkpoint',
128
+ text: ending({
129
+ lead: transition({ label: 'Checkpoint', fact: `Step ${step} complete`, detail: leadDetail(sha, pointer) }),
130
+ verdict: landed.verdict,
131
+ advisories: [drift, flipNotice],
132
+ pointer: landed.pointer,
133
+ }),
134
+ });
130
135
  return 0;
131
136
  }
132
137
  /**
@@ -172,19 +177,23 @@ function checkpointPlan(root, args) {
172
177
  clearTick(root);
173
178
  // No Verdict: the plan commit measures nothing. The pointer aims at the step
174
179
  // the build starts on, which is what `handoff` renders from a null step.
175
- process.stdout.write(ending({
176
- lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
177
- advisories: staged
178
- ? []
179
- : [
180
- advisory({
181
- fact: 'the plan rides the commit message',
182
- detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
183
- remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
184
- }),
185
- ],
186
- pointer: boundaryEnding(root, activeBuild(root), null).pointer,
187
- }));
180
+ printEnding(root, {
181
+ tier: 'orientation',
182
+ render: 'plan-commit',
183
+ text: ending({
184
+ lead: transition({ label: 'Plan', fact: 'committed', detail: leadDetail(sha, pointer) }),
185
+ advisories: staged
186
+ ? []
187
+ : [
188
+ advisory({
189
+ fact: 'the plan rides the commit message',
190
+ detail: ['record-only', '.plumbbob/ is gitignored, so the files stay untracked'],
191
+ remedy: 'unignore .plumbbob/builds/ to keep the record in the tree',
192
+ }),
193
+ ],
194
+ pointer: boundaryEnding(root, activeBuild(root), null).pointer,
195
+ }),
196
+ });
188
197
  return 0;
189
198
  }
190
199
  /**
@@ -200,7 +209,7 @@ function planSubject(root) {
200
209
  /**
201
210
  * Resolve the step being checkpointed, or null when none can be determined.
202
211
  *
203
- * Explicit arg > in-flight STEP file > first undone step in intent.md. A `-m`
212
+ * Explicit arg > in-flight STEP file > first undone step in build order. A `-m`
204
213
  * value is a message, never a step: `checkpoint -m "2"` must not read as
205
214
  * step 2.
206
215
  */
@@ -214,7 +223,7 @@ function resolveStep(root, args) {
214
223
  return inFlight;
215
224
  }
216
225
  try {
217
- return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => !s.done)?.n ?? null;
226
+ return parseOrderedSteps(readFileSync(intentPath(root), 'utf8')).find((s) => !s.done)?.n ?? null;
218
227
  }
219
228
  catch {
220
229
  return null;
@@ -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 post-edit hook from
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 hook, and names the fix for anything missing. The failure
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 post-edit hook, each with its fix.
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: 'post-edit hook present (hooks/hooks.json, auto-registers)' }
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
  *
@@ -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
- process.stdout.write(ending({
88
- lead: transition({
89
- label: 'Session',
90
- fact: 'finished',
91
- detail: [sha.slice(0, 9), `${where} rides your branch into the PR`],
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
- advisories: reported
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
  /**