plumbbob 0.8.18 → 0.9.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.
Files changed (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +50 -44
  3. package/dist/cli-core.js +35 -15
  4. package/dist/lib/agents.js +230 -112
  5. package/dist/lib/buildlog.js +50 -30
  6. package/dist/lib/buildlogsync.js +21 -12
  7. package/dist/lib/check.js +69 -35
  8. package/dist/lib/commitmsg.js +53 -32
  9. package/dist/lib/git.js +86 -31
  10. package/dist/lib/intent.js +91 -31
  11. package/dist/lib/latch.js +70 -39
  12. package/dist/lib/orient.js +78 -39
  13. package/dist/lib/plugins.js +11 -8
  14. package/dist/lib/settings.js +65 -32
  15. package/dist/lib/sidecar.js +320 -150
  16. package/dist/lib/templates.js +17 -6
  17. package/dist/verbs/agent.js +116 -55
  18. package/dist/verbs/build.js +27 -12
  19. package/dist/verbs/check.js +33 -7
  20. package/dist/verbs/checkpoint.js +153 -78
  21. package/dist/verbs/doctor.js +192 -95
  22. package/dist/verbs/finish.js +86 -45
  23. package/dist/verbs/handoff.js +56 -20
  24. package/dist/verbs/init.js +37 -12
  25. package/dist/verbs/park.js +14 -3
  26. package/dist/verbs/revert.js +64 -24
  27. package/dist/verbs/spike.js +89 -30
  28. package/dist/verbs/start.js +72 -38
  29. package/dist/verbs/status.js +44 -17
  30. package/dist/verbs/turn.js +88 -45
  31. package/dist/verbs/use.js +20 -9
  32. package/package.json +1 -1
  33. package/skills/{pb-build → build}/SKILL.md +25 -25
  34. package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
  35. package/skills/{pb-finish → finish}/SKILL.md +3 -3
  36. package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
  37. package/skills/{pb-park → park}/SKILL.md +2 -2
  38. package/skills/{pb-plan → plan}/SKILL.md +11 -11
  39. package/skills/{pb-refine → refine}/SKILL.md +7 -7
  40. package/skills/{pb-revert → revert}/SKILL.md +2 -2
  41. package/skills/{pb-spike → spike}/SKILL.md +4 -4
  42. package/skills/{pb-status → status}/SKILL.md +3 -3
  43. package/skills/{pb-step → step}/SKILL.md +9 -9
  44. package/skills/{pb-verify → verify}/SKILL.md +8 -8
  45. package/templates/build-log.md +7 -7
  46. package/templates/intent.md +8 -8
  47. package/templates/spike-report.md +1 -1
@@ -1,10 +1,16 @@
1
- // `plumbbob spike` (D18) — the spike lifecycle for a genuine fork the design
2
- // phase couldn't settle. `spike "<slug>" [opt…]` creates a sibling git worktree +
3
- // `spike/<slug>-<opt>` branch per option OUTSIDE the repo root (default opts a/b)
4
- // and drops the SPIKE marker; the main tree stays put while you experiment in the
5
- // worktrees, which are hook-dormant by construction — the untracked sidecar (D17)
6
- // doesn't exist in a fresh checkout, so the hooks find no STATE there.
7
- // `spike done` removes every spike worktree + branch and clears the marker.
1
+ // `plumbbob spike` — the spike lifecycle for a genuine fork the design phase
2
+ // couldn't settle. `spike "<slug>" [opt…]` creates a sibling git worktree and a
3
+ // `spike/<slug>-<opt>` branch per option OUTSIDE the repo root (default options
4
+ // a/b) and drops the SPIKE marker — one of the untracked per-build control files
5
+ // under `.plumbbob/` that record what's in flight. The main tree stays put while
6
+ // you experiment in the worktrees, which are hook-dormant by construction: the
7
+ // untracked control files don't exist in a fresh checkout, so the hooks find no
8
+ // STATE (the session sentinel) there. `spike done` removes every spike worktree
9
+ // and branch and clears the marker.
10
+ //
11
+ // Every spike also leaves a durable report — `spike-NN-<slug>.md` beside
12
+ // intent.md in the tracked `builds/<slug>/` folder — so the verdict rides the
13
+ // branch into the PR instead of evaporating with the throwaway worktrees.
8
14
  //
9
15
  // Worktree git calls run directly here rather than via lib/git.ts (which holds the
10
16
  // shared additive read/commit helpers): worktree management is spike-local, and
@@ -15,10 +21,19 @@ import { basename, dirname, join, relative } from 'node:path';
15
21
  import { findRepoRoot } from "../lib/git.js";
16
22
  import { buildFolder, clearSpike, hasSession, inSpike, listSpikeReports, markSpike, nextSpikeReportPath, resolveBuild, stepPath, } from "../lib/sidecar.js";
17
23
  import { readTemplate, stampTemplate } from "../lib/templates.js";
24
+ // Worktree/branch names when the caller lists none — a fork defaults to two arms.
18
25
  const DEFAULT_OPTIONS = ['a', 'b'];
19
- // The verbatim Verdict placeholder from templates/spike-report.md — its presence means
20
- // the spike's call was never recorded, which `spike done` nudges on (D70).
26
+ /**
27
+ * The verbatim Verdict placeholder from templates/spike-report.md — its presence
28
+ * means the spike's call was never recorded, which `spike done` nudges on.
29
+ */
21
30
  const VERDICT_PLACEHOLDER = '*(viable | not viable | partial';
31
+ /**
32
+ * Entry point for `plumbbob spike` — dispatches to open, `report`, or `done`.
33
+ *
34
+ * Requires an active session (the STATE sentinel) and resolves which build the
35
+ * spike belongs to before dispatching.
36
+ */
22
37
  export function spike(cwd, args) {
23
38
  const root = findRepoRoot(cwd);
24
39
  if (root === null || !hasSession(root)) {
@@ -35,11 +50,14 @@ export function spike(cwd, args) {
35
50
  }
36
51
  return spikeStart(root, buildSlug, positionals);
37
52
  }
38
- // `spike report "<slug>"` (D70) — scaffold a spike report WITHOUT worktrees, for the
39
- // spike-as-step case (a planned step titled `spike: …`, where the increment itself is
40
- // the experiment). No boundary requirement and no SPIKE marker: a step in flight is
41
- // exactly when this runs. Provenance is `step <n>` when a STEP is in flight, else
42
- // `/pb-spike`.
53
+ /**
54
+ * `spike report "<slug>"` — scaffold a spike report WITHOUT worktrees.
55
+ *
56
+ * Serves the spike-as-step case: a planned step titled `spike: …`, where the
57
+ * increment itself is the experiment. No boundary requirement and no SPIKE
58
+ * marker — a step in flight is exactly when this runs. Provenance is stamped
59
+ * `step <n>` when a step is in flight, else `/plumbbob:spike`.
60
+ */
43
61
  function spikeReport(root, buildSlug, positionals) {
44
62
  const slug = sanitize(positionals[0] ?? '');
45
63
  if (slug.length === 0) {
@@ -47,23 +65,32 @@ function spikeReport(root, buildSlug, positionals) {
47
65
  return 1;
48
66
  }
49
67
  const inFlight = readInFlightStep(root, buildSlug);
50
- const via = inFlight !== null ? `step ${inFlight}` : '/pb-spike';
68
+ const via = inFlight !== null ? `step ${inFlight}` : '/plumbbob:spike';
51
69
  const path = scaffoldSpikeReport(root, buildSlug, slug, via);
52
70
  process.stdout.write(`plumbbob: spike report scaffolded — ${relative(root, path)}. Fill Findings and the Verdict as you go; ` +
53
71
  `a recorded Verdict is what closes a spike step.\n`);
54
72
  return 0;
55
73
  }
56
- // Write a fresh spike report from the template at the next free `spike-NN-<slug>.md`
57
- // in the build folder, and return its path. Shared by the explicit `/pb-spike` open
58
- // and the spike-as-step `spike report` — one artifact, two entry points (D70).
74
+ /**
75
+ * Write a fresh spike report from the template at the next free
76
+ * `spike-NN-<slug>.md` in the build folder, and return its path.
77
+ *
78
+ * Shared by the worktree-opening spike and the spike-as-step `spike report` —
79
+ * one artifact, two entry points. The CLI owns the numbering; the human never
80
+ * creates or numbers a report.
81
+ */
59
82
  function scaffoldSpikeReport(root, buildSlug, spikeSlug, via) {
60
83
  const path = nextSpikeReportPath(root, buildSlug, spikeSlug);
61
84
  const date = new Date().toISOString().slice(0, 10);
62
85
  writeFileSync(path, stampTemplate(readTemplate('spike-report.md'), { TITLE: spikeSlug, VIA: via, DATE: date }));
63
86
  return path;
64
87
  }
65
- // The in-flight STEP number for the build, or null — reads the STEP marker `build`
66
- // writes. Used to stamp a spike-as-step report's provenance.
88
+ /**
89
+ * The build's in-flight step number, or null — reads the STEP marker `build`
90
+ * writes.
91
+ *
92
+ * Used to stamp a spike-as-step report's provenance as `step <n>`.
93
+ */
67
94
  function readInFlightStep(root, buildSlug) {
68
95
  try {
69
96
  const raw = readFileSync(stepPath(root, buildSlug), 'utf8').trim();
@@ -73,6 +100,14 @@ function readInFlightStep(root, buildSlug) {
73
100
  return null;
74
101
  }
75
102
  }
103
+ /**
104
+ * Open a spike: one throwaway worktree + `spike/<slug>-<opt>` branch per option.
105
+ *
106
+ * Refuses when a spike is already open or a step is in flight — a spike is a
107
+ * deliberate fork from a settled boundary, so the current step must checkpoint
108
+ * or revert first. Marks the SPIKE control file and scaffolds the report before
109
+ * returning.
110
+ */
76
111
  function spikeStart(root, buildSlug, positionals) {
77
112
  if (inSpike(root, buildSlug)) {
78
113
  process.stderr.write('plumbbob: already in a spike. Run `plumbbob spike done` to close it first.\n');
@@ -101,20 +136,26 @@ function spikeStart(root, buildSlug, positionals) {
101
136
  created.push(path);
102
137
  }
103
138
  markSpike(root, buildSlug);
104
- // Scaffold the report NOW, while the worktrees live (D70): findings accrue during the
139
+ // Scaffold the report NOW, while the worktrees live: findings accrue during the
105
140
  // experiment, not from memory after the teardown. Provenance names the worktrees.
106
- const report = scaffoldSpikeReport(root, buildSlug, slug, `/pb-spike — worktrees (${options.join(', ')})`);
141
+ const report = scaffoldSpikeReport(root, buildSlug, slug, `/plumbbob:spike — worktrees (${options.join(', ')})`);
107
142
  process.stdout.write(`plumbbob: spiking — the main tree stays put. Experiment in the throwaway worktrees:\n${created
108
143
  .map((p) => ` ${p}`)
109
144
  .join('\n')}\nRecord findings and the Verdict in ${relative(root, report)} as you go, then run \`plumbbob spike done\`.\n`);
110
145
  return 0;
111
146
  }
147
+ /**
148
+ * Close the spike: remove every spike worktree and branch, clear the marker.
149
+ *
150
+ * Nudges — but never refuses — when a report's Verdict is still the template
151
+ * placeholder: guidance, not a gate.
152
+ */
112
153
  function spikeDone(root, buildSlug) {
113
154
  if (!inSpike(root, buildSlug)) {
114
155
  process.stderr.write('plumbbob: no active spike to close.\n');
115
156
  return 1;
116
157
  }
117
- // Check for an unrecorded verdict BEFORE teardown (D70). The reports live in the
158
+ // Check for an unrecorded verdict BEFORE teardown. The reports live in the
118
159
  // build folder, not the spike worktrees, so they survive the removal — but the
119
160
  // learning that fills them does not, so this is the moment to nudge.
120
161
  const unfilled = spikeReportsMissingVerdict(root, buildSlug);
@@ -126,8 +167,8 @@ function spikeDone(root, buildSlug) {
126
167
  git(root, ['branch', '-D', branch]);
127
168
  }
128
169
  clearSpike(root, buildSlug);
129
- // Guidance, not a gate (the enforce→guide pivot): a missing verdict is a nudge, and
130
- // the spike still closes. Name the reports so the human knows where to write it.
170
+ // 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.
131
172
  if (unfilled.length > 0) {
132
173
  process.stderr.write(`plumbbob: heads-up — no verdict recorded in ${unfilled.join(', ')}. ` +
133
174
  `The worktrees are gone; capture the call there before it fades.\n`);
@@ -138,9 +179,13 @@ function spikeDone(root, buildSlug) {
138
179
  `Record the verdict (which option won, and why) in ${where} before you \`build\`.\n`);
139
180
  return 0;
140
181
  }
141
- // The spike-report filenames in the build folder whose Verdict is still the template
142
- // placeholder — the ones `spike done` nudges on. Best-effort per file: an unreadable
143
- // report is skipped rather than blocking the close.
182
+ /**
183
+ * The spike-report filenames in the build folder whose Verdict is still the
184
+ * template placeholder — the ones `spike done` nudges on.
185
+ *
186
+ * Best-effort per file: an unreadable report is skipped rather than blocking
187
+ * the close.
188
+ */
144
189
  function spikeReportsMissingVerdict(root, buildSlug) {
145
190
  const dir = buildFolder(root, buildSlug);
146
191
  return listSpikeReports(root, buildSlug).filter((name) => {
@@ -152,8 +197,11 @@ function spikeReportsMissingVerdict(root, buildSlug) {
152
197
  }
153
198
  });
154
199
  }
155
- // Worktree paths whose checked-out branch is under spike/ — parsed from the
156
- // porcelain output (blank-line-separated `worktree <path>` / `branch <ref>` blocks).
200
+ /**
201
+ * Worktree paths whose checked-out branch is under spike/ — parsed from the
202
+ * porcelain output (blank-line-separated `worktree <path>` / `branch <ref>`
203
+ * blocks).
204
+ */
157
205
  function spikeWorktrees(root) {
158
206
  const out = git(root, ['worktree', 'list', '--porcelain']);
159
207
  const paths = [];
@@ -168,16 +216,27 @@ function spikeWorktrees(root) {
168
216
  }
169
217
  return paths;
170
218
  }
219
+ /**
220
+ * List the local `spike/<slug>` branch names.
221
+ */
171
222
  function spikeBranches(root) {
172
223
  const out = git(root, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/spike/']);
173
224
  return out.length === 0 ? [] : out.split('\n').filter((b) => b.length > 0);
174
225
  }
226
+ /**
227
+ * Slugify a raw spike name — lowercase, non-alphanumerics collapsed to dashes.
228
+ */
175
229
  function sanitize(raw) {
176
230
  return raw
177
231
  .toLowerCase()
178
232
  .replace(/[^a-z0-9]+/g, '-')
179
233
  .replace(/^-+|-+$/g, '');
180
234
  }
235
+ /**
236
+ * Run a git command at `root` and return its trimmed stdout.
237
+ *
238
+ * Stderr passes through to the terminal; a non-zero exit throws.
239
+ */
181
240
  function git(root, args) {
182
241
  return execFileSync('git', ['-C', root, ...args], {
183
242
  encoding: 'utf8',
@@ -1,5 +1,8 @@
1
- // `plumbbob start "<title>"` — scaffold the sidecar, record the baseline, open
2
- // the session. Refuses on a dirty tree (D22), an existing session, or a non-git dir.
1
+ // `plumbbob start "<title>"` — open a session: scaffold the sidecar (the
2
+ // `.plumbbob/` tree of build artifacts and control files), record the baseline
3
+ // commit, and point the session at a new build. Refuses on a dirty tree (a
4
+ // clean baseline is the contract; `--allow-dirty` overrides), an existing
5
+ // session, or a non-git directory.
3
6
  import { existsSync, writeFileSync, mkdirSync } from 'node:fs';
4
7
  import { join } from 'node:path';
5
8
  import { detectGate } from "../lib/check.js";
@@ -7,10 +10,27 @@ import { readTemplate, stampTemplate } from "../lib/templates.js";
7
10
  import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
8
11
  import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
9
12
  import { settingsPath } from "../lib/settings.js";
10
- // D24/D32: the gate is checkride unless a `check` setting overrides it, so
11
- // settings.json seeds with no `check` key at all — absence IS the default. The
12
- // templates' {{CHECK}} line is a human-readable echo of that.
13
+ /**
14
+ * Human-readable echo of the default check gate, stamped into the templates'
15
+ * {{CHECK}} line.
16
+ *
17
+ * The gate is checkride unless a `check` setting overrides it, so settings.json
18
+ * seeds with no `check` key at all — absence IS the default; this line says
19
+ * that in prose where the templates need it.
20
+ */
13
21
  const CHECK_ECHO = 'checkride (set a "check" key in .plumbbob/settings.json to override)';
22
+ /**
23
+ * Open a session: validate the repo, scaffold the build folder, record the
24
+ * baseline commit.
25
+ *
26
+ * Refuses without a title, outside a git repo, on a commitless repo, when a
27
+ * session is already active, and on a dirty tree (`--allow-dirty` records the
28
+ * current HEAD anyway, with a data-loss warning). The default layout is a
29
+ * tracked `.plumbbob/builds/<slug>/` folder that rides the branch into the PR;
30
+ * `--local` keeps the whole sidecar untracked for repos that won't carry tool
31
+ * folders. Ends with a plan-time probe of the check gate so an unconfigured
32
+ * repo hears about it while the human is still deciding.
33
+ */
14
34
  export async function start(cwd, args) {
15
35
  const positionals = args.filter((a) => !a.startsWith('--'));
16
36
  const allowDirty = args.includes('--allow-dirty');
@@ -51,25 +71,28 @@ export async function start(cwd, args) {
51
71
  }
52
72
  const sha = headSha(root);
53
73
  mkdirSync(sidecarDir(root), { recursive: true });
54
- // beginSession writes STATE — the session sentinel and, in its content, the cursor
55
- // (D28). Point it at this build (null under --local, which has no cursor). The
56
- // cursor lives in STATE, not settings.local.json, so that overlay stays human-owned.
74
+ // beginSession writes STATE — one file that is both the session sentinel (its
75
+ // existence means a session is live) and the active-build cursor (its single
76
+ // line names the build every verb acts on). Point it at this build — null
77
+ // under --local, which has no cursor. The cursor lives in STATE, not
78
+ // settings.local.json, so that personal overlay stays purely human-owned.
57
79
  beginSession(root, local ? null : slug);
58
80
  // Scaffold the tracked settings.json ONLY when absent, and seed it EMPTY — the
59
81
  // human owns this file once it exists (their `check` gate lives here), so a
60
- // re-start must never touch it. We inject no opinions even on first create:
61
- // absence of `check` already means checkride and absence of `auto` already means
62
- // false (see settings.ts), so `{}` is exactly "all defaults". `auto` in
63
- // particular is a personal preference that belongs in settings.local.json, not
64
- // this tracked file — seeding it here was the bug that clobbered custom checks.
82
+ // re-start must never touch it. Inject no opinions even on first create:
83
+ // absence of `check` already means checkride and absence of `auto` already
84
+ // means false (see settings.ts), so `{}` is exactly "all defaults". `auto` in
85
+ // particular is a personal preference that belongs in settings.local.json,
86
+ // never this tracked file.
65
87
  if (!existsSync(settingsPath(root))) {
66
88
  writeFileSync(settingsPath(root), `${JSON.stringify({}, null, 2)}\n`);
67
89
  }
68
- // D26: `--local` keeps today's fully-untracked flat layout (whole `.plumbbob/`
69
- // excluded); the default plants a tracked, PR-riding `builds/<slug>/` folder
70
- // (D26) and points the per-worktree cursor at it (D28), excluding only control
71
- // files (D17). The slug is date-prefixed when derived (see datedSlug) and
72
- // validated unique above — the CLI refuses, never suffixes (D38).
90
+ // `--local` keeps a fully-untracked flat layout (the whole `.plumbbob/`
91
+ // git-excluded); the default plants a tracked `builds/<slug>/` folder — the
92
+ // build's record (intent, build-log, checkpoints) rides its branch into the
93
+ // PR — and git-excludes only the per-worktree control files. The slug is
94
+ // date-prefixed when derived (see datedSlug) and validated unique above —
95
+ // the CLI refuses a collision, never auto-suffixes.
73
96
  let intentLocation;
74
97
  if (local) {
75
98
  writeFileSync(checkpointsPath(root), `baseline ${sha}\n`);
@@ -87,20 +110,23 @@ export async function start(cwd, args) {
87
110
  excludeControl(root);
88
111
  intentLocation = `.plumbbob/builds/${slug}/intent.md`;
89
112
  }
90
- // The plan's entry stamp (D64): `checkpoint --plan` latches on this tick. Skipped
91
- // when TURN is absent — including the known first-session seam where the hook has
92
- // never ticked, which leaves that one plan commit guidance-governed. Any grant
93
- // lying around is a leftover from an earlier session (a legitimate one is minted
94
- // by a `/pb-build` prompt, which never runs `start`) — clear it so a stale `auto`
95
- // can't self-approve this session's plan (D65: one-turn lifetime).
113
+ // The plan's entry stamp for the checkpoint latch: set TICK to the current
114
+ // TURN, so `checkpoint --plan` only lands once the harness ledger records a
115
+ // human turn after this moment. Skipped when TURN is absent — a hookless host
116
+ // grows no ledger and the latch stays dormant, which also covers a first
117
+ // session where the hook has never ticked; that one plan commit stays
118
+ // guidance-governed. Any GRANT file lying around predates this session (a
119
+ // legitimate one is minted by a `/build` prompt, which never runs `start`)
120
+ // — clear it so a stale grant can't self-approve this session's plan; a
121
+ // grant's lifetime is one turn.
96
122
  setGrant(root, null);
97
123
  stampTick(root, local ? null : slug);
98
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`);
99
- // The plan-time gate probe (research/07 Build 2a): if checkride sees no code
100
- // checks here, say so NOW — while the human is still deciding — instead of at
101
- // the first checkpoint, where the gate either refuses a vacuous run or, worse,
102
- // greens on the always-on repo checks alone. Guidance only: never the exit
103
- // code (C1), and a configured `check` or a probe hiccup stays silent.
125
+ // The plan-time gate probe: if checkride sees no code checks here, say so
126
+ // NOW — while the human is still deciding — instead of at the first
127
+ // checkpoint, where the gate either refuses a vacuous run or, worse, greens
128
+ // on the always-on repo checks alone. Guidance only: it never changes the
129
+ // exit code, and a configured `check` or a probe hiccup stays silent.
104
130
  const gate = await detectGate(root);
105
131
  if (gate.configured === null && !gate.detected) {
106
132
  process.stderr.write('plumbbob: heads-up — the check gate sees no code checks in this repo (checkride detected no tools).\n' +
@@ -109,11 +135,15 @@ export async function start(cwd, args) {
109
135
  }
110
136
  return 0;
111
137
  }
112
- // Derived slugs carry a `YYYY-MM-DD-` prefix (local time) so `builds/` sorts
113
- // chronologically under `listBuilds`' plain lexical sort — ordering by
114
- // construction, not by titling convention. An explicit `--slug` stays verbatim
115
- // (D38: the CLI never rewrites what the caller chose). An untitleable title
116
- // yields `''` so the empty-slug guard fires instead of minting a date-only slug.
138
+ /**
139
+ * Derive a `YYYY-MM-DD-<slug>` build slug from the title (local time).
140
+ *
141
+ * The date prefix makes `builds/` sort chronologically under `listBuilds`'
142
+ * plain lexical sort — ordering by construction, not by titling convention. An
143
+ * explicit `--slug` bypasses this and stays verbatim — the CLI never rewrites
144
+ * what the caller chose. An untitleable title yields `''` so the empty-slug
145
+ * guard fires instead of minting a date-only slug.
146
+ */
117
147
  function datedSlug(title) {
118
148
  const base = slugify(title);
119
149
  if (base.length === 0)
@@ -122,14 +152,18 @@ function datedSlug(title) {
122
152
  const pad = (n) => String(n).padStart(2, '0');
123
153
  return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${base}`;
124
154
  }
125
- // Read the value that follows a `--flag` in argv (e.g. `--slug my-build`), or
126
- // undefined when the flag is absent or trails with no value.
155
+ /**
156
+ * Read the value that follows a `--flag` in argv (e.g. `--slug my-build`), or
157
+ * undefined when the flag is absent or trails with no value.
158
+ */
127
159
  function flagValue(args, flag) {
128
160
  const i = args.indexOf(flag);
129
161
  return i >= 0 ? args[i + 1] : undefined;
130
162
  }
131
- // Stamp a build template's `{{TITLE}}`/`{{CHECK}}` — the check is always the echo
132
- // constant (absence of a `check` setting IS the default, D24/D32).
163
+ /**
164
+ * Stamp a build template's `{{TITLE}}`/`{{CHECK}}` — the check is always the
165
+ * echo constant, since an absent `check` setting IS the checkride default.
166
+ */
133
167
  function stampBuild(template, title) {
134
168
  return stampTemplate(template, { TITLE: title, CHECK: CHECK_ECHO });
135
169
  }
@@ -1,4 +1,7 @@
1
- // `plumbbob status` — the orientation dashboard (D8/D15), or NO ACTIVE SESSION.
1
+ // `plumbbob status` — the orientation dashboard, or NO ACTIVE SESSION. It
2
+ // parses the live session — intent.md, the build-log, the checkpoints ledger,
3
+ // the in-flight STEP marker — into a where-am-I view that suggests one primary
4
+ // next move while printing the full picture, so you can always override.
2
5
  // Read-only, always exits 0. Skills pre-inject this output to gate their own
3
6
  // behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
4
7
  import { readFileSync } from 'node:fs';
@@ -7,6 +10,9 @@ import { commitsSince, findRepoRoot } from "../lib/git.js";
7
10
  import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
8
11
  import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
9
12
  import { formatOrientation, lastLedgerSha, orient } from "../lib/orient.js";
13
+ /**
14
+ * Read a file as utf8, or '' when it is missing or unreadable.
15
+ */
10
16
  function readOr(path) {
11
17
  try {
12
18
  return readFileSync(path, 'utf8');
@@ -15,6 +21,13 @@ function readOr(path) {
15
21
  return '';
16
22
  }
17
23
  }
24
+ /**
25
+ * Print the orientation dashboard for the active build, plus its harness bindings.
26
+ *
27
+ * No session prints the exact `NO ACTIVE SESSION` sentinel; a session with
28
+ * builds but no active one lists them and points at `use`. Always exits 0 —
29
+ * status is a report, never a gate.
30
+ */
18
31
  export function status(cwd, args = []) {
19
32
  const root = findRepoRoot(cwd);
20
33
  if (root === null || !hasSession(root)) {
@@ -33,10 +46,11 @@ export function status(cwd, args = []) {
33
46
  }
34
47
  }
35
48
  const inFlightRaw = readOr(stepPath(root, slug)).trim();
36
- // The out-of-band count (D66) needs git (orient is pure), so status measures it:
37
- // commits on HEAD since the last ledger line's SHA — baseline, plan, or step, so
38
- // a commit landing before the first step checkpoint still surfaces. No ledger yet
39
- // ⇒ nothing to reconcile against ⇒ 0.
49
+ // Out-of-band commits — ones that landed outside the checkpoints ledger — are
50
+ // surfaced here, never blocked. Counting them needs git (orient is pure), so
51
+ // status measures it: commits on HEAD since the last ledger line's SHA —
52
+ // baseline, plan, or step, so a commit landing before the first step
53
+ // checkpoint still surfaces. No ledger yet ⇒ nothing to reconcile against ⇒ 0.
40
54
  const checkpoints = readOr(checkpointsPath(root, slug));
41
55
  const anchor = lastLedgerSha(checkpoints);
42
56
  const orientation = orient({
@@ -51,12 +65,18 @@ export function status(cwd, args = []) {
51
65
  process.stdout.write(`${lines.join('\n')}\n`);
52
66
  return 0;
53
67
  }
54
- // The active build's harness bindings (D48): the agents bound to each slot, per
55
- // `defaults` and per step, with a warning on any bound agent that does not resolve
56
- // (D54 — a teammate can lack a personal agent and the loop still works). Returns []
57
- // when the build has no harness.json (a clean no-op) or the file binds nothing, so
58
- // the dashboard stays uncluttered for builds that use no agents. A broken
59
- // harness.json surfaces its parse error rather than hiding.
68
+ /**
69
+ * The active build's harness bindings: the agents bound to each slot, per
70
+ * `defaults` and per step.
71
+ *
72
+ * Bindings live in `builds/<slug>/harness.json` and status is the surface that
73
+ * reports them — no separate `agent check` verb. Any bound agent that does not
74
+ * resolve gets a warning, not an error: a teammate can lack a personal agent
75
+ * and the loop still works. Returns [] when the build has no harness.json (a
76
+ * clean no-op) or the file binds nothing, so the dashboard stays uncluttered
77
+ * for builds that use no agents. A broken harness.json surfaces its parse
78
+ * error rather than hiding.
79
+ */
60
80
  function harnessSection(root, slug) {
61
81
  const parsed = readHarnessFile(join(buildFolder(root, slug), 'harness.json'));
62
82
  if (parsed === null)
@@ -77,9 +97,12 @@ function harnessSection(root, slug) {
77
97
  return [];
78
98
  return ['', 'harness bindings:', ...rows, ...bindingWarnings(root, harness)];
79
99
  }
80
- // Render a SlotBindings as `slot: name1, name2` fragments, in slot order. A slot
81
- // bound to no agents (an explicit "override to none") is omitted — there is nothing
82
- // to run and nothing to name.
100
+ /**
101
+ * Render a SlotBindings as `slot: name1, name2` fragments, in slot order.
102
+ *
103
+ * A slot bound to no agents (an explicit "override to none") is omitted —
104
+ * there is nothing to run and nothing to name.
105
+ */
83
106
  function slotFragments(bindings) {
84
107
  const out = [];
85
108
  for (const slot of SLOTS) {
@@ -89,9 +112,13 @@ function slotFragments(bindings) {
89
112
  }
90
113
  return out;
91
114
  }
92
- // A warning line for every distinct agent bound anywhere in the harness that does
93
- // not resolve (D54). Deduped and sorted so the report is stable; a name bound in
94
- // several slots warns once.
115
+ /**
116
+ * A warning line for every distinct agent bound anywhere in the harness that
117
+ * does not resolve.
118
+ *
119
+ * Deduped and sorted so the report is stable; a name bound in several slots
120
+ * warns once.
121
+ */
95
122
  function bindingWarnings(root, harness) {
96
123
  const names = new Set();
97
124
  for (const slot of SLOTS)