plumbbob 0.13.6 → 0.13.7
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 +5 -5
- package/dist/cli-core.js +2 -1
- package/dist/lib/links.js +97 -0
- package/dist/lib/notice.js +1 -1
- package/dist/lib/orient.js +114 -11
- package/dist/lib/sidecar.js +42 -5
- package/dist/verbs/build.js +20 -12
- package/dist/verbs/checkpoint.js +8 -4
- package/dist/verbs/handoff.js +248 -91
- package/dist/verbs/harvest.js +1 -1
- package/dist/verbs/recover.js +2 -2
- package/dist/verbs/relay.js +74 -6
- package/dist/verbs/start.js +40 -14
- package/dist/verbs/status.js +16 -4
- package/dist/verbs/turn.js +29 -12
- package/package.json +1 -1
- package/skills/build/SKILL.md +26 -14
- package/skills/park/SKILL.md +9 -6
- package/skills/plan/SKILL.md +35 -13
- package/skills/refine/SKILL.md +13 -4
- package/skills/run/SKILL.md +19 -6
- package/skills/status/SKILL.md +7 -0
- package/skills/verify/SKILL.md +12 -7
- package/templates/intent.md +14 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.13.
|
|
4
|
+
"version": "0.13.7",
|
|
5
5
|
"description": "Attention-first build process — driver skills + a CLI that keep you the decider: guidance on the work, a latch on the record.",
|
|
6
6
|
"author": { "name": "Rob McLarty", "email": "hello@robmclarty.com", "url": "https://robmclarty.com" },
|
|
7
7
|
"homepage": "https://github.com/robmclarty/plumbbob#readme",
|
package/README.md
CHANGED
|
@@ -175,9 +175,9 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
175
175
|
|
|
176
176
|
| **Verdict** | ● Plumb |
|
|
177
177
|
| --- | --- |
|
|
178
|
-
| Now | Step 1
|
|
179
|
-
| Next Up | Step 2
|
|
180
|
-
| Range | 1
|
|
178
|
+
| Now | **Step 1** on Opus - feat(limiter): add a token-bucket limiter |
|
|
179
|
+
| Next Up | **Step 2** on Sonnet - feat(login): wire the limiter into POST /login (details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
|
|
180
|
+
| Range | **1** lands with step 1 |
|
|
181
181
|
|
|
182
182
|
**Recommendation**: Approve it, refilling on read. The gate is green and the seam held, and a bucket that refills when it is read needs no timer for the route to clean up.
|
|
183
183
|
|
|
@@ -200,8 +200,8 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
200
200
|
|
|
201
201
|
| **Verdict** | ● Plumb |
|
|
202
202
|
| --- | --- |
|
|
203
|
-
| Next Up | Step 2
|
|
204
|
-
| Range | 2
|
|
203
|
+
| Next Up | **Step 2** on Sonnet - feat(login): wire the limiter into POST /login (details: [intent.md:28](.plumbbob/builds/2026-07-03-rate-limit-the-login-endpoint/intent.md#L28)) |
|
|
204
|
+
| Range | **2** on Sonnet: /plumbbob:run (steps 2-3) |
|
|
205
205
|
```
|
|
206
206
|
|
|
207
207
|

|
package/dist/cli-core.js
CHANGED
|
@@ -61,7 +61,7 @@ const VERBS = [
|
|
|
61
61
|
{
|
|
62
62
|
name: 'status',
|
|
63
63
|
description: 'print the orientation dashboard, or NO ACTIVE SESSION',
|
|
64
|
-
synopsis: ['status [--build <slug>] [--invoked "<args>"] [--run "<args>"]'],
|
|
64
|
+
synopsis: ['status [--build <slug>] [--invoked "<args>"] [--run "<args>"] [--questions]'],
|
|
65
65
|
flags: [
|
|
66
66
|
BUILD_FLAG,
|
|
67
67
|
{
|
|
@@ -74,6 +74,7 @@ const VERBS = [
|
|
|
74
74
|
value: '"<args>"',
|
|
75
75
|
gloss: 'the raw /plumbbob:run invocation; prints the run it names above the dashboard, or the refusal as its pointer',
|
|
76
76
|
},
|
|
77
|
+
{ name: '--questions', gloss: "print the plan's open questions as one table (question, why it matters, lean), the deferred ones on a line beneath, in place of the dashboard" },
|
|
77
78
|
],
|
|
78
79
|
notes: 'Read-only; always exits 0. With no cursor it lists the available builds.',
|
|
79
80
|
},
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Relative links written for one directory, re-aimed for another. A section of
|
|
2
|
+
// `.plumbbob/detail.md` cites `builds/<slug>/intent.md#d5` relative to
|
|
3
|
+
// `.plumbbob/`; copied verbatim into `builds/<slug>/build-log.md` the same
|
|
4
|
+
// target now points one directory too deep and the link breaks. Only the copy
|
|
5
|
+
// step knows both directories, so this is the one place the target moves:
|
|
6
|
+
// every inline link and image in prose gets its target resolved against the
|
|
7
|
+
// directory the text was written for and re-expressed from the directory it is
|
|
8
|
+
// bound for. Code is never touched: a fenced block is tracked across lines and
|
|
9
|
+
// an inline code span across one, because a link inside a sample is part of
|
|
10
|
+
// the sample. Pure string work; no fs, no process, `node:path` only.
|
|
11
|
+
import { relative, resolve, sep } from 'node:path';
|
|
12
|
+
import { closesFence, fenceOpening } from "./orient.js";
|
|
13
|
+
/** One inline code span on a line: a run of backticks up to the next run of the same length. */
|
|
14
|
+
const CODE_SPAN = /(`+).*?\1/g;
|
|
15
|
+
/**
|
|
16
|
+
* One inline link or image in prose, in three parts: everything up to and
|
|
17
|
+
* including the opening parenthesis, the target, and the rest (an optional
|
|
18
|
+
* quoted title, then the closing parenthesis). An angle-bracketed target does
|
|
19
|
+
* not match, so it rides through untouched.
|
|
20
|
+
*/
|
|
21
|
+
const LINK = /(!?\[[^\]]*\]\()([^\s()<>]*)((?:\s+(?:"[^"]*"|'[^']*'|\([^()]*\)))?\))/g;
|
|
22
|
+
/** A target that names its own scheme (`https:`, `mailto:`), which no directory move can change. */
|
|
23
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i;
|
|
24
|
+
/**
|
|
25
|
+
* Re-aim every relative link in `text` so it resolves the same from `toDir`
|
|
26
|
+
* as it did from `fromDir`.
|
|
27
|
+
*
|
|
28
|
+
* A link aimed at `builds/x/intent.md#d5`, written for `.plumbbob/`, aims at
|
|
29
|
+
* `intent.md#d5` once it is bound for `.plumbbob/builds/x/`; `../README.md` becomes
|
|
30
|
+
* `../../../README.md`. A fragment rides along unchanged. The text comes
|
|
31
|
+
* back byte for byte when the two directories are one, when no link is
|
|
32
|
+
* relative, or when every link sits inside a fence or a code span.
|
|
33
|
+
*/
|
|
34
|
+
export function rerootLinks(text, fromDir, toDir) {
|
|
35
|
+
const from = resolve(fromDir);
|
|
36
|
+
const to = resolve(toDir);
|
|
37
|
+
if (from === to)
|
|
38
|
+
return text;
|
|
39
|
+
let fence = null;
|
|
40
|
+
const lines = [];
|
|
41
|
+
for (const line of text.split('\n')) {
|
|
42
|
+
if (fence !== null) {
|
|
43
|
+
if (closesFence(line, fence))
|
|
44
|
+
fence = null;
|
|
45
|
+
lines.push(line);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const opened = fenceOpening(line);
|
|
49
|
+
if (opened !== null) {
|
|
50
|
+
fence = opened;
|
|
51
|
+
lines.push(line);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
lines.push(rerootLine(line, from, to));
|
|
55
|
+
}
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* One unfenced line with its prose segments rewritten and its code spans
|
|
60
|
+
* passed through.
|
|
61
|
+
*
|
|
62
|
+
* The line splits at every code span; the span is copied as it stands and the
|
|
63
|
+
* text between spans is where links move. A lone backtick with no closer is
|
|
64
|
+
* prose, as it is to a markdown renderer.
|
|
65
|
+
*/
|
|
66
|
+
function rerootLine(line, from, to) {
|
|
67
|
+
let out = '';
|
|
68
|
+
let last = 0;
|
|
69
|
+
for (const span of line.matchAll(CODE_SPAN)) {
|
|
70
|
+
out += rerootProse(line.slice(last, span.index), from, to) + span[0];
|
|
71
|
+
last = span.index + span[0].length;
|
|
72
|
+
}
|
|
73
|
+
return out + rerootProse(line.slice(last), from, to);
|
|
74
|
+
}
|
|
75
|
+
/** A prose segment with the target of every inline link and image re-aimed. */
|
|
76
|
+
function rerootProse(prose, from, to) {
|
|
77
|
+
return prose.replace(LINK, (_, open, target, close) => `${open}${rerootTarget(target, from, to)}${close}`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* One link target, moved from `from` to `to` when it is relative and returned
|
|
81
|
+
* as written otherwise.
|
|
82
|
+
*
|
|
83
|
+
* Untouched: an empty target, a bare `#anchor`, a root-relative `/docs/a.md`,
|
|
84
|
+
* and anything with a scheme. A relative target resolves against `from`, is
|
|
85
|
+
* re-expressed from `to` with posix separators, and keeps its `#fragment`.
|
|
86
|
+
* A target that lands on `to` itself comes back as `.` rather than as
|
|
87
|
+
* nothing.
|
|
88
|
+
*/
|
|
89
|
+
function rerootTarget(target, from, to) {
|
|
90
|
+
if (target === '' || target.startsWith('#') || target.startsWith('/') || SCHEME.test(target))
|
|
91
|
+
return target;
|
|
92
|
+
const hash = target.indexOf('#');
|
|
93
|
+
const file = hash === -1 ? target : target.slice(0, hash);
|
|
94
|
+
const fragment = hash === -1 ? '' : target.slice(hash);
|
|
95
|
+
const moved = relative(to, resolve(from, file)).split(sep).join('/');
|
|
96
|
+
return `${moved === '' ? '.' : moved}${fragment}`;
|
|
97
|
+
}
|
package/dist/lib/notice.js
CHANGED
|
@@ -91,7 +91,7 @@ export function ending(e) {
|
|
|
91
91
|
* ```text
|
|
92
92
|
* | **Verdict** | ● Plumb |
|
|
93
93
|
* | --- | --- |
|
|
94
|
-
* | Now | Step 2
|
|
94
|
+
* | Now | **Step 2** on Sonnet - feat(login): wire the limiter into POST /login |
|
|
95
95
|
* ```
|
|
96
96
|
*/
|
|
97
97
|
export function stateTable(rows) {
|
package/dist/lib/orient.js
CHANGED
|
@@ -70,8 +70,10 @@ export function parseSteps(intent) {
|
|
|
70
70
|
const block = lines.slice(s.idx, blockEnd).join('\n');
|
|
71
71
|
const dw = /\*\*done when:\*\*\s*(.+)/i.exec(block);
|
|
72
72
|
const doneWhen = dw ? (dw[1] ?? '').trim() : null;
|
|
73
|
+
// A model line still opening on the template's `<...>` fill-in recommends
|
|
74
|
+
// nothing, so it reads as no line at all rather than as a model named `<smallest`.
|
|
73
75
|
const md = /^\s*-\s*model:\s*(.+)$/im.exec(block);
|
|
74
|
-
const model = md ? (md[1] ?? '').trim() : null;
|
|
76
|
+
const model = md && !/^<[^<>]+>/.test((md[1] ?? '').trim()) ? (md[1] ?? '').trim() : null;
|
|
75
77
|
return { n: s.n, done: s.done, title: s.title, planned: /done when/i.test(block), doneWhen, model, line: offset + s.idx + 1 };
|
|
76
78
|
});
|
|
77
79
|
}
|
|
@@ -1127,14 +1129,115 @@ export function markStepDone(intent, n) {
|
|
|
1127
1129
|
* substring read that as resolved and dropped a live hole off the dashboard. And an
|
|
1128
1130
|
* opener whose body has not been filled in yet, still opening on its `<...>`
|
|
1129
1131
|
* fill-in, is scaffold rather than a question, which is what makes a fresh build
|
|
1130
|
-
* report zero rather than one.
|
|
1132
|
+
* report zero rather than one. A question deferred to the work still counts:
|
|
1133
|
+
* nothing has been decided (D120 (deferred-questions)).
|
|
1131
1134
|
*/
|
|
1132
1135
|
export function parseOpenQuestions(intent) {
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1136
|
+
return parseOpenQuestionRows(intent).length;
|
|
1137
|
+
}
|
|
1138
|
+
// An open question's opener: the handle, an optional anchor and slug, then
|
|
1139
|
+
// the body after the colon (D76 (resolved-on-opener)).
|
|
1140
|
+
const QUESTION_OPENER = /^-\s+(?:<a id="[^"]*"><\/a>\s*)?\*{0,2}(Q\d+)(?: \(([^)]+)\))?\*{0,2}:\s*(.*)$/;
|
|
1141
|
+
// A sub-line beneath an opener: `- *plain:* …` or `- *lean:* …`.
|
|
1142
|
+
const QUESTION_FIELD = /^-\s+\*(plain|lean):\*\s*(.*)$/;
|
|
1143
|
+
/**
|
|
1144
|
+
* The open questions, each with what the table shows, in the order the plan
|
|
1145
|
+
* lists them. The rules `parseOpenQuestions` counts by decide which openers
|
|
1146
|
+
* are open: a `resolved` marker on the opener line closes it, and a body still
|
|
1147
|
+
* opening on its `<...>` fill-in is scaffold. The `*plain:*` sub-line is why
|
|
1148
|
+
* the question matters, the `*lean:*` sub-line the proposed answer, each with
|
|
1149
|
+
* any lines it wraps onto (D118 (questions-table)).
|
|
1150
|
+
*
|
|
1151
|
+
* A `*deferred:*` marker on the opener keeps the question open and records what
|
|
1152
|
+
* will settle it. Its until-clause is the human's own words, so a "resolved"
|
|
1153
|
+
* inside it does not close the question; only the literal `*resolved:*` marker
|
|
1154
|
+
* does, wherever it sits on the line (D120 (deferred-questions)).
|
|
1155
|
+
*/
|
|
1156
|
+
export function parseOpenQuestionRows(intent) {
|
|
1157
|
+
const { offset, lines } = sectionLines(intent, '## Open questions');
|
|
1158
|
+
const rows = [];
|
|
1159
|
+
let current = null;
|
|
1160
|
+
let field = null;
|
|
1161
|
+
lines.forEach((raw, i) => {
|
|
1162
|
+
const text = raw.trim();
|
|
1163
|
+
const opener = QUESTION_OPENER.exec(text);
|
|
1164
|
+
if (opener !== null) {
|
|
1165
|
+
const body = opener[3] ?? '';
|
|
1166
|
+
const open = !resolvedOpener(raw) && !/^<[^>]*>/.test(body);
|
|
1167
|
+
current = open
|
|
1168
|
+
? { id: opener[1] ?? '', slug: opener[2] ?? null, line: offset + i + 1, question: questionText(body), why: null, lean: null, deferred: deferredText(body) }
|
|
1169
|
+
: null;
|
|
1170
|
+
if (current !== null)
|
|
1171
|
+
rows.push(current);
|
|
1172
|
+
field = null;
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
const sub = QUESTION_FIELD.exec(text);
|
|
1176
|
+
if (current !== null && sub !== null) {
|
|
1177
|
+
field = sub[1] === 'plain' ? 'why' : 'lean';
|
|
1178
|
+
current[field] = (sub[2] ?? '').trim();
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (current !== null && field !== null && text.length > 0 && /^\s/.test(raw) && !text.startsWith('-')) {
|
|
1182
|
+
current[field] = `${current[field] ?? ''} ${text}`.trim();
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
field = null;
|
|
1186
|
+
});
|
|
1187
|
+
return rows;
|
|
1188
|
+
}
|
|
1189
|
+
// The marker a question deferred to the work carries on its opener line.
|
|
1190
|
+
const DEFERRED_MARKER = /\*deferred:\*/i;
|
|
1191
|
+
/**
|
|
1192
|
+
* Whether an opener line closes its question: "resolved" as a whole word
|
|
1193
|
+
* anywhere before a `*deferred:*` marker (D76 (resolved-on-opener)), or the
|
|
1194
|
+
* literal `*resolved:*` marker anywhere on the line. The until-clause after a
|
|
1195
|
+
* deferral is free text, so a bare "resolved" there leaves the question open.
|
|
1196
|
+
*/
|
|
1197
|
+
function resolvedOpener(raw) {
|
|
1198
|
+
const deferredAt = raw.search(DEFERRED_MARKER);
|
|
1199
|
+
const head = deferredAt === -1 ? raw : raw.slice(0, deferredAt);
|
|
1200
|
+
return /\bresolved\b/i.test(head) || /\*resolved:\*/i.test(raw);
|
|
1201
|
+
}
|
|
1202
|
+
/** An opener's question: its body without the trailing `*resolve by:*` or `*deferred:*` tag. */
|
|
1203
|
+
function questionText(body) {
|
|
1204
|
+
return body.replace(/,?\s*\*(?:resolve by|deferred):\*.*$/i, '').trim();
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* What a deferred opener says will settle it: the text after its `*deferred:*`
|
|
1208
|
+
* marker, without the leading date or a `*resolve by:*` tag left beside it,
|
|
1209
|
+
* and empty for a bare marker. Null when the opener carries no marker.
|
|
1210
|
+
*/
|
|
1211
|
+
function deferredText(body) {
|
|
1212
|
+
const at = body.search(DEFERRED_MARKER);
|
|
1213
|
+
if (at === -1) {
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
return body
|
|
1217
|
+
.slice(at)
|
|
1218
|
+
.replace(DEFERRED_MARKER, '')
|
|
1219
|
+
.replace(/,?\s*\*resolve by:\*.*$/i, '')
|
|
1220
|
+
.trim()
|
|
1221
|
+
.replace(/^\d{4}-\d{2}-\d{2},?\s*/, '')
|
|
1222
|
+
.trim();
|
|
1223
|
+
}
|
|
1224
|
+
/** Split open question rows by who acts on them (D120 (deferred-questions)). */
|
|
1225
|
+
export function questionTotals(rows) {
|
|
1226
|
+
const deferred = rows.filter((q) => q.deferred !== null).length;
|
|
1227
|
+
return { toAnswer: rows.length - deferred, deferred };
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* The dashboard's question count, split the way the card's Questions row
|
|
1231
|
+
* splits it: `open questions 1 to answer, 2 deferred`, either half alone when
|
|
1232
|
+
* the other is zero, and `open questions 0` with none open. A comma joins the
|
|
1233
|
+
* halves because ` · ` already separates the line's fields.
|
|
1234
|
+
*/
|
|
1235
|
+
export function questionsPhrase(totals) {
|
|
1236
|
+
const halves = [
|
|
1237
|
+
...(totals.toAnswer > 0 ? [`${totals.toAnswer} to answer`] : []),
|
|
1238
|
+
...(totals.deferred > 0 ? [`${totals.deferred} deferred`] : []),
|
|
1239
|
+
];
|
|
1240
|
+
return `open questions ${halves.length === 0 ? '0' : halves.join(', ')}`;
|
|
1138
1241
|
}
|
|
1139
1242
|
/**
|
|
1140
1243
|
* Count the park list: the `- [ ]` lines still open, and every checklist line
|
|
@@ -1338,7 +1441,7 @@ export function orient(input) {
|
|
|
1338
1441
|
lastCheckpoint: parseLastCheckpoint(input.checkpoints),
|
|
1339
1442
|
parked: parks.open,
|
|
1340
1443
|
parkedTotal: parks.total,
|
|
1341
|
-
|
|
1444
|
+
questions: questionTotals(parseOpenQuestionRows(input.intent)),
|
|
1342
1445
|
next: (idle ? ranges.waitingMove : null) ??
|
|
1343
1446
|
(atBoundary ? ranges.move : null) ??
|
|
1344
1447
|
nextMove(input.spiking, ordered, input.inFlight, parks.open, requested),
|
|
@@ -2079,12 +2182,12 @@ function proseBlocks(text) {
|
|
|
2079
2182
|
return blocks;
|
|
2080
2183
|
}
|
|
2081
2184
|
/** The fence a line opens: three or more backticks or tildes, indented three spaces at most. Null when it opens none. */
|
|
2082
|
-
function fenceOpening(line) {
|
|
2185
|
+
export function fenceOpening(line) {
|
|
2083
2186
|
const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
|
|
2084
2187
|
return marker === undefined ? null : { char: marker.charAt(0), length: marker.length };
|
|
2085
2188
|
}
|
|
2086
2189
|
/** Whether a line closes an open fence: the same character, at least as many of it, and nothing after. */
|
|
2087
|
-
function closesFence(line, fence) {
|
|
2190
|
+
export function closesFence(line, fence) {
|
|
2088
2191
|
const marker = /^ {0,3}(`{3,}|~{3,})\s*$/.exec(line)?.[1];
|
|
2089
2192
|
return marker !== undefined && marker.startsWith(fence.char) && marker.length >= fence.length;
|
|
2090
2193
|
}
|
|
@@ -2254,7 +2357,7 @@ export function formatOrientation(o) {
|
|
|
2254
2357
|
cpLine,
|
|
2255
2358
|
...receipts,
|
|
2256
2359
|
...orderRow,
|
|
2257
|
-
`${parkedPhrase({ open: o.parked, total: o.parkedTotal })} ·
|
|
2360
|
+
`${parkedPhrase({ open: o.parked, total: o.parkedTotal })} · ${questionsPhrase(o.questions)}`,
|
|
2258
2361
|
'',
|
|
2259
2362
|
`next → ${o.next}`,
|
|
2260
2363
|
].join('\n');
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// a STEP is in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
|
|
21
21
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
22
22
|
import { dirname, join } from 'node:path';
|
|
23
|
-
import { gitPath } from "./git.js";
|
|
23
|
+
import { gitPath, isIgnored } from "./git.js";
|
|
24
24
|
const DIRNAME = '.plumbbob'; // the sidecar folder name at the repo root
|
|
25
25
|
/**
|
|
26
26
|
* The sidecar root: `<root>/.plumbbob`.
|
|
@@ -175,6 +175,37 @@ function artifactDir(root, slug) {
|
|
|
175
175
|
export function buildFolder(root, slug) {
|
|
176
176
|
return artifactDir(root, slug);
|
|
177
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Whether git ignores the build folder here, by the repo's own `.gitignore` or
|
|
180
|
+
* by a `.plumbbob/` line in info/exclude (`--local`): the probe `checkpoint
|
|
181
|
+
* --plan` acts on, since an ignored folder lands its plan commit record-only.
|
|
182
|
+
*
|
|
183
|
+
* Best-effort: a probe that throws (a fatal `git check-ignore`) reads as not
|
|
184
|
+
* ignored, so a caller only warning about it stays quiet rather than crashing.
|
|
185
|
+
*/
|
|
186
|
+
export function buildFolderIgnored(root, slug) {
|
|
187
|
+
try {
|
|
188
|
+
return isIgnored(root, buildFolder(root, slug));
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Whether the build's plan has landed: a `plan <sha>` line in its checkpoints
|
|
196
|
+
* ledger, which only `checkpoint --plan` writes. A missing ledger reads as not
|
|
197
|
+
* landed.
|
|
198
|
+
*/
|
|
199
|
+
export function planCommitted(root, slug) {
|
|
200
|
+
try {
|
|
201
|
+
return readFileSync(checkpointsPath(root, slug), 'utf8')
|
|
202
|
+
.split('\n')
|
|
203
|
+
.some((line) => line.startsWith('plan '));
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
178
209
|
/**
|
|
179
210
|
* The SPIKE marker: a single-purpose presence flag, like SEAM/STEP.
|
|
180
211
|
*
|
|
@@ -217,7 +248,8 @@ export function clearStep(root, slug) {
|
|
|
217
248
|
* PAUSE: which card the human has already read, so a later `handoff` prints
|
|
218
249
|
* the one line that points back at it instead of the card. It holds a step
|
|
219
250
|
* number once that step's pause card has shown (`2`), or `landed 2` once the
|
|
220
|
-
* boundary step 2 landed on has shown
|
|
251
|
+
* boundary step 2 landed on has shown, `landed 2 answered` when that landing
|
|
252
|
+
* answered step 2's pause card.
|
|
221
253
|
*
|
|
222
254
|
* It records that a card showed, never what the card said: a later turn that
|
|
223
255
|
* changes the work is the model's reply, and the card comes back only on
|
|
@@ -233,8 +265,12 @@ export function pausePath(root, slug) {
|
|
|
233
265
|
*/
|
|
234
266
|
export function readPause(root, slug) {
|
|
235
267
|
try {
|
|
236
|
-
const m = /^(landed )?(\d+)(?:\s
|
|
237
|
-
|
|
268
|
+
const m = /^(landed )?(\d+)(?:\s+(\S+))?/.exec(readFileSync(pausePath(root, slug), 'utf8').trim());
|
|
269
|
+
if (m === null) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
const stamp = { kind: m[1] === undefined ? 'pause' : 'landed', step: Number(m[2]) };
|
|
273
|
+
return stamp.kind === 'landed' && m[3] === 'answered' ? { ...stamp, answered: true } : stamp;
|
|
238
274
|
}
|
|
239
275
|
catch {
|
|
240
276
|
return null;
|
|
@@ -246,7 +282,8 @@ export function readPause(root, slug) {
|
|
|
246
282
|
*/
|
|
247
283
|
export function writePause(root, slug, stamp) {
|
|
248
284
|
try {
|
|
249
|
-
|
|
285
|
+
const answered = stamp.kind === 'landed' && stamp.answered === true ? ' answered' : '';
|
|
286
|
+
writeFileSync(pausePath(root, slug), `${stamp.kind === 'landed' ? 'landed ' : ''}${stamp.step}${answered}\n`);
|
|
250
287
|
}
|
|
251
288
|
catch {
|
|
252
289
|
// A read-only tree or a vanished build folder: the card still renders.
|
package/dist/verbs/build.js
CHANGED
|
@@ -200,13 +200,15 @@ function reservedCallNotice(intent, steps, step) {
|
|
|
200
200
|
* That step lands like every step before it, and then the turn ends on its
|
|
201
201
|
* checkpoint: a clean halt. Saying so as the step is entered puts the fact in
|
|
202
202
|
* front of the model before it chooses between landing the step and holding it
|
|
203
|
-
* at a pause, which is the choice a clean halt kept getting wrong.
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
*
|
|
203
|
+
* at a pause, which is the choice a clean halt kept getting wrong. The next
|
|
204
|
+
* step is the first undone step in build order other than this one, wherever
|
|
205
|
+
* it sits, because a range names plan numbers and the skill halts before any
|
|
206
|
+
* step the range does not name: a range typed above an earlier undone step
|
|
207
|
+
* halts after its first step, and the detail names the step it halts before.
|
|
208
|
+
* A range reaches its top, or the last undone step it names when the plan ends
|
|
209
|
+
* first or the next step in build order is one it does not name; `--auto`
|
|
210
|
+
* reaches the plan's last undone step. A range that lists its steps walks them
|
|
211
|
+
* in build order, so its top is the last undone step it names.
|
|
210
212
|
*/
|
|
211
213
|
function lastGrantedNotice(grant, steps, step) {
|
|
212
214
|
if (grant === null) {
|
|
@@ -216,24 +218,30 @@ function lastGrantedNotice(grant, steps, step) {
|
|
|
216
218
|
if (grant.kind === 'steps') {
|
|
217
219
|
if (!hasStep(grant.steps, step))
|
|
218
220
|
return '';
|
|
219
|
-
const rest = steps.
|
|
221
|
+
const rest = steps.filter((s) => !s.done && s.n !== step);
|
|
220
222
|
const after = rest[0];
|
|
221
223
|
if (after !== undefined && hasStep(grant.steps, after.n))
|
|
222
224
|
return '';
|
|
225
|
+
const detail = after === undefined ? [] : [unnamedNext(after.n)];
|
|
223
226
|
return rest.some((s) => hasStep(grant.steps, s.n))
|
|
224
|
-
? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
|
|
225
|
-
: notice({ fact: `step ${step} is the top of the range you granted`, remedy });
|
|
227
|
+
? notice({ fact: `step ${step} is the last undone step in the range you granted`, detail, remedy })
|
|
228
|
+
: notice({ fact: `step ${step} is the top of the range you granted`, detail, remedy });
|
|
226
229
|
}
|
|
227
230
|
if (grant.kind === 'range') {
|
|
228
231
|
if (step === grant.ceiling) {
|
|
229
232
|
return notice({ fact: `step ${step} is the top of the range you granted`, remedy });
|
|
230
233
|
}
|
|
231
|
-
const after = steps.
|
|
234
|
+
const after = steps.find((s) => !s.done && s.n !== step);
|
|
232
235
|
const more = after !== undefined && after.n <= grant.ceiling;
|
|
236
|
+
const detail = after === undefined ? [] : [unnamedNext(after.n)];
|
|
233
237
|
return step < grant.ceiling && !more
|
|
234
|
-
? notice({ fact: `step ${step} is the last undone step in the range you granted`, remedy })
|
|
238
|
+
? notice({ fact: `step ${step} is the last undone step in the range you granted`, detail, remedy })
|
|
235
239
|
: '';
|
|
236
240
|
}
|
|
237
241
|
const more = steps.some((s) => !s.done && s.n !== step);
|
|
238
242
|
return more ? '' : notice({ fact: `step ${step} is the last undone step in the plan`, remedy });
|
|
239
243
|
}
|
|
244
|
+
/** The detail naming the step a range halts before: the next undone one in build order, which the range does not name. */
|
|
245
|
+
function unnamedNext(n) {
|
|
246
|
+
return `step ${n} is next in the build order, and the range does not name it`;
|
|
247
|
+
}
|
package/dist/verbs/checkpoint.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
13
|
import { relative } from 'node:path';
|
|
14
14
|
import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
|
|
15
|
-
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readRun, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, writePause, } from "../lib/sidecar.js";
|
|
15
|
+
import { activeBuild, buildFolder, buildLogPath, buildScope, bumpStepStat, checkpointsPath, clearDetail, clearHandoff, clearTick, detailPath, hasSession, intentPath, readPause, readRun, readStats, refreshExcludes, seamPath, stampStepStat, stepPath, writePause, } from "../lib/sidecar.js";
|
|
16
16
|
import { runCheck } from "../lib/check.js";
|
|
17
17
|
import { readCommitBody } from "../lib/commitbody.js";
|
|
18
18
|
import { checkLatch } from "../lib/latch.js";
|
|
@@ -123,8 +123,12 @@ export async function checkpoint(cwd, args) {
|
|
|
123
123
|
clearTick(root); // the step's entry stamp is spent; the next `build <n>` re-stamps.
|
|
124
124
|
// The pause it stamped is over, and the boundary this ending prints is the
|
|
125
125
|
// card the stamp now names, so a later handoff here ends on its pointer
|
|
126
|
-
// (D110 (reply-after-the-card)); the next step's pause renders whole.
|
|
127
|
-
|
|
126
|
+
// (D110 (reply-after-the-card)); the next step's pause renders whole. A
|
|
127
|
+
// landing that answers this step's own pause card, already shown, says so,
|
|
128
|
+
// and the stop card it may owe prints short (D119 (short-stop-after-a-halt)).
|
|
129
|
+
const shown = readPause(root);
|
|
130
|
+
const answered = shown?.kind === 'pause' && shown.step === step;
|
|
131
|
+
writePause(root, undefined, { kind: 'landed', step, ...(answered ? { answered: true } : {}) });
|
|
128
132
|
// The whole ending, one block: the lead line, the advisories this checkpoint
|
|
129
133
|
// alone can state, and the state table headed by the Verdict folded over
|
|
130
134
|
// what just landed. Read after the flip and the ledger append, so the fold
|
|
@@ -181,7 +185,7 @@ function checkpointPlan(root, args) {
|
|
|
181
185
|
appendFileSync(checkpointsPath(root), `plan ${sha}\n`);
|
|
182
186
|
// The cold read that approved the plan is the Log's first entry, beneath a
|
|
183
187
|
// dated plan line; the detail file is then spent, the same as at a step.
|
|
184
|
-
const pointer = logPlan(root, sha, planRecord(readDetail(root, null) ?? ''));
|
|
188
|
+
const pointer = logPlan(root, sha, planRecord(root, activeBuild(root), readDetail(root, null) ?? ''));
|
|
185
189
|
clearDetail(root);
|
|
186
190
|
// Landing the plan consumes `start`'s entry stamp: a later hand-built diff
|
|
187
191
|
// (no `build <n>`) must find no stale TICK and stay guidance-governed.
|