plumbbob 0.8.18 → 0.9.2
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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +51 -44
- package/dist/cli-core.js +35 -15
- package/dist/lib/agents.js +230 -112
- package/dist/lib/buildlog.js +50 -30
- package/dist/lib/buildlogsync.js +21 -12
- package/dist/lib/check.js +69 -35
- package/dist/lib/commitmsg.js +53 -32
- package/dist/lib/git.js +86 -31
- package/dist/lib/intent.js +91 -31
- package/dist/lib/latch.js +70 -39
- package/dist/lib/orient.js +78 -39
- package/dist/lib/plugins.js +11 -8
- package/dist/lib/settings.js +65 -32
- package/dist/lib/sidecar.js +356 -150
- package/dist/lib/templates.js +17 -6
- package/dist/verbs/agent.js +116 -55
- package/dist/verbs/build.js +27 -12
- package/dist/verbs/check.js +33 -7
- package/dist/verbs/checkpoint.js +158 -79
- package/dist/verbs/doctor.js +192 -95
- package/dist/verbs/finish.js +88 -46
- package/dist/verbs/handoff.js +56 -20
- package/dist/verbs/init.js +37 -12
- package/dist/verbs/park.js +14 -3
- package/dist/verbs/revert.js +64 -24
- package/dist/verbs/spike.js +89 -30
- package/dist/verbs/start.js +72 -38
- package/dist/verbs/status.js +44 -17
- package/dist/verbs/turn.js +88 -45
- package/dist/verbs/use.js +20 -9
- package/package.json +1 -1
- package/skills/{pb-build → build}/SKILL.md +25 -25
- package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
- package/skills/{pb-finish → finish}/SKILL.md +3 -3
- package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
- package/skills/{pb-park → park}/SKILL.md +2 -2
- package/skills/{pb-plan → plan}/SKILL.md +11 -11
- package/skills/{pb-refine → refine}/SKILL.md +7 -7
- package/skills/{pb-revert → revert}/SKILL.md +2 -2
- package/skills/{pb-spike → spike}/SKILL.md +4 -4
- package/skills/{pb-status → status}/SKILL.md +3 -3
- package/skills/{pb-step → step}/SKILL.md +9 -9
- package/skills/{pb-verify → verify}/SKILL.md +8 -8
- package/templates/build-log.md +7 -7
- package/templates/intent.md +8 -8
- package/templates/spike-report.md +1 -1
package/dist/lib/sidecar.js
CHANGED
|
@@ -1,50 +1,73 @@
|
|
|
1
|
-
// The .plumbbob/ sidecar
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The .plumbbob/ sidecar — plumbbob's control directory beside the code. Control
|
|
2
|
+
// state lives in flat files (bare numbers, plain path lists, one-line markers) so
|
|
3
|
+
// shell hooks can read it with a grep and no markdown parsing. Functional and
|
|
4
|
+
// procedural, node builtins only.
|
|
4
5
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// Two planes share the directory: the tracked ARTIFACT plane (settings.json and
|
|
7
|
+
// each build's `builds/<slug>/` folder — intent, build-log, checkpoints, report),
|
|
8
|
+
// which rides its branch into the PR, and the git-excluded CONTROL plane (STATE,
|
|
9
|
+
// the personal settings overlay, the turn ledger, and each build's in-flight
|
|
10
|
+
// markers), which stays per-worktree and never commits.
|
|
11
|
+
//
|
|
12
|
+
// STATE is the session sentinel AND the active-build cursor: its EXISTENCE means
|
|
13
|
+
// "a session is active", and its CONTENT names the build that session is on — the
|
|
14
|
+
// two reinforce each other (a session is always on some build; the cursor is
|
|
8
15
|
// meaningless without a session). Content is empty under --local / no build.
|
|
9
|
-
// hasSession stays existence-only; activeBuild reads the content. Homing the
|
|
10
|
-
// here — not in settings.local.json — keeps that overlay purely
|
|
11
|
-
// (check/auto): the tool only ever reads it, never rewrites it. The
|
|
12
|
-
// dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored — BUILD ⇔
|
|
13
|
-
// in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
|
|
16
|
+
// hasSession stays existence-only; activeBuild reads the content. Homing the
|
|
17
|
+
// cursor here — not in settings.local.json — keeps that overlay purely
|
|
18
|
+
// human-owned (check/auto): the tool only ever reads it, never rewrites it. The
|
|
19
|
+
// phase the dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored — BUILD ⇔
|
|
20
|
+
// a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
|
|
14
21
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
15
22
|
import { dirname, join } from 'node:path';
|
|
16
23
|
import { gitPath } from "./git.js";
|
|
17
|
-
const DIRNAME = '.plumbbob';
|
|
24
|
+
const DIRNAME = '.plumbbob'; // the sidecar folder name at the repo root
|
|
25
|
+
/**
|
|
26
|
+
* The sidecar root: `<root>/.plumbbob`.
|
|
27
|
+
*/
|
|
18
28
|
export function sidecarDir(root) {
|
|
19
29
|
return join(root, DIRNAME);
|
|
20
30
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
31
|
+
/**
|
|
32
|
+
* The tracked artifact plane's root: `.plumbbob/builds/`, one folder per build.
|
|
33
|
+
*
|
|
34
|
+
* Each build owns a self-contained `builds/<slug>/` folder that rides its branch
|
|
35
|
+
* into the PR; intent.md, build-log.md, checkpoints, and report.md live inside it
|
|
36
|
+
* (the in-flight STEP/SEAM/SPIKE markers do too, but stay git-excluded).
|
|
37
|
+
*/
|
|
25
38
|
function buildsDir(root) {
|
|
26
39
|
return join(root, DIRNAME, 'builds');
|
|
27
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* One build's folder: `.plumbbob/builds/<slug>`.
|
|
43
|
+
*/
|
|
28
44
|
export function buildDir(root, slug) {
|
|
29
45
|
return join(buildsDir(root), slug);
|
|
30
46
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Derive a filesystem-safe slug from a build title.
|
|
49
|
+
*
|
|
50
|
+
* Lowercased, every run of non-alphanumerics collapsed to a single hyphen,
|
|
51
|
+
* trimmed of leading/trailing hyphens. The CLI stays dumb and explicit —
|
|
52
|
+
* collision handling belongs to the caller (`start` refuses an existing slug
|
|
53
|
+
* rather than silently suffixing `-2`, and prepends the `YYYY-MM-DD-` date that
|
|
54
|
+
* keeps `listBuilds`' lexical sort chronological). A title with no alphanumerics
|
|
55
|
+
* yields `''`, which the caller must reject or override.
|
|
56
|
+
*/
|
|
37
57
|
export function slugify(title) {
|
|
38
58
|
return title
|
|
39
59
|
.toLowerCase()
|
|
40
60
|
.replace(/[^a-z0-9]+/g, '-')
|
|
41
61
|
.replace(/^-+|-+$/g, '');
|
|
42
62
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
63
|
+
/**
|
|
64
|
+
* The Conventional-Commit scope for a build: its slug with the `YYYY-MM-DD-`
|
|
65
|
+
* date prefix `start` prepends stripped off.
|
|
66
|
+
*
|
|
67
|
+
* `2026-07-18-escape-hatch` yields the human-meaningful `escape-hatch`. Null
|
|
68
|
+
* when no build resolves (`--local`, or a slug that is nothing but a date) — the
|
|
69
|
+
* caller then omits the `(scope)` segment, which Conventional Commits permit.
|
|
70
|
+
*/
|
|
48
71
|
export function buildScope(slug) {
|
|
49
72
|
if (slug === null) {
|
|
50
73
|
return null;
|
|
@@ -52,8 +75,12 @@ export function buildScope(slug) {
|
|
|
52
75
|
const stripped = slug.replace(/^\d{4}-\d{2}-\d{2}-/, '');
|
|
53
76
|
return stripped.length > 0 ? stripped : null;
|
|
54
77
|
}
|
|
55
|
-
|
|
56
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Existing build slugs — the directory names under `builds/`, sorted.
|
|
80
|
+
*
|
|
81
|
+
* Empty when `builds/` is absent (a `--local` repo, or before the first tracked
|
|
82
|
+
* `start`).
|
|
83
|
+
*/
|
|
57
84
|
export function listBuilds(root) {
|
|
58
85
|
try {
|
|
59
86
|
return readdirSync(buildsDir(root), { withFileTypes: true })
|
|
@@ -65,10 +92,14 @@ export function listBuilds(root) {
|
|
|
65
92
|
return [];
|
|
66
93
|
}
|
|
67
94
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Resolve which build a verb acts on: an explicit `--build <slug>` flag → the
|
|
97
|
+
* cursor in STATE → the sole build in `builds/` → null (the caller then refuses
|
|
98
|
+
* with a hint).
|
|
99
|
+
*
|
|
100
|
+
* One-active-per-worktree holds by construction: the cursor is STATE's
|
|
101
|
+
* single-line content, so it can never point at two builds.
|
|
102
|
+
*/
|
|
72
103
|
export function activeBuild(root, flag) {
|
|
73
104
|
if (flag !== undefined && flag.length > 0)
|
|
74
105
|
return flag;
|
|
@@ -78,13 +109,16 @@ export function activeBuild(root, flag) {
|
|
|
78
109
|
const builds = listBuilds(root);
|
|
79
110
|
return builds.length === 1 ? (builds[0] ?? null) : null;
|
|
80
111
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
/**
|
|
113
|
+
* STATE's content — the active-build cursor — or null when it names no build.
|
|
114
|
+
*
|
|
115
|
+
* Empty means "no build" (--local, or a session whose cursor is unset), so
|
|
116
|
+
* activeBuild falls through to the sole-build rule. The literal value `active`
|
|
117
|
+
* is reserved and reads as unset: a legacy STATE layout wrote it as a bare
|
|
118
|
+
* sentinel (the cursor lived elsewhere then), and treating it as a cursor would
|
|
119
|
+
* send the tool chasing a build named "active". No derived slug collides with it
|
|
120
|
+
* (they carry a YYYY-MM-DD- prefix).
|
|
121
|
+
*/
|
|
88
122
|
function readCursor(root) {
|
|
89
123
|
let content;
|
|
90
124
|
try {
|
|
@@ -95,75 +129,113 @@ function readCursor(root) {
|
|
|
95
129
|
}
|
|
96
130
|
return content.length > 0 && content !== 'active' ? content : null;
|
|
97
131
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The build a verb should act on, plus its argv with the `--build <slug>` pair
|
|
134
|
+
* stripped.
|
|
135
|
+
*
|
|
136
|
+
* Every verb resolves through this: an explicit `--build <slug>` wins, else the
|
|
137
|
+
* cursor / sole-build fallback of `activeBuild`. `rest` matters because the slug
|
|
138
|
+
* is a bare token — scanning positionals on the raw argv would let it masquerade
|
|
139
|
+
* as a step number or a spike slug, so callers scan `rest` instead.
|
|
140
|
+
*/
|
|
103
141
|
export function resolveBuild(root, args) {
|
|
104
142
|
const i = args.indexOf('--build');
|
|
105
143
|
if (i === -1)
|
|
106
144
|
return { build: activeBuild(root), rest: args };
|
|
107
145
|
return { build: activeBuild(root, args[i + 1]), rest: [...args.slice(0, i), ...args.slice(i + 2)] };
|
|
108
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* The STATE file — session sentinel and active-build cursor in one.
|
|
149
|
+
*/
|
|
109
150
|
function statePath(root) {
|
|
110
151
|
return join(root, DIRNAME, 'STATE');
|
|
111
152
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
153
|
+
/**
|
|
154
|
+
* Where a build's artifacts and in-flight markers live: the `builds/<slug>/`
|
|
155
|
+
* folder for the resolved build, else the flat sidecar root.
|
|
156
|
+
*
|
|
157
|
+
* `slug` is the value the verb already resolved via `resolveBuild`/`activeBuild`;
|
|
158
|
+
* omit it and the dir resolves from the cursor (the default the
|
|
159
|
+
* executor-agnostic path reads lean on). Either way a `null` slug falls back to
|
|
160
|
+
* the flat sidecar root, which covers the `--local` layout and any
|
|
161
|
+
* no-cursor/no-build repo — the path reads stay stable even before a tracked
|
|
162
|
+
* build exists or when a "no active session" guard is about to fire.
|
|
163
|
+
*/
|
|
120
164
|
function artifactDir(root, slug) {
|
|
121
165
|
const resolved = slug === undefined ? activeBuild(root) : slug;
|
|
122
166
|
return resolved === null ? sidecarDir(root) : buildDir(root, resolved);
|
|
123
167
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
168
|
+
/**
|
|
169
|
+
* The resolved build's artifact folder — the `builds/<slug>/` dir for the active
|
|
170
|
+
* build (or the flat sidecar root under `--local`/no-cursor).
|
|
171
|
+
*
|
|
172
|
+
* Public so the plan-approval commit can stage exactly this build's scaffold and
|
|
173
|
+
* nothing else; `slug` follows the same resolution as the path helpers above.
|
|
174
|
+
*/
|
|
128
175
|
export function buildFolder(root, slug) {
|
|
129
176
|
return artifactDir(root, slug);
|
|
130
177
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
178
|
+
/**
|
|
179
|
+
* The SPIKE marker — a single-purpose presence flag, like SEAM/STEP.
|
|
180
|
+
*
|
|
181
|
+
* Written by `spike` on open, removed on `spike done`. Its existence is the one
|
|
182
|
+
* signal the dashboard and the spike gates read to know "a spike is active".
|
|
183
|
+
*/
|
|
134
184
|
export function spikePath(root, slug) {
|
|
135
185
|
return join(artifactDir(root, slug), 'SPIKE');
|
|
136
186
|
}
|
|
137
|
-
|
|
138
|
-
|
|
187
|
+
/**
|
|
188
|
+
* SEAM — the in-flight step's edit grant: a plain list of the paths the step may
|
|
189
|
+
* touch, one per line, so the hooks read it with a grep and no markdown parsing.
|
|
190
|
+
*/
|
|
139
191
|
export function seamPath(root, slug) {
|
|
140
192
|
return join(artifactDir(root, slug), 'SEAM');
|
|
141
193
|
}
|
|
194
|
+
/**
|
|
195
|
+
* STEP — the number of the step in flight, a bare number in a flat file.
|
|
196
|
+
*/
|
|
142
197
|
export function stepPath(root, slug) {
|
|
143
198
|
return join(artifactDir(root, slug), 'STEP');
|
|
144
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* The build's `checkpoints` ledger — the recorded SHAs of landed plan and step
|
|
202
|
+
* commits, one line each.
|
|
203
|
+
*/
|
|
145
204
|
export function checkpointsPath(root, slug) {
|
|
146
205
|
return join(artifactDir(root, slug), 'checkpoints');
|
|
147
206
|
}
|
|
207
|
+
/**
|
|
208
|
+
* intent.md — the build's plan: steps, seams, decisions, done-when.
|
|
209
|
+
*/
|
|
148
210
|
export function intentPath(root, slug) {
|
|
149
211
|
return join(artifactDir(root, slug), 'intent.md');
|
|
150
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* build-log.md — the human-facing build ledger (the Steps mirror and the Log).
|
|
215
|
+
*/
|
|
151
216
|
export function buildLogPath(root, slug) {
|
|
152
217
|
return join(artifactDir(root, slug), 'build-log.md');
|
|
153
218
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
219
|
+
/**
|
|
220
|
+
* report.md — the close-out report, beside intent.md / build-log.md.
|
|
221
|
+
*
|
|
222
|
+
* The finish skill writes it and `finish` commits it with the folder; the
|
|
223
|
+
* tracked build folder is the archive, so the report rides the branch into the
|
|
224
|
+
* PR rather than living in some local-only copy.
|
|
225
|
+
*/
|
|
158
226
|
export function reportPath(root, slug) {
|
|
159
227
|
return join(artifactDir(root, slug), 'report.md');
|
|
160
228
|
}
|
|
161
|
-
// Spike reports
|
|
162
|
-
// One per spike — an explicit `/
|
|
163
|
-
// several forks tells its decision history through the folder
|
|
164
|
-
// CLI-allocated (the human never numbers them), zero-padded for a
|
|
165
|
-
//
|
|
166
|
-
|
|
229
|
+
// --- Spike reports: `spike-NN-<slug>.md` files in the build folder, beside
|
|
230
|
+
// report.md. One per spike — an explicit `/plumbbob:spike` or a planned `spike:` step —
|
|
231
|
+
// so a build with several forks tells its decision history through the folder
|
|
232
|
+
// listing. NN is CLI-allocated (the human never numbers them), zero-padded for a
|
|
233
|
+
// stable sort. ---
|
|
234
|
+
/**
|
|
235
|
+
* The existing spike-report filenames in the build folder, sorted.
|
|
236
|
+
*
|
|
237
|
+
* Empty when the folder is absent or holds none.
|
|
238
|
+
*/
|
|
167
239
|
export function listSpikeReports(root, slug) {
|
|
168
240
|
try {
|
|
169
241
|
return readdirSync(artifactDir(root, slug))
|
|
@@ -174,27 +246,39 @@ export function listSpikeReports(root, slug) {
|
|
|
174
246
|
return [];
|
|
175
247
|
}
|
|
176
248
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
249
|
+
/**
|
|
250
|
+
* The path for a NEW spike report: the next free zero-padded index and the
|
|
251
|
+
* sanitized spike slug.
|
|
252
|
+
*
|
|
253
|
+
* One past the highest existing NN, so a gap never reallocates a number. Pure
|
|
254
|
+
* allocation — the caller writes the stamped template there.
|
|
255
|
+
*/
|
|
180
256
|
export function nextSpikeReportPath(root, slug, spikeSlug) {
|
|
181
257
|
const used = listSpikeReports(root, slug).map((name) => Number(name.slice('spike-'.length, 'spike-'.length + 2)));
|
|
182
258
|
const next = (used.length > 0 ? Math.max(...used) : 0) + 1;
|
|
183
259
|
const nn = String(next).padStart(2, '0');
|
|
184
260
|
return join(artifactDir(root, slug), `spike-${nn}-${spikeSlug}.md`);
|
|
185
261
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
262
|
+
/**
|
|
263
|
+
* handoff.json — the agent-run ledger.
|
|
264
|
+
*
|
|
265
|
+
* Each `agent run` appends its validated envelope here so a later invocation can
|
|
266
|
+
* thread earlier results into the next call's `context[]` — the CLI itself is
|
|
267
|
+
* memoryless between runs, and the file survives context-window compaction where
|
|
268
|
+
* inline stdout does not. It is in-flight control state like STEP/SEAM:
|
|
269
|
+
* untracked (excludeControl), scoped to the current step, cleared when the step
|
|
270
|
+
* checkpoints (`clearHandoff`).
|
|
271
|
+
*/
|
|
192
272
|
export function handoffPath(root, slug) {
|
|
193
273
|
return join(artifactDir(root, slug), 'handoff.json');
|
|
194
274
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
275
|
+
/**
|
|
276
|
+
* Append one entry to the build's handoff.json.
|
|
277
|
+
*
|
|
278
|
+
* Creates the file (as a JSON array) when absent and tolerates a malformed
|
|
279
|
+
* existing file by starting fresh — a corrupt ledger must never wedge a run.
|
|
280
|
+
* Pretty-printed so it stays readable.
|
|
281
|
+
*/
|
|
198
282
|
export function appendHandoff(root, slug, entry) {
|
|
199
283
|
const path = handoffPath(root, slug);
|
|
200
284
|
let entries = [];
|
|
@@ -210,16 +294,25 @@ export function appendHandoff(root, slug, entry) {
|
|
|
210
294
|
mkdirSync(dirname(path), { recursive: true });
|
|
211
295
|
writeFileSync(path, `${JSON.stringify(entries, null, 2)}\n`);
|
|
212
296
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Remove the build's handoff.json — the step-scoped ledger clears when the step
|
|
299
|
+
* checkpoints, the same beat that clears STEP/SEAM.
|
|
300
|
+
*
|
|
301
|
+
* An absent file is a no-op (`force`).
|
|
302
|
+
*/
|
|
216
303
|
export function clearHandoff(root, slug) {
|
|
217
304
|
rmSync(handoffPath(root, slug), { force: true });
|
|
218
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* stats.json — the per-build stats receipt, beside checkpoints.
|
|
308
|
+
*/
|
|
219
309
|
export function statsPath(root, slug) {
|
|
220
310
|
return join(artifactDir(root, slug), 'stats.json');
|
|
221
311
|
}
|
|
222
|
-
|
|
312
|
+
/**
|
|
313
|
+
* The whole stats file, or {} when absent or corrupt — malformed contributes
|
|
314
|
+
* nothing.
|
|
315
|
+
*/
|
|
223
316
|
export function readStats(root, slug) {
|
|
224
317
|
try {
|
|
225
318
|
const parsed = JSON.parse(readFileSync(statsPath(root, slug), 'utf8'));
|
|
@@ -229,16 +322,30 @@ export function readStats(root, slug) {
|
|
|
229
322
|
return {};
|
|
230
323
|
}
|
|
231
324
|
}
|
|
232
|
-
|
|
233
|
-
|
|
325
|
+
/**
|
|
326
|
+
* Increment one counter on one step.
|
|
327
|
+
*
|
|
328
|
+
* Best-effort by contract: a failed write is swallowed — the stats are a
|
|
329
|
+
* receipt, never a gate on the verb that accrues them.
|
|
330
|
+
*/
|
|
234
331
|
export function bumpStepStat(root, slug, step, key) {
|
|
235
332
|
patchStepStat(root, slug, step, (current) => ({ [key]: (current[key] ?? 0) + 1 }));
|
|
236
333
|
}
|
|
237
|
-
|
|
238
|
-
|
|
334
|
+
/**
|
|
335
|
+
* Stamp one timestamp on one step — startedAt at `build <n>`, landedAt at
|
|
336
|
+
* checkpoint.
|
|
337
|
+
*
|
|
338
|
+
* Same best-effort contract as bumpStepStat.
|
|
339
|
+
*/
|
|
239
340
|
export function stampStepStat(root, slug, step, key, value) {
|
|
240
341
|
patchStepStat(root, slug, step, () => ({ [key]: value }));
|
|
241
342
|
}
|
|
343
|
+
/**
|
|
344
|
+
* Read-modify-write one step's stats entry, swallowing any failure.
|
|
345
|
+
*
|
|
346
|
+
* The catch is deliberate: the stats are a receipt, and a failed write must
|
|
347
|
+
* never wedge the build/checkpoint/revert verb that accrued it.
|
|
348
|
+
*/
|
|
242
349
|
function patchStepStat(root, slug, step, patch) {
|
|
243
350
|
try {
|
|
244
351
|
const stats = readStats(root, slug);
|
|
@@ -249,57 +356,92 @@ function patchStepStat(root, slug, step, patch) {
|
|
|
249
356
|
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
250
357
|
}
|
|
251
358
|
catch {
|
|
252
|
-
// Best-effort receipt
|
|
359
|
+
// Best-effort receipt: never wedge build/checkpoint/revert over it.
|
|
253
360
|
}
|
|
254
361
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
362
|
+
/**
|
|
363
|
+
* A session exists iff STATE exists.
|
|
364
|
+
*
|
|
365
|
+
* Deleting STATE (at finish) is what flips the repo back to "no session" — the
|
|
366
|
+
* file is the single source of truth for "is there a session". `start` calls
|
|
367
|
+
* beginSession; `finish` removes the file. Existence is the whole session
|
|
368
|
+
* signal; STATE's content is a separate axis (the cursor).
|
|
369
|
+
*/
|
|
259
370
|
export function hasSession(root) {
|
|
260
371
|
return existsSync(statePath(root));
|
|
261
372
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
373
|
+
/**
|
|
374
|
+
* Open the session and point its cursor at `slug`.
|
|
375
|
+
*
|
|
376
|
+
* Null under --local / no build, which leaves STATE present but empty so
|
|
377
|
+
* activeBuild falls through to the sole-build rule. `finish` removes the file,
|
|
378
|
+
* closing the session and clearing the cursor in one delete.
|
|
379
|
+
*/
|
|
266
380
|
export function beginSession(root, slug = null) {
|
|
267
381
|
writeCursor(root, slug);
|
|
268
382
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Re-point the cursor at an existing build (`use`) — a plain content rewrite
|
|
385
|
+
* that leaves the session sentinel (STATE's existence) intact.
|
|
386
|
+
*
|
|
387
|
+
* Callers guard hasSession first, so this never resurrects a finished session.
|
|
388
|
+
*/
|
|
272
389
|
export function setActiveBuild(root, slug) {
|
|
273
390
|
writeCursor(root, slug);
|
|
274
391
|
}
|
|
392
|
+
/**
|
|
393
|
+
* Write STATE's content: empty for no build, else the slug plus newline.
|
|
394
|
+
*/
|
|
275
395
|
function writeCursor(root, slug) {
|
|
276
396
|
writeFileSync(statePath(root), slug === null ? '' : `${slug}\n`);
|
|
277
397
|
}
|
|
278
|
-
|
|
398
|
+
/**
|
|
399
|
+
* True while a spike is open — the SPIKE marker exists (content is irrelevant).
|
|
400
|
+
*/
|
|
279
401
|
export function inSpike(root, slug) {
|
|
280
402
|
return existsSync(spikePath(root, slug));
|
|
281
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Drop the SPIKE marker — existence is the whole signal.
|
|
406
|
+
*/
|
|
282
407
|
export function markSpike(root, slug) {
|
|
283
408
|
writeFileSync(spikePath(root, slug), 'active\n');
|
|
284
409
|
}
|
|
410
|
+
/**
|
|
411
|
+
* Remove the SPIKE marker (`spike done`); absent is a no-op.
|
|
412
|
+
*/
|
|
285
413
|
export function clearSpike(root, slug) {
|
|
286
414
|
rmSync(spikePath(root, slug), { force: true });
|
|
287
415
|
}
|
|
288
|
-
// --- The turn ledger
|
|
289
|
-
//
|
|
290
|
-
// (never per-build, never committed).
|
|
291
|
-
// ticks TURN once per human prompt
|
|
292
|
-
//
|
|
293
|
-
//
|
|
416
|
+
// --- The turn ledger: the checkpoint latch's inputs. `.plumbbob/TURN` is a count
|
|
417
|
+
// of human prompts the model never writes, with a one-turn `.plumbbob/GRANT`
|
|
418
|
+
// beside it — both flat, per-worktree control (never per-build, never committed).
|
|
419
|
+
// `plumbbob turn` (the UserPromptSubmit hook) ticks TURN once per human prompt
|
|
420
|
+
// and rewrites GRANT from the literal prompt; the checkpoint latch reads them to
|
|
421
|
+
// refuse a land until a human turn has intervened since the step was entered.
|
|
422
|
+
// Kept in the sidecar so the lone GRANT `rmSync` stays where deletions of control
|
|
423
|
+
// state are allowed. ---
|
|
424
|
+
/**
|
|
425
|
+
* TURN — the human-turn count the UserPromptSubmit hook maintains.
|
|
426
|
+
*/
|
|
294
427
|
export function turnPath(root) {
|
|
295
428
|
return join(root, DIRNAME, 'TURN');
|
|
296
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* GRANT — the one-turn self-approval minted from the human's literal prompt.
|
|
432
|
+
*/
|
|
297
433
|
export function grantPath(root) {
|
|
298
434
|
return join(root, DIRNAME, 'GRANT');
|
|
299
435
|
}
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
|
|
438
|
+
* (`auto` | `range M`), null clears it.
|
|
439
|
+
*
|
|
440
|
+
* The turn hook calls this on every human prompt — minted on a match, cleared
|
|
441
|
+
* otherwise — so a grant lives exactly one turn by construction. Pairing the
|
|
442
|
+
* write with its delete here means a grant can never half-persist, and the
|
|
443
|
+
* delete lives where deletions belong.
|
|
444
|
+
*/
|
|
303
445
|
export function setGrant(root, grant) {
|
|
304
446
|
if (grant === null) {
|
|
305
447
|
rmSync(grantPath(root), { force: true });
|
|
@@ -308,31 +450,44 @@ export function setGrant(root, grant) {
|
|
|
308
450
|
writeFileSync(grantPath(root), `${grant}\n`);
|
|
309
451
|
}
|
|
310
452
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
453
|
+
/**
|
|
454
|
+
* TICK — the per-build entry stamp: the TURN value recorded when work was
|
|
455
|
+
* entered (`build <n>` for a step, `start` for the plan), cleared when
|
|
456
|
+
* `checkpoint` lands.
|
|
457
|
+
*
|
|
458
|
+
* The checkpoint latch compares TURN against it to know whether a human turn
|
|
459
|
+
* intervened across the entry→checkpoint span.
|
|
460
|
+
*/
|
|
315
461
|
export function tickPath(root, slug) {
|
|
316
462
|
return join(artifactDir(root, slug), 'TICK');
|
|
317
463
|
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Stamp TICK with the current TURN.
|
|
466
|
+
*
|
|
467
|
+
* Stamped only when TURN holds a readable count: a host with no hooks never
|
|
468
|
+
* grows a ledger, so the latch stays dormant there instead of wedging.
|
|
469
|
+
*/
|
|
321
470
|
export function stampTick(root, slug) {
|
|
322
471
|
const turn = readTurn(root);
|
|
323
472
|
if (turn === null)
|
|
324
473
|
return;
|
|
325
474
|
writeFileSync(tickPath(root, slug), `${turn}\n`);
|
|
326
475
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Consume the entry stamp — `checkpoint` clears it when a step (or the plan)
|
|
478
|
+
* lands, the same beat that clears STEP/SEAM/handoff.
|
|
479
|
+
*
|
|
480
|
+
* Absent is a no-op; the rmSync lives here with the sidecar's other deletions.
|
|
481
|
+
*/
|
|
330
482
|
export function clearTick(root, slug) {
|
|
331
483
|
rmSync(tickPath(root, slug), { force: true });
|
|
332
484
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
485
|
+
/**
|
|
486
|
+
* The TURN count, or null when the ledger is absent or unreadable.
|
|
487
|
+
*
|
|
488
|
+
* Absence is the "dormant" signal stampTick keys off and the doctor latch probe
|
|
489
|
+
* reports — never an error.
|
|
490
|
+
*/
|
|
336
491
|
export function readTurn(root) {
|
|
337
492
|
try {
|
|
338
493
|
const n = Number.parseInt(readFileSync(turnPath(root), 'utf8').trim(), 10);
|
|
@@ -342,11 +497,14 @@ export function readTurn(root) {
|
|
|
342
497
|
return null;
|
|
343
498
|
}
|
|
344
499
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
500
|
+
/**
|
|
501
|
+
* Walk up from `cwd` to the nearest ancestor with an active session (a
|
|
502
|
+
* `.plumbbob/STATE` file), or null when there is none.
|
|
503
|
+
*
|
|
504
|
+
* The turn hook runs on every human prompt in every repo; this is the cheap,
|
|
505
|
+
* git-free probe that keeps it a silent no-op outside a live plumbbob session —
|
|
506
|
+
* filesystem only, never a host sniff.
|
|
507
|
+
*/
|
|
350
508
|
export function findSessionRoot(cwd) {
|
|
351
509
|
let dir = cwd;
|
|
352
510
|
let parent = dirname(dir);
|
|
@@ -358,10 +516,15 @@ export function findSessionRoot(cwd) {
|
|
|
358
516
|
}
|
|
359
517
|
return hasSession(dir) ? dir : null;
|
|
360
518
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
519
|
+
/**
|
|
520
|
+
* Append `patterns` to the repo's info/exclude, each at most once.
|
|
521
|
+
*
|
|
522
|
+
* Idempotent — a re-`start` after finish must not double-add. `gitPath` resolves
|
|
523
|
+
* to the *common* gitdir's exclude — the only one git reads — so this works from
|
|
524
|
+
* a linked worktree, whose per-worktree gitdir has no `info/`. info/exclude
|
|
525
|
+
* keeps the exclusion personal machinery, never something imposed on the repo's
|
|
526
|
+
* own tracked ignore file.
|
|
527
|
+
*/
|
|
365
528
|
function addExcludes(root, patterns) {
|
|
366
529
|
const exclude = gitPath(root, 'info/exclude');
|
|
367
530
|
mkdirSync(dirname(exclude), { recursive: true });
|
|
@@ -379,37 +542,80 @@ function addExcludes(root, patterns) {
|
|
|
379
542
|
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
380
543
|
appendFileSync(exclude, `${prefix}${missing.join('\n')}\n`);
|
|
381
544
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
545
|
+
/**
|
|
546
|
+
* Git-exclude the control plane while the artifact plane stays tracked.
|
|
547
|
+
*
|
|
548
|
+
* With `builds/<slug>/` tracked, only the per-worktree control files are
|
|
549
|
+
* excluded — the session sentinel STATE (whose content is the active-build
|
|
550
|
+
* cursor), the personal settings overlay, the turn ledger, and the in-flight
|
|
551
|
+
* markers inside every build. Everything else under `.plumbbob/` (settings.json,
|
|
552
|
+
* and each build's intent/build-log/checkpoints/report) rides the branch into
|
|
553
|
+
* the PR.
|
|
554
|
+
*/
|
|
388
555
|
export function excludeControl(root) {
|
|
389
556
|
addExcludes(root, [
|
|
390
557
|
`${DIRNAME}/STATE`,
|
|
391
558
|
`${DIRNAME}/settings.local.json`,
|
|
392
|
-
// The turn ledger and its one-turn grant
|
|
393
|
-
//
|
|
559
|
+
// The turn ledger and its one-turn grant: per-worktree control the model
|
|
560
|
+
// never writes, and never commits.
|
|
394
561
|
`${DIRNAME}/TURN`,
|
|
395
562
|
`${DIRNAME}/GRANT`,
|
|
396
563
|
`${DIRNAME}/builds/*/STEP`,
|
|
397
564
|
`${DIRNAME}/builds/*/SEAM`,
|
|
398
565
|
`${DIRNAME}/builds/*/SPIKE`,
|
|
399
|
-
// The entry stamp
|
|
566
|
+
// The entry stamp is in-flight control like STEP/SEAM — checkpoint's
|
|
400
567
|
// stageAll must never sweep it into a step commit.
|
|
401
568
|
`${DIRNAME}/builds/*/TICK`,
|
|
402
|
-
// The agent-run handoff ledger
|
|
569
|
+
// The agent-run handoff ledger is step-scoped in-flight state, not a
|
|
403
570
|
// tracked artifact — it must never ride a step commit into the PR.
|
|
404
571
|
`${DIRNAME}/builds/*/handoff.json`,
|
|
405
|
-
// The checkride gate
|
|
572
|
+
// The checkride gate writes raw tool output to `.check/`; checkpoint's
|
|
406
573
|
// stageAll must never sweep it into a step commit.
|
|
407
574
|
'.check/',
|
|
408
575
|
]);
|
|
409
576
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
577
|
+
/**
|
|
578
|
+
* `start --local`: opt out of the tracked layout into a fully-untracked sidecar —
|
|
579
|
+
* some team repos won't accept tool folders in-tree.
|
|
580
|
+
*
|
|
581
|
+
* Excludes the whole `.plumbbob/` directory.
|
|
582
|
+
*/
|
|
413
583
|
export function excludeSidecar(root) {
|
|
414
584
|
addExcludes(root, [`${DIRNAME}/`, '.check/']);
|
|
415
585
|
}
|
|
586
|
+
/**
|
|
587
|
+
* Whether info/exclude already carries `pattern` verbatim.
|
|
588
|
+
*
|
|
589
|
+
* Absent or unreadable reads as "no" — the caller's job is to write it, not to
|
|
590
|
+
* fail on a repo that has never been excluded.
|
|
591
|
+
*/
|
|
592
|
+
function hasExclude(root, pattern) {
|
|
593
|
+
try {
|
|
594
|
+
return readFileSync(gitPath(root, 'info/exclude'), 'utf8')
|
|
595
|
+
.split('\n')
|
|
596
|
+
.some((line) => line.trim() === pattern);
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return false;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Re-apply the control-plane excludes on the way into a `git add -A`.
|
|
604
|
+
*
|
|
605
|
+
* `start` writes them once, but a plumbbob upgraded mid-build can add a control
|
|
606
|
+
* file the running session's exclude never learned — TICK, GRANT and
|
|
607
|
+
* handoff.json all arrived that way — and checkpoint/finish stage with `-A`,
|
|
608
|
+
* which would sweep the unexcluded file into the commit and ride it into the PR.
|
|
609
|
+
* Idempotent, so the common path is one read and no write.
|
|
610
|
+
*
|
|
611
|
+
* A fully-untracked sidecar is already covered by its blanket `.plumbbob/` line;
|
|
612
|
+
* layering the narrow patterns underneath would only add noise, so skip it. That
|
|
613
|
+
* check reads info/exclude rather than the cursor because a null cursor means
|
|
614
|
+
* "`--local` OR no build named" — and calling excludeSidecar on the tracked
|
|
615
|
+
* layout would untrack the whole artifact plane.
|
|
616
|
+
*/
|
|
617
|
+
export function refreshExcludes(root) {
|
|
618
|
+
if (hasExclude(root, `${DIRNAME}/`))
|
|
619
|
+
return;
|
|
620
|
+
excludeControl(root);
|
|
621
|
+
}
|