plumbbob 0.6.6 → 0.8.3
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 +2 -2
- package/README.md +24 -7
- package/dist/cli-core.js +7 -2
- package/dist/lib/buildlog.js +5 -2
- package/dist/lib/check.js +33 -1
- package/dist/lib/git.js +17 -0
- package/dist/lib/latch.js +97 -0
- package/dist/lib/orient.js +24 -0
- package/dist/lib/settings.js +3 -9
- package/dist/lib/sidecar.js +169 -18
- package/dist/verbs/build.js +5 -1
- package/dist/verbs/checkpoint.js +51 -2
- package/dist/verbs/doctor.js +40 -5
- package/dist/verbs/finish.js +61 -11
- package/dist/verbs/revert.js +19 -1
- package/dist/verbs/start.js +38 -7
- package/dist/verbs/status.js +10 -3
- package/dist/verbs/turn.js +106 -0
- package/dist/verbs/use.js +4 -5
- package/hooks/hooks.json +15 -0
- package/hooks/post-edit.sh +7 -7
- package/hooks/pre-bash-commit.sh +68 -0
- package/node_modules/checkride/CHANGELOG.md +111 -0
- package/node_modules/checkride/README.md +185 -20
- package/node_modules/checkride/dist/adapters.d.ts +9 -4
- package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
- package/node_modules/checkride/dist/adapters.js +62 -2
- package/node_modules/checkride/dist/adapters.js.map +1 -1
- package/node_modules/checkride/dist/agent-setup/hook.d.ts +63 -0
- package/node_modules/checkride/dist/agent-setup/hook.d.ts.map +1 -0
- package/node_modules/checkride/dist/agent-setup/hook.js +92 -0
- package/node_modules/checkride/dist/agent-setup/hook.js.map +1 -0
- package/node_modules/checkride/dist/agent-setup/index.d.ts +8 -0
- package/node_modules/checkride/dist/agent-setup/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/agent-setup/index.js +7 -0
- package/node_modules/checkride/dist/agent-setup/index.js.map +1 -0
- package/node_modules/checkride/dist/atomic.d.ts +13 -0
- package/node_modules/checkride/dist/atomic.d.ts.map +1 -0
- package/node_modules/checkride/dist/atomic.js +24 -0
- package/node_modules/checkride/dist/atomic.js.map +1 -0
- package/node_modules/checkride/dist/baseline/command.d.ts +42 -0
- package/node_modules/checkride/dist/baseline/command.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/command.js +58 -0
- package/node_modules/checkride/dist/baseline/command.js.map +1 -0
- package/node_modules/checkride/dist/baseline/fingerprint.d.ts +41 -0
- package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/fingerprint.js +119 -0
- package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -0
- package/node_modules/checkride/dist/baseline/index.d.ts +13 -0
- package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/index.js +10 -0
- package/node_modules/checkride/dist/baseline/index.js.map +1 -0
- package/node_modules/checkride/dist/baseline/store.d.ts +67 -0
- package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/store.js +110 -0
- package/node_modules/checkride/dist/baseline/store.js.map +1 -0
- package/node_modules/checkride/dist/cli.d.ts.map +1 -1
- package/node_modules/checkride/dist/cli.js +37 -3
- package/node_modules/checkride/dist/cli.js.map +1 -1
- package/node_modules/checkride/dist/config.d.ts +43 -3
- package/node_modules/checkride/dist/config.d.ts.map +1 -1
- package/node_modules/checkride/dist/config.js +95 -22
- package/node_modules/checkride/dist/config.js.map +1 -1
- package/node_modules/checkride/dist/digest/digest.d.ts +52 -0
- package/node_modules/checkride/dist/digest/digest.d.ts.map +1 -0
- package/node_modules/checkride/dist/digest/digest.js +147 -0
- package/node_modules/checkride/dist/digest/digest.js.map +1 -0
- package/node_modules/checkride/dist/digest/index.d.ts +8 -0
- package/node_modules/checkride/dist/digest/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/digest/index.js +7 -0
- package/node_modules/checkride/dist/digest/index.js.map +1 -0
- package/node_modules/checkride/dist/doctor.d.ts +4 -0
- package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
- package/node_modules/checkride/dist/doctor.js +33 -10
- package/node_modules/checkride/dist/doctor.js.map +1 -1
- package/node_modules/checkride/dist/index.d.ts +1 -1
- package/node_modules/checkride/dist/index.d.ts.map +1 -1
- package/node_modules/checkride/dist/index.js +1 -1
- package/node_modules/checkride/dist/index.js.map +1 -1
- package/node_modules/checkride/dist/init.d.ts +38 -0
- package/node_modules/checkride/dist/init.d.ts.map +1 -1
- package/node_modules/checkride/dist/init.js +97 -8
- package/node_modules/checkride/dist/init.js.map +1 -1
- package/node_modules/checkride/dist/orchestrator.d.ts +58 -1
- package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
- package/node_modules/checkride/dist/orchestrator.js +155 -27
- package/node_modules/checkride/dist/orchestrator.js.map +1 -1
- package/node_modules/checkride/dist/pm/detect.d.ts +21 -0
- package/node_modules/checkride/dist/pm/detect.d.ts.map +1 -0
- package/node_modules/checkride/dist/pm/detect.js +60 -0
- package/node_modules/checkride/dist/pm/detect.js.map +1 -0
- package/node_modules/checkride/dist/pm/index.d.ts +10 -0
- package/node_modules/checkride/dist/pm/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/pm/index.js +9 -0
- package/node_modules/checkride/dist/pm/index.js.map +1 -0
- package/node_modules/checkride/dist/pm/translate.d.ts +28 -0
- package/node_modules/checkride/dist/pm/translate.d.ts.map +1 -0
- package/node_modules/checkride/dist/pm/translate.js +40 -0
- package/node_modules/checkride/dist/pm/translate.js.map +1 -0
- package/node_modules/checkride/package.json +20 -17
- package/node_modules/checkride/schema/checkride.config.schema.json +97 -0
- package/node_modules/checkride/schema/checkride.summary.schema.json +57 -0
- package/node_modules/checkride/templates/shared/cspell.json +1 -0
- package/node_modules/checkride/templates/shared/prettierrc.json +6 -0
- package/package.json +8 -4
- package/skills/pb-build/SKILL.md +31 -1
- package/skills/pb-finish/SKILL.md +2 -1
- package/skills/pb-plan/SKILL.md +17 -0
- package/skills/pb-verify/SKILL.md +32 -0
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// the same way.
|
|
10
10
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
11
|
import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
|
|
12
|
-
import { buildFolder, buildLogPath, checkpointsPath, clearHandoff, hasSession, intentPath, seamPath, stepPath, } from "../lib/sidecar.js";
|
|
12
|
+
import { buildFolder, buildLogPath, bumpStepStat, checkpointsPath, clearHandoff, clearTick, hasSession, intentPath, readStats, seamPath, stampStepStat, stepPath, } from "../lib/sidecar.js";
|
|
13
13
|
import { runCheck } from "../lib/check.js";
|
|
14
|
+
import { checkLatch } from "../lib/latch.js";
|
|
14
15
|
import { markStepDone, parseSteps, parseTitle } from "../lib/orient.js";
|
|
15
16
|
import { parseStepSeam, scopeDrift } from "../lib/intent.js";
|
|
16
17
|
import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
|
|
@@ -28,8 +29,21 @@ export async function checkpoint(cwd, args) {
|
|
|
28
29
|
process.stderr.write('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
|
|
29
30
|
return 1;
|
|
30
31
|
}
|
|
32
|
+
// The approval latch (D64) precedes the check gate (C4): the tick may not land
|
|
33
|
+
// without a human turn since entry, a grant, or a dormant ledger. The refusal
|
|
34
|
+
// message is the pause affordance — exit 1 and hand the turn back.
|
|
35
|
+
const latch = checkLatch(root, step);
|
|
36
|
+
if (!latch.allow) {
|
|
37
|
+
process.stderr.write(latch.message);
|
|
38
|
+
return 1;
|
|
39
|
+
}
|
|
31
40
|
const gate = await runCheck(root);
|
|
32
41
|
if (gate !== 0) {
|
|
42
|
+
if (gate === 1) {
|
|
43
|
+
// The dogfood receipt (research/07 Build 2b): red attempts before green
|
|
44
|
+
// are a per-step count. Harness breakage (2) is not a red check.
|
|
45
|
+
bumpStepStat(root, undefined, step, 'redChecks');
|
|
46
|
+
}
|
|
33
47
|
process.stderr.write(gate === 2
|
|
34
48
|
? 'plumbbob: the check gate itself broke — checkpoint refuses until the harness is fixed.\n'
|
|
35
49
|
: 'plumbbob: check failed (red) — checkpoint refuses on red. Fix it and re-run.\n');
|
|
@@ -47,10 +61,12 @@ export async function checkpoint(cwd, args) {
|
|
|
47
61
|
}
|
|
48
62
|
appendFileSync(checkpointsPath(root), `step ${step} ${sha}\n`);
|
|
49
63
|
flipIntent(root, step);
|
|
64
|
+
stampStepStat(root, undefined, step, 'landedAt', new Date().toISOString());
|
|
50
65
|
logCheckpoint(root, step, sha);
|
|
51
66
|
rmSync(seamPath(root), { force: true });
|
|
52
67
|
rmSync(stepPath(root), { force: true });
|
|
53
68
|
clearHandoff(root); // the agent-run ledger is step-scoped (D47) — clear it with the markers.
|
|
69
|
+
clearTick(root); // the entry stamp (D64) is spent — the next `build <n>` re-stamps.
|
|
54
70
|
process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
|
|
55
71
|
return 0;
|
|
56
72
|
}
|
|
@@ -62,9 +78,20 @@ export async function checkpoint(cwd, args) {
|
|
|
62
78
|
// An optional `--body` (stdin heredoc, D34) rides along; the folder is whitelisted
|
|
63
79
|
// artifact plane, so there is no scope-drift to warn about.
|
|
64
80
|
function checkpointPlan(root, args) {
|
|
81
|
+
// The latch (D64) covers the plan commit too, keyed on the TICK that `start`
|
|
82
|
+
// stamped: the plan pause is a pause. No step number — a range grant does not
|
|
83
|
+
// speak to a plan.
|
|
84
|
+
const latch = checkLatch(root, null);
|
|
85
|
+
if (!latch.allow) {
|
|
86
|
+
process.stderr.write(latch.message);
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
65
89
|
stagePath(root, buildFolder(root));
|
|
66
90
|
const sha = commit(root, planSubject(root), bodyArg(args) ?? undefined);
|
|
67
91
|
appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
|
|
92
|
+
// The plan landing consumes `start`'s entry stamp (D64): a later hand-built diff
|
|
93
|
+
// (no `build <n>`) must find no stale TICK and stay guidance-governed.
|
|
94
|
+
clearTick(root);
|
|
68
95
|
process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
|
|
69
96
|
return 0;
|
|
70
97
|
}
|
|
@@ -108,6 +135,7 @@ function warnScopeDrift(root, step) {
|
|
|
108
135
|
const seam = seamTokens(root, step);
|
|
109
136
|
const outside = scopeDrift(stagedPaths(root), seam);
|
|
110
137
|
if (outside.length > 0) {
|
|
138
|
+
bumpStepStat(root, undefined, step, 'driftWarnings'); // the dogfood receipt (research/07 2b)
|
|
111
139
|
process.stderr.write(`plumbbob: heads-up — staged paths outside step ${step}'s seam: ${outside.join(', ')}. ` +
|
|
112
140
|
`The checkpoint captures them; if that's real scope drift, the plan may need a \`/pb-step\` revision.\n`);
|
|
113
141
|
}
|
|
@@ -150,7 +178,7 @@ function logCheckpoint(root, step, sha) {
|
|
|
150
178
|
try {
|
|
151
179
|
const path = buildLogPath(root);
|
|
152
180
|
const date = new Date().toISOString().slice(0, 10);
|
|
153
|
-
const line = checkpointLogLine(date, step, sha, titleForStep(root, step));
|
|
181
|
+
const line = checkpointLogLine(date, step, sha, titleForStep(root, step), statsSuffix(root, step));
|
|
154
182
|
const updated = appendToSection(readFileSync(path, 'utf8'), 'Log', line);
|
|
155
183
|
if (updated !== null) {
|
|
156
184
|
writeFileSync(path, updated);
|
|
@@ -160,6 +188,27 @@ function logCheckpoint(root, step, sha) {
|
|
|
160
188
|
// best-effort ledger; never fail a checkpoint over the build-log.
|
|
161
189
|
}
|
|
162
190
|
}
|
|
191
|
+
// The compact receipt riding the Log line (research/07 Build 2b): only what
|
|
192
|
+
// accrued — a clean first-try step gets no suffix at all. Wall-clock needs both
|
|
193
|
+
// stamps (a hand-built step never ran `build <n>`, so it has no startedAt).
|
|
194
|
+
function statsSuffix(root, step) {
|
|
195
|
+
const stats = readStats(root)[String(step)];
|
|
196
|
+
if (stats === undefined)
|
|
197
|
+
return null;
|
|
198
|
+
const parts = [];
|
|
199
|
+
if ((stats.redChecks ?? 0) > 0)
|
|
200
|
+
parts.push(`${stats.redChecks} red`);
|
|
201
|
+
if ((stats.driftWarnings ?? 0) > 0)
|
|
202
|
+
parts.push(`${stats.driftWarnings} drift`);
|
|
203
|
+
if ((stats.reverts ?? 0) > 0)
|
|
204
|
+
parts.push(`${stats.reverts} revert${stats.reverts === 1 ? '' : 's'}`);
|
|
205
|
+
if (stats.startedAt !== undefined && stats.landedAt !== undefined) {
|
|
206
|
+
const ms = Date.parse(stats.landedAt) - Date.parse(stats.startedAt);
|
|
207
|
+
if (Number.isFinite(ms) && ms >= 0)
|
|
208
|
+
parts.push(ms < 60_000 ? '<1m' : `${Math.round(ms / 60_000)}m`);
|
|
209
|
+
}
|
|
210
|
+
return parts.length > 0 ? parts.join(', ') : null;
|
|
211
|
+
}
|
|
163
212
|
// The CLI-owned, deterministic commit subject: the step's title when intent.md still
|
|
164
213
|
// carries one, else the bare `plumbbob: step N done` fallback (D34 — the CLI owns the
|
|
165
214
|
// subject; a `-m` override or `--body` prose is a separate concern).
|
package/dist/verbs/doctor.js
CHANGED
|
@@ -28,10 +28,11 @@ import { dirname, isAbsolute, join } from 'node:path';
|
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
import { runDoctor } from 'checkride';
|
|
30
30
|
import { listAgents } from "../lib/agents.js";
|
|
31
|
+
import { gateDetectsTools } from "../lib/check.js";
|
|
31
32
|
import { marketplacePlumbbob } from "../lib/plugins.js";
|
|
32
33
|
import { findRepoRoot, gitPath, stagePath } from "../lib/git.js";
|
|
33
|
-
import { buildDir, excludeControl, listBuilds, sidecarDir, slugify } from "../lib/sidecar.js";
|
|
34
|
-
import { resolveString, settingsPath
|
|
34
|
+
import { buildDir, excludeControl, listBuilds, readTurn, setActiveBuild, sidecarDir, slugify } from "../lib/sidecar.js";
|
|
35
|
+
import { resolveString, settingsPath } from "../lib/settings.js";
|
|
35
36
|
// The plumbbob package's own skills/ dir (the canonical set), off this module's
|
|
36
37
|
// URL so it resolves the same from src/ (dev) and dist/ (published).
|
|
37
38
|
function packageDir(name) {
|
|
@@ -205,8 +206,12 @@ export function migrateSidecar(root) {
|
|
|
205
206
|
const configPath = join(dir, 'config');
|
|
206
207
|
if (existsSync(configPath)) {
|
|
207
208
|
if (!existsSync(settingsPath(root))) {
|
|
209
|
+
// Carry forward ONLY what the legacy config actually held — a `check` line,
|
|
210
|
+
// nothing else. No invented `auto` (the config never had one, it defaults to
|
|
211
|
+
// false, and it belongs in settings.local.json): migration yields exactly what
|
|
212
|
+
// a fresh `start` would, plus the check. An empty config → an empty `{}`.
|
|
208
213
|
const check = configCheck(configPath);
|
|
209
|
-
const settings = check === null ? {
|
|
214
|
+
const settings = check === null ? {} : { check };
|
|
210
215
|
writeFileSync(settingsPath(root), `${JSON.stringify(settings, null, 2)}\n`);
|
|
211
216
|
actions.push(check === null ? 'config → settings.json' : `config → settings.json (check: ${check})`);
|
|
212
217
|
}
|
|
@@ -219,7 +224,7 @@ export function migrateSidecar(root) {
|
|
|
219
224
|
const slug = uniqueSlug(slugify(titleFromIntent(flatIntent)), taken);
|
|
220
225
|
taken.add(slug);
|
|
221
226
|
moveInto(dir, buildDir(root, slug), ['intent.md', 'build-log.md', 'checkpoints', 'STEP', 'SEAM', 'SPIKE']);
|
|
222
|
-
|
|
227
|
+
setActiveBuild(root, slug);
|
|
223
228
|
actions.push(`active session → builds/${slug} (the cursor)`);
|
|
224
229
|
}
|
|
225
230
|
// archive/<slug> → builds/<slug>. These are "done" by simply not being the cursor.
|
|
@@ -384,6 +389,15 @@ async function gateReport(cwd) {
|
|
|
384
389
|
if (c.required && c.status !== 'ok')
|
|
385
390
|
failed += 1;
|
|
386
391
|
}
|
|
392
|
+
// The week-1 bounce, called out where the human can see it coming
|
|
393
|
+
// (research/07 Build 2a): no CODE checks detected means checkpoints are
|
|
394
|
+
// gated only by checkride's always-on repo checks — either a vacuous
|
|
395
|
+
// refusal or, worse, a green that tested nothing. Informational, with the
|
|
396
|
+
// exact fix; `start` surfaces the same probe at plan time.
|
|
397
|
+
if (!gateDetectsTools(report.checks)) {
|
|
398
|
+
lines.push(' ○ gate: no code checks detected — checkpoints would be gated by the always-on repo checks alone;' +
|
|
399
|
+
' set {"check": "npm test"} in .plumbbob/settings.json, or add tool configs checkride can see');
|
|
400
|
+
}
|
|
387
401
|
return { lines, failed };
|
|
388
402
|
}
|
|
389
403
|
catch (err) {
|
|
@@ -402,6 +416,25 @@ function toolRow(c) {
|
|
|
402
416
|
const hint = c.status !== 'ok' && c.hint !== null ? `\n → ${c.hint}` : '';
|
|
403
417
|
return ` ${mark} ${c.slot ?? c.name} ← ${c.adapter}${version}${hint}`;
|
|
404
418
|
}
|
|
419
|
+
// The approval-latch health probe (D64): is the turn ledger live? `.plumbbob/TURN`
|
|
420
|
+
// holds a count once the UserPromptSubmit hook has ticked at least once, so a present
|
|
421
|
+
// count means the checkpoint latch is armed — its absence means the tick is
|
|
422
|
+
// guidance-only. Dormant is a legitimate state (a host with no hooks behaves exactly
|
|
423
|
+
// as it always has), never a failure — so this section adds nothing to the problem
|
|
424
|
+
// count. The dormant hint names the hook because a missing ledger almost always means
|
|
425
|
+
// it never wired, and that hook rides in either install kind (the marketplace
|
|
426
|
+
// plugin's hooks/hooks.json or an init-style link). Outside a repo there is no
|
|
427
|
+
// worktree ledger to read, so the section is skipped entirely.
|
|
428
|
+
function latchReport(cwd) {
|
|
429
|
+
const root = findRepoRoot(cwd);
|
|
430
|
+
if (root === null)
|
|
431
|
+
return { lines: [], failed: 0 };
|
|
432
|
+
const turn = readTurn(root);
|
|
433
|
+
const line = turn === null
|
|
434
|
+
? ' ○ latch: dormant — guidance only (no turn ledger yet; it ticks on your first prompt when the UserPromptSubmit hook is wired — re-run after one to confirm)'
|
|
435
|
+
: ` ✓ latch: live (turn ${turn})`;
|
|
436
|
+
return { lines: ['', 'plumbbob doctor — approval latch (D64)', line], failed: 0 };
|
|
437
|
+
}
|
|
405
438
|
export async function doctor(cwd, args = []) {
|
|
406
439
|
const checks = pluginChecks();
|
|
407
440
|
const out = ['plumbbob doctor — plugin install'];
|
|
@@ -414,7 +447,9 @@ export async function doctor(cwd, args = []) {
|
|
|
414
447
|
out.push(...agents.lines);
|
|
415
448
|
const gate = await gateReport(cwd);
|
|
416
449
|
out.push(...gate.lines);
|
|
417
|
-
const
|
|
450
|
+
const latch = latchReport(cwd);
|
|
451
|
+
out.push(...latch.lines);
|
|
452
|
+
const failed = checks.filter((c) => !c.ok).length + sidecar.failed + agents.failed + gate.failed + latch.failed;
|
|
418
453
|
out.push('');
|
|
419
454
|
out.push(failed === 0
|
|
420
455
|
? 'plumbbob: all checks passed. If a skill still misbehaves, restart Claude Code (or /reload-plugins).'
|
package/dist/verbs/finish.js
CHANGED
|
@@ -8,8 +8,7 @@
|
|
|
8
8
|
import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { commit, findRepoRoot, isDirty, stageAll } from "../lib/git.js";
|
|
11
|
-
import { checkpointsPath, hasSession, intentPath, reportPath, resolveBuild, seamPath, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
|
|
12
|
-
import { setLocalSetting } from "../lib/settings.js";
|
|
11
|
+
import { checkpointsPath, clearTick, hasSession, intentPath, readStats, reportPath, resolveBuild, seamPath, setGrant, sidecarDir, spikePath, stepPath, } from "../lib/sidecar.js";
|
|
13
12
|
import { parseTitle } from "../lib/orient.js";
|
|
14
13
|
export function finish(cwd, args = []) {
|
|
15
14
|
const root = findRepoRoot(cwd);
|
|
@@ -20,6 +19,7 @@ export function finish(cwd, args = []) {
|
|
|
20
19
|
const { build: slug } = resolveBuild(root, args);
|
|
21
20
|
if (existsSync(reportPath(root, slug))) {
|
|
22
21
|
appendCheckpointShas(root, slug);
|
|
22
|
+
appendStats(root, slug);
|
|
23
23
|
}
|
|
24
24
|
else {
|
|
25
25
|
process.stderr.write('plumbbob: note — no report.md found; finishing without one ' +
|
|
@@ -34,18 +34,19 @@ export function finish(cwd, args = []) {
|
|
|
34
34
|
stageAll(root);
|
|
35
35
|
}
|
|
36
36
|
const sha = commit(root, subject(root, slug), bodyArg(args) ?? undefined);
|
|
37
|
-
// Clear the control state: the in-flight markers, the
|
|
38
|
-
//
|
|
39
|
-
//
|
|
37
|
+
// Clear the control state: the in-flight markers first, then the session sentinel
|
|
38
|
+
// last (so "no session" flips exactly at the end). Deleting STATE also drops the
|
|
39
|
+
// cursor (D28) — cursor and session share the one file now, so a single delete does
|
|
40
|
+
// both. The tracked artifacts stay in place — only the ephemera go.
|
|
40
41
|
rmSync(seamPath(root, slug), { force: true });
|
|
41
42
|
rmSync(stepPath(root, slug), { force: true });
|
|
42
43
|
rmSync(spikePath(root, slug), { force: true });
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
44
|
+
// The latch's per-build entry stamp and the one-turn grant go with the session
|
|
45
|
+
// (D64/D65): a grant is one-turn by construction, but the session's last tick is
|
|
46
|
+
// the last time it was rewritten — left behind, a stale `auto` could self-approve
|
|
47
|
+
// the next session's first landing.
|
|
48
|
+
clearTick(root, slug);
|
|
49
|
+
setGrant(root, null);
|
|
49
50
|
rmSync(join(sidecarDir(root), 'STATE'), { force: true });
|
|
50
51
|
const where = slug === null ? '.plumbbob/' : `.plumbbob/builds/${slug}/`;
|
|
51
52
|
process.stdout.write(`plumbbob: finished — ${sha.slice(0, 9)}. ${where} rides your branch into the PR. ` +
|
|
@@ -81,6 +82,55 @@ function bodyArg(args) {
|
|
|
81
82
|
return null;
|
|
82
83
|
}
|
|
83
84
|
}
|
|
85
|
+
// Roll the per-step receipts (research/07 Build 2b) into report.md as a `## Stats`
|
|
86
|
+
// table — one row per step plus totals, so "is the loop worth it?" is a table,
|
|
87
|
+
// not a feeling. Silently skipped when nothing accrued (an old build, a build
|
|
88
|
+
// with no red/revert/drift and no `build <n>` stamps has nothing to say).
|
|
89
|
+
function appendStats(root, slug) {
|
|
90
|
+
const stats = readStats(root, slug);
|
|
91
|
+
const steps = Object.keys(stats)
|
|
92
|
+
.map(Number)
|
|
93
|
+
.filter((n) => Number.isFinite(n))
|
|
94
|
+
.sort((a, b) => a - b);
|
|
95
|
+
if (steps.length === 0) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const totals = { redChecks: 0, driftWarnings: 0, reverts: 0, wallMs: 0 };
|
|
99
|
+
const rows = steps.map((n) => {
|
|
100
|
+
const s = stats[String(n)] ?? {};
|
|
101
|
+
totals.redChecks += s.redChecks ?? 0;
|
|
102
|
+
totals.driftWarnings += s.driftWarnings ?? 0;
|
|
103
|
+
totals.reverts += s.reverts ?? 0;
|
|
104
|
+
const wall = wallClockMs(s);
|
|
105
|
+
totals.wallMs += wall ?? 0;
|
|
106
|
+
return `| ${n} | ${s.redChecks ?? 0} | ${s.driftWarnings ?? 0} | ${s.reverts ?? 0} | ${formatWall(wall)} |`;
|
|
107
|
+
});
|
|
108
|
+
appendFileSync(reportPath(root, slug), [
|
|
109
|
+
'',
|
|
110
|
+
'## Stats',
|
|
111
|
+
'',
|
|
112
|
+
'| step | red checks | drift warnings | reverts | wall-clock |',
|
|
113
|
+
'|------|------------|----------------|---------|------------|',
|
|
114
|
+
...rows,
|
|
115
|
+
`| **total** | ${totals.redChecks} | ${totals.driftWarnings} | ${totals.reverts} | ${formatWall(totals.wallMs > 0 ? totals.wallMs : null)} |`,
|
|
116
|
+
'',
|
|
117
|
+
].join('\n'));
|
|
118
|
+
}
|
|
119
|
+
function wallClockMs(s) {
|
|
120
|
+
if (s.startedAt === undefined || s.landedAt === undefined) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const ms = Date.parse(s.landedAt) - Date.parse(s.startedAt);
|
|
124
|
+
return Number.isFinite(ms) && ms >= 0 ? ms : null;
|
|
125
|
+
}
|
|
126
|
+
// `—` when unknown (a hand-built step never ran `build <n>`, so it has no
|
|
127
|
+
// startedAt), `<1m` under a minute, whole minutes otherwise.
|
|
128
|
+
function formatWall(ms) {
|
|
129
|
+
if (ms === null) {
|
|
130
|
+
return '—';
|
|
131
|
+
}
|
|
132
|
+
return ms < 60_000 ? '<1m' : `${Math.round(ms / 60_000)}m`;
|
|
133
|
+
}
|
|
84
134
|
// Append the recorded checkpoints (baseline + each `step n <sha>`) to report.md as a
|
|
85
135
|
// `## Checkpoints` section, so the report — which now rides the branch into the PR —
|
|
86
136
|
// lists the SHAs. Best-effort: an unreadable checkpoints file yields an empty list.
|
package/dist/verbs/revert.js
CHANGED
|
@@ -18,7 +18,7 @@ import { tmpdir } from 'node:os';
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { findRepoRoot, resetHard, untrackedPaths } from "../lib/git.js";
|
|
21
|
-
import { checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
|
|
21
|
+
import { bumpStepStat, checkpointsPath, hasSession, resolveBuild, seamPath, sidecarDir, stepPath } from "../lib/sidecar.js";
|
|
22
22
|
import { isArtifactPath, matchesSeam } from "../lib/intent.js";
|
|
23
23
|
export function revert(cwd, args) {
|
|
24
24
|
const root = findRepoRoot(cwd);
|
|
@@ -53,12 +53,19 @@ export function revert(cwd, args) {
|
|
|
53
53
|
// ignored files alone, so they must be removed explicitly afterward).
|
|
54
54
|
const seam = readSeamTokens(root, slug);
|
|
55
55
|
const toRemove = untrackedPaths(root).filter((p) => matchesSeam(p, seam) && !isArtifactPath(p));
|
|
56
|
+
// The dogfood receipt (research/07 2b): a revert against an in-flight step is a
|
|
57
|
+
// datapoint — read the marker before it goes, bump after the reset (the sidecar
|
|
58
|
+
// is preserved through it, so the write survives).
|
|
59
|
+
const inFlight = readInFlightStep(root, slug);
|
|
56
60
|
resetPreserving(root, sha, plumbbobOwnedPaths(root));
|
|
57
61
|
for (const rel of toRemove) {
|
|
58
62
|
rmSync(join(root, rel), { force: true, recursive: true });
|
|
59
63
|
}
|
|
60
64
|
rmSync(seamPath(root, slug), { force: true });
|
|
61
65
|
rmSync(stepPath(root, slug), { force: true });
|
|
66
|
+
if (inFlight !== null) {
|
|
67
|
+
bumpStepStat(root, slug, inFlight, 'reverts');
|
|
68
|
+
}
|
|
62
69
|
process.stdout.write(`plumbbob: reverted to ${sha.slice(0, 9)} — back at the boundary. Park lines and intent edits were preserved.\n`);
|
|
63
70
|
return 0;
|
|
64
71
|
}
|
|
@@ -139,3 +146,14 @@ function readSeamTokens(root, slug) {
|
|
|
139
146
|
return [];
|
|
140
147
|
}
|
|
141
148
|
}
|
|
149
|
+
// The in-flight STEP number, or null when none — read for the revert receipt
|
|
150
|
+
// before the marker is cleared.
|
|
151
|
+
function readInFlightStep(root, slug) {
|
|
152
|
+
try {
|
|
153
|
+
const raw = readFileSync(stepPath(root, slug), 'utf8').trim();
|
|
154
|
+
return /^\d+$/.test(raw) ? Number(raw) : null;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/dist/verbs/start.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
// `plumbbob start "<title>"` — scaffold the sidecar, record the baseline, open
|
|
2
2
|
// the session. Refuses on a dirty tree (D22), an existing session, or a non-git dir.
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { detectGate } from "../lib/check.js";
|
|
6
7
|
import { findRepoRoot, hasCommit, headSha, isDirty } from "../lib/git.js";
|
|
7
|
-
import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, } from "../lib/sidecar.js";
|
|
8
|
-
import { settingsPath
|
|
8
|
+
import { sidecarDir, buildDir, listBuilds, slugify, checkpointsPath, intentPath, buildLogPath, beginSession, hasSession, excludeControl, excludeSidecar, setGrant, stampTick, } from "../lib/sidecar.js";
|
|
9
|
+
import { settingsPath } from "../lib/settings.js";
|
|
9
10
|
// D24/D32: the gate is checkride unless a `check` setting overrides it, so
|
|
10
11
|
// settings.json seeds with no `check` key at all — absence IS the default. The
|
|
11
12
|
// templates' {{CHECK}} line is a human-readable echo of that.
|
|
12
13
|
const CHECK_ECHO = 'checkride (set a "check" key in .plumbbob/settings.json to override)';
|
|
13
|
-
export function start(cwd, args) {
|
|
14
|
+
export async function start(cwd, args) {
|
|
14
15
|
const positionals = args.filter((a) => !a.startsWith('--'));
|
|
15
16
|
const allowDirty = args.includes('--allow-dirty');
|
|
16
17
|
const local = args.includes('--local');
|
|
@@ -50,8 +51,20 @@ export function start(cwd, args) {
|
|
|
50
51
|
}
|
|
51
52
|
const sha = headSha(root);
|
|
52
53
|
mkdirSync(sidecarDir(root), { recursive: true });
|
|
53
|
-
beginSession
|
|
54
|
-
|
|
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.
|
|
57
|
+
beginSession(root, local ? null : slug);
|
|
58
|
+
// Scaffold the tracked settings.json ONLY when absent, and seed it EMPTY — the
|
|
59
|
+
// 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.
|
|
65
|
+
if (!existsSync(settingsPath(root))) {
|
|
66
|
+
writeFileSync(settingsPath(root), `${JSON.stringify({}, null, 2)}\n`);
|
|
67
|
+
}
|
|
55
68
|
// D26: `--local` keeps today's fully-untracked flat layout (whole `.plumbbob/`
|
|
56
69
|
// excluded); the default plants a tracked, PR-riding `builds/<slug>/` folder
|
|
57
70
|
// (D26) and points the per-worktree cursor at it (D28), excluding only control
|
|
@@ -71,11 +84,29 @@ export function start(cwd, args) {
|
|
|
71
84
|
writeFileSync(join(dir, 'checkpoints'), `baseline ${sha}\n`);
|
|
72
85
|
writeFileSync(join(dir, 'intent.md'), stamp(readTemplate('intent.md'), title, CHECK_ECHO));
|
|
73
86
|
writeFileSync(join(dir, 'build-log.md'), stamp(readTemplate('build-log.md'), title, CHECK_ECHO));
|
|
74
|
-
setLocalSetting(root, 'activeBuild', slug);
|
|
75
87
|
excludeControl(root);
|
|
76
88
|
intentLocation = `.plumbbob/builds/${slug}/intent.md`;
|
|
77
89
|
}
|
|
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).
|
|
96
|
+
setGrant(root, null);
|
|
97
|
+
stampTick(root, local ? null : slug);
|
|
78
98
|
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.
|
|
104
|
+
const gate = await detectGate(root);
|
|
105
|
+
if (gate.configured === null && !gate.detected) {
|
|
106
|
+
process.stderr.write('plumbbob: heads-up — the check gate sees no code checks in this repo (checkride detected no tools).\n' +
|
|
107
|
+
' Set one while you are still planning: add {"check": "npm test"} to .plumbbob/settings.json.\n' +
|
|
108
|
+
' Checkpoints gate on it — with nothing to run, the gate refuses or greens vacuously.\n');
|
|
109
|
+
}
|
|
79
110
|
return 0;
|
|
80
111
|
}
|
|
81
112
|
// Derived slugs carry a `YYYY-MM-DD-` prefix (local time) so `builds/` sorts
|
package/dist/verbs/status.js
CHANGED
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
// behavior, so the `NO ACTIVE SESSION` sentinel is kept exact.
|
|
4
4
|
import { readFileSync } from 'node:fs';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { findRepoRoot } from "../lib/git.js";
|
|
6
|
+
import { commitsSince, findRepoRoot } from "../lib/git.js";
|
|
7
7
|
import { SLOTS, readHarnessFile, resolveAgent, } from "../lib/agents.js";
|
|
8
8
|
import { buildFolder, buildLogPath, checkpointsPath, hasSession, inSpike, intentPath, listBuilds, resolveBuild, stepPath, } from "../lib/sidecar.js";
|
|
9
|
-
import { formatOrientation, orient } from "../lib/orient.js";
|
|
9
|
+
import { formatOrientation, lastLedgerSha, orient } from "../lib/orient.js";
|
|
10
10
|
function readOr(path) {
|
|
11
11
|
try {
|
|
12
12
|
return readFileSync(path, 'utf8');
|
|
@@ -33,12 +33,19 @@ export function status(cwd, args = []) {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
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.
|
|
40
|
+
const checkpoints = readOr(checkpointsPath(root, slug));
|
|
41
|
+
const anchor = lastLedgerSha(checkpoints);
|
|
36
42
|
const orientation = orient({
|
|
37
43
|
intent: readOr(intentPath(root, slug)),
|
|
38
44
|
buildLog: readOr(buildLogPath(root, slug)),
|
|
39
|
-
checkpoints
|
|
45
|
+
checkpoints,
|
|
40
46
|
inFlight: /^\d+$/.test(inFlightRaw) ? Number(inFlightRaw) : null,
|
|
41
47
|
spiking: inSpike(root, slug),
|
|
48
|
+
outOfBand: anchor === null ? 0 : commitsSince(root, anchor),
|
|
42
49
|
});
|
|
43
50
|
const lines = [formatOrientation(orientation), ...harnessSection(root, slug)];
|
|
44
51
|
process.stdout.write(`${lines.join('\n')}\n`);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// `plumbbob turn` — the turn-ledger hook (D64). Wired to Claude Code's
|
|
2
|
+
// UserPromptSubmit, it runs once per human prompt: it ticks `.plumbbob/TURN` (the
|
|
3
|
+
// monotonic count of human turns the checkpoint latch reads to know a human turn
|
|
4
|
+
// intervened since a step began) and rewrites `.plumbbob/GRANT` from the literal
|
|
5
|
+
// prompt — minting a one-turn self-approval only when the human typed
|
|
6
|
+
// `/pb-build --auto` or a `N-M` range (D65), clearing it otherwise. Pure machinery,
|
|
7
|
+
// not a user verb: it writes nothing to stdout and ALWAYS exits 0, so a broken tick
|
|
8
|
+
// can never wedge prompting (C3).
|
|
9
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { findSessionRoot, setGrant, turnPath } from "../lib/sidecar.js";
|
|
11
|
+
export function turn(cwd, _args) {
|
|
12
|
+
try {
|
|
13
|
+
applyTurn(cwd, readStdin());
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// C3: a broken turn must never wedge a prompt — swallow and exit 0.
|
|
17
|
+
}
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
// The testable core: apply one tick given the raw hook stdin. Split from the fd-0
|
|
21
|
+
// read so the unit tests can drive it directly — an in-process test cannot feed fd 0
|
|
22
|
+
// (the same constraint checkpoint's `--body` lives under).
|
|
23
|
+
export function applyTurn(cwd, raw) {
|
|
24
|
+
const root = findSessionRoot(cwd);
|
|
25
|
+
if (root === null)
|
|
26
|
+
return 0; // no active session above cwd — silent no-op.
|
|
27
|
+
writeFileSync(turnPath(root), `${readTurn(root) + 1}\n`);
|
|
28
|
+
setGrant(root, grantFromPrompt(extractPrompt(raw)));
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
// GRANT is minted only from a literal `pb-build` invocation the human typed:
|
|
32
|
+
// `pb-build` is disable-model-invocation, so this string can only come from a human
|
|
33
|
+
// keystroke, never model-authored pressure (D65). A bounded range beats `--auto`
|
|
34
|
+
// (bounded wins); a bare `/pb-build`, or any non-invocation prompt, mints nothing.
|
|
35
|
+
// The namespaced `/plumbbob:pb-build` form is honored alongside the bare one.
|
|
36
|
+
//
|
|
37
|
+
// Only the invocation's own arguments can mint: the tokens that follow it on its
|
|
38
|
+
// line, up to the first token that isn't argument-shaped (a flag, a step number, a
|
|
39
|
+
// range). An incidental range elsewhere in the prompt — an issue number, a pasted
|
|
40
|
+
// `2020-2024` — is prose, not a grant. Trailing sentence punctuation on an argument
|
|
41
|
+
// (`/pb-build 1-3.`) is still the argument the human typed.
|
|
42
|
+
export function grantFromPrompt(prompt) {
|
|
43
|
+
const invocation = /\/(?:plumbbob:)?pb-build\b/.exec(prompt);
|
|
44
|
+
if (invocation === null)
|
|
45
|
+
return null;
|
|
46
|
+
const line = prompt.slice(invocation.index + invocation[0].length).split('\n', 1)[0] ?? '';
|
|
47
|
+
let auto = false;
|
|
48
|
+
let ceiling;
|
|
49
|
+
for (const token of line.split(/\s+/)) {
|
|
50
|
+
const word = token.replace(/[.,;:!?]+$/, '');
|
|
51
|
+
if (word === '')
|
|
52
|
+
continue;
|
|
53
|
+
if (word === '--auto') {
|
|
54
|
+
auto = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const range = /^\d+-(\d+)$/.exec(word);
|
|
58
|
+
if (range !== null) {
|
|
59
|
+
ceiling = range[1];
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (/^\d+$/.test(word) || word.startsWith('-'))
|
|
63
|
+
continue; // a step number or another flag
|
|
64
|
+
break; // free text — the arguments have ended
|
|
65
|
+
}
|
|
66
|
+
if (ceiling !== undefined)
|
|
67
|
+
return `range ${ceiling}`;
|
|
68
|
+
return auto ? 'auto' : null;
|
|
69
|
+
}
|
|
70
|
+
// Extract the `prompt` field from the UserPromptSubmit JSON, or '' when the input is
|
|
71
|
+
// absent, unparseable, or carries no string prompt. Malformed input contributes
|
|
72
|
+
// nothing (D27): the tick still lands (a human turn did occur) and the empty prompt
|
|
73
|
+
// clears any GRANT; the verb never throws over bad stdin.
|
|
74
|
+
function extractPrompt(raw) {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(raw);
|
|
77
|
+
return typeof parsed.prompt === 'string' ? parsed.prompt : '';
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// The current TURN as a non-negative integer, or 0 when the ledger is absent or
|
|
84
|
+
// unreadable — so the first tick creates it at 1.
|
|
85
|
+
function readTurn(root) {
|
|
86
|
+
try {
|
|
87
|
+
const n = Number.parseInt(readFileSync(turnPath(root), 'utf8').trim(), 10);
|
|
88
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Read the hook JSON from fd 0. An interactive TTY never sends EOF, so skip the read
|
|
95
|
+
// there rather than hang (mirrors checkpoint's `--body`); a read error degrades to
|
|
96
|
+
// '' — extractPrompt then clears the grant and the tick still lands.
|
|
97
|
+
function readStdin() {
|
|
98
|
+
if (process.stdin.isTTY === true)
|
|
99
|
+
return '';
|
|
100
|
+
try {
|
|
101
|
+
return readFileSync(0, 'utf8');
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return '';
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/verbs/use.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// `plumbbob use <slug>` (D30) — re-point the per-worktree cursor at an existing
|
|
2
2
|
// build and resume it. Both switching and resuming are the same one word (Q10,
|
|
3
|
-
// `nvm use`-shaped): the cursor is
|
|
4
|
-
//
|
|
3
|
+
// `nvm use`-shaped): the cursor is STATE's single-line content, so pointing it
|
|
4
|
+
// elsewhere IS the switch and one-active-per-worktree holds by
|
|
5
5
|
// construction (D28). It validates the target folder exists, and warns — but
|
|
6
6
|
// allows — leaving a build with a step in flight: that surviving in-flight state
|
|
7
7
|
// is D26's whole payoff, resumed the next time you `use` that build.
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import { findRepoRoot } from "../lib/git.js";
|
|
10
|
-
import { activeBuild, hasSession, intentPath, listBuilds, stepPath } from "../lib/sidecar.js";
|
|
11
|
-
import { setLocalSetting } from "../lib/settings.js";
|
|
10
|
+
import { activeBuild, hasSession, intentPath, listBuilds, setActiveBuild, stepPath } from "../lib/sidecar.js";
|
|
12
11
|
export function use(cwd, args) {
|
|
13
12
|
const root = findRepoRoot(cwd);
|
|
14
13
|
if (root === null || !hasSession(root)) {
|
|
@@ -32,7 +31,7 @@ export function use(cwd, args) {
|
|
|
32
31
|
process.stderr.write(`plumbbob: note — build "${leaving}" has a step in flight; its in-flight state is preserved ` +
|
|
33
32
|
`and resumes when you \`use ${leaving}\` again.\n`);
|
|
34
33
|
}
|
|
35
|
-
|
|
34
|
+
setActiveBuild(root, target);
|
|
36
35
|
const resuming = existsSync(stepPath(root, target)) ? ' (a step is in flight — `status` shows where)' : '';
|
|
37
36
|
process.stdout.write(`plumbbob: now on build "${target}"${resuming}. \`status\` to orient.\n`);
|
|
38
37
|
return 0;
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Bash",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-bash-commit.sh\"" }
|
|
8
|
+
]
|
|
9
|
+
}
|
|
10
|
+
],
|
|
3
11
|
"PostToolUse": [
|
|
4
12
|
{
|
|
5
13
|
"matcher": "Edit|Write|MultiEdit|NotebookEdit",
|
|
@@ -7,6 +15,13 @@
|
|
|
7
15
|
{ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/post-edit.sh\"" }
|
|
8
16
|
]
|
|
9
17
|
}
|
|
18
|
+
],
|
|
19
|
+
"UserPromptSubmit": [
|
|
20
|
+
{
|
|
21
|
+
"hooks": [
|
|
22
|
+
{ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/plumbbob\" turn" }
|
|
23
|
+
]
|
|
24
|
+
}
|
|
10
25
|
]
|
|
11
26
|
}
|
|
12
27
|
}
|
package/hooks/post-edit.sh
CHANGED
|
@@ -5,16 +5,16 @@
|
|
|
5
5
|
# the tools are absent or there is no session. tsc is deferred to the heavy tier
|
|
6
6
|
# (D25): it has no true single-file mode and would tax every keystroke.
|
|
7
7
|
|
|
8
|
-
# The repo root is the nearest ancestor whose .plumbbob/
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
8
|
+
# The repo root is the nearest ancestor whose .plumbbob/STATE is NON-EMPTY — the
|
|
9
|
+
# per-worktree signal that a tracked build is live here (D28). STATE's existence
|
|
10
|
+
# means "a session is active"; its content is the active-build cursor, so a non-empty
|
|
11
|
+
# STATE is exactly "a tracked build is live" (the `--local` layout leaves STATE empty,
|
|
12
|
+
# and this file-scoped check is a tracked-build affordance). `-s` tests both existence
|
|
13
|
+
# and non-emptiness — no JSON parse, no cursor file to read.
|
|
13
14
|
find_root() {
|
|
14
15
|
d=$(pwd -P)
|
|
15
16
|
while [ -n "$d" ]; do
|
|
16
|
-
if [ -
|
|
17
|
-
grep -q '"activeBuild"' "$d/.plumbbob/settings.local.json" 2>/dev/null; then
|
|
17
|
+
if [ -s "$d/.plumbbob/STATE" ]; then
|
|
18
18
|
printf '%s' "$d"
|
|
19
19
|
return 0
|
|
20
20
|
fi
|