plumbbob 0.12.1 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "plumbbob",
3
3
  "displayName": "PlumbBob",
4
- "version": "0.12.1",
4
+ "version": "0.13.0",
5
5
  "description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
6
6
  "author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
7
7
  "homepage": "https://github.com/robmclarty/plumbbob#readme",
package/README.md CHANGED
@@ -140,12 +140,14 @@ In Claude Code, inside any git repo with a clean tree:
140
140
  nothing is written around it.
141
141
 
142
142
  ````markdown
143
+ **Now**: Step 1 of 3 - feat(limiter): add a token-bucket limiter
144
+
143
145
  **Summary**: A per-IP token bucket now refuses the 6th attempt inside a minute. (details: `.plumbbob/detail.md`)
144
146
 
145
147
  1. The bucket lives in memory, keyed by IP, refilling five tokens a minute.
146
148
  2. The clock is injected, so the test advances time instead of sleeping on it.
147
149
 
148
- **Readout**: Step 1 - feat(limiter): add a token-bucket limiter
150
+ **Readout**:
149
151
 
150
152
  ```text
151
153
  check green: 7 of 7 checks
@@ -164,7 +166,7 @@ In Claude Code, inside any git repo with a clean tree:
164
166
  **Your Call**:
165
167
 
166
168
  - `looks good` → I checkpoint Step 1; back to the boundary
167
- - `expand`, or any question → I show more of what is there; nothing changes
169
+ - `expand`, or any question → I show more of what is there; nothing changes, and `show the pause` brings all of this back
168
170
  - anything that reads as direction → I take it as what to change; nothing lands until you approve
169
171
  - `revert` → I wind the work back to the last checkpoint
170
172
 
@@ -192,8 +194,10 @@ In Claude Code, inside any git repo with a clean tree:
192
194
  ![The boundary after looks good](docs/media/checkpoint-boundary.svg)
193
195
 
194
196
  Ask instead (`expand 2`, or any question) and it shows more of what is there and
195
- changes nothing; direct it and it takes that as the fix, and nothing lands until you
196
- say `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
197
+ changes nothing. The answer closes on one line pointing back at the step, not on the
198
+ whole block again, since you have already read it; say `show the pause` to see the
199
+ block again. Direct it and it takes that as the fix, and nothing lands until you say
200
+ `looks good`. Then fire `/plumbbob:build` again for the next step: re-firing it
197
201
  *is* the clock tick. Whenever you lose the thread, `/plumbbob:status` shows where you
198
202
  are and names the next move.
199
203
  4. **Finish.** When the last step is done, `/plumbbob:finish` writes a report of what shipped
@@ -254,9 +258,10 @@ All fourteen, with inputs and effects, are in
254
258
  [`docs/skills-reference.md`](docs/skills-reference.md).
255
259
 
256
260
  Under the skills ships a lean `plumbbob` CLI (the mechanical verbs the
257
- skills shell out to), three hooks (a non-blocking post-edit lint, a `git commit`
258
- ask-hook while a step is in flight, and the per-prompt turn ledger the approval latch
259
- reads), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
261
+ skills shell out to), four hooks (a non-blocking post-edit lint, a `git commit`
262
+ ask-hook while a step is in flight, the per-prompt turn ledger the approval latch
263
+ reads, and a check at each turn's end that the turn relayed the ending plumbbob
264
+ printed), and a `.plumbbob/` sidecar you can open and edit by hand at any time: a tracked
260
265
  `builds/<slug>/` folder per build (`intent.md`, `build-log.md`, `checkpoints`, `report.md`)
261
266
  that rides the branch into the PR, plus an untracked control plane (`settings.local.json`,
262
267
  the session sentinel, the in-flight markers).
package/dist/cli-core.js CHANGED
@@ -28,6 +28,7 @@ import { init } from "./verbs/init.js";
28
28
  import { doctor } from "./verbs/doctor.js";
29
29
  import { recover } from "./verbs/recover.js";
30
30
  import { agent } from "./verbs/agent.js";
31
+ import { relay } from "./verbs/relay.js";
31
32
  import { turn } from "./verbs/turn.js";
32
33
  /** Shared across every verb that resolves through the active-build cursor. */
