plumbbob 0.10.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,6 +23,8 @@ import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
23
23
  import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
24
24
  import { isArtifactPath, matchesSeam } from "../lib/intent.js";
25
25
  import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
26
+ import { ending, notice, transition } from "../lib/notice.js";
27
+ import { driverPointer } from "./handoff.js";
26
28
  /**
27
29
  * Rewind to a recorded checkpoint and return the build to the boundary.
28
30
  *
@@ -35,13 +37,13 @@ import { AT_BOUNDARY, syncBuildLogState } from "../lib/buildlogsync.js";
35
37
  export function revert(cwd, args) {
36
38
  const root = findRepoRoot(cwd);
37
39
  if (root === null || !hasSession(root)) {
38
- process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
40
+ process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
39
41
  return 1;
40
42
  }
41
43
  const { build: slug, rest } = resolveBuild(root, args);
42
44
  const to = parseTo(rest);
43
45
  if (to === 'invalid') {
44
- process.stderr.write('plumbbob: revert --to needs a step number. Try: plumbbob revert --to 2.\n');
46
+ process.stderr.write(notice({ fact: 'revert --to needs a step number', remedy: 'plumbbob revert --to 2' }));
45
47
  return 1;
46
48
  }
47
49
  const checkpoints = readCheckpoints(root, slug);
@@ -52,13 +54,13 @@ export function revert(cwd, args) {
52
54
  else {
53
55
  const entry = checkpoints.steps.find((e) => e.n === to);
54
56
  if (entry === undefined) {
55
- process.stderr.write(`plumbbob: no checkpoint recorded for step ${to}.\n`);
57
+ process.stderr.write(notice({ fact: `no checkpoint recorded for step ${to}` }));
56
58
  return 1;
57
59
  }
58
60
  sha = entry.sha;
59
61
  }
60
62
  if (sha === undefined) {
61
- process.stderr.write('plumbbob: no baseline recorded in checkpoints — cannot revert.\n');
63
+ process.stderr.write(notice({ fact: 'no baseline recorded in checkpoints', detail: ['nothing to revert to'] }));
62
64
  return 1;
63
65
  }
64
66
  // Compute untracked-in-seam BEFORE the reset (reset --hard leaves untracked and
@@ -83,7 +85,16 @@ export function revert(cwd, args) {
83
85
  // revert keeps intent edits, so intent's checkboxes stay the truth to reflect.
84
86
  // Best-effort, like every build-log write.
85
87
  syncBuildLogState(root, slug, AT_BOUNDARY);
86
- process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — back at the boundary. Park lines and intent edits were preserved.\n`);
88
+ // A step exit: nothing landed, so no Verdict rides above the pointer, and with
89
+ // the markers cleared it aims forward from the boundary.
90
+ process.stdout.write(ending({
91
+ lead: transition({
92
+ label: 'Reverted',
93
+ fact: `to ${sha.slice(0, 9)}`,
94
+ detail: ['park lines and intent edits preserved'],
95
+ }),
96
+ pointer: driverPointer(root, slug),
97
+ }));
87
98
  return 0;
88
99
  }
89
100
  /**
@@ -21,6 +21,8 @@ import { basename, dirname, join, relative } from 'node:path';
21
21
  import { findRepoRoot } from "../lib/git.js";
22
22
  import { buildFolder, clearSpike, hasSession, inSpike, listSpikeReports, markSpike, nextSpikeReportPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
23
23
  import { readTemplate, stampTemplate } from "../lib/templates.js";
24
+ import { advisory, blocks, ending, notice, transition } from "../lib/notice.js";
25
+ import { driverPointer } from "./handoff.js";
24
26
  // Worktree/branch names when the caller lists none: a fork defaults to two arms.
25
27
  const DEFAULT_OPTIONS = ['a', 'b'];
26
28
  /**
@@ -37,7 +39,7 @@ const VERDICT_PLACEHOLDER = '*(viable | not viable | partial';
37
39
  export function spike(cwd, args) {
38
40
  const root = findRepoRoot(cwd);
39
41
  if (root === null || !hasSession(root)) {
40
- process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
42
+ process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
41
43
  return 1;
42
44
  }
43
45
  const { build: buildSlug, rest } = resolveBuild(root, args);
@@ -61,14 +63,21 @@ export function spike(cwd, args) {
61
63
  function spikeReport(root, buildSlug, positionals) {
62
64
  const slug = sanitize(positionals[0] ?? '');
63
65
  if (slug.length === 0) {
64
- process.stderr.write('plumbbob: spike report needs a slug. Try: plumbbob spike report "auth-store".\n');
66
+ process.stderr.write(notice({ fact: 'spike report needs a slug', remedy: 'plumbbob spike report "auth-store"' }));
65
67
  return 1;
66
68
  }
67
69
  const inFlight = readInFlightStep(root, buildSlug);
68
70
  const via = inFlight !== null ? `step ${inFlight}` : '/plumbbob:spike';
69
71
  const path = scaffoldSpikeReport(root, buildSlug, slug, via);
70
- process.stdout.write(`plumbbob: spike report scaffolded — ${relative(root, path)}. Fill Findings and the Verdict as you go; ` +
71
- `a recorded Verdict is what closes a spike step.\n`);
72
+ process.stdout.write(ending({
73
+ lead: transition({
74
+ label: 'Spike report',
75
+ fact: 'scaffolded',
76
+ detail: [relative(root, path)],
77
+ remedy: 'record Findings and the Verdict there, which is what closes a spike step',
78
+ }),
79
+ pointer: driverPointer(root, buildSlug),
80
+ }));
72
81
  return 0;
73
82
  }
74
83
  /**
@@ -110,17 +119,20 @@ function readInFlightStep(root, buildSlug) {
110
119
  */
