plumbbob 0.8.18 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +50 -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 +320 -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 +153 -78
- package/dist/verbs/doctor.js +192 -95
- package/dist/verbs/finish.js +86 -45
- 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/git.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
|
-
// Thin git wrapper over `node:child_process`
|
|
2
|
-
// deliberate
|
|
3
|
-
//
|
|
1
|
+
// Thin git wrapper over `node:child_process` — the CLI stays on node builtins
|
|
2
|
+
// plus a small deliberate dependency allowlist, and subprocess spawning is
|
|
3
|
+
// centralized in files like this one. Plumbbob's git footprint is additive by
|
|
4
|
+
// rule: these helpers read, locate, stage, and commit forward, and reset
|
|
5
|
+
// `--hard` only to plumbbob's own recorded checkpoint SHAs — never a
|
|
6
|
+
// history-rewriting operation on anything pushed.
|
|
4
7
|
import { execFileSync } from 'node:child_process';
|
|
5
8
|
import { isAbsolute, join } from 'node:path';
|
|
9
|
+
/**
|
|
10
|
+
* Run git in `root` and return its trimmed stdout.
|
|
11
|
+
*
|
|
12
|
+
* stderr is discarded and a non-zero exit throws — callers that tolerate
|
|
13
|
+
* failure wrap the call in their own try/catch.
|
|
14
|
+
*/
|
|
6
15
|
function runGit(root, args) {
|
|
7
16
|
return execFileSync('git', ['-C', root, ...args], {
|
|
8
17
|
encoding: 'utf8',
|
|
9
18
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
10
19
|
}).trim();
|
|
11
20
|
}
|
|
12
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The git toplevel for `cwd`, or null when `cwd` is not inside a git repo.
|
|
23
|
+
*/
|
|
13
24
|
export function findRepoRoot(cwd) {
|
|
14
25
|
try {
|
|
15
26
|
return runGit(cwd, ['rev-parse', '--show-toplevel']);
|
|
@@ -18,17 +29,29 @@ export function findRepoRoot(cwd) {
|
|
|
18
29
|
return null;
|
|
19
30
|
}
|
|
20
31
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Absolute path to a file inside the repo's *common* git dir.
|
|
34
|
+
*
|
|
35
|
+
* Plumbbob writes its control-plane excludes to the shared gitdir's
|
|
36
|
+
* `info/exclude` (personal machinery, never the repo's `.gitignore`), and
|
|
37
|
+
* `--git-path` maps common-dir entries like `info/exclude` to the shared file
|
|
38
|
+
* even from a linked worktree — whose per-worktree gitdir has no `info/` and
|
|
39
|
+
* which git never reads for excludes. The result is relative to `root` unless
|
|
40
|
+
* already absolute.
|
|
41
|
+
*/
|
|
25
42
|
export function gitPath(root, relative) {
|
|
26
43
|
const out = runGit(root, ['rev-parse', '--git-path', relative]);
|
|
27
44
|
return isAbsolute(out) ? out : join(root, out);
|
|
28
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* The SHA of the current HEAD commit.
|
|
48
|
+
*/
|
|
29
49
|
export function headSha(root) {
|
|
30
50
|
return runGit(root, ['rev-parse', 'HEAD']);
|
|
31
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* True when the repo has at least one commit (HEAD resolves).
|
|
54
|
+
*/
|
|
32
55
|
export function hasCommit(root) {
|
|
33
56
|
try {
|
|
34
57
|
runGit(root, ['rev-parse', '--verify', 'HEAD']);
|
|
@@ -38,18 +61,25 @@ export function hasCommit(root) {
|
|
|
38
61
|
return false;
|
|
39
62
|
}
|
|
40
63
|
}
|
|
41
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Dirty = any tracked change or non-ignored untracked file.
|
|
66
|
+
*/
|
|
42
67
|
export function isDirty(root) {
|
|
43
68
|
return runGit(root, ['status', '--porcelain']).length > 0;
|
|
44
69
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
/**
|
|
71
|
+
* The number of commits on HEAD not reachable from `sha` — `git rev-list
|
|
72
|
+
* --count --first-parent <sha>..HEAD`.
|
|
73
|
+
*
|
|
74
|
+
* `status` reads this for its receipts line: one neutral note when commits
|
|
75
|
+
* landed since the last checkpoint outside plumbbob's ledger (out-of-band
|
|
76
|
+
* commits are surfaced, never blocked). `--first-parent` keeps the count on
|
|
77
|
+
* the branch's own line: merging upstream reads as the one merge commit, not
|
|
78
|
+
* the dozens it carried — those didn't land "outside the ledger" in any sense
|
|
79
|
+
* the receipt should nag about. Best-effort and never throws: 0 when the range
|
|
80
|
+
* is empty, `sha` is unknown to the repo, or HEAD is unborn — the count is
|
|
81
|
+
* informational (the human commits freely), never a gate.
|
|
82
|
+
*/
|
|
53
83
|
export function commitsSince(root, sha) {
|
|
54
84
|
try {
|
|
55
85
|
const n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', `${sha}..HEAD`]), 10);
|
|
@@ -60,42 +90,67 @@ export function commitsSince(root, sha) {
|
|
|
60
90
|
}
|
|
61
91
|
}
|
|
62
92
|
// --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
|
|
63
|
-
// only
|
|
93
|
+
// only: stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
|
|
94
|
+
/**
|
|
95
|
+
* Stage everything (`git add -A`) — the checkpoint sweep that carries the work
|
|
96
|
+
* plus plumbbob's own bookkeeping into one commit.
|
|
97
|
+
*/
|
|
64
98
|
export function stageAll(root) {
|
|
65
99
|
runGit(root, ['add', '-A']);
|
|
66
100
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Stage a single path (vs `stageAll`'s `-A`).
|
|
103
|
+
*
|
|
104
|
+
* The plan-approval commit stages only the build's artifact folder so the
|
|
105
|
+
* first step's diff can't absorb the plan scaffold — plan approval gets its
|
|
106
|
+
* own commit. `path` may be absolute or repo-relative — git resolves it
|
|
107
|
+
* against `root`. The `--` guards a path that could look like a flag.
|
|
108
|
+
*/
|
|
71
109
|
export function stagePath(root, path) {
|
|
72
110
|
runGit(root, ['add', '--', path]);
|
|
73
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* The repo-relative paths of non-ignored untracked files.
|
|
114
|
+
*/
|
|
74
115
|
export function untrackedPaths(root) {
|
|
75
116
|
const out = runGit(root, ['ls-files', '--others', '--exclude-standard']);
|
|
76
117
|
return out.length === 0 ? [] : out.split('\n');
|
|
77
118
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Commit whatever is staged as a checkpoint and return its SHA.
|
|
121
|
+
*
|
|
122
|
+
* `--allow-empty` so a step that touched only ignored files still gets a
|
|
123
|
+
* checkpoint to revert to. An optional `body` becomes the commit message body:
|
|
124
|
+
* git joins the two `-m` paragraphs with a blank line, so the CLI-owned
|
|
125
|
+
* subject stays the first line and the body rides beneath it.
|
|
126
|
+
*/
|
|
82
127
|
export function commit(root, subject, body) {
|
|
83
128
|
const message = body ? ['-m', subject, '-m', body] : ['-m', subject];
|
|
84
129
|
runGit(root, ['commit', '--allow-empty', ...message]);
|
|
85
130
|
return headSha(root);
|
|
86
131
|
}
|
|
87
|
-
|
|
88
|
-
|
|
132
|
+
/**
|
|
133
|
+
* The `--stat` summary of what is currently staged (vs HEAD) — the diffstat
|
|
134
|
+
* the deterministic checkpoint body carries when no `--body` prose arrives.
|
|
135
|
+
* Empty when nothing is staged.
|
|
136
|
+
*/
|
|
89
137
|
export function stagedStat(root) {
|
|
90
138
|
return runGit(root, ['diff', '--cached', '--stat']);
|
|
91
139
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
140
|
+
/**
|
|
141
|
+
* The repo-relative paths currently staged (vs HEAD) — the set `checkpoint`
|
|
142
|
+
* checks against the step's seam (its granted edit paths) to warn about scope
|
|
143
|
+
* drift. Empty when nothing is staged.
|
|
144
|
+
*/
|
|
95
145
|
export function stagedPaths(root) {
|
|
96
146
|
const out = runGit(root, ['diff', '--cached', '--name-only']);
|
|
97
147
|
return out.length === 0 ? [] : out.split('\n');
|
|
98
148
|
}
|
|
149
|
+
/**
|
|
150
|
+
* Reset the worktree hard to `sha` — only ever one of plumbbob's own recorded
|
|
151
|
+
* checkpoint SHAs, and only `revert` imports this (an ast-grep rule pins that
|
|
152
|
+
* single importer).
|
|
153
|
+
*/
|
|
99
154
|
export function resetHard(root, sha) {
|
|
100
155
|
runGit(root, ['reset', '--hard', sha]);
|
|
101
156
|
}
|
package/dist/lib/intent.js
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
// The intent parser.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// The intent.md parser. intent.md is a build's plan document — title, the
|
|
2
|
+
// numbered increments under `## Steps`, decisions, constraints, open questions.
|
|
3
|
+
// This module reads it in two registers. The seam parse is strict by design: a
|
|
4
|
+
// seam is a step's edit grant — the backtick-wrapped, repo-relative paths on
|
|
5
|
+
// its single `seam:` sub-line — and it gates git behavior, so the parse refuses
|
|
6
|
+
// precisely on globs, absolute paths, a missing step, a missing seam, or more
|
|
7
|
+
// than one seam line, rather than guessing. The best-effort scrapers below
|
|
8
|
+
// (title, done-when, decisions, constraints) feed an agent's context, not a
|
|
9
|
+
// gate, so they never refuse — they return what they can and report the lines
|
|
10
|
+
// they skipped.
|
|
8
11
|
const GLOB_CHARS = /[*?[\]{}]/;
|
|
12
|
+
/**
|
|
13
|
+
* The lines belonging to step `n` under `## Steps` — from its `N. ` opener to
|
|
14
|
+
* the next opener or the section end.
|
|
15
|
+
*
|
|
16
|
+
* Shared by the strict seam parse and the best-effort meta scrape; it keeps the
|
|
17
|
+
* "no Steps section" and "no such step" error strings distinct so both callers
|
|
18
|
+
* can tell them apart.
|
|
19
|
+
*/
|
|
9
20
|
function sliceStep(content, step) {
|
|
10
21
|
const lines = content.split('\n');
|
|
11
22
|
const stepsIdx = lines.findIndex((l) => l.trim() === '## Steps');
|
|
@@ -32,6 +43,14 @@ function sliceStep(content, step) {
|
|
|
32
43
|
const itemEnd = nextStart === undefined ? sectionEnd : nextStart;
|
|
33
44
|
return { ok: true, itemLines: lines.slice(itemStart, itemEnd) };
|
|
34
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse step `n`'s seam — the backtick-wrapped paths on its single `seam:` sub-line.
|
|
48
|
+
*
|
|
49
|
+
* Strict: the seam is the step's edit grant and gates git behavior, so this
|
|
50
|
+
* refuses on a missing step, a missing or duplicated seam line, an empty token,
|
|
51
|
+
* a glob, or an absolute path rather than guessing. Tokens are exact
|
|
52
|
+
* repo-relative paths or `dir/` prefix grants — never globs.
|
|
53
|
+
*/
|
|
35
54
|
export function parseStepSeam(content, step) {
|
|
36
55
|
const slice = sliceStep(content, step);
|
|
37
56
|
if (!slice.ok) {
|
|
@@ -52,7 +71,7 @@ export function parseStepSeam(content, step) {
|
|
|
52
71
|
// Truncate each at an HTML-comment opener so a trailing `<!-- ... -->` note
|
|
53
72
|
// (which may carry its own backticks) is never read as a seam token; a
|
|
54
73
|
// continuation ends at the first line whose pre-comment text has no backtick
|
|
55
|
-
// or that opens a new sub-bullet (a `- model:` note may carry backticks too
|
|
74
|
+
// or that opens a new sub-bullet (a `- model:` note may carry backticks too).
|
|
56
75
|
const seamStart = seamLines[0] ?? 0;
|
|
57
76
|
const decl = [];
|
|
58
77
|
for (let i = seamStart; i < itemLines.length; i++) {
|
|
@@ -88,25 +107,37 @@ export function parseStepSeam(content, step) {
|
|
|
88
107
|
}
|
|
89
108
|
return { ok: true, seam };
|
|
90
109
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Seam membership: a repo-relative path is in-seam if it equals an exact token,
|
|
112
|
+
* or is prefixed by a `dir/` grant.
|
|
113
|
+
*
|
|
114
|
+
* Shared by `checkpoint` (the scope-drift warning) and `revert` (untracked
|
|
115
|
+
* cleanup).
|
|
116
|
+
*/
|
|
94
117
|
export function matchesSeam(relPath, tokens) {
|
|
95
118
|
return tokens.some((token) => (token.endsWith('/') ? relPath.startsWith(token) : relPath === token));
|
|
96
119
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Whether a repo-relative path is plumbbob's own bookkeeping under `.plumbbob/`.
|
|
122
|
+
*
|
|
123
|
+
* That plane holds the tracked intent/build-log/checkpoints that ride the
|
|
124
|
+
* branch, plus the git-excluded control markers. It is never the user's code,
|
|
125
|
+
* so it never counts as scope drift, and `revert`'s untracked cleanup must
|
|
126
|
+
* never delete it. `checkpoint` stages this plane itself on every tick (the
|
|
127
|
+
* `[x]` flip, the build-log line), so without this whitelist every checkpoint
|
|
128
|
+
* would cry wolf about its own writes.
|
|
129
|
+
*/
|
|
103
130
|
export function isArtifactPath(relPath) {
|
|
104
131
|
return relPath === '.plumbbob' || relPath.startsWith('.plumbbob/');
|
|
105
132
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
133
|
+
/**
|
|
134
|
+
* The staged paths that fall outside the step's seam AND outside the artifact
|
|
135
|
+
* plane — the scope-drift set `checkpoint` warns about.
|
|
136
|
+
*
|
|
137
|
+
* Guidance, not a gate: the checkpoint still commits them. An empty seam yields
|
|
138
|
+
* no drift, so callers that cannot resolve a seam simply skip the warning
|
|
139
|
+
* rather than flagging everything.
|
|
140
|
+
*/
|
|
110
141
|
export function scopeDrift(paths, seam) {
|
|
111
142
|
if (seam.length === 0) {
|
|
112
143
|
return [];
|
|
@@ -114,6 +145,14 @@ export function scopeDrift(paths, seam) {
|
|
|
114
145
|
return paths.filter((p) => !matchesSeam(p, seam) && !isArtifactPath(p));
|
|
115
146
|
}
|
|
116
147
|
const DONE_WHEN = /\*\*done when:\*\*/i;
|
|
148
|
+
/**
|
|
149
|
+
* Scrape step `n`'s title and done-when, best-effort.
|
|
150
|
+
*
|
|
151
|
+
* `title` is the text between the `N. [ ]` opener and the `**done when:**`
|
|
152
|
+
* marker; `doneWhen` is everything after it, with wrapped continuation lines
|
|
153
|
+
* joined until the first sub-bullet (the seam line). A missing section or step
|
|
154
|
+
* yields empty strings, never a refusal.
|
|
155
|
+
*/
|
|
117
156
|
export function parseStepMeta(content, step) {
|
|
118
157
|
const slice = sliceStep(content, step);
|
|
119
158
|
if (!slice.ok) {
|
|
@@ -139,7 +178,9 @@ export function parseStepMeta(content, step) {
|
|
|
139
178
|
doneWhen: body.slice(m.index + m[0].length).trim(),
|
|
140
179
|
};
|
|
141
180
|
}
|
|
142
|
-
|
|
181
|
+
/**
|
|
182
|
+
* The build title — the first `# ` heading. '' when absent.
|
|
183
|
+
*/
|
|
143
184
|
export function parseBuildTitle(content) {
|
|
144
185
|
for (const line of content.split('\n')) {
|
|
145
186
|
const m = /^#\s+(.+)$/.exec(line);
|
|
@@ -151,13 +192,18 @@ export function parseBuildTitle(content) {
|
|
|
151
192
|
}
|
|
152
193
|
const SCOPE_HEADER = /^\*\*Scope:\*\*\s*(.*)$/;
|
|
153
194
|
const SCOPE_PLACEHOLDER = /^<.*>$/;
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
195
|
+
/**
|
|
196
|
+
* The build's default Conventional-Commit scope — the `**Scope:**` header field
|
|
197
|
+
* authored once in intent.md at plan time.
|
|
198
|
+
*
|
|
199
|
+
* The value is read verbatim off the line, with any trailing `<!-- ... -->`
|
|
200
|
+
* guidance note stripped. An unfilled placeholder — an empty value or an
|
|
201
|
+
* angle-bracket placeholder like `<feature>` — parses as absent, so a freshly
|
|
202
|
+
* scaffolded, not-yet-edited build falls through to the next rung of the scope
|
|
203
|
+
* fallback chain (the build slug) rather than landing commits scoped
|
|
204
|
+
* `(<feature>)`. null when the header is missing entirely, so an intent.md
|
|
205
|
+
* without one behaves the same way.
|
|
206
|
+
*/
|
|
161
207
|
export function parseBuildScope(content) {
|
|
162
208
|
for (const line of content.split('\n')) {
|
|
163
209
|
const m = SCOPE_HEADER.exec(line.trim());
|
|
@@ -169,6 +215,15 @@ export function parseBuildScope(content) {
|
|
|
169
215
|
}
|
|
170
216
|
return null;
|
|
171
217
|
}
|
|
218
|
+
/**
|
|
219
|
+
* Scrape the top-level `- ` bullets under a `## Heading`, each verbatim with
|
|
220
|
+
* its wrapped continuation lines joined into one string.
|
|
221
|
+
*
|
|
222
|
+
* Verbatim keeps a bullet's `*because*` rationale intact for the agent that
|
|
223
|
+
* reads it. `skipped` holds any line under the heading that was neither a
|
|
224
|
+
* bullet, a continuation, nor blank — a malformed bullet the caller warns
|
|
225
|
+
* about rather than dropping silently.
|
|
226
|
+
*/
|
|
172
227
|
export function scrapeBullets(content, heading) {
|
|
173
228
|
const lines = content.split('\n');
|
|
174
229
|
const start = lines.findIndex((l) => l.trim() === heading);
|
|
@@ -208,11 +263,16 @@ export function scrapeBullets(content, heading) {
|
|
|
208
263
|
flush();
|
|
209
264
|
return { items, skipped };
|
|
210
265
|
}
|
|
211
|
-
|
|
212
|
-
|
|
266
|
+
/**
|
|
267
|
+
* Trim a trailing em-dash or hyphen (the `Title —` separator) plus surrounding
|
|
268
|
+
* space, so a title never carries the marker that introduced its done-when.
|
|
269
|
+
*/
|
|
213
270
|
function stripTrailingDash(text) {
|
|
214
271
|
return text.replace(/\s*[—-]+\s*$/, '').trim();
|
|
215
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Shorthand for a failed SeamParse.
|
|
275
|
+
*/
|
|
216
276
|
function fail(error) {
|
|
217
277
|
return { ok: false, error };
|
|
218
278
|
}
|
package/dist/lib/latch.js
CHANGED
|
@@ -1,51 +1,70 @@
|
|
|
1
|
-
// The approval latch
|
|
2
|
-
//
|
|
3
|
-
// tick). The work plane stays guidance
|
|
4
|
-
// is latched: a step may not land unless a human
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
1
|
+
// The approval latch — the ledger-plane predicate `checkpoint` evaluates before
|
|
2
|
+
// its check gate (the latch is cheap, so it runs first; the heavy gate already
|
|
3
|
+
// ran in the verify tick). The work plane stays guidance — no edit is ever
|
|
4
|
+
// blocked — but the commit tick is latched: a step may not land unless a human
|
|
5
|
+
// turn intervened since the step was entered, the human granted a one-turn
|
|
6
|
+
// self-approval from their literal prompt, or the turn ledger is dormant (a
|
|
7
|
+
// host with no hooks grows no ledger, and the latch stays out of the way rather
|
|
8
|
+
// than wedging). The ledger is a pair of flat sidecar control files: TURN
|
|
9
|
+
// counts human prompts (ticked by the `plumbbob turn` hook), TICK stamps that
|
|
10
|
+
// count when a step is entered — TURN > TICK means a human turn intervened.
|
|
11
|
+
// Five rows, evaluated in order, first hit wins; the refusal message *is* the
|
|
12
|
+
// pause affordance. A standing `auto` in a settings file is never a grant: a
|
|
13
|
+
// model can write that file, and a grant it can forge is no grant — the latch
|
|
14
|
+
// reads it only to say so at the pause, never to allow. It reads only
|
|
15
|
+
// TURN/TICK/GRANT and isTTY — it never sniffs the host session. Named exports,
|
|
16
|
+
// node builtins.
|
|
11
17
|
import { readFileSync } from 'node:fs';
|
|
12
18
|
import { grantPath, tickPath, turnPath } from "./sidecar.js";
|
|
13
19
|
import { resolveBoolean } from "./settings.js";
|
|
14
20
|
const ALLOW = { allow: true };
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The refusal message is the affordance — it tells the model what the pause is,
|
|
23
|
+
* how it ends (approval on the next turn lands it; `/plumbbob:build` only starts the
|
|
24
|
+
* next step), and names the only legitimate grants.
|
|
25
|
+
*/
|
|
20
26
|
const NO_TURN_MESSAGE = `plumbbob: checkpoint refused — no human turn since this step began. This is the
|
|
21
27
|
pause: present the diff and the self-review, then end the turn. The human's approval on
|
|
22
28
|
their next turn is what lets this land — re-run \`checkpoint\` then and stop at the
|
|
23
|
-
boundary; \`/
|
|
24
|
-
\`/
|
|
29
|
+
boundary; \`/plumbbob:build\` only starts the next step, it never lands this one. (An explicit
|
|
30
|
+
\`/plumbbob:build --auto\` or a step range in the human's own prompt is the only self-approval.)
|
|
25
31
|
`;
|
|
32
|
+
/**
|
|
33
|
+
* The plan commit gets its own wording: there is no step, no diff, and no
|
|
34
|
+
* self-review at plan time — what the human approves is the plan itself.
|
|
35
|
+
*/
|
|
26
36
|
const NO_TURN_PLAN_MESSAGE = `plumbbob: checkpoint refused — no human turn since \`start\` stamped this plan. This
|
|
27
37
|
is the plan pause: present the plan, then end the turn; the human's approving
|
|
28
|
-
message is the tick that lets it land on re-fire. (An explicit \`/
|
|
38
|
+
message is the tick that lets it land on re-fire. (An explicit \`/plumbbob:build --auto\` or a
|
|
29
39
|
step range in the human's own prompt is the only self-approval.)
|
|
30
40
|
`;
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
41
|
+
/**
|
|
42
|
+
* A settings `auto` is never a grant — a model can write that file, and a grant
|
|
43
|
+
* it can forge is no grant. When one is set, the refusal names it rather than
|
|
44
|
+
* silently ignoring it, and points at the only self-approval that works.
|
|
45
|
+
*/
|
|
46
|
+
const SETTINGS_AUTO_NOTE = `note: \`auto\` is set in settings but is no longer a grant (D67) — re-fire \`/plumbbob:build --auto\` to self-approve.
|
|
35
47
|
`;
|
|
48
|
+
/**
|
|
49
|
+
* The refusal for a range grant whose ceiling the landing step exceeds.
|
|
50
|
+
*/
|
|
36
51
|
function ceilingMessage(ceiling) {
|
|
37
52
|
return `plumbbob: checkpoint refused — the range you granted ends at step ${ceiling} — pause here; re-fire to continue.\n`;
|
|
38
53
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The five-row matrix, in order, first hit wins.
|
|
56
|
+
*
|
|
57
|
+
* A `range` grant speaks only to numbered steps: on a `--plan` checkpoint (step
|
|
58
|
+
* null) it neither allows nor refuses, and the decision falls through to the
|
|
59
|
+
* turn comparison.
|
|
60
|
+
*/
|
|
42
61
|
export function evaluateLatch(input) {
|
|
43
62
|
if (input.isTTY)
|
|
44
63
|
return ALLOW; // 1 — a human at the keyboard
|
|
45
64
|
if (input.turn === null || input.tick === null)
|
|
46
|
-
return ALLOW; // 2 — dormant / hand-built
|
|
65
|
+
return ALLOW; // 2 — dormant ledger / hand-built diff
|
|
47
66
|
if (input.grant?.kind === 'auto')
|
|
48
|
-
return ALLOW; // 3 — one-turn grant
|
|
67
|
+
return ALLOW; // 3 — one-turn grant the human typed
|
|
49
68
|
if (input.grant?.kind === 'range' && input.step !== null) {
|
|
50
69
|
if (input.step <= input.grant.ceiling)
|
|
51
70
|
return ALLOW;
|
|
@@ -53,14 +72,17 @@ export function evaluateLatch(input) {
|
|
|
53
72
|
}
|
|
54
73
|
if (input.turn > input.tick)
|
|
55
74
|
return ALLOW; // 4 — a human turn intervened
|
|
56
|
-
// 5 — the pause (plan-worded when no step is landing). A settings `auto`
|
|
57
|
-
// allows
|
|
75
|
+
// 5 — the pause (plan-worded when no step is landing). A settings `auto` never
|
|
76
|
+
// allows; when one is set, the refusal notes it rather than honoring it.
|
|
58
77
|
const base = input.step === null ? NO_TURN_PLAN_MESSAGE : NO_TURN_MESSAGE;
|
|
59
78
|
return { allow: false, reason: 'no-turn', message: input.settingsAuto ? base + SETTINGS_AUTO_NOTE : base };
|
|
60
79
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
80
|
+
/**
|
|
81
|
+
* Parse a GRANT file's content (`auto` | `range <M>`).
|
|
82
|
+
*
|
|
83
|
+
* Anything else — including a corrupted or hand-mangled file — contributes
|
|
84
|
+
* nothing: no grant, never an error.
|
|
85
|
+
*/
|
|
64
86
|
export function parseGrant(raw) {
|
|
65
87
|
const trimmed = raw.trim();
|
|
66
88
|
if (trimmed === 'auto')
|
|
@@ -70,9 +92,12 @@ export function parseGrant(raw) {
|
|
|
70
92
|
return { kind: 'range', ceiling: Number(range[1]) };
|
|
71
93
|
return null;
|
|
72
94
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Gather the latch's inputs from the worktree and evaluate.
|
|
97
|
+
*
|
|
98
|
+
* `step` is the step number being checkpointed, or null for the plan commit
|
|
99
|
+
* (which latches on the TICK that `start` stamped).
|
|
100
|
+
*/
|
|
76
101
|
export function checkLatch(root, step) {
|
|
77
102
|
return evaluateLatch({
|
|
78
103
|
isTTY: process.stdin.isTTY === true,
|
|
@@ -80,10 +105,13 @@ export function checkLatch(root, step) {
|
|
|
80
105
|
tick: readCount(tickPath(root)),
|
|
81
106
|
grant: readGrant(root),
|
|
82
107
|
step,
|
|
83
|
-
//
|
|
108
|
+
// Read from the settings ladder — but only to decorate the pause, never to allow.
|
|
84
109
|
settingsAuto: resolveBoolean(root, 'auto', false),
|
|
85
110
|
});
|
|
86
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* The current GRANT, or null when the file is absent or unparseable.
|
|
114
|
+
*/
|
|
87
115
|
function readGrant(root) {
|
|
88
116
|
try {
|
|
89
117
|
return parseGrant(readFileSync(grantPath(root), 'utf8'));
|
|
@@ -92,9 +120,12 @@ function readGrant(root) {
|
|
|
92
120
|
return null;
|
|
93
121
|
}
|
|
94
122
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
123
|
+
/**
|
|
124
|
+
* A ledger count, or null when the file is absent or holds garbage.
|
|
125
|
+
*
|
|
126
|
+
* Malformed contributes nothing, and an unreadable ledger reads as dormant
|
|
127
|
+
* (row 2) rather than wedging the land.
|
|
128
|
+
*/
|
|
98
129
|
function readCount(path) {
|
|
99
130
|
try {
|
|
100
131
|
const n = Number.parseInt(readFileSync(path, 'utf8').trim(), 10);
|