33
34
  const BUILD_FLAG = {
@@ -80,15 +81,16 @@ const VERBS = [
80
81
  },
81
82
  {
82
83
  name: 'handoff',
83
- description: 'print the footer card that ends a turn (pause, boundary, plan, or driver); read-only',
84
- synopsis: ['handoff [<n>] [--plan] [--driver] [--build <slug>]'],
84
+ description: 'print the footer card that ends a turn (pause, boundary, plan, or driver), or a reminder for a pause already shown',
85
+ synopsis: ['handoff [<n>] [--full] [--plan] [--driver] [--build <slug>]'],
85
86
  args: [{ name: '<n>', gloss: 'step to report on (default: the in-flight step, else the last checkpointed one)' }],
86
87
  flags: [
88
+ { name: '--full', gloss: 'print the whole pause card even when that pause was already shown' },
87
89
  { name: '--plan', gloss: 'render the plan-pause card instead of a step tier' },
88
90
  { name: '--driver', gloss: "render a driver turn's pointer back at the open step" },
89
91
  BUILD_FLAG,
90
92
  ],
91
- notes: 'Read-only; refuses (exit 1) with no session, or with --plan and --driver together.',
93
+ notes: 'Writes only the detail file (a blank line around each heading) and the PAUSE stamp; refuses (exit 1) with no session, or with --plan and --driver together.',
92
94
  },
93
95
  {
94
96
  name: 'check',
@@ -225,6 +227,13 @@ const VERBS = [
225
227
  tolerateUnknownFlags: true,
226
228
  notes: 'Reads the hook payload from stdin and always exits 0 — a broken hook must never wedge a prompt.',
227
229
  },
230
+ {
231
+ name: 'relay',
232
+ description: 'Stop hook machinery: check that the turn carried the ending it printed (not a user verb)',
233
+ synopsis: ['relay'],
234
+ tolerateUnknownFlags: true,
235
+ notes: "Reads the hook payload from stdin and always exits 0: a broken hook must never wedge a turn's end.",
236
+ },
228
237
  ];
229
238
  /**
230
239
  * Render the verb table for `plumbbob help`.
@@ -379,6 +388,8 @@ async function dispatch(verb, cwd, rest) {
379
388
  return agent(cwd, rest);
380
389
  case 'turn':
381
390
  return turn(cwd, rest);
391
+ case 'relay':
392
+ return relay(cwd, rest);
382
393
  default:
383
394
  process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
384
395
  return 1;
package/dist/lib/git.js CHANGED
@@ -68,18 +68,30 @@ export function hasCommit(root) {
68
68
  export function isDirty(root) {
69
69
  return runGit(root, ['status', '--porcelain']).length > 0;
70
70
  }
71
+ /**
72
+ * The body line that marks a plan commit made by hand (a `chore(plan)`
73
+ * harvest). A harvest lands between nearly every step, so counting it as
74
+ * out-of-band would trip the advisory on routine housekeeping and stop meaning
75
+ * anything within three steps. `status` and `handoff` both pass it to
76
+ * `commitsSince`, which is what keeps the two counts one quantity.
77
+ */
78
+ export const PLAN_COMMIT_MARKER = '^plumbbob plan$';
71
79
  /**
72
80
  * The number of commits on HEAD not reachable from `sha`: `git rev-list
73
81
  * --count --first-parent <sha>..HEAD`.
74
82
  *
75
- * `status` reads this for its receipts line: one neutral note when commits
76
- * landed since the last checkpoint outside plumbbob's ledger (out-of-band
77
- * commits are surfaced, never blocked). `--first-parent` keeps the count on
78
- * the branch's own line: merging upstream reads as the one merge commit, not
79
- * the dozens it carried; those didn't land "outside the ledger" in any sense
80
- * the receipt should nag about. `excluding` is a commit-message pattern whose
81
- * matches are left out of the count, which is how a caller drops plumbbob's own
82
- * commits from a tally of what landed around them.
83
+ * `status` and `handoff` both read this for their out-of-band count: one
84
+ * neutral note when commits landed since the last checkpoint outside
85
+ * plumbbob's ledger (out-of-band commits are surfaced, never blocked). Two
86
+ * mechanisms keep plumbbob's own commits out of that count. A commit any
87
+ * plumbbob verb makes is recorded in the ledger and becomes the anchor, so the
88
+ * range starts after it. A plan commit made by hand carries
89
+ * `PLAN_COMMIT_MARKER` on a line of its body, and both callers pass it as
90
+ * `excluding` so the range drops it. `--first-parent` keeps the count on the
91
+ * branch's own line: merging upstream reads as the one merge commit, not the
92
+ * dozens it carried; those didn't land "outside the ledger" in any sense the
93
+ * receipt should nag about. `excluding` is a commit-message pattern whose
94
+ * matches are left out of the count.
83
95
  *
84
96
  * Best-effort and never throws: 0 when the range is empty, `sha` is unknown to
85
97
  * the repo, or HEAD is unborn: the count is informational (the human commits
@@ -11,7 +11,10 @@
11
11
  // the edge, and an optional indented arrow line carrying what to do next. One
12
12
  // shape, so moving it is one edit rather than a sweep. The pointer at the next
13
13
  // step is not a line's job: `handoff` renders one, and the verb prints it as
14
- // the ending's last part. Pure string assembly, no fs, no gate.
14
+ // the ending's last part. The formatters are pure string assembly, no fs, no
15
+ // gate; `printEnding` is the one function here that writes, and every ending
16
+ // a verb prints goes through it so the relay check knows what was said.
17
+ import { writeRelay } from "./sidecar.js";
15
18
  /** The column budget a notice line aims to fit, matching the readout fence. */
16
19
  const WIDTH = 80;
17
20
  /** The fewest detail items a degraded parenthetical still names, so the count has a universe to size. */
@@ -83,6 +86,17 @@ export function blocks(parts) {
83
86
  const kept = parts.filter((p) => p !== null && p.trim().length > 0);
84
87
  return `${kept.map((p) => p.replace(/\n+$/, '')).join('\n\n')}\n\n`;
85
88
  }
89
+ /**
90
+ * Print an ending to stdout and record it in RELAY, for the Stop check that
91
+ * the turn closed on it.
92
+ *
93
+ * Stdout first, and the record never throws: a failed write leaves the
94
+ * printed ending and the verb's exit code exactly as they would have been.
95
+ */
96
+ export function printEnding(root, relay) {
97
+ process.stdout.write(relay.text);
98
+ writeRelay(root, relay);
99
+ }
86
100
  /**
87
101
  * The head, its trailing parenthetical, and the remedy line beneath, assembled.
88
102
  *
@@ -903,6 +903,45 @@ export function parseSummary(detail) {
903
903
  }
904
904
  return { lead, highlights };
905
905
  }
906
+ /**
907
+ * `.plumbbob/detail.md` with a blank line around every heading, and everything
908
+ * else as written.
909
+ *
910
+ * A model copying the detail template can run a heading straight into its body
911
+ * or its predecessor, and markdownlint's MD022 fails that file wherever
912
+ * `.plumbbob/` is linted, which turns a downstream checkpoint red. Heading lines
913
+ * inside a fenced block are content, not headings, so a fence is left whole.
914
+ * Pure and idempotent: text that already has its blank lines comes back byte for
915
+ * byte, so a caller can compare the result to the input to learn whether
916
+ * anything needed fixing.
917
+ */
918
+ export function normalizeDetailHeadings(detail) {
919
+ const out = [];
920
+ let fence = null;
921
+ let afterHeading = false;
922
+ for (const line of detail.split('\n')) {
923
+ if (fence !== null) {
924
+ const close = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
925
+ if (close !== undefined && close.startsWith(fence.char) && close.length >= fence.length) {
926
+ fence = null;
927
+ }
928
+ out.push(line);
929
+ continue;
930
+ }
931
+ const heading = /^ {0,3}#{1,6}(\s|$)/.test(line);
932
+ const prior = out[out.length - 1];
933
+ if ((heading || afterHeading) && line.trim() !== '' && prior !== undefined && prior.trim() !== '') {
934
+ out.push('');
935
+ }
936
+ const open = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
937
+ if (open !== undefined) {
938
+ fence = { char: open.charAt(0), length: open.length };
939
+ }
940
+ out.push(line);
941
+ afterHeading = heading;
942
+ }
943
+ return out.join('\n');
944
+ }
906
945
  /**
907
946
  * The `## <n>` sections of `.plumbbob/detail.md` with their bodies: the full
908
947
  * story behind each highlight, in the order the model wrote them. The body is
@@ -989,7 +1028,10 @@ export function formatOrientation(o) {
989
1028
  const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
990
1029
  // A neutral reconciliation note, only when there is something to reconcile:
991
1030
  // commits landed since the last checkpoint that plumbbob's ledger didn't
992
- // record. Informational: the human commits freely, so this never gates.
1031
+ // record. A commit a plumbbob verb makes starts the range, and a hand-made
1032
+ // plan commit drops out on its `plumbbob plan` body marker, so the count is
1033
+ // the same one `handoff` folds into its Verdict. Informational: the human
1034
+ // commits freely, so this never gates.
993
1035
  const receipts = o.outOfBand > 0
994
1036
  ? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
995
1037
  : [];
@@ -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
  /**
@@ -12,7 +12,7 @@
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";
@@ -21,7 +21,7 @@ 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
  /**
@@ -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
  *