111
120
  function spikeStart(root, buildSlug, positionals) {
112
121
  if (inSpike(root, buildSlug)) {
113
- process.stderr.write('plumbbob: already in a spike. Run `plumbbob spike done` to close it first.\n');
122
+ process.stderr.write(notice({ fact: 'already in a spike', remedy: 'plumbbob spike done to close it first' }));
114
123
  return 1;
115
124
  }
116
125
  if (existsSync(stepPath(root, buildSlug))) {
117
- process.stderr.write('plumbbob: spike starts from a settled boundary, but a step is in flight. ' +
118
- 'A spike is a deliberate fork — checkpoint, abandon, or revert the current step first.\n');
126
+ process.stderr.write(notice({
127
+ fact: 'a step is in flight',
128
+ detail: ['a spike is a deliberate fork from a settled boundary'],
129
+ remedy: 'checkpoint, abandon, or revert the current step first',
130
+ }));
119
131
  return 1;
120
132
  }
121
133
  const slug = sanitize(positionals[0] ?? '');
122
134
  if (slug.length === 0) {
123
- process.stderr.write('plumbbob: spike needs a slug. Try: plumbbob spike "auth-store" a b.\n');
135
+ process.stderr.write(notice({ fact: 'spike needs a slug', remedy: 'plumbbob spike "auth-store" a b' }));
124
136
  return 1;
125
137
  }
126
138
  const explicit = positionals.slice(1).map(sanitize).filter((o) => o.length > 0);
@@ -129,7 +141,11 @@ function spikeStart(root, buildSlug, positionals) {
129
141
  for (const opt of options) {
130
142
  const path = join(dirname(root), `${basename(root)}-spike-${slug}-${opt}`);
131
143
  if (existsSync(path)) {
132
- process.stderr.write(`plumbbob: ${path} already exists — remove it or run \`plumbbob spike done\` first.\n`);
144
+ process.stderr.write(notice({
145
+ fact: 'the spike worktree path already exists',
146
+ detail: [path],
147
+ remedy: 'remove it, or run plumbbob spike done first',
148
+ }));
133
149
  return 1;
134
150
  }
135
151
  git(root, ['worktree', 'add', '-b', `spike/${slug}-${opt}`, path, 'HEAD']);
@@ -139,9 +155,25 @@ function spikeStart(root, buildSlug, positionals) {
139
155
  // Scaffold the report NOW, while the worktrees live: findings accrue during the
140
156
  // experiment, not from memory after the teardown. Provenance names the worktrees.
141
157
  const report = scaffoldSpikeReport(root, buildSlug, slug, `/plumbbob:spike — worktrees (${options.join(', ')})`);
142
- process.stdout.write(`plumbbob: spiking — the main tree stays put. Experiment in the throwaway worktrees:\n${created
143
- .map((p) => ` ${p}`)
144
- .join('\n')}\nRecord findings and the Verdict in ${relative(root, report)} as you go, then run \`plumbbob spike done\`.\n`);
158
+ // The lead states the fact; the throwaway worktrees are a list, so they ride
159
+ // as a readout beneath it rather than crowding the line, and the report the
160
+ // spike scaffolded is a second landing under the same lead.
161
+ process.stdout.write(ending({
162
+ lead: blocks([
163
+ transition({
164
+ label: 'Spike',
165
+ fact: 'opened',
166
+ detail: ['the main tree stays put', `${created.length} throwaway worktree${created.length === 1 ? '' : 's'}`],
167
+ }) + created.map((path) => ` ${path}`).join('\n'),
168
+ transition({
169
+ label: 'Spike report',
170
+ fact: 'scaffolded',
171
+ detail: [relative(root, report)],
172
+ remedy: 'record findings and the Verdict there, then run plumbbob spike done',
173
+ }),
174
+ ]),
175
+ pointer: driverPointer(root, buildSlug),
176
+ }));
145
177
  return 0;
146
178
  }
147
179
  /**
@@ -152,7 +184,7 @@ function spikeStart(root, buildSlug, positionals) {
152
184
  */
153
185
  function spikeDone(root, buildSlug) {
154
186
  if (!inSpike(root, buildSlug)) {
155
- process.stderr.write('plumbbob: no active spike to close.\n');
187
+ process.stderr.write(notice({ fact: 'no active spike to close' }));
156
188
  return 1;
157
189
  }
158
190
  // Check for an unrecorded verdict BEFORE teardown. The reports live in the
@@ -168,15 +200,22 @@ function spikeDone(root, buildSlug) {
168
200
  }
169
201
  clearSpike(root, buildSlug);
170
202
  // Guidance, not a gate: a missing verdict is a nudge, and the spike still
171
- // closes. Name the reports so the human knows where to write it.
172
- if (unfilled.length > 0) {
173
- process.stderr.write(`plumbbob: heads-up — no verdict recorded in ${unfilled.join(', ')}. ` +
174
- `The worktrees are gone; capture the call there before it fades.\n`);
175
- }
176
- const reports = listSpikeReports(root, buildSlug);
177
- const where = reports.length > 0 ? `the spike report${reports.length === 1 ? '' : 's'} (${reports.join(', ')})` : 'intent.md';
178
- process.stdout.write(`plumbbob: spike closed — worktrees and branches removed, back at the boundary. ` +
179
- `Record the verdict (which option won, and why) in ${where} before you \`build\`.\n`);
203
+ // closes. It follows the line it qualifies, and names the reports so the
204
+ // human knows where to write the call. The pointer, read once the marker is
205
+ // cleared, aims back at the step the spike interrupted, or forward.
206
+ process.stdout.write(ending({
207
+ lead: transition({ label: 'Spike', fact: 'closed', detail: ['worktrees and branches removed'] }),
208
+ advisories: unfilled.length === 0
209
+ ? []
210
+ : [
211
+ advisory({
212
+ fact: 'no verdict recorded',
213
+ detail: unfilled,
214
+ remedy: 'record which option won, and why, now the worktrees are gone',
215
+ }),
216
+ ],
217
+ pointer: driverPointer(root, buildSlug),
218
+ }));
180
219
  return 0;
181
220
  }
182
221
  /**
@@ -10,6 +10,7 @@ import { readTemplate, stampTemplate } from "../lib/templates.js";
10
10
  import { findRepoRoot, hasCommit, headSha, isDirty, isIgnored } from "../lib/git.js";
11
11
  import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
12
12
  import { settingsPath } from "../lib/settings.js";
13
+ import { advisory, ending, notice, transition } from "../lib/notice.js";
13
14
  /**
14
15
  * Human-readable echo of the default check gate, stamped into the templates'
15
16
  * {{CHECK}} line.
@@ -37,36 +38,59 @@ export async function start(cwd, args) {
37
38
  const local = args.includes('--local');
38
39
  const title = (positionals[0] ?? '').trim();
39
40
  const slug = (flagValue(args, '--slug') ?? datedSlug(title)).trim();
41
+ // Every advisory the session accrues on its way open, collected rather than
42
+ // printed: they belong inside the one ending below, in the order found.
43
+ const advisories = [];
40
44
  if (title.length === 0) {
41
- process.stderr.write('plumbbob: start needs a title. Try: plumbbob start "what you are building".\n');
45
+ process.stderr.write(notice({ fact: 'start needs a title', remedy: 'plumbbob start "what you are building"' }));
42
46
  return 1;
43
47
  }
44
48
  const root = findRepoRoot(cwd);
45
49
  if (root === null) {
46
- process.stderr.write('plumbbob: not a git repository. PlumbBob records a baseline commit — run `git init` and make an initial commit first.\n');
50
+ process.stderr.write(notice({
51
+ fact: 'not a git repository',
52
+ detail: ['plumbbob records a baseline commit'],
53
+ remedy: 'git init, then make an initial commit',
54
+ }));
47
55
  return 1;
48
56
  }
49
57
  if (!hasCommit(root)) {
50
- process.stderr.write('plumbbob: this repository has no commits yet. Make an initial commit so `start` can record a baseline.\n');
58
+ process.stderr.write(notice({
59
+ fact: 'this repository has no commits yet',
60
+ remedy: 'make an initial commit, so start can record a baseline',
61
+ }));
51
62
  return 1;
52
63
  }
53
64
  if (hasSession(root)) {
54
- process.stderr.write('plumbbob: a session is already active here. Run `plumbbob finish` to close it before starting another.\n');
65
+ process.stderr.write(notice({ fact: 'a session is already active here', remedy: 'plumbbob finish to close it before starting another' }));
55
66
  return 1;
56
67
  }
57
68
  if (isDirty(root)) {
58
69
  if (!allowDirty) {
59
- process.stderr.write('plumbbob: the working tree is dirty. Commit or stash first, or `plumbbob start --allow-dirty "<title>"` to record the current HEAD as the baseline.\n');
70
+ process.stderr.write(notice({
71
+ fact: 'the working tree is dirty',
72
+ remedy: 'commit or stash first, or plumbbob start --allow-dirty "<title>" to record HEAD as the baseline',
73
+ }));
60
74
  return 1;
61
75
  }
62
- process.stderr.write('plumbbob: WARNING --allow-dirty: recording HEAD as baseline with a dirty tree. A later revert-to-baseline will DISCARD the uncommitted work.\n');
76
+ advisories.push(advisory({
77
+ fact: 'recording HEAD as the baseline with a dirty tree',
78
+ detail: ['--allow-dirty'],
79
+ remedy: 'a later revert to baseline will DISCARD the uncommitted work',
80
+ }));
63
81
  }
64
82
  if (!local && slug.length === 0) {
65
- process.stderr.write(`plumbbob: could not derive a build slug from "${title}". Pass --slug <name>, or retitle with letters or digits.\n`);
83
+ process.stderr.write(notice({
84
+ fact: `could not derive a build slug from "${title}"`,
85
+ remedy: 'pass --slug <name>, or retitle with letters or digits',
86
+ }));
66
87
  return 1;
67
88
  }
68
89
  if (!local && listBuilds(root).includes(slug)) {
69
- process.stderr.write(`plumbbob: a build named "${slug}" already exists in .plumbbob/builds/. Retitle, or pass --slug <name> to choose another.\n`);
90
+ process.stderr.write(notice({
91
+ fact: `a build named "${slug}" already exists in .plumbbob/builds/`,
92
+ remedy: 'retitle, or pass --slug <name> to choose another',
93
+ }));
70
94
  return 1;
71
95
  }
72
96
  const sha = headSha(root);
@@ -121,7 +145,6 @@ export async function start(cwd, args) {
121
145
  // grant's lifetime is one turn.
122
146
  setGrant(root, null);
123
147
  stampTick(root, local ? null : slug);
124
- process.stdout.write(`plumbbob: started "${title}" — baseline ${sha.slice(0, 9)}. Frame and decide in ${intentLocation}; \`build\` a step once the decisions are made.\n`);
125
148
  // The tracked layout plants `builds/<slug>/` to ride the branch into the PR,
126
149
  // but a repo whose own `.gitignore` excludes `.plumbbob/` overrides that, and
127
150
  // an explicit `git add` of an ignored folder is a hard error. Detect it here,
@@ -132,9 +155,11 @@ export async function start(cwd, args) {
132
155
  // Guidance only, and the session is already open: a probe hiccup stays silent
133
156
  // rather than half-crashing a scaffolded session.
134
157
  if (!local && sidecarIsIgnored(root, slug)) {
135
- process.stderr.write('plumbbob: heads-up — this repo gitignores .plumbbob/, so the tracked build folder cannot ride the branch.\n' +
136
- ' Plan and finish commits will be record-only (empty commits; the plan and report ride the commit message).\n' +
137
- ' To keep the record in-tree, unignore .plumbbob/builds/ (or the whole sidecar) before the first checkpoint.\n');
158
+ advisories.push(advisory({
159
+ fact: 'this repo gitignores .plumbbob/',
160
+ detail: ['the build folder cannot ride the branch', 'plan and finish commits will be record-only'],
161
+ remedy: 'unignore .plumbbob/builds/ before the first checkpoint to keep the record in the tree',
162
+ }));
138
163
  }
139
164
  // The plan-time gate probe: if checkride sees no code checks here, say so
140
165
  // NOW (while the human is still deciding) instead of at the first
@@ -143,10 +168,24 @@ export async function start(cwd, args) {
143
168
  // exit code, and a configured `check` or a probe hiccup stays silent.
144
169
  const gate = await detectGate(root);
145
170
  if (gate.configured === null && !gate.detected) {
146
- process.stderr.write('plumbbob: heads-up — the check gate sees no code checks in this repo (checkride detected no tools).\n' +
147
- ' Set one while you are still planning: add {"check": "npm test"} to .plumbbob/settings.json.\n' +
148
- ' Checkpoints gate on it — with nothing to run, the gate refuses or greens vacuously.\n');
171
+ advisories.push(advisory({
172
+ fact: 'the check gate sees no code checks in this repo',
173
+ detail: ['checkride detected no tools', 'checkpoints gate on it and would green vacuously'],
174
+ remedy: 'add {"check": "npm test"} to .plumbbob/settings.json while you are still planning',
175
+ }));
149
176
  }
177
+ // No pointer: the plan skill owns the turn that follows, and there is no step
178
+ // yet to aim at, so the ending closes on the remedy line that says where to
179
+ // frame the build.
180
+ process.stdout.write(ending({
181
+ lead: transition({
182
+ label: 'Session',
183
+ fact: `started "${title}"`,
184
+ detail: [`baseline ${sha.slice(0, 9)}`],
185
+ remedy: `frame and decide in ${intentLocation}, then build a step`,
186
+ }),
187
+ advisories,
188
+ }));
150
189
  return 0;
151
190
  }
152
191
  /**
@@ -167,11 +206,11 @@ function datedSlug(title) {
167
206
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${base}`;
168
207
  }
169
208
  /**
170
- * Whether the tracked build folder is gitignored: the record-only heads-up's
209
+ * Whether the tracked build folder is gitignored: the record-only advisory's
171
210
  * predicate, probing exactly the path `checkpoint --plan` will try to stage.
172
211
  *
173
212
  * Best-effort: a probe that throws (a fatal `git check-ignore`) reads as
174
- * not-ignored so the heads-up stays silent rather than crashing a session that
213
+ * not-ignored so the advisory stays silent rather than crashing a session that
175
214
  * is already open.
176
215
  */
177
216
  function sidecarIsIgnored(root, slug) {
package/dist/verbs/use.js CHANGED
@@ -11,6 +11,8 @@
11
11
  import { existsSync } from 'node:fs';
12
12
  import { findRepoRoot } from "../lib/git.js";
13
13
  import { activeBuild, hasSession, intentPath, listBuilds, setActiveBuild, stepPath } from "../lib/sidecar.js";
14
+ import { advisory, ending, notice, transition } from "../lib/notice.js";
15
+ import { driverPointer } from "./handoff.js";
14
16
  /**
15
17
  * Switch the active-build cursor to the named build.
16
18
  *
@@ -20,35 +22,43 @@ import { activeBuild, hasSession, intentPath, listBuilds, setActiveBuild, stepPa
20
22
  export function use(cwd, args) {
21
23
  const root = findRepoRoot(cwd);
22
24
  if (root === null || !hasSession(root)) {
23
- process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
25
+ process.stderr.write(notice({ fact: 'no active session', remedy: 'plumbbob start "<title>"' }));
24
26
  return 1;
25
27
  }
26
28
  const builds = listBuilds(root);
27
29
  const target = args.find((a) => !a.startsWith('--'));
28
30
  if (target === undefined || target.length === 0) {
29
- process.stderr.write(`plumbbob: use needs a build slug.${buildsHint(builds)}\n`);
31
+ process.stderr.write(notice({ fact: 'use needs a build slug', detail: builds, remedy: 'plumbbob use <slug>' }));
30
32
  return 1;
31
33
  }
32
34
  // Validate by the folder's intent.md, not the dir alone: an empty `builds/<slug>/`
33
35
  // is not a resumable build, and the flat `--local` layout has no builds/ at all.
34
36
  if (!existsSync(intentPath(root, target))) {
35
- process.stderr.write(`plumbbob: no build named "${target}" in .plumbbob/builds/.${buildsHint(builds)}\n`);
37
+ process.stderr.write(notice({ fact: `no build named "${target}" in .plumbbob/builds/`, detail: builds }));
36
38
  return 1;
37
39
  }
38
40
  const leaving = activeBuild(root);
39
- if (leaving !== null && leaving !== target && existsSync(stepPath(root, leaving))) {
40
- process.stderr.write(`plumbbob: note — build "${leaving}" has a step in flight; its in-flight state is preserved ` +
41
- `and resumes when you \`use ${leaving}\` again.\n`);
42
- }
41
+ const left = leaving !== null && leaving !== target && existsSync(stepPath(root, leaving)) ? leaving : null;
43
42
  setActiveBuild(root, target);
44
- const resuming = existsSync(stepPath(root, target)) ? ' (a step is in flight — `status` shows where)' : '';
45
- process.stdout.write(`plumbbob: now on build "${target}"${resuming}. \`status\` to orient.\n`);
43
+ // The lead line, then the advisory it qualifies, then the pointer into the
44
+ // build just switched to: one fixed order for every ending, so the relay never
45
+ // has to guess which line leads.
46
+ process.stdout.write(ending({
47
+ lead: transition({
48
+ label: 'Active build',
49
+ fact: target,
50
+ detail: existsSync(stepPath(root, target)) ? ['a step is in flight'] : [],
51
+ }),
52
+ advisories: left === null
53
+ ? []
54
+ : [
55
+ advisory({
56
+ fact: `build "${left}" has a step in flight`,
57
+ detail: ['its in-flight state is preserved'],
58
+ remedy: `plumbbob use ${left} to pick it back up`,
59
+ }),
60
+ ],
61
+ pointer: driverPointer(root, target),
62
+ }));
46
63
  return 0;
47
64
  }
48
- /**
49
- * A trailing " Builds: a, b." hint when any exist, so a bad or missing slug
50
- * points the user straight at the valid choices.
51
- */
52
- function buildsHint(builds) {
53
- return builds.length > 0 ? ` Builds: ${builds.join(', ')}.` : '';
54
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plumbbob",
3
- "version": "0.10.3",
3
+ "version": "0.11.0",
4
4
  "description": "Attention-first build process: 14 skills + a CLI that keep you the decider — guidance on the work, a latch on the record.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -10,7 +10,7 @@ allowed-tools: Bash(plumbbob status:*), Bash(plumbbob abandon:*)
10
10
 
11
11
  Current session state (injected when this skill runs): !`plumbbob status 2>/dev/null || echo "plumbbob CLI not on PATH in this session. Marketplace install: confirm the plugin is enabled in /plugin, then /reload-plugins. Skills-dir/global install: npm i -g plumbbob && plumbbob init."`
12
12
 
13
- This is a **driver skill**: a chat-side trigger for the mechanical `plumbbob abandon` verb, so the whole workflow runs from the agent window instead of a terminal. It is `disable-model-invocation: true`: only the human fires it. It carries **no Edit and no Write tool**; its only action is to shell the verb and report the verb's output verbatim, including any refusal. The CLI is the source of truth: never retry a refused transition, and never edit a file to work around one.
13
+ This is a **driver skill**: a chat-side trigger for the mechanical `plumbbob abandon` verb, so the whole workflow runs from the agent window instead of a terminal. It is `disable-model-invocation: true`: only the human fires it. It carries **no Edit and no Write tool**; it shells the verb and relays its output verbatim, refusal included. A successful transition prints its whole ending, pointer and all, so one command is the whole turn. The CLI is the source of truth: never retry a refused transition, and never edit a file to work around one.
14
14
 
15
15
  Abandon is the third exit from an in-flight step. `/plumbbob:verify` lands the step and `/plumbbob:revert` destroys its work; abandon drops the attempt and keeps the working-tree diff exactly where it is. The step keeps its `[ ]` in the plan, re-buildable later, and the diff stays yours to keep, rework, or commit by hand.
16
16
 
@@ -18,3 +18,4 @@ Abandon is the third exit from an in-flight step. `/plumbbob:verify` lands the s
18
18
 
19
19
  1. Run `plumbbob abandon` via Bash. It clears the in-flight markers (`STEP`, `SEAM`, `TICK`, and the step's agent handoff ledger), appends an abandon line to the build log, and touches neither the working tree nor git.
20
20
  2. Report the verb's output verbatim: the step it dropped, or any refusal. A step exit crosses the same boundary as a checkpoint, so abandon honors the same approval latch; a "no human turn since this step began" refusal is the pause working, not an error. End the turn, and the human's next turn is what lets a re-fired abandon land.
21
+ 3. A successful abandon prints its own ending (the driver tier of the [turn anatomy](https://github.com/robmclarty/plumbbob/blob/main/docs/presentation.md)): the lead line, a blank line, and the pointer, which aims at the step just dropped, since it kept its `[ ]` in the plan. Relay that block whole and run no second command. The latch refusal above is not a transition; it carries no pointer, so relay it and end the turn.
@@ -104,31 +104,110 @@ switch with `/model` and rerun to honor it, or wave you on. Advisory, never a ga
104
104
  build the step and reach the pause *in one turn*, so under plumbbob's turn hook the
105
105
  checkpoint at the end of that turn is **refused** ("no human turn since this step
106
106
  began"), and that refusal **is** the pause working as designed. Do exactly what the
107
- pause asks: present the diff and the self-review, emit the closing block below, and
108
- **end the turn**. The human's next message (their approval) is the human turn that
109
- lets the checkpoint land: on that turn you run `plumbbob checkpoint <n>` (now allowed)
110
- and **stop at the boundary**. Landing the checkpoint is its own deliberate beat (the
111
- thing approval triggers), never a side effect of starting the next step, and
112
- `/plumbbob:build` only ever starts the *next* step. **Never route around a refusal with a
113
- raw `git commit`**; the work plane stays free, but the *record* is latched on purpose.
114
-
115
- **End every default build turn with the standardized hand-off block.** Run `plumbbob
116
- handoff` and present its output; it renders the same three-part shape (the state, the
117
- choice, what's next) straight from the session: step N built, the looks-good /
118
- needs-work choice, and the next undone step with its `- model:` recommendation. Show
119
- your diff and self-review above it. The CLI owns the block so it can't drift from what
120
- `/plumbbob:status` reports; you supply only the judgment above it. It already drops the model
121
- clause when the next step has none, and says so when no planned step remains; you
122
- don't reproduce the template by hand.
123
-
124
- **Then hand off with the next model.** Once the checkpoint lands in the approval turn,
125
- run `plumbbob handoff` again and relay its boundary block; with the step gone from
126
- in-flight it renders "step N checkpointed" and points at the next undone step, carrying
127
- that step's `- model:` recommendation (the plan's smallest-model-that-fits call) so the
128
- human knows which `/model` to select before running `/plumbbob:build` again. It is what
129
- carries the plan's suggestion across a fresh context window, which inherits the session
130
- model, not the plan's. No `- model:` line means any model will do. Guidance, never a
131
- gate.
107
+ pause asks: write the detail file, run `plumbbob handoff`, paste its block, and **end
108
+ the turn there**. The human's next
109
+ message (their approval) is the human turn that lets the checkpoint land: on that turn
110
+ you run `plumbbob checkpoint <n>` (now allowed) and **stop at the boundary**. Landing
111
+ the checkpoint is its own deliberate beat (the thing approval triggers), never a side
112
+ effect of starting the next step, and `/plumbbob:build` only ever starts the *next*
113
+ step. **Never route around a refusal with a raw `git commit`**; the work plane stays
114
+ free, but the *record* is latched on purpose.
115
+
116
+ **Render the pause: write the file, run handoff, paste.** A build pause is a decision
117
+ turn, and the
118
+ [turn anatomy](https://github.com/robmclarty/plumbbob/blob/main/docs/presentation.md)
119
+ fixes it as one block. You author none of it in the chat. Your judgment goes into
120
+ `.plumbbob/detail.md`; `plumbbob handoff` renders the whole turn from that file and its
121
+ own measurements; you paste its output and write nothing before or after it. The rule is
122
+ positional on purpose: with the relay as the turn's whole text, no line is left for
123
+ "here is the pause", for reading the check verdict back in prose, or for a closing
124
+ courtesy. Each fact appears once, in its part.
125
+
126
+ *Write the detail file.* Before you call `plumbbob handoff`, overwrite
127
+ `.plumbbob/detail.md` for this step; never let a past step's detail linger. It is the
128
+ wire handoff parses, and it is the only path by which your judgment reaches the turn:
129
+
130
+ ```markdown
131
+ # Detail · Step <N> · <the step title>
132
+
133
+ ── recap · step <N> of <M> ──
134
+ done-when met
135
+ decisions honored: D1 (some-slug), D2 (another-slug)
136
+ constraints all honored
137
+
138
+ ## Summary
139
+
140
+ <the outcome, not the activity: one sentence when one will do, a short
141
+ paragraph when the step needs explaining>
142
+
143
+ ## 1 <the first highlight: one sentence, one move>
144
+ <the full story: what moved, why, what was tried and dropped>
145
+
146
+ ## 2 ...
147
+
148
+ ## Recommendation
149
+
150
+ <The move.> <The reason, one or two sentences.>
151
+ ```
152
+
153
+ The three rows under the header rule are yours, and only those three; `check`, `seam`,
154
+ `diff`, and `spent` are measured by the CLI, and a row you wrote for them would be
155
+ overwritten. Keep the rows contiguous (the first blank line ends them). Each opens with a
156
+ verdict word from the closed set in the turn anatomy (`met`, `not met`, `drift`;
157
+ `honored`, `none exercised`, `bent`, `drift`; `all honored`, `bent`, `drift`); a cited
158
+ decision or constraint carries its slug (`C1 (no-new-deps)`, never a bare `C1`); a row
159
+ that cannot apply vanishes. A green row collapses to its word or its count and a red one
160
+ names the one offender in a short clause, which is what keeps the whole row inside the
161
+ fence's 80 columns (13 of label, 67 of value); two or more items break onto indented
162
+ continuation lines opening with `-`, and a red row's evidence onto an indented `→` line.
163
+
164
+ The `## Summary` lead and the `## <n>` section titles are the turn's opening block:
165
+ handoff prints the lead behind the `**Summary**:` label, appends the `(details: …)`
166
+ bracket, and renders the titles as the numbered highlights, so you type neither the path
167
+ nor the list. Five highlights at most, each one sentence and one move, plain English
168
+ first, a path in backticks only when the name itself is the news. The numbers are
169
+ handles: "expand 2" opens `## 2`, so every highlight has a section behind it with the
170
+ full story. A judgment or a flag (a stray the seam row will name, a decision you had to
171
+ bend, a doubt about the done-when) is one of those highlights, never a paragraph of its
172
+ own. The recommendation is the last section: the move you would take as its own
173
+ sentence, closed by a period, then the reason as a capitalized sentence or two. The lead
174
+ and the recommendation are flowing prose and handoff unwraps both to the renderer's
175
+ width, so never hard-wrap them; handoff also prepends the bold `**Recommendation**:`
176
+ label, which you never type. `checkpoint` folds this file into the commit body and then
177
+ truncates it, so the detail lands in the durable commit rather than piling up on disk.
178
+
179
+ *Paste the turn.* Run `plumbbob handoff` and paste its output whole, verbatim, at top
180
+ level, trailing blank line included, then end the turn. Never nest it inside a fence of
181
+ your own; it carries fences of its own and they cannot nest. It prints the Summary and
182
+ its highlights, the Readout fence (its measured `check`, `seam`, `diff`, and `spent`
183
+ rows folded with your three), a `diff` fence when the change is 20 lines or fewer, the
184
+ Verdict folded worst-of from the same rows, Next Up, Your Call, and your recommendation
185
+ last. The gate verdict's one home is that `check` row: no standalone verdict line
186
+ exists, and you never restate the verdict in prose. A narrowed run names the slots it
187
+ skipped there (`· without test`), and that named narrowing is the whole disclosure. The
188
+ notice checkride's Stop hook appends after your turn is not yours to relay or repeat;
189
+ the trailing blank line you kept is what lands it on its own line. Relayed CLI strings
190
+ keep their em-dashes; your own lines never use one, the write-versus-relay line of
191
+ [D78 (em-dash-ban)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d78).
192
+
193
+ *Then read the reply as an ask or a direction.* A message that **asks** ("expand 2",
194
+ "what does that mean?", "why did the seam row flag that?") is an expand: answer it from the detail file, from
195
+ `git diff`, or from `git show`, never from recall, then run `plumbbob handoff` again and
196
+ paste it. The step is still in flight, so it renders the same pause, and the Your Call
197
+ block stays the CLI's to render rather than yours to retype. A message that **directs**
198
+ is needs-work: take it as what to change, and nothing lands until the human says
199
+ `looks good`.
200
+
201
+ **Then hand off with the next model.** `plumbbob checkpoint` prints the whole boundary
202
+ ending itself: `**Checkpoint**: Step N complete (<sha>)`, the Verdict the step earned,
203
+ any advisory (a staged path outside the seam is the common one), and Next Up. Relay
204
+ that block whole, run no second command, and write nothing around it. It is the
205
+ orientation tier (no Your Call block, no recommendation), and it points at the next
206
+ undone step carrying that step's `- model:` recommendation (the plan's
207
+ smallest-model-that-fits call) so the human knows which `/model` to select before
208
+ running `/plumbbob:build` again. It is what carries the plan's suggestion across a fresh
209
+ context window, which inherits the session model, not the plan's. No `- model:` line
210
+ means any model will do. Guidance, never a gate.
132
211
 
133
212
  ## Running bound agents
134
213
 
@@ -181,10 +260,10 @@ and approves in the human's place**, and it **chains**:
181
260
  bound agent returns `blocked` or `drift` (unblock-and-re-run, or `/plumbbob:refine`; an
182
261
  agent cannot advance the loop), a new decision is needed, no planned steps
183
262
  remain, or the top of a requested range is reached. **When `--auto` halts back to the
184
- human, give the same hand-off the default pause does**; run `plumbbob handoff` and
185
- relay its block: the step just completed, the next undone step, and that next step's
186
- `- model:` recommendation if it has one, so a fresh context window knows which `/model`
187
- to select.
263
+ human, end on the same turn the default pause does**: write the detail file, run
264
+ `plumbbob handoff`, and paste its block: the step just completed, the next undone step,
265
+ and that next step's `- model:` recommendation if it has one, so a fresh context window
266
+ knows which `/model` to select.
188
267
 
189
268
  `--auto` and a step range are the only paths that checkpoint without a human pause, and
190
269
  only because the human asked for it by name; a range re-imposes the pause at its top.
@@ -229,15 +308,17 @@ just the one more entry already in the halt list above.
229
308
  who verifies and (bar `--auto`) the human is still the clock. See **§ Running bound
230
309
  agents** for the mechanics.
231
310
  - **A refused checkpoint is the pause, not an error.** Under plumbbob's turn
232
- hook a same-turn `checkpoint` is refused by design: present the diff and the closing
233
- block, **end the turn**; the human's approval on their next turn is what lets you land
311
+ hook a same-turn `checkpoint` is refused by design: write the file, run `plumbbob
312
+ handoff`, paste its block, **end the turn there**; the human's approval on their next
313
+ turn is what lets you land
234
314
  it, and landing it is a deliberate beat, not a side effect of the next `/plumbbob:build`.
235
315
  Never route around it with a raw `git commit`. An explicit `/plumbbob:build --auto` or a typed
236
316
  step range in the human's own prompt are the only self-approvals; **never write `auto`
237
317
  into a settings file yourself to unlock a checkpoint. A grant you mint is no grant (the
238
318
  latch ignores it; [D67 (auto-not-a-grant)](https://github.com/robmclarty/plumbbob/blob/main/docs/decisions.md#d67)); ask the human to type
239
319
  `/plumbbob:build --auto` again instead.**
240
- - **Close with the next model.** When a step lands, run `plumbbob handoff` and relay its
241
- block; it cites the completed step and the next undone step, and names that next step's
242
- `- model:` recommendation if it has one, which is what a fresh context window needs to
243
- pick the right `/model` before the next run. Guidance, never a gate.
320
+ - **Close with the next model.** When a step lands, `plumbbob checkpoint` prints the whole
321
+ boundary ending; relay it with nothing written around it and no second command. It cites
322
+ the completed step and the next undone step, and names that next step's `- model:`
323
+ recommendation if it has one, which is what a fresh context window needs to pick the
324
+ right `/model` before the next run. Guidance, never a gate.