plumbbob 0.10.3 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,16 @@
3
3
  // build-log.md (the build's human-facing ledger). Capture stays dumb by design:
4
4
  // a grep-readable append, no markdown parsing, no model turn (wording the line
5
5
  // well is /plumbbob:park's job); triage waits for a step boundary, via harvest.
6
+ // The capture prints its whole ending, the line and the pointer back at the
7
+ // step it interrupted, so the relay is one command.
6
8
  // Capture is not a state transition, so it runs in any context (terminal or
7
9
  // in-session).
8
10
  import { readFileSync, writeFileSync } from 'node:fs';
9
11
  import { findRepoRoot } from "../lib/git.js";
10
- import { hasSession, buildLogPath } from "../lib/sidecar.js";
12
+ import { activeBuild, hasSession, buildLogPath } from "../lib/sidecar.js";
11
13
  import { appendToSection } from "../lib/buildlog.js";
14
+ import { ending, notice, transition } from "../lib/notice.js";
15
+ import { driverPointer } from "./handoff.js";
12
16
  /**
13
17
  * Append the given text as one unchecked `- [ ]` line to the build-log's Park list.
14
18
  *
@@ -19,7 +23,7 @@ import { appendToSection } from "../lib/buildlog.js";
19
23
  export function park(cwd, args) {
20
24
  const root = findRepoRoot(cwd);
21
25
  if (root === null || !hasSession(root)) {
22
- process.stderr.write('plumbbob: no active session — nothing to park to. Run `plumbbob start "<title>"` first.\n');
26
+ process.stderr.write(notice({ fact: 'no active session', detail: ['nothing to park to'], remedy: 'plumbbob start "<title>"' }));
23
27
  return 1;
24
28
  }
25
29
  const text = args
@@ -27,16 +31,18 @@ export function park(cwd, args) {
27
31
  .join(' ')
28
32
  .trim();
29
33
  if (text.length === 0) {
30
- process.stderr.write('plumbbob: park needs text. Try: plumbbob park "the idea you do not want to chase right now".\n');
34
+ process.stderr.write(notice({ fact: 'park needs text', remedy: 'plumbbob park "the idea you do not want to chase right now"' }));
31
35
  return 1;
32
36
  }
33
37
  const path = buildLogPath(root);
34
38
  const updated = appendToSection(readFileSync(path, 'utf8'), 'Park list', `- [ ] ${text}`);
35
39
  if (updated === null) {
36
- process.stderr.write('plumbbob: could not find a "## Park list" section in build-log.md.\n');
40
+ process.stderr.write(notice({ fact: 'no "## Park list" section in build-log.md', remedy: 'add the section, then park again' }));
37
41
  return 1;
38
42
  }
39
43
  writeFileSync(path, updated);
40
- process.stdout.write(`parked: ${text}\n`);
44
+ // A park interrupts a step without ending it, so the ending is the capture
45
+ // and the pointer back at what was interrupted.
46
+ process.stdout.write(ending({ lead: transition({ label: 'Parked', fact: text }), pointer: driverPointer(root, activeBuild(root)) }));
41
47
  return 0;
42
48
  }
@@ -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.1",
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.