plumbbob 0.4.13 → 0.5.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 +1 -1
- package/README.md +168 -70
- package/dist/cli-core.js +24 -17
- package/dist/cli.js +1 -1
- package/dist/lib/buildlog.js +2 -2
- package/dist/lib/check.js +89 -20
- package/dist/lib/git.js +35 -9
- package/dist/lib/intent.js +21 -2
- package/dist/lib/orient.js +2 -2
- package/dist/lib/settings.js +71 -0
- package/dist/lib/sidecar.js +142 -32
- package/dist/verbs/build.js +7 -6
- package/dist/verbs/check.js +39 -5
- package/dist/verbs/checkpoint.js +145 -9
- package/dist/verbs/doctor.js +266 -26
- package/dist/verbs/finish.js +101 -0
- package/dist/verbs/revert.js +20 -14
- package/dist/verbs/spike.js +12 -11
- package/dist/verbs/start.js +45 -21
- package/dist/verbs/status.js +18 -7
- package/dist/verbs/use.js +44 -0
- package/hooks/post-edit.sh +9 -3
- package/package.json +12 -11
- package/skills/pb-build/SKILL.md +10 -4
- package/skills/{pb-wrap → pb-finish}/SKILL.md +20 -15
- package/skills/pb-plan/SKILL.md +9 -2
- package/skills/pb-verify/SKILL.md +24 -7
- package/templates/build-log.md +3 -3
- package/templates/intent.md +2 -2
- package/dist/lib/archive.js +0 -72
- package/dist/verbs/wrap.js +0 -55
package/dist/lib/intent.js
CHANGED
|
@@ -78,11 +78,30 @@ export function parseStepSeam(content, step) {
|
|
|
78
78
|
return { ok: true, seam };
|
|
79
79
|
}
|
|
80
80
|
// Seam membership (D23): a repo-relative path is in-seam if it equals an exact
|
|
81
|
-
// token, or is prefixed by a `dir/` grant. Shared by `
|
|
82
|
-
// and `revert` (untracked cleanup).
|
|
81
|
+
// token, or is prefixed by a `dir/` grant. Shared by `checkpoint` (scope-drift
|
|
82
|
+
// warn) and `revert` (untracked cleanup).
|
|
83
83
|
export function matchesSeam(relPath, tokens) {
|
|
84
84
|
return tokens.some((token) => (token.endsWith('/') ? relPath.startsWith(token) : relPath === token));
|
|
85
85
|
}
|
|
86
|
+
// Plumbbob's own artifact plane (D17): everything under `.plumbbob/` is plumbbob's
|
|
87
|
+
// bookkeeping — the tracked intent/build-log/checkpoints that ride the branch, and
|
|
88
|
+
// the excluded control markers. It is never the user's code, so it never counts as
|
|
89
|
+
// scope drift, and `revert`'s untracked cleanup must never delete it. checkpoint
|
|
90
|
+
// stages this plane itself on every tick (the `[x]` flip, the build-log line), so
|
|
91
|
+
// without this whitelist every checkpoint would cry wolf about its own writes.
|
|
92
|
+
export function isArtifactPath(relPath) {
|
|
93
|
+
return relPath === '.plumbbob' || relPath.startsWith('.plumbbob/');
|
|
94
|
+
}
|
|
95
|
+
// The staged paths that fall outside the step's seam AND outside the artifact
|
|
96
|
+
// plane — the scope-drift set `checkpoint` warns about (guidance, not a gate: the
|
|
97
|
+
// checkpoint still commits them). An empty seam yields no drift, so callers that
|
|
98
|
+
// cannot resolve a seam simply skip the warning rather than flagging everything.
|
|
99
|
+
export function scopeDrift(paths, seam) {
|
|
100
|
+
if (seam.length === 0) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
return paths.filter((p) => !matchesSeam(p, seam) && !isArtifactPath(p));
|
|
104
|
+
}
|
|
86
105
|
function fail(error) {
|
|
87
106
|
return { ok: false, error };
|
|
88
107
|
}
|
package/dist/lib/orient.js
CHANGED
|
@@ -110,9 +110,9 @@ function nextMove(spiking, steps, inFlight, parked) {
|
|
|
110
110
|
return 'plan the first step — `/plumbbob:pb-step`';
|
|
111
111
|
}
|
|
112
112
|
// Batch-default: the steps were planned up front, so finishing them usually
|
|
113
|
-
// means "
|
|
113
|
+
// means "finish up" — but `/plumbbob:pb-step` can still add an increment if reality grew.
|
|
114
114
|
const harvest = parked > 0 ? `harvest ${parked} parked idea${parked === 1 ? '' : 's'} — \`/plumbbob:pb-harvest\`; then ` : '';
|
|
115
|
-
return `${harvest}
|
|
115
|
+
return `${harvest}finish up — \`/plumbbob:pb-finish\` (or \`/plumbbob:pb-step\` to add another increment)`;
|
|
116
116
|
}
|
|
117
117
|
return nextUndone.planned
|
|
118
118
|
? `build step ${nextUndone.n} — \`/plumbbob:pb-build\` (or \`/plumbbob:pb-step\` to revise it first)`
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The settings ladder (D27): a resolved setting comes from, in priority order,
|
|
2
|
+
// 1. a CLI flag — passed in by the verb (undefined when absent)
|
|
3
|
+
// 2. settings.local.json — untracked personal overlay + per-worktree cursor
|
|
4
|
+
// 3. settings.json — tracked project defaults
|
|
5
|
+
// 4. a built-in default — supplied by the caller
|
|
6
|
+
// The first defined rung wins. Both files are optional JSON; a missing or
|
|
7
|
+
// malformed file contributes nothing rather than throwing, so a broken personal
|
|
8
|
+
// overlay can never wedge the tool. Functional/procedural, node builtins (C1/C2).
|
|
9
|
+
//
|
|
10
|
+
// Known keys: `check` (string — the heavy gate, tracked in settings.json) and
|
|
11
|
+
// `auto` (boolean — whether the agent approves in the human's place; a personal
|
|
12
|
+
// preference, so it belongs in settings.local.json). `activeBuild` (the
|
|
13
|
+
// per-worktree cursor) also lives in settings.local.json but is resolved by
|
|
14
|
+
// sidecar.ts, not here.
|
|
15
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
const DIRNAME = '.plumbbob';
|
|
18
|
+
export function settingsPath(root) {
|
|
19
|
+
return join(root, DIRNAME, 'settings.json');
|
|
20
|
+
}
|
|
21
|
+
export function localSettingsPath(root) {
|
|
22
|
+
return join(root, DIRNAME, 'settings.local.json');
|
|
23
|
+
}
|
|
24
|
+
function readSettings(path) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
27
|
+
return typeof parsed === 'object' && parsed !== null ? parsed : {};
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// The raw ladder: flag → local overlay → project defaults → undefined. The typed
|
|
34
|
+
// helpers below apply the built-in default and reject wrong-typed rungs.
|
|
35
|
+
function resolveSetting(root, key, flag) {
|
|
36
|
+
if (flag !== undefined)
|
|
37
|
+
return flag;
|
|
38
|
+
const local = readSettings(localSettingsPath(root))[key];
|
|
39
|
+
if (local !== undefined)
|
|
40
|
+
return local;
|
|
41
|
+
return readSettings(settingsPath(root))[key];
|
|
42
|
+
}
|
|
43
|
+
// Resolve a string setting (e.g. `check`). A missing rung, or one holding a
|
|
44
|
+
// non-string / blank value, yields the caller's fallback rather than gating on
|
|
45
|
+
// garbage.
|
|
46
|
+
export function resolveString(root, key, fallback, flag) {
|
|
47
|
+
const value = resolveSetting(root, key, flag);
|
|
48
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : fallback;
|
|
49
|
+
}
|
|
50
|
+
// Resolve a boolean setting (e.g. `auto`). A missing or non-boolean rung yields
|
|
51
|
+
// the caller's fallback.
|
|
52
|
+
export function resolveBoolean(root, key, fallback, flag) {
|
|
53
|
+
const value = resolveSetting(root, key, flag);
|
|
54
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
55
|
+
}
|
|
56
|
+
// Read one key from the untracked local overlay ONLY — no project or built-in
|
|
57
|
+
// fallback. The `activeBuild` cursor lives here and must never resolve from the
|
|
58
|
+
// tracked settings.json (it is per-worktree state, not a shared default).
|
|
59
|
+
export function localSetting(root, key) {
|
|
60
|
+
return readSettings(localSettingsPath(root))[key];
|
|
61
|
+
}
|
|
62
|
+
// Merge one key into settings.local.json, preserving the other keys and creating
|
|
63
|
+
// the file when absent. Pretty-printed so the overlay stays hand-editable. A
|
|
64
|
+
// malformed existing file contributes nothing (readSettings yields {}) and is
|
|
65
|
+
// overwritten with a clean object rather than throwing.
|
|
66
|
+
export function setLocalSetting(root, key, value) {
|
|
67
|
+
const path = localSettingsPath(root);
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
const merged = { ...readSettings(path), [key]: value };
|
|
70
|
+
writeFileSync(path, `${JSON.stringify(merged, null, 2)}\n`);
|
|
71
|
+
}
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -6,45 +6,128 @@
|
|
|
6
6
|
// and nothing reads its content. The phase the dashboard shows (DESIGN/BUILD/
|
|
7
7
|
// SPIKE) is derived, not stored — BUILD ⇔ a STEP is in flight, SPIKE ⇔ the SPIKE
|
|
8
8
|
// marker is present, otherwise DESIGN.
|
|
9
|
-
import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync } from 'node:fs';
|
|
10
|
-
import { join } from 'node:path';
|
|
11
|
-
import {
|
|
9
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
10
|
+
import { dirname, join } from 'node:path';
|
|
11
|
+
import { gitPath } from "./git.js";
|
|
12
|
+
import { localSetting } from "./settings.js";
|
|
12
13
|
const DIRNAME = '.plumbbob';
|
|
13
14
|
export function sidecarDir(root) {
|
|
14
15
|
return join(root, DIRNAME);
|
|
15
16
|
}
|
|
17
|
+
// The tracked artifact plane (D26): each build owns a self-contained folder under
|
|
18
|
+
// `.plumbbob/builds/<slug>/` that rides its branch into the PR. `buildDir` is the
|
|
19
|
+
// per-build root; intent.md, build-log.md, checkpoints, and report.md live inside
|
|
20
|
+
// it (the in-flight STEP/SEAM/SPIKE markers do too, but stay git-excluded).
|
|
21
|
+
function buildsDir(root) {
|
|
22
|
+
return join(root, DIRNAME, 'builds');
|
|
23
|
+
}
|
|
24
|
+
export function buildDir(root, slug) {
|
|
25
|
+
return join(buildsDir(root), slug);
|
|
26
|
+
}
|
|
27
|
+
// Derive a filesystem-safe slug from a build title: lowercased, every run of
|
|
28
|
+
// non-alphanumerics collapsed to a single hyphen, trimmed of leading/trailing
|
|
29
|
+
// hyphens. The CLI stays dumb and explicit (D38) — collision handling belongs to
|
|
30
|
+
// the caller (`start` refuses rather than silently suffixing `-2`). A title with
|
|
31
|
+
// no alphanumerics yields `''`, which the caller must reject or override.
|
|
32
|
+
export function slugify(title) {
|
|
33
|
+
return title
|
|
34
|
+
.toLowerCase()
|
|
35
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
36
|
+
.replace(/^-+|-+$/g, '');
|
|
37
|
+
}
|
|
38
|
+
// Existing build slugs — the directory names under `builds/`, sorted. Empty when
|
|
39
|
+
// `builds/` is absent (a `--local` repo, or before the first tracked `start`).
|
|
40
|
+
export function listBuilds(root) {
|
|
41
|
+
try {
|
|
42
|
+
return readdirSync(buildsDir(root), { withFileTypes: true })
|
|
43
|
+
.filter((entry) => entry.isDirectory())
|
|
44
|
+
.map((entry) => entry.name)
|
|
45
|
+
.sort();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Resolve which build a verb acts on (D28): an explicit `--build <slug>` flag →
|
|
52
|
+
// the `activeBuild` cursor in settings.local.json → the sole build in `builds/`
|
|
53
|
+
// → null (the caller then refuses with a hint). One-active-per-worktree holds by
|
|
54
|
+
// construction: the cursor is a single scalar key in an untracked file, so it can
|
|
55
|
+
// never point at two builds (D28).
|
|
56
|
+
export function activeBuild(root, flag) {
|
|
57
|
+
if (flag !== undefined && flag.length > 0)
|
|
58
|
+
return flag;
|
|
59
|
+
const cursor = localSetting(root, 'activeBuild');
|
|
60
|
+
if (typeof cursor === 'string' && cursor.length > 0)
|
|
61
|
+
return cursor;
|
|
62
|
+
const builds = listBuilds(root);
|
|
63
|
+
return builds.length === 1 ? (builds[0] ?? null) : null;
|
|
64
|
+
}
|
|
65
|
+
// The build a verb should act on, plus its argv with the `--build <slug>` pair
|
|
66
|
+
// stripped (D28). Every verb resolves through this: an explicit `--build <slug>`
|
|
67
|
+
// wins, else the cursor / sole-build fallback of `activeBuild`. `rest` matters
|
|
68
|
+
// because the slug is a bare token — scanning positionals on the raw argv would let
|
|
69
|
+
// it masquerade as a step number or a spike slug, so callers scan `rest` instead.
|
|
70
|
+
export function resolveBuild(root, args) {
|
|
71
|
+
const i = args.indexOf('--build');
|
|
72
|
+
if (i === -1)
|
|
73
|
+
return { build: activeBuild(root), rest: args };
|
|
74
|
+
return { build: activeBuild(root, args[i + 1]), rest: [...args.slice(0, i), ...args.slice(i + 2)] };
|
|
75
|
+
}
|
|
16
76
|
function statePath(root) {
|
|
17
77
|
return join(root, DIRNAME, 'STATE');
|
|
18
78
|
}
|
|
79
|
+
// Where a build's artifacts and in-flight markers live (D26): the `builds/<slug>/`
|
|
80
|
+
// folder for the resolved build, else the flat sidecar root. `slug` is the value
|
|
81
|
+
// the verb already resolved via `resolveBuild`/`activeBuild`; omit it and the dir
|
|
82
|
+
// resolves from the cursor (the default the executor-agnostic path reads lean on).
|
|
83
|
+
// Either way a `null` slug falls back to the flat sidecar root, which covers the
|
|
84
|
+
// `--local` layout (D26) and any no-cursor/no-build repo, so the path reads stay
|
|
85
|
+
// stable even before a tracked build exists or when a "no active session" guard is
|
|
86
|
+
// about to fire.
|
|
87
|
+
function artifactDir(root, slug) {
|
|
88
|
+
const resolved = slug === undefined ? activeBuild(root) : slug;
|
|
89
|
+
return resolved === null ? sidecarDir(root) : buildDir(root, resolved);
|
|
90
|
+
}
|
|
91
|
+
// The resolved build's artifact folder — the `builds/<slug>/` dir for the active
|
|
92
|
+
// build (or the flat sidecar root under `--local`/no-cursor). Public so the
|
|
93
|
+
// plan-approval commit can stage exactly this build's scaffold and nothing else
|
|
94
|
+
// (D36); `slug` follows the same resolution as the path helpers above.
|
|
95
|
+
export function buildFolder(root, slug) {
|
|
96
|
+
return artifactDir(root, slug);
|
|
97
|
+
}
|
|
19
98
|
// The SPIKE marker (a single-purpose presence flag, like SEAM/STEP): written by
|
|
20
99
|
// `spike` on open, removed on `spike done`. Its existence is the one signal that
|
|
21
100
|
// the dashboard and the spike gates read to know "a spike is active".
|
|
22
|
-
export function spikePath(root) {
|
|
23
|
-
return join(root,
|
|
101
|
+
export function spikePath(root, slug) {
|
|
102
|
+
return join(artifactDir(root, slug), 'SPIKE');
|
|
24
103
|
}
|
|
25
104
|
// SEAM and STEP carry the in-flight step (D4/D7): a plain path list and a bare
|
|
26
105
|
// number, so the hooks read them with a grep and no markdown parsing.
|
|
27
|
-
export function seamPath(root) {
|
|
28
|
-
return join(root,
|
|
106
|
+
export function seamPath(root, slug) {
|
|
107
|
+
return join(artifactDir(root, slug), 'SEAM');
|
|
29
108
|
}
|
|
30
|
-
export function stepPath(root) {
|
|
31
|
-
return join(root,
|
|
109
|
+
export function stepPath(root, slug) {
|
|
110
|
+
return join(artifactDir(root, slug), 'STEP');
|
|
32
111
|
}
|
|
33
|
-
export function checkpointsPath(root) {
|
|
34
|
-
return join(root,
|
|
112
|
+
export function checkpointsPath(root, slug) {
|
|
113
|
+
return join(artifactDir(root, slug), 'checkpoints');
|
|
35
114
|
}
|
|
36
|
-
export function
|
|
37
|
-
return join(root,
|
|
115
|
+
export function intentPath(root, slug) {
|
|
116
|
+
return join(artifactDir(root, slug), 'intent.md');
|
|
38
117
|
}
|
|
39
|
-
export function
|
|
40
|
-
return join(root,
|
|
118
|
+
export function buildLogPath(root, slug) {
|
|
119
|
+
return join(artifactDir(root, slug), 'build-log.md');
|
|
41
120
|
}
|
|
42
|
-
|
|
43
|
-
|
|
121
|
+
// report.md sits beside intent.md / build-log.md inside the build folder. The
|
|
122
|
+
// pb-finish skill writes it and `finish` commits it with the folder — the folder
|
|
123
|
+
// IS the archive now (D29), so the report rides the branch into the PR instead of
|
|
124
|
+
// being copied into a local-only `archive/` (which retired with `archive.ts`).
|
|
125
|
+
export function reportPath(root, slug) {
|
|
126
|
+
return join(artifactDir(root, slug), 'report.md');
|
|
44
127
|
}
|
|
45
|
-
// A session exists iff STATE exists. Deleting STATE (at
|
|
128
|
+
// A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
|
|
46
129
|
// repo back to "no session" — so it is the single source of truth for "is there
|
|
47
|
-
// a session". `start` calls beginSession; `
|
|
130
|
+
// a session". `start` calls beginSession; `finish` removes the file.
|
|
48
131
|
export function hasSession(root) {
|
|
49
132
|
return existsSync(statePath(root));
|
|
50
133
|
}
|
|
@@ -52,19 +135,22 @@ export function beginSession(root) {
|
|
|
52
135
|
writeFileSync(statePath(root), 'active\n');
|
|
53
136
|
}
|
|
54
137
|
// SPIKE marker helpers — existence is the whole signal (content is irrelevant).
|
|
55
|
-
export function inSpike(root) {
|
|
56
|
-
return existsSync(spikePath(root));
|
|
138
|
+
export function inSpike(root, slug) {
|
|
139
|
+
return existsSync(spikePath(root, slug));
|
|
57
140
|
}
|
|
58
|
-
export function markSpike(root) {
|
|
59
|
-
writeFileSync(spikePath(root), 'active\n');
|
|
141
|
+
export function markSpike(root, slug) {
|
|
142
|
+
writeFileSync(spikePath(root, slug), 'active\n');
|
|
60
143
|
}
|
|
61
|
-
export function clearSpike(root) {
|
|
62
|
-
rmSync(spikePath(root), { force: true });
|
|
144
|
+
export function clearSpike(root, slug) {
|
|
145
|
+
rmSync(spikePath(root, slug), { force: true });
|
|
63
146
|
}
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
147
|
+
// Append `patterns` to the repo's info/exclude, each at most once (idempotent —
|
|
148
|
+
// a re-`start` after finish must not double-add). `gitPath` resolves to the
|
|
149
|
+
// *common* gitdir's exclude — the only one git reads — so this works from a
|
|
150
|
+
// linked worktree, whose per-worktree gitdir has no `info/` (D33).
|
|
151
|
+
function addExcludes(root, patterns) {
|
|
152
|
+
const exclude = gitPath(root, 'info/exclude');
|
|
153
|
+
mkdirSync(dirname(exclude), { recursive: true });
|
|
68
154
|
let current = '';
|
|
69
155
|
try {
|
|
70
156
|
current = readFileSync(exclude, 'utf8');
|
|
@@ -72,9 +158,33 @@ export function excludeSidecar(root) {
|
|
|
72
158
|
catch {
|
|
73
159
|
current = '';
|
|
74
160
|
}
|
|
75
|
-
|
|
161
|
+
const present = new Set(current.split('\n').map((line) => line.trim()));
|
|
162
|
+
const missing = patterns.filter((pattern) => !present.has(pattern));
|
|
163
|
+
if (missing.length === 0)
|
|
76
164
|
return;
|
|
77
|
-
}
|
|
78
165
|
const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
79
|
-
appendFileSync(exclude, `${prefix}${
|
|
166
|
+
appendFileSync(exclude, `${prefix}${missing.join('\n')}\n`);
|
|
167
|
+
}
|
|
168
|
+
// The narrowed control plane (D17): with `builds/<slug>/` now tracked, only the
|
|
169
|
+
// per-worktree control files stay git-excluded — the local settings overlay (its
|
|
170
|
+
// `activeBuild` cursor), the session sentinel, and the in-flight step markers
|
|
171
|
+
// inside every build. Everything else under `.plumbbob/` (settings.json, and each
|
|
172
|
+
// build's intent/build-log/checkpoints/report) rides the branch into the PR.
|
|
173
|
+
export function excludeControl(root) {
|
|
174
|
+
addExcludes(root, [
|
|
175
|
+
`${DIRNAME}/STATE`,
|
|
176
|
+
`${DIRNAME}/settings.local.json`,
|
|
177
|
+
`${DIRNAME}/builds/*/STEP`,
|
|
178
|
+
`${DIRNAME}/builds/*/SEAM`,
|
|
179
|
+
`${DIRNAME}/builds/*/SPIKE`,
|
|
180
|
+
// The checkride gate (D32) writes raw tool output to `.check/`; checkpoint's
|
|
181
|
+
// stageAll must never sweep it into a step commit.
|
|
182
|
+
'.check/',
|
|
183
|
+
]);
|
|
184
|
+
}
|
|
185
|
+
// D26: `start --local` opts out of the tracked layout into a fully-untracked
|
|
186
|
+
// sidecar (today's behavior) — some team repos won't accept tool folders in-tree.
|
|
187
|
+
// Excludes the whole `.plumbbob/` directory.
|
|
188
|
+
export function excludeSidecar(root) {
|
|
189
|
+
addExcludes(root, [`${DIRNAME}/`, '.check/']);
|
|
80
190
|
}
|
package/dist/verbs/build.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// BUILD phase from it); it never checkpoints (only `checkpoint` commits).
|
|
4
4
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
5
5
|
import { findRepoRoot } from "../lib/git.js";
|
|
6
|
-
import { hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
|
|
6
|
+
import { hasSession, intentPath, resolveBuild, seamPath, stepPath } from "../lib/sidecar.js";
|
|
7
7
|
import { parseStepSeam } from "../lib/intent.js";
|
|
8
8
|
export function build(cwd, args) {
|
|
9
9
|
const root = findRepoRoot(cwd);
|
|
@@ -11,19 +11,20 @@ export function build(cwd, args) {
|
|
|
11
11
|
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
|
|
12
12
|
return 1;
|
|
13
13
|
}
|
|
14
|
-
const
|
|
14
|
+
const { build: slug, rest } = resolveBuild(root, args);
|
|
15
|
+
const raw = rest.find((a) => !a.startsWith('--'));
|
|
15
16
|
if (raw === undefined || !/^\d+$/.test(raw) || Number(raw) < 1) {
|
|
16
17
|
process.stderr.write('plumbbob: build needs a step number. Try: plumbbob build 2.\n');
|
|
17
18
|
return 1;
|
|
18
19
|
}
|
|
19
20
|
const step = Number(raw);
|
|
20
|
-
const parsed = parseStepSeam(readFileSync(intentPath(root), 'utf8'), step);
|
|
21
|
+
const parsed = parseStepSeam(readFileSync(intentPath(root, slug), 'utf8'), step);
|
|
21
22
|
if (!parsed.ok) {
|
|
22
23
|
process.stderr.write(`plumbbob: ${parsed.error} Fix the step's seam in intent.md, then \`build ${step}\` again.\n`);
|
|
23
24
|
return 1;
|
|
24
25
|
}
|
|
25
|
-
writeFileSync(seamPath(root), `${parsed.seam.join('\n')}\n`);
|
|
26
|
-
writeFileSync(stepPath(root), `${step}\n`);
|
|
27
|
-
process.stdout.write(`plumbbob: building step ${step}. Seam (for orientation; not a lock
|
|
26
|
+
writeFileSync(seamPath(root, slug), `${parsed.seam.join('\n')}\n`);
|
|
27
|
+
writeFileSync(stepPath(root, slug), `${step}\n`);
|
|
28
|
+
process.stdout.write(`plumbbob: building step ${step}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
|
|
28
29
|
return 0;
|
|
29
30
|
}
|
package/dist/verbs/check.js
CHANGED
|
@@ -1,17 +1,51 @@
|
|
|
1
|
-
// `plumbbob check` — run the heavy gate (D16/D24) and report, with NO state
|
|
1
|
+
// `plumbbob check` — run the heavy gate (D16/D24/D32) and report, with NO state
|
|
2
2
|
// change. The read-only half of the verify tick: `/plumbbob:pb-verify` runs this before the
|
|
3
3
|
// pause so the human approves on a known-green check. Exits with the check's own
|
|
4
|
-
// code (0 = green).
|
|
4
|
+
// code (0 = green, 1 = red, 2 = the gate itself broke).
|
|
5
|
+
//
|
|
6
|
+
// Narrowing flags for the iteration loop (`check --bail --only types,lint`) map
|
|
7
|
+
// straight onto checkride's RunFlags (D32). Only this verb takes them — the
|
|
8
|
+
// checkpoint gate is always full-fat.
|
|
5
9
|
import { findRepoRoot } from "../lib/git.js";
|
|
6
10
|
import { hasSession } from "../lib/sidecar.js";
|
|
7
11
|
import { runCheck } from "../lib/check.js";
|
|
8
|
-
export function check(cwd) {
|
|
12
|
+
export async function check(cwd, args = []) {
|
|
9
13
|
const root = findRepoRoot(cwd);
|
|
10
14
|
if (root === null || !hasSession(root)) {
|
|
11
15
|
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
|
|
12
16
|
return 1;
|
|
13
17
|
}
|
|
14
|
-
const code = runCheck(root);
|
|
15
|
-
process.stdout.write(code
|
|
18
|
+
const code = await runCheck(root, parseFlags(args));
|
|
19
|
+
process.stdout.write(verdictLine(code));
|
|
16
20
|
return code;
|
|
17
21
|
}
|
|
22
|
+
// argv → CheckFlags: bare booleans plus comma-separated slot lists. Unknown
|
|
23
|
+
// args are ignored rather than refused — the gate itself is the point.
|
|
24
|
+
function parseFlags(args) {
|
|
25
|
+
return {
|
|
26
|
+
...(args.includes('--bail') ? { bail: true } : {}),
|
|
27
|
+
...(args.includes('--changed') ? { changed: true } : {}),
|
|
28
|
+
...(args.includes('--all') ? { all: true } : {}),
|
|
29
|
+
...slotList(args, '--only'),
|
|
30
|
+
...slotList(args, '--skip'),
|
|
31
|
+
...slotList(args, '--include'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function slotList(args, flag) {
|
|
35
|
+
const i = args.indexOf(flag);
|
|
36
|
+
const value = i >= 0 ? args[i + 1] : undefined;
|
|
37
|
+
if (value === undefined || value.startsWith('--'))
|
|
38
|
+
return {};
|
|
39
|
+
const names = value
|
|
40
|
+
.split(',')
|
|
41
|
+
.map((s) => s.trim())
|
|
42
|
+
.filter((s) => s.length > 0);
|
|
43
|
+
return names.length > 0 ? { [flag.slice(2)]: names } : {};
|
|
44
|
+
}
|
|
45
|
+
function verdictLine(code) {
|
|
46
|
+
if (code === 0)
|
|
47
|
+
return '\nplumbbob: check green.\n';
|
|
48
|
+
if (code === 2)
|
|
49
|
+
return '\nplumbbob: check ERROR — the gate itself broke; fix the harness before trusting green or red.\n';
|
|
50
|
+
return '\nplumbbob: check RED — fix it before checkpointing.\n';
|
|
51
|
+
}
|
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `plumbbob checkpoint [<n>] [-m <msg>]` — the executor-agnostic commit tick (D3).
|
|
2
|
-
//
|
|
2
|
+
// It does NOT require a STEP file: the step is whatever you pass,
|
|
3
3
|
// else the in-flight STEP, else the next undone step in intent. It gates on a green
|
|
4
4
|
// check, then commits any pending work (or records the existing HEAD when the tree
|
|
5
5
|
// is already clean — the human's commit skill may have committed first), records the
|
|
@@ -8,30 +8,39 @@
|
|
|
8
8
|
// `/plumbbob:pb-build`, your hands, a vibe session, or another harness all checkpoint
|
|
9
9
|
// the same way.
|
|
10
10
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
11
|
-
import { commit, findRepoRoot, headSha, isDirty, stageAll } from "../lib/git.js";
|
|
12
|
-
import { buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
|
|
11
|
+
import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
|
|
12
|
+
import { buildFolder, buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
|
|
13
13
|
import { runCheck } from "../lib/check.js";
|
|
14
|
-
import { markStepDone, parseSteps } from "../lib/orient.js";
|
|
14
|
+
import { markStepDone, parseSteps, parseTitle } from "../lib/orient.js";
|
|
15
|
+
import { parseStepSeam, scopeDrift } from "../lib/intent.js";
|
|
15
16
|
import { appendToSection, checkpointLogLine } from "../lib/buildlog.js";
|
|
16
|
-
export function checkpoint(cwd, args) {
|
|
17
|
+
export async function checkpoint(cwd, args) {
|
|
17
18
|
const root = findRepoRoot(cwd);
|
|
18
19
|
if (root === null || !hasSession(root)) {
|
|
19
20
|
process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
|
|
20
21
|
return 1;
|
|
21
22
|
}
|
|
23
|
+
if (args.includes('--plan')) {
|
|
24
|
+
return checkpointPlan(root, args);
|
|
25
|
+
}
|
|
22
26
|
const step = resolveStep(root, args);
|
|
23
27
|
if (step === null) {
|
|
24
28
|
process.stderr.write('plumbbob: no step to checkpoint — pass a number, or plan a step in intent.md first.\n');
|
|
25
29
|
return 1;
|
|
26
30
|
}
|
|
27
|
-
|
|
28
|
-
|
|
31
|
+
const gate = await runCheck(root);
|
|
32
|
+
if (gate !== 0) {
|
|
33
|
+
process.stderr.write(gate === 2
|
|
34
|
+
? 'plumbbob: the check gate itself broke — checkpoint refuses until the harness is fixed.\n'
|
|
35
|
+
: 'plumbbob: check failed (red) — checkpoint refuses on red. Fix it and re-run.\n');
|
|
29
36
|
return 1;
|
|
30
37
|
}
|
|
31
38
|
let sha;
|
|
32
39
|
if (isDirty(root)) {
|
|
33
40
|
stageAll(root);
|
|
34
|
-
|
|
41
|
+
warnScopeDrift(root, step);
|
|
42
|
+
const body = bodyArg(args) ?? fallbackBody(root, step);
|
|
43
|
+
sha = commit(root, messageArg(args) ?? subjectForStep(root, step), body);
|
|
35
44
|
}
|
|
36
45
|
else {
|
|
37
46
|
sha = headSha(root);
|
|
@@ -44,6 +53,32 @@ export function checkpoint(cwd, args) {
|
|
|
44
53
|
process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
|
|
45
54
|
return 0;
|
|
46
55
|
}
|
|
56
|
+
// The plan-approval commit (D36): stage only the active build's artifact folder and
|
|
57
|
+
// commit it as `plumbbob: plan — <title>`, then record a `plan <sha>` line. Giving
|
|
58
|
+
// the plan its own commit keeps the first step's diff from absorbing the scaffold, so
|
|
59
|
+
// `git log` reads baseline → plan → steps. No check gate (there is no code work to
|
|
60
|
+
// verify yet), no intent flip, no step markers — the plan lives entirely in DESIGN.
|
|
61
|
+
// An optional `--body` (stdin heredoc, D34) rides along; the folder is whitelisted
|
|
62
|
+
// artifact plane, so there is no scope-drift to warn about.
|
|
63
|
+
function checkpointPlan(root, args) {
|
|
64
|
+
stagePath(root, buildFolder(root));
|
|
65
|
+
const sha = commit(root, planSubject(root), bodyArg(args) ?? undefined);
|
|
66
|
+
appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
|
|
67
|
+
process.stdout.write(`plumbbob: plan committed — ${sha.slice(0, 9)}. Baseline → plan → steps.\n`);
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
// The plan commit's CLI-owned subject: the intent's `# <title>`, else a bare
|
|
71
|
+
// `plumbbob: plan` when the title can't be read (mirrors `subjectForStep`'s fallback).
|
|
72
|
+
function planSubject(root) {
|
|
73
|
+
let title = null;
|
|
74
|
+
try {
|
|
75
|
+
title = parseTitle(readFileSync(intentPath(root), 'utf8'));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
title = null;
|
|
79
|
+
}
|
|
80
|
+
return title ? `plumbbob: plan — ${title}` : 'plumbbob: plan';
|
|
81
|
+
}
|
|
47
82
|
// Step resolution (D3): explicit arg > in-flight STEP file > first undone step in
|
|
48
83
|
// intent.md. Returns null when none can be determined.
|
|
49
84
|
function resolveStep(root, args) {
|
|
@@ -62,6 +97,38 @@ function resolveStep(root, args) {
|
|
|
62
97
|
return null;
|
|
63
98
|
}
|
|
64
99
|
}
|
|
100
|
+
// Guidance, not a gate (the enforce→guide pivot): warn when the staged tree reaches
|
|
101
|
+
// beyond the step's seam, then commit anyway. The seam comes from the in-flight SEAM
|
|
102
|
+
// file when a build is live, else the step's declared seam in intent.md. Plumbbob's
|
|
103
|
+
// own artifact plane is whitelisted (`scopeDrift`), so the `[x]` flip and build-log
|
|
104
|
+
// line this very checkpoint stages never read as drift. No seam ⇒ no warning.
|
|
105
|
+
function warnScopeDrift(root, step) {
|
|
106
|
+
const seam = seamTokens(root, step);
|
|
107
|
+
const outside = scopeDrift(stagedPaths(root), seam);
|
|
108
|
+
if (outside.length > 0) {
|
|
109
|
+
process.stderr.write(`plumbbob: heads-up — staged paths outside step ${step}'s seam: ${outside.join(', ')}. ` +
|
|
110
|
+
`The checkpoint captures them; if that's real scope drift, the plan may need a \`/plumbbob:pb-step\` revision.\n`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
// The seam tokens for the in-flight step: the normalized SEAM file `build` wrote
|
|
114
|
+
// (authoritative while a build is live), falling back to the step's declared seam
|
|
115
|
+
// parsed from intent.md. Empty when neither resolves — the caller then skips the
|
|
116
|
+
// warning rather than flagging the whole tree.
|
|
117
|
+
function seamTokens(root, step) {
|
|
118
|
+
try {
|
|
119
|
+
const fromFile = readFileSync(seamPath(root), 'utf8')
|
|
120
|
+
.split('\n')
|
|
121
|
+
.map((l) => l.trim())
|
|
122
|
+
.filter((l) => l.length > 0);
|
|
123
|
+
if (fromFile.length > 0) {
|
|
124
|
+
return fromFile;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// no SEAM file — fall through to the declared seam.
|
|
129
|
+
}
|
|
130
|
+
return seamForStep(root, step);
|
|
131
|
+
}
|
|
65
132
|
function flipIntent(root, step) {
|
|
66
133
|
try {
|
|
67
134
|
writeFileSync(intentPath(root), markStepDone(readFileSync(intentPath(root), 'utf8'), step));
|
|
@@ -71,7 +138,7 @@ function flipIntent(root, step) {
|
|
|
71
138
|
}
|
|
72
139
|
}
|
|
73
140
|
// Append a dated line to the build-log's `## Log` so the build's history accrues at
|
|
74
|
-
// each checkpoint instead of being reconstructed at
|
|
141
|
+
// each checkpoint instead of being reconstructed at finish. The step's title is lifted
|
|
75
142
|
// from intent.md when still present. Best-effort: a missing/odd build-log never blocks
|
|
76
143
|
// a checkpoint — the `checkpoints` SHA is the source of truth.
|
|
77
144
|
function logCheckpoint(root, step, sha) {
|
|
@@ -88,6 +155,13 @@ function logCheckpoint(root, step, sha) {
|
|
|
88
155
|
// best-effort ledger; never fail a checkpoint over the build-log.
|
|
89
156
|
}
|
|
90
157
|
}
|
|
158
|
+
// The CLI-owned, deterministic commit subject: the step's title when intent.md still
|
|
159
|
+
// carries one, else the bare `plumbbob: step N done` fallback (D34 — the CLI owns the
|
|
160
|
+
// subject; a `-m` override or `--body` prose is a separate concern).
|
|
161
|
+
function subjectForStep(root, step) {
|
|
162
|
+
const title = titleForStep(root, step);
|
|
163
|
+
return title ? `plumbbob: step ${step} — ${title}` : `plumbbob: step ${step} done`;
|
|
164
|
+
}
|
|
91
165
|
function titleForStep(root, step) {
|
|
92
166
|
try {
|
|
93
167
|
return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => s.n === step)?.title ?? null;
|
|
@@ -100,6 +174,68 @@ function messageArg(args) {
|
|
|
100
174
|
const i = args.indexOf('-m');
|
|
101
175
|
return i !== -1 && i + 1 < args.length ? (args[i + 1] ?? null) : null;
|
|
102
176
|
}
|
|
177
|
+
// `--body` reads the commit body from stdin (the single-quoted heredoc of D34),
|
|
178
|
+
// so the skill can compose proportional prose the CLI never could. Returns null
|
|
179
|
+
// when the flag is absent or stdin is empty — either way the deterministic
|
|
180
|
+
// fallback body takes over. Reading fd 0 blocks until EOF, which the heredoc
|
|
181
|
+
// supplies; a read error (no stdin attached) degrades to the fallback.
|
|
182
|
+
function bodyArg(args) {
|
|
183
|
+
if (!args.includes('--body')) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const raw = readFileSync(0, 'utf8').trimEnd();
|
|
188
|
+
return raw.length > 0 ? raw : null;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// The deterministic checkpoint body (D35): the step's done-when, its seam, and the
|
|
195
|
+
// staged diffstat — so a hand-built or vibed checkpoint still gets informative
|
|
196
|
+
// history without a model turn. Each part is best-effort; a missing piece is
|
|
197
|
+
// simply omitted, and an empty result leaves the commit body blank.
|
|
198
|
+
function fallbackBody(root, step) {
|
|
199
|
+
const parts = [];
|
|
200
|
+
const doneWhen = doneWhenForStep(root, step);
|
|
201
|
+
if (doneWhen !== null) {
|
|
202
|
+
parts.push(`done when: ${doneWhen}`);
|
|
203
|
+
}
|
|
204
|
+
const seam = seamForStep(root, step);
|
|
205
|
+
if (seam.length > 0) {
|
|
206
|
+
parts.push(`seam: ${seam.join(', ')}`);
|
|
207
|
+
}
|
|
208
|
+
const stat = safeStagedStat(root);
|
|
209
|
+
if (stat.length > 0) {
|
|
210
|
+
parts.push(stat);
|
|
211
|
+
}
|
|
212
|
+
return parts.length > 0 ? parts.join('\n\n') : undefined;
|
|
213
|
+
}
|
|
214
|
+
function doneWhenForStep(root, step) {
|
|
215
|
+
try {
|
|
216
|
+
return parseSteps(readFileSync(intentPath(root), 'utf8')).find((s) => s.n === step)?.doneWhen ?? null;
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function seamForStep(root, step) {
|
|
223
|
+
try {
|
|
224
|
+
const parsed = parseStepSeam(readFileSync(intentPath(root), 'utf8'), step);
|
|
225
|
+
return parsed.ok ? parsed.seam : [];
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return [];
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
function safeStagedStat(root) {
|
|
232
|
+
try {
|
|
233
|
+
return stagedStat(root);
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
return '';
|
|
237
|
+
}
|
|
238
|
+
}
|
|
103
239
|
function readStep(root) {
|
|
104
240
|
try {
|
|
105
241
|
const raw = readFileSync(stepPath(root), 'utf8').trim();
|