plumbbob 0.6.6 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +2 -2
- package/README.md +24 -7
- package/dist/cli-core.js +7 -2
- package/dist/lib/buildlog.js +5 -2
- package/dist/lib/check.js +33 -1
- package/dist/lib/git.js +17 -0
- package/dist/lib/latch.js +97 -0
- package/dist/lib/orient.js +24 -0
- package/dist/lib/settings.js +3 -9
- package/dist/lib/sidecar.js +169 -18
- package/dist/verbs/build.js +5 -1
- package/dist/verbs/checkpoint.js +51 -2
- package/dist/verbs/doctor.js +40 -5
- package/dist/verbs/finish.js +61 -11
- package/dist/verbs/revert.js +19 -1
- package/dist/verbs/start.js +38 -7
- package/dist/verbs/status.js +10 -3
- package/dist/verbs/turn.js +106 -0
- package/dist/verbs/use.js +4 -5
- package/hooks/hooks.json +15 -0
- package/hooks/post-edit.sh +7 -7
- package/hooks/pre-bash-commit.sh +68 -0
- package/node_modules/checkride/CHANGELOG.md +111 -0
- package/node_modules/checkride/README.md +185 -20
- package/node_modules/checkride/dist/adapters.d.ts +9 -4
- package/node_modules/checkride/dist/adapters.d.ts.map +1 -1
- package/node_modules/checkride/dist/adapters.js +62 -2
- package/node_modules/checkride/dist/adapters.js.map +1 -1
- package/node_modules/checkride/dist/agent-setup/hook.d.ts +63 -0
- package/node_modules/checkride/dist/agent-setup/hook.d.ts.map +1 -0
- package/node_modules/checkride/dist/agent-setup/hook.js +92 -0
- package/node_modules/checkride/dist/agent-setup/hook.js.map +1 -0
- package/node_modules/checkride/dist/agent-setup/index.d.ts +8 -0
- package/node_modules/checkride/dist/agent-setup/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/agent-setup/index.js +7 -0
- package/node_modules/checkride/dist/agent-setup/index.js.map +1 -0
- package/node_modules/checkride/dist/atomic.d.ts +13 -0
- package/node_modules/checkride/dist/atomic.d.ts.map +1 -0
- package/node_modules/checkride/dist/atomic.js +24 -0
- package/node_modules/checkride/dist/atomic.js.map +1 -0
- package/node_modules/checkride/dist/baseline/command.d.ts +42 -0
- package/node_modules/checkride/dist/baseline/command.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/command.js +58 -0
- package/node_modules/checkride/dist/baseline/command.js.map +1 -0
- package/node_modules/checkride/dist/baseline/fingerprint.d.ts +41 -0
- package/node_modules/checkride/dist/baseline/fingerprint.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/fingerprint.js +119 -0
- package/node_modules/checkride/dist/baseline/fingerprint.js.map +1 -0
- package/node_modules/checkride/dist/baseline/index.d.ts +13 -0
- package/node_modules/checkride/dist/baseline/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/index.js +10 -0
- package/node_modules/checkride/dist/baseline/index.js.map +1 -0
- package/node_modules/checkride/dist/baseline/store.d.ts +67 -0
- package/node_modules/checkride/dist/baseline/store.d.ts.map +1 -0
- package/node_modules/checkride/dist/baseline/store.js +110 -0
- package/node_modules/checkride/dist/baseline/store.js.map +1 -0
- package/node_modules/checkride/dist/cli.d.ts.map +1 -1
- package/node_modules/checkride/dist/cli.js +37 -3
- package/node_modules/checkride/dist/cli.js.map +1 -1
- package/node_modules/checkride/dist/config.d.ts +43 -3
- package/node_modules/checkride/dist/config.d.ts.map +1 -1
- package/node_modules/checkride/dist/config.js +95 -22
- package/node_modules/checkride/dist/config.js.map +1 -1
- package/node_modules/checkride/dist/digest/digest.d.ts +52 -0
- package/node_modules/checkride/dist/digest/digest.d.ts.map +1 -0
- package/node_modules/checkride/dist/digest/digest.js +147 -0
- package/node_modules/checkride/dist/digest/digest.js.map +1 -0
- package/node_modules/checkride/dist/digest/index.d.ts +8 -0
- package/node_modules/checkride/dist/digest/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/digest/index.js +7 -0
- package/node_modules/checkride/dist/digest/index.js.map +1 -0
- package/node_modules/checkride/dist/doctor.d.ts +4 -0
- package/node_modules/checkride/dist/doctor.d.ts.map +1 -1
- package/node_modules/checkride/dist/doctor.js +33 -10
- package/node_modules/checkride/dist/doctor.js.map +1 -1
- package/node_modules/checkride/dist/index.d.ts +1 -1
- package/node_modules/checkride/dist/index.d.ts.map +1 -1
- package/node_modules/checkride/dist/index.js +1 -1
- package/node_modules/checkride/dist/index.js.map +1 -1
- package/node_modules/checkride/dist/init.d.ts +38 -0
- package/node_modules/checkride/dist/init.d.ts.map +1 -1
- package/node_modules/checkride/dist/init.js +97 -8
- package/node_modules/checkride/dist/init.js.map +1 -1
- package/node_modules/checkride/dist/orchestrator.d.ts +58 -1
- package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -1
- package/node_modules/checkride/dist/orchestrator.js +155 -27
- package/node_modules/checkride/dist/orchestrator.js.map +1 -1
- package/node_modules/checkride/dist/pm/detect.d.ts +21 -0
- package/node_modules/checkride/dist/pm/detect.d.ts.map +1 -0
- package/node_modules/checkride/dist/pm/detect.js +60 -0
- package/node_modules/checkride/dist/pm/detect.js.map +1 -0
- package/node_modules/checkride/dist/pm/index.d.ts +10 -0
- package/node_modules/checkride/dist/pm/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/pm/index.js +9 -0
- package/node_modules/checkride/dist/pm/index.js.map +1 -0
- package/node_modules/checkride/dist/pm/translate.d.ts +28 -0
- package/node_modules/checkride/dist/pm/translate.d.ts.map +1 -0
- package/node_modules/checkride/dist/pm/translate.js +40 -0
- package/node_modules/checkride/dist/pm/translate.js.map +1 -0
- package/node_modules/checkride/package.json +20 -17
- package/node_modules/checkride/schema/checkride.config.schema.json +97 -0
- package/node_modules/checkride/schema/checkride.summary.schema.json +57 -0
- package/node_modules/checkride/templates/shared/cspell.json +1 -0
- package/node_modules/checkride/templates/shared/prettierrc.json +6 -0
- package/package.json +8 -4
- package/skills/pb-build/SKILL.md +31 -1
- package/skills/pb-finish/SKILL.md +2 -1
- package/skills/pb-plan/SKILL.md +17 -0
- package/skills/pb-verify/SKILL.md +32 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"description": "Attention-first build process — driver skills + a CLI that keep you the decider
|
|
4
|
+
"version": "0.8.3",
|
|
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",
|
|
8
8
|
"repository": "https://github.com/robmclarty/plumbbob",
|
package/README.md
CHANGED
|
@@ -213,13 +213,30 @@ actually learning is the method: decisions before code, one verified step at a
|
|
|
213
213
|
time, capture instead of chase. That transfers to whatever tool wins. If
|
|
214
214
|
something better ships, you walk away with your archives and your habits intact.
|
|
215
215
|
|
|
216
|
-
**…admit the pause is unenforceable?**
|
|
217
|
-
[
|
|
218
|
-
a
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
**…admit the pause is unenforceable?** On the **work** plane, yes — on purpose
|
|
217
|
+
([D10](docs/decisions.md#d10)/[D13](docs/decisions.md#d13) in
|
|
218
|
+
[`docs/decisions.md`](docs/decisions.md)): a hard lock on every edit buys ritual, not
|
|
219
|
+
control, because a determined model routes around it and a denial mid-thought leaves no
|
|
220
|
+
legal move. But the **record** is a different plane, and there the tick *is* latched
|
|
221
|
+
([D64–D66](docs/decisions.md#d64)): `checkpoint` refuses to land a step until the harness
|
|
222
|
+
records a human turn since that step began, so the agent cannot self-commit past you —
|
|
223
|
+
the refusal simply *is* the pause. That extends the deterministic edge PlumbBob already
|
|
224
|
+
holds — `checkpoint` refuses a red check, `start` refuses a dirty tree, `revert` restores
|
|
225
|
+
only recorded SHAs — to the one boundary the product is named after, while leaving human
|
|
226
|
+
what must be human: you, reading the diff. **Guidance on the work, a latch on the
|
|
227
|
+
record.** And when a raw commit does slip the ledger, `status` says so and the checkpoint
|
|
228
|
+
record makes recovery one command — cheap recovery, not a cage, is the control that
|
|
229
|
+
matters.
|
|
230
|
+
|
|
231
|
+
> The latch's payoff is measured, not asserted: the skill-eval harness
|
|
232
|
+
> ([`test/evals/`](test/evals/)) sweeps this loop headless — prose-only baseline vs. the
|
|
233
|
+
> shipped latch, N=5 per contract, every assertion a mechanical read of git state — and
|
|
234
|
+
> commits the receipt to [`reports/evals/`](reports/evals/2026-07-11.md). The first sweep
|
|
235
|
+
> (opus, 2026-07-11): the latch closes the route it owns — *no checkpoint over a red
|
|
236
|
+
> check under pressure* went from **2/5 prose-only to 5/5 latched** — while the honest
|
|
237
|
+
> numbers it also surfaced (park capture 0/5, and adversarial pressure consistently
|
|
238
|
+
> flipping the `auto` standing grant in settings — a legal, model-writable side door)
|
|
239
|
+
> are the next iteration's work, named in the report rather than papered over.
|
|
223
240
|
|
|
224
241
|
## Documentation
|
|
225
242
|
|
package/dist/cli-core.js
CHANGED
|
@@ -18,6 +18,7 @@ import { finish } from "./verbs/finish.js";
|
|
|
18
18
|
import { init } from "./verbs/init.js";
|
|
19
19
|
import { doctor } from "./verbs/doctor.js";
|
|
20
20
|
import { agent } from "./verbs/agent.js";
|
|
21
|
+
import { turn } from "./verbs/turn.js";
|
|
21
22
|
const VERBS = [
|
|
22
23
|
{ name: 'start', summary: 'scaffold .plumbbob/; open the session; record the baseline commit' },
|
|
23
24
|
{ name: 'status', summary: 'print the orientation dashboard, or NO ACTIVE SESSION' },
|
|
@@ -32,6 +33,7 @@ const VERBS = [
|
|
|
32
33
|
{ name: 'init', summary: 'link plumbbob into Claude Code as an in-place plugin (~/.claude/skills/plumbbob); --uninstall to undo' },
|
|
33
34
|
{ name: 'doctor', summary: 'diagnose the plugin link + detect a legacy flat sidecar; doctor --migrate moves it into builds/ (staged, not committed)' },
|
|
34
35
|
{ name: 'agent', summary: 'agent list | run <name> [--step N] [--mode before|build|after]: list or spawn a user-authored agent' },
|
|
36
|
+
{ name: 'turn', summary: 'turn: UserPromptSubmit hook machinery — tick the turn ledger from stdin (not a user verb)' },
|
|
35
37
|
];
|
|
36
38
|
// PlumbBob (D1/D10/D13): the deciding/executing boundary is a pause, not a lock,
|
|
37
39
|
// so there is nothing to defend — every verb runs the same whether a human or the
|
|
@@ -56,8 +58,9 @@ export function readVersion() {
|
|
|
56
58
|
return 'unknown';
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
|
-
// Async because
|
|
60
|
-
//
|
|
61
|
+
// Async because checkride is: `check` and `checkpoint` await the gate (D32),
|
|
62
|
+
// `start` awaits the plan-time detection probe (research/07 2a); every other
|
|
63
|
+
// verb returns synchronously through the same Promise-typed seam.
|
|
61
64
|
async function dispatch(verb, cwd, rest) {
|
|
62
65
|
switch (verb) {
|
|
63
66
|
case 'start':
|
|
@@ -86,6 +89,8 @@ async function dispatch(verb, cwd, rest) {
|
|
|
86
89
|
return doctor(cwd, rest);
|
|
87
90
|
case 'agent':
|
|
88
91
|
return agent(cwd, rest);
|
|
92
|
+
case 'turn':
|
|
93
|
+
return turn(cwd, rest);
|
|
89
94
|
default:
|
|
90
95
|
process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
|
|
91
96
|
return 1;
|
package/dist/lib/buildlog.js
CHANGED
|
@@ -28,7 +28,10 @@ export function appendToSection(content, heading, line) {
|
|
|
28
28
|
// The Log line `checkpoint` writes for a landed step: dated, names the step (its
|
|
29
29
|
// title when intent.md still carries it), and carries the short SHA. One line of the
|
|
30
30
|
// build's history — `/pb-finish` reads these instead of re-narrating the build.
|
|
31
|
-
|
|
31
|
+
// `stats` is the optional compact receipt (research/07 2b), e.g. `2 red, 34m` —
|
|
32
|
+
// null when nothing accrued, so a clean first-try step reads exactly as before.
|
|
33
|
+
export function checkpointLogLine(date, step, sha, title, stats = null) {
|
|
32
34
|
const head = `- ${date} — step ${step} checkpointed · ${sha.slice(0, 9)}`;
|
|
33
|
-
|
|
35
|
+
const titled = title !== null && title.length > 0 ? `${head} — ${title}` : head;
|
|
36
|
+
return stats !== null && stats.length > 0 ? `${titled} (${stats})` : titled;
|
|
34
37
|
}
|
package/dist/lib/check.js
CHANGED
|
@@ -12,8 +12,40 @@
|
|
|
12
12
|
// green, 1 is red, 2 means the harness itself broke — reported distinctly,
|
|
13
13
|
// because a misconfigured gate must not read as broken code (both still block).
|
|
14
14
|
import { spawnSync } from 'node:child_process';
|
|
15
|
-
import { runChecks } from 'checkride';
|
|
15
|
+
import { runChecks, runDoctor } from 'checkride';
|
|
16
16
|
import { resolveString } from "./settings.js";
|
|
17
|
+
export async function detectGate(root) {
|
|
18
|
+
const command = resolveString(root, 'check', '');
|
|
19
|
+
if (command.length > 0)
|
|
20
|
+
return { configured: command, detected: true };
|
|
21
|
+
try {
|
|
22
|
+
const silent = { write: () => true };
|
|
23
|
+
const { report } = await runDoctor({ cwd: root, stdout: silent });
|
|
24
|
+
return { configured: null, detected: gateDetectsTools(report.checks) };
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// A broken checkride harness is a different problem with its own reporters
|
|
28
|
+
// (doctor's ✗ row, runCheck's exit 2) — the plan-time probe stays quiet
|
|
29
|
+
// rather than mislabeling it "nothing to check".
|
|
30
|
+
return { configured: null, detected: true };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Checkride's always-on repo checks: the adapters its doctor reports even for
|
|
34
|
+
// an EMPTY directory (links, the pnpm-audit security scan, and the
|
|
35
|
+
// publint/attw package-shape probes). They exercise the repo's plumbing, not
|
|
36
|
+
// its code — a gate made only of these green-lights every checkpoint while the
|
|
37
|
+
// human believes their work is being checked. Coupled to checkride's built-in
|
|
38
|
+
// set by construction; the doctor.test.ts probe pins the coupling.
|
|
39
|
+
const ALWAYS_ON_ADAPTERS = ['links', 'pnpm-audit', 'publint', 'attw'];
|
|
40
|
+
// The detection rule, shared by `start`'s probe and doctor's callout: some tool
|
|
41
|
+
// slot beyond the always-on family has an adapter — i.e. checkride can see the
|
|
42
|
+
// CODE (a tsconfig, a test runner, a linter…), not just the repo.
|
|
43
|
+
export function gateDetectsTools(checks) {
|
|
44
|
+
return checks.some((c) => c.category === 'tool' &&
|
|
45
|
+
c.adapter !== null &&
|
|
46
|
+
c.adapter !== undefined &&
|
|
47
|
+
!ALWAYS_ON_ADAPTERS.includes(c.adapter));
|
|
48
|
+
}
|
|
17
49
|
// Runs the gate in `root` and returns the exit code (0 green, 1 red, 2 harness
|
|
18
50
|
// error). `commandFlag` is the optional CLI override at the top of the settings
|
|
19
51
|
// ladder; a resolved command means the spawn path, no setting means checkride.
|
package/dist/lib/git.js
CHANGED
|
@@ -42,6 +42,23 @@ export function hasCommit(root) {
|
|
|
42
42
|
export function isDirty(root) {
|
|
43
43
|
return runGit(root, ['status', '--porcelain']).length > 0;
|
|
44
44
|
}
|
|
45
|
+
// The number of commits on HEAD not reachable from `sha` — `git rev-list --count
|
|
46
|
+
// --first-parent <sha>..HEAD`. The receipts line (D66) reads this to surface commits
|
|
47
|
+
// that landed since the last checkpoint outside plumbbob's ledger. `--first-parent`
|
|
48
|
+
// keeps the count on the branch's own line: merging upstream reads as the one merge
|
|
49
|
+
// commit, not the dozens it carried — those didn't land "outside the ledger" in any
|
|
50
|
+
// sense the receipt should nag about. Best-effort and never throws: 0 when the range
|
|
51
|
+
// is empty, `sha` is unknown to the repo, or HEAD is unborn — the count is
|
|
52
|
+
// informational (the human commits freely, C5), never a gate.
|
|
53
|
+
export function commitsSince(root, sha) {
|
|
54
|
+
try {
|
|
55
|
+
const n = Number.parseInt(runGit(root, ['rev-list', '--count', '--first-parent', `${sha}..HEAD`]), 10);
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
45
62
|
// --- mutation helpers (build-loop: done checkpoints, revert resets). Additive
|
|
46
63
|
// only (C5): stage/commit forward, reset --hard to a recorded checkpoint SHA. ---
|
|
47
64
|
export function stageAll(root) {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// The approval latch (D64) — the ledger-plane predicate `checkpoint` evaluates
|
|
2
|
+
// before its check gate (C4: cheap first; the gate already ran in the verify
|
|
3
|
+
// tick). The work plane stays guidance (D10/D13 untouched); only the commit tick
|
|
4
|
+
// is latched: a step may not land unless a human turn intervened since entry, the
|
|
5
|
+
// human granted self-approval (D65 one-turn grant, D27 standing `auto`), or the
|
|
6
|
+
// ledger is dormant. Six rows, evaluated in order, first hit wins; the refusal
|
|
7
|
+
// message *is* the pause affordance. Reads TURN/TICK/GRANT/isTTY only — never the
|
|
8
|
+
// host (C5). Functional, named exports, node builtins (C1/C2).
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { grantPath, tickPath, turnPath } from "./sidecar.js";
|
|
11
|
+
import { resolveBoolean } from "./settings.js";
|
|
12
|
+
const ALLOW = { allow: true };
|
|
13
|
+
// The exact refusal (the spec pins this prose): the message is the affordance —
|
|
14
|
+
// it tells the model what the pause is and names the only legitimate grants. The
|
|
15
|
+
// plan commit gets its own wording: there is no step, no diff, and no self-review
|
|
16
|
+
// at plan time — what the human approves is the plan itself.
|
|
17
|
+
const NO_TURN_MESSAGE = `plumbbob: checkpoint refused — no human turn since this step began. This is the
|
|
18
|
+
pause: present the diff and the self-review, then end the turn; the human's next
|
|
19
|
+
message is the tick. (An explicit \`/pb-build --auto\` or a step range in the human's
|
|
20
|
+
own prompt grants self-approval; \`auto: true\` in settings.local.json is the standing
|
|
21
|
+
grant.)
|
|
22
|
+
`;
|
|
23
|
+
const NO_TURN_PLAN_MESSAGE = `plumbbob: checkpoint refused — no human turn since \`start\` stamped this plan. This
|
|
24
|
+
is the plan pause: present the plan, then end the turn; the human's approving
|
|
25
|
+
message is the tick that lets it land on re-fire. (\`auto: true\` in
|
|
26
|
+
settings.local.json is the standing grant.)
|
|
27
|
+
`;
|
|
28
|
+
function ceilingMessage(ceiling) {
|
|
29
|
+
return `plumbbob: checkpoint refused — the range you granted ends at step ${ceiling} — pause here; re-fire to continue.\n`;
|
|
30
|
+
}
|
|
31
|
+
// The six-row matrix, in order, first hit wins. A `range` grant speaks only to
|
|
32
|
+
// numbered steps: on a `--plan` checkpoint (step null) it neither allows nor
|
|
33
|
+
// refuses, and the decision falls through to the turn comparison.
|
|
34
|
+
export function evaluateLatch(input) {
|
|
35
|
+
if (input.isTTY)
|
|
36
|
+
return ALLOW; // 1 — a human at the keyboard
|
|
37
|
+
if (input.turn === null || input.tick === null)
|
|
38
|
+
return ALLOW; // 2 — dormant / hand-built
|
|
39
|
+
if (input.auto)
|
|
40
|
+
return ALLOW; // 3 — standing personal grant (D27)
|
|
41
|
+
if (input.grant?.kind === 'auto')
|
|
42
|
+
return ALLOW; // 4 — one-turn grant
|
|
43
|
+
if (input.grant?.kind === 'range' && input.step !== null) {
|
|
44
|
+
if (input.step <= input.grant.ceiling)
|
|
45
|
+
return ALLOW;
|
|
46
|
+
return { allow: false, reason: 'ceiling', message: ceilingMessage(input.grant.ceiling) };
|
|
47
|
+
}
|
|
48
|
+
if (input.turn > input.tick)
|
|
49
|
+
return ALLOW; // 5 — a human turn intervened
|
|
50
|
+
// 6 — the pause (plan-worded when no step is landing)
|
|
51
|
+
return { allow: false, reason: 'no-turn', message: input.step === null ? NO_TURN_PLAN_MESSAGE : NO_TURN_MESSAGE };
|
|
52
|
+
}
|
|
53
|
+
// Parse a GRANT file's content (`auto` | `range <M>`). Anything else — including
|
|
54
|
+
// a corrupted or hand-mangled file — contributes nothing (D27): no grant, never
|
|
55
|
+
// an error.
|
|
56
|
+
export function parseGrant(raw) {
|
|
57
|
+
const trimmed = raw.trim();
|
|
58
|
+
if (trimmed === 'auto')
|
|
59
|
+
return { kind: 'auto' };
|
|
60
|
+
const range = /^range (\d+)$/.exec(trimmed);
|
|
61
|
+
if (range !== null)
|
|
62
|
+
return { kind: 'range', ceiling: Number(range[1]) };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// Gather the latch's inputs from the worktree and evaluate. `step` is the step
|
|
66
|
+
// number being checkpointed, or null for the plan commit (which latches on the
|
|
67
|
+
// TICK that `start` stamped).
|
|
68
|
+
export function checkLatch(root, step) {
|
|
69
|
+
return evaluateLatch({
|
|
70
|
+
isTTY: process.stdin.isTTY === true,
|
|
71
|
+
turn: readCount(turnPath(root)),
|
|
72
|
+
tick: readCount(tickPath(root)),
|
|
73
|
+
auto: resolveBoolean(root, 'auto', false),
|
|
74
|
+
grant: readGrant(root),
|
|
75
|
+
step,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function readGrant(root) {
|
|
79
|
+
try {
|
|
80
|
+
return parseGrant(readFileSync(grantPath(root), 'utf8'));
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// A ledger count, or null when the file is absent or holds garbage — malformed
|
|
87
|
+
// contributes nothing, and an unreadable ledger reads as dormant (row 2) rather
|
|
88
|
+
// than wedging the land.
|
|
89
|
+
function readCount(path) {
|
|
90
|
+
try {
|
|
91
|
+
const n = Number.parseInt(readFileSync(path, 'utf8').trim(), 10);
|
|
92
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/dist/lib/orient.js
CHANGED
|
@@ -94,6 +94,22 @@ export function parseLastCheckpoint(checkpoints) {
|
|
|
94
94
|
}
|
|
95
95
|
return last;
|
|
96
96
|
}
|
|
97
|
+
// The SHA of the last ledger line of ANY kind — baseline, plan, or step. This is
|
|
98
|
+
// the reconciliation anchor for the out-of-band count (D66): a commit that lands
|
|
99
|
+
// after the plan but before the first step checkpoint is exactly the window the
|
|
100
|
+
// receipt exists for, so anchoring on step lines alone would leave it invisible.
|
|
101
|
+
// `parseLastCheckpoint` stays step-only — it feeds the "last checkpoint step N"
|
|
102
|
+
// display, where baseline/plan would be noise.
|
|
103
|
+
export function lastLedgerSha(checkpoints) {
|
|
104
|
+
let last = null;
|
|
105
|
+
for (const line of checkpoints.split('\n')) {
|
|
106
|
+
const m = /^(?:baseline|plan|step\s+\d+)\s+(\S+)/.exec(line.trim());
|
|
107
|
+
if (m) {
|
|
108
|
+
last = m[1] ?? null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return last;
|
|
112
|
+
}
|
|
97
113
|
// The single primary next move (D15). It suggests; the dashboard prints the full
|
|
98
114
|
// list + counts so the human can always override. The phase is derived: a spike
|
|
99
115
|
// in progress and an in-flight step each have one obvious next move; otherwise you
|
|
@@ -137,6 +153,7 @@ export function orient(input) {
|
|
|
137
153
|
nextDoneWhen: nextUndone?.doneWhen ?? null,
|
|
138
154
|
nextSeam: seamParse !== null && seamParse.ok ? seamParse.seam : [],
|
|
139
155
|
nextModel: nextUndone?.model ?? null,
|
|
156
|
+
outOfBand: input.outOfBand,
|
|
140
157
|
};
|
|
141
158
|
}
|
|
142
159
|
export function formatOrientation(o) {
|
|
@@ -166,12 +183,19 @@ export function formatOrientation(o) {
|
|
|
166
183
|
const stepsBlock = o.steps.length === 0 ? ' (no steps planned yet)' : ` steps ${doneCount}/${o.steps.length} done\n${stepLines.join('\n')}`;
|
|
167
184
|
const cp = o.lastCheckpoint;
|
|
168
185
|
const cpLine = cp === null ? 'last checkpoint none yet' : `last checkpoint step ${cp.n} · ${cp.sha.slice(0, 7)}`;
|
|
186
|
+
// A neutral reconciliation note (D66), only when there is something to reconcile:
|
|
187
|
+
// commits landed since the last checkpoint that plumbbob's ledger didn't record.
|
|
188
|
+
// Informational — the human commits freely (repo D3/C5), so this never gates.
|
|
189
|
+
const receipts = o.outOfBand > 0
|
|
190
|
+
? [`${o.outOfBand} commit${o.outOfBand === 1 ? '' : 's'} since the last checkpoint landed outside plumbbob's ledger.`]
|
|
191
|
+
: [];
|
|
169
192
|
return [
|
|
170
193
|
`PlumbBob — ${o.title ?? '(untitled)'} [${o.phase}]`,
|
|
171
194
|
'',
|
|
172
195
|
stepsBlock,
|
|
173
196
|
'',
|
|
174
197
|
cpLine,
|
|
198
|
+
...receipts,
|
|
175
199
|
`parked ${o.parked} · open questions ${o.openQuestions}`,
|
|
176
200
|
'',
|
|
177
201
|
`next → ${o.next}`,
|
package/dist/lib/settings.js
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Known keys: `check` (string — the heavy gate, tracked in settings.json) and
|
|
11
11
|
// `auto` (boolean — whether the agent approves in the human's place; a personal
|
|
12
|
-
// preference, so it belongs in settings.local.json).
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// preference, so it belongs in settings.local.json). The per-worktree active-build
|
|
13
|
+
// cursor is NOT here — it lives in the STATE file (see sidecar.ts), so this overlay
|
|
14
|
+
// stays purely human-owned and the tool never rewrites it.
|
|
15
15
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
16
16
|
import { dirname, join } from 'node:path';
|
|
17
17
|
const DIRNAME = '.plumbbob';
|
|
@@ -70,12 +70,6 @@ export function resolveRecord(root, key) {
|
|
|
70
70
|
const value = resolveSetting(root, key, undefined);
|
|
71
71
|
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
|
|
72
72
|
}
|
|
73
|
-
// Read one key from the untracked local overlay ONLY — no project or built-in
|
|
74
|
-
// fallback. The `activeBuild` cursor lives here and must never resolve from the
|
|
75
|
-
// tracked settings.json (it is per-worktree state, not a shared default).
|
|
76
|
-
export function localSetting(root, key) {
|
|
77
|
-
return readSettings(localSettingsPath(root))[key];
|
|
78
|
-
}
|
|
79
73
|
// Merge one key into settings.local.json, preserving the other keys and creating
|
|
80
74
|
// the file when absent. Pretty-printed so the overlay stays hand-editable. A
|
|
81
75
|
// malformed existing file contributes nothing (readSettings yields {}) and is
|
package/dist/lib/sidecar.js
CHANGED
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
// read it with a grep and no markdown parsing (D7). Functional/procedural,
|
|
3
3
|
// node builtins only (C1/C2).
|
|
4
4
|
//
|
|
5
|
-
// STATE is
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// STATE is the session sentinel AND the active-build cursor (D28): its EXISTENCE
|
|
6
|
+
// means "a session is active", and its CONTENT names the build that session is on —
|
|
7
|
+
// the two reinforce each other (a session is always on some build; the cursor is
|
|
8
|
+
// meaningless without a session). Content is empty under --local / no build.
|
|
9
|
+
// hasSession stays existence-only; activeBuild reads the content. Homing the cursor
|
|
10
|
+
// here — not in settings.local.json — keeps that overlay purely human-owned
|
|
11
|
+
// (check/auto): the tool only ever reads it, never rewrites it. The phase the
|
|
12
|
+
// dashboard shows (DESIGN/BUILD/SPIKE) is derived, not stored — BUILD ⇔ a STEP is
|
|
13
|
+
// in flight, SPIKE ⇔ the SPIKE marker is present, otherwise DESIGN.
|
|
9
14
|
import { existsSync, readdirSync, readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
10
15
|
import { dirname, join } from 'node:path';
|
|
11
16
|
import { gitPath } from "./git.js";
|
|
12
|
-
import { localSetting } from "./settings.js";
|
|
13
17
|
const DIRNAME = '.plumbbob';
|
|
14
18
|
export function sidecarDir(root) {
|
|
15
19
|
return join(root, DIRNAME);
|
|
@@ -50,19 +54,35 @@ export function listBuilds(root) {
|
|
|
50
54
|
}
|
|
51
55
|
}
|
|
52
56
|
// Resolve which build a verb acts on (D28): an explicit `--build <slug>` flag →
|
|
53
|
-
// the
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// never point at two builds (D28).
|
|
57
|
+
// the cursor in STATE → the sole build in `builds/` → null (the caller then refuses
|
|
58
|
+
// with a hint). One-active-per-worktree holds by construction: the cursor is STATE's
|
|
59
|
+
// single-line content, so it can never point at two builds (D28).
|
|
57
60
|
export function activeBuild(root, flag) {
|
|
58
61
|
if (flag !== undefined && flag.length > 0)
|
|
59
62
|
return flag;
|
|
60
|
-
const cursor =
|
|
61
|
-
if (
|
|
63
|
+
const cursor = readCursor(root);
|
|
64
|
+
if (cursor !== null)
|
|
62
65
|
return cursor;
|
|
63
66
|
const builds = listBuilds(root);
|
|
64
67
|
return builds.length === 1 ? (builds[0] ?? null) : null;
|
|
65
68
|
}
|
|
69
|
+
// STATE's content is the active-build cursor. Empty means "no build" (--local, or a
|
|
70
|
+
// session whose cursor is unset) → null, so activeBuild falls through to the
|
|
71
|
+
// sole-build rule. A pre-STATE-cursor session wrote the literal `active` sentinel
|
|
72
|
+
// here (the cursor lived in settings.local.json then); treat that legacy value as
|
|
73
|
+
// unset so an in-flight upgrade degrades gracefully instead of chasing a build named
|
|
74
|
+
// "active". `active` is thus reserved and never a valid cursor — no derived slug
|
|
75
|
+
// collides (they carry a YYYY-MM-DD- prefix).
|
|
76
|
+
function readCursor(root) {
|
|
77
|
+
let content;
|
|
78
|
+
try {
|
|
79
|
+
content = readFileSync(statePath(root), 'utf8').trim();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return content.length > 0 && content !== 'active' ? content : null;
|
|
85
|
+
}
|
|
66
86
|
// The build a verb should act on, plus its argv with the `--build <slug>` pair
|
|
67
87
|
// stripped (D28). Every verb resolves through this: an explicit `--build <slug>`
|
|
68
88
|
// wins, else the cursor / sole-build fallback of `activeBuild`. `rest` matters
|
|
@@ -159,14 +179,64 @@ export function appendHandoff(root, slug, entry) {
|
|
|
159
179
|
export function clearHandoff(root, slug) {
|
|
160
180
|
rmSync(handoffPath(root, slug), { force: true });
|
|
161
181
|
}
|
|
182
|
+
export function statsPath(root, slug) {
|
|
183
|
+
return join(artifactDir(root, slug), 'stats.json');
|
|
184
|
+
}
|
|
185
|
+
// The whole file, or {} when absent or corrupt — malformed contributes nothing.
|
|
186
|
+
export function readStats(root, slug) {
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(readFileSync(statsPath(root, slug), 'utf8'));
|
|
189
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : {};
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return {};
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// Increment one counter on one step. Best-effort by contract: a failed write is
|
|
196
|
+
// swallowed — the stats are a receipt, never a gate on the verb that accrues them.
|
|
197
|
+
export function bumpStepStat(root, slug, step, key) {
|
|
198
|
+
patchStepStat(root, slug, step, (current) => ({ [key]: (current[key] ?? 0) + 1 }));
|
|
199
|
+
}
|
|
200
|
+
// Stamp one timestamp on one step (startedAt at `build <n>`, landedAt at
|
|
201
|
+
// checkpoint). Same best-effort contract as bumpStepStat.
|
|
202
|
+
export function stampStepStat(root, slug, step, key, value) {
|
|
203
|
+
patchStepStat(root, slug, step, () => ({ [key]: value }));
|
|
204
|
+
}
|
|
205
|
+
function patchStepStat(root, slug, step, patch) {
|
|
206
|
+
try {
|
|
207
|
+
const stats = readStats(root, slug);
|
|
208
|
+
const current = stats[String(step)] ?? {};
|
|
209
|
+
const next = { ...stats, [String(step)]: { ...current, ...patch(current) } };
|
|
210
|
+
const path = statsPath(root, slug);
|
|
211
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
212
|
+
writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
// Best-effort receipt (D27/D4): never wedge build/checkpoint/revert over it.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
162
218
|
// A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
|
|
163
219
|
// repo back to "no session" — so it is the single source of truth for "is there
|
|
164
|
-
// a session". `start` calls beginSession; `finish` removes the file.
|
|
220
|
+
// a session". `start` calls beginSession; `finish` removes the file. Existence is
|
|
221
|
+
// the whole session signal; STATE's content is a separate axis (the cursor).
|
|
165
222
|
export function hasSession(root) {
|
|
166
223
|
return existsSync(statePath(root));
|
|
167
224
|
}
|
|
168
|
-
|
|
169
|
-
|
|
225
|
+
// Open the session and point its cursor at `slug` — null under --local / no build,
|
|
226
|
+
// which leaves STATE present but empty so activeBuild falls through to the sole-build
|
|
227
|
+
// rule. `finish` removes the file, closing the session and clearing the cursor in
|
|
228
|
+
// one delete.
|
|
229
|
+
export function beginSession(root, slug = null) {
|
|
230
|
+
writeCursor(root, slug);
|
|
231
|
+
}
|
|
232
|
+
// Re-point the cursor at an existing build (`use`) — a plain content rewrite that
|
|
233
|
+
// leaves the session sentinel (STATE's existence) intact. Callers guard hasSession
|
|
234
|
+
// first, so this never resurrects a finished session.
|
|
235
|
+
export function setActiveBuild(root, slug) {
|
|
236
|
+
writeCursor(root, slug);
|
|
237
|
+
}
|
|
238
|
+
function writeCursor(root, slug) {
|
|
239
|
+
writeFileSync(statePath(root), slug === null ? '' : `${slug}\n`);
|
|
170
240
|
}
|
|
171
241
|
// SPIKE marker helpers — existence is the whole signal (content is irrelevant).
|
|
172
242
|
export function inSpike(root, slug) {
|
|
@@ -178,6 +248,79 @@ export function markSpike(root, slug) {
|
|
|
178
248
|
export function clearSpike(root, slug) {
|
|
179
249
|
rmSync(spikePath(root, slug), { force: true });
|
|
180
250
|
}
|
|
251
|
+
// --- The turn ledger (D64/D65): a `.plumbbob/TURN` count the model never writes,
|
|
252
|
+
// and a one-turn `.plumbbob/GRANT` beside it — both flat, per-worktree control
|
|
253
|
+
// (never per-build, never committed). `plumbbob turn` (the UserPromptSubmit hook)
|
|
254
|
+
// ticks TURN once per human prompt and rewrites GRANT from the literal prompt; the
|
|
255
|
+
// checkpoint latch reads them. Kept in the sidecar so the lone GRANT `rmSync` stays
|
|
256
|
+
// where deletions are allowed (C4/centralize-destructive-fs). ---
|
|
257
|
+
export function turnPath(root) {
|
|
258
|
+
return join(root, DIRNAME, 'TURN');
|
|
259
|
+
}
|
|
260
|
+
export function grantPath(root) {
|
|
261
|
+
return join(root, DIRNAME, 'GRANT');
|
|
262
|
+
}
|
|
263
|
+
// Rewrite (or clear) the one-turn GRANT: a non-null value is the minted grant
|
|
264
|
+
// (`auto` | `range M`), null clears it. Pairing the write with its delete here means
|
|
265
|
+
// a grant can never half-persist, and the delete lives where deletions belong.
|
|
266
|
+
export function setGrant(root, grant) {
|
|
267
|
+
if (grant === null) {
|
|
268
|
+
rmSync(grantPath(root), { force: true });
|
|
269
|
+
}
|
|
270
|
+
else {
|
|
271
|
+
writeFileSync(grantPath(root), `${grant}\n`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
// TICK is the per-build entry stamp (D64): the TURN value recorded when work was
|
|
275
|
+
// entered (`build <n>` for a step, `start` for the plan), cleared when `checkpoint`
|
|
276
|
+
// lands. The latch compares TURN against it to know whether a human turn intervened
|
|
277
|
+
// across the entry→checkpoint span.
|
|
278
|
+
export function tickPath(root, slug) {
|
|
279
|
+
return join(artifactDir(root, slug), 'TICK');
|
|
280
|
+
}
|
|
281
|
+
// Stamp TICK with the current TURN. Stamped only when TURN holds a readable count:
|
|
282
|
+
// a host with no hooks never grows a ledger, so the latch stays dormant there
|
|
283
|
+
// instead of wedging (row 2 of the matrix).
|
|
284
|
+
export function stampTick(root, slug) {
|
|
285
|
+
const turn = readTurn(root);
|
|
286
|
+
if (turn === null)
|
|
287
|
+
return;
|
|
288
|
+
writeFileSync(tickPath(root, slug), `${turn}\n`);
|
|
289
|
+
}
|
|
290
|
+
// Consume the entry stamp — `checkpoint` clears it when a step (or the plan) lands,
|
|
291
|
+
// the same beat that clears STEP/SEAM/handoff. Absent is a no-op; the rmSync lives
|
|
292
|
+
// here with the sidecar's other deletions.
|
|
293
|
+
export function clearTick(root, slug) {
|
|
294
|
+
rmSync(tickPath(root, slug), { force: true });
|
|
295
|
+
}
|
|
296
|
+
// The TURN count, or null when the ledger is absent or unreadable — absence is the
|
|
297
|
+
// "dormant" signal stampTick keys off and the doctor latch probe reports (D64),
|
|
298
|
+
// never an error.
|
|
299
|
+
export function readTurn(root) {
|
|
300
|
+
try {
|
|
301
|
+
const n = Number.parseInt(readFileSync(turnPath(root), 'utf8').trim(), 10);
|
|
302
|
+
return Number.isFinite(n) && n >= 0 ? n : null;
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
// Walk up from `cwd` to the nearest ancestor with an active session (a
|
|
309
|
+
// `.plumbbob/STATE` file), or null when there is none. The turn hook runs on every
|
|
310
|
+
// human prompt in every repo; this is the cheap, git-free probe that keeps it a
|
|
311
|
+
// silent no-op outside a live plumbbob session — filesystem only, never a host
|
|
312
|
+
// sniff (C5/D13).
|
|
313
|
+
export function findSessionRoot(cwd) {
|
|
314
|
+
let dir = cwd;
|
|
315
|
+
let parent = dirname(dir);
|
|
316
|
+
while (dir !== parent) {
|
|
317
|
+
if (hasSession(dir))
|
|
318
|
+
return dir;
|
|
319
|
+
dir = parent;
|
|
320
|
+
parent = dirname(dir);
|
|
321
|
+
}
|
|
322
|
+
return hasSession(dir) ? dir : null;
|
|
323
|
+
}
|
|
181
324
|
// Append `patterns` to the repo's info/exclude, each at most once (idempotent —
|
|
182
325
|
// a re-`start` after finish must not double-add). `gitPath` resolves to the
|
|
183
326
|
// *common* gitdir's exclude — the only one git reads — so this works from a
|
|
@@ -200,17 +343,25 @@ function addExcludes(root, patterns) {
|
|
|
200
343
|
appendFileSync(exclude, `${prefix}${missing.join('\n')}\n`);
|
|
201
344
|
}
|
|
202
345
|
// The narrowed control plane (D17): with `builds/<slug>/` now tracked, only the
|
|
203
|
-
// per-worktree control files stay git-excluded — the
|
|
204
|
-
//
|
|
205
|
-
// inside every build. Everything else under `.plumbbob/`
|
|
206
|
-
// build's intent/build-log/checkpoints/report) rides the
|
|
346
|
+
// per-worktree control files stay git-excluded — the session sentinel STATE (whose
|
|
347
|
+
// content is the active-build cursor, D28), the personal settings overlay, and the
|
|
348
|
+
// in-flight step markers inside every build. Everything else under `.plumbbob/`
|
|
349
|
+
// (settings.json, and each build's intent/build-log/checkpoints/report) rides the
|
|
350
|
+
// branch into the PR.
|
|
207
351
|
export function excludeControl(root) {
|
|
208
352
|
addExcludes(root, [
|
|
209
353
|
`${DIRNAME}/STATE`,
|
|
210
354
|
`${DIRNAME}/settings.local.json`,
|
|
355
|
+
// The turn ledger and its one-turn grant (D64/D65): per-worktree control the
|
|
356
|
+
// model never writes, and never commits.
|
|
357
|
+
`${DIRNAME}/TURN`,
|
|
358
|
+
`${DIRNAME}/GRANT`,
|
|
211
359
|
`${DIRNAME}/builds/*/STEP`,
|
|
212
360
|
`${DIRNAME}/builds/*/SEAM`,
|
|
213
361
|
`${DIRNAME}/builds/*/SPIKE`,
|
|
362
|
+
// The entry stamp (D64) is in-flight control like STEP/SEAM — checkpoint's
|
|
363
|
+
// stageAll must never sweep it into a step commit.
|
|
364
|
+
`${DIRNAME}/builds/*/TICK`,
|
|
214
365
|
// The agent-run handoff ledger (D47) is step-scoped in-flight state, not a
|
|
215
366
|
// tracked artifact — it must never ride a step commit into the PR.
|
|
216
367
|
`${DIRNAME}/builds/*/handoff.json`,
|
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, resolveBuild, seamPath, stepPath } from "../lib/sidecar.js";
|
|
6
|
+
import { hasSession, intentPath, resolveBuild, seamPath, stampStepStat, stampTick, 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);
|
|
@@ -32,6 +32,10 @@ export function build(cwd, args) {
|
|
|
32
32
|
}
|
|
33
33
|
writeFileSync(seamPath(root, slug), `${parsed.seam.join('\n')}\n`);
|
|
34
34
|
writeFileSync(stepPath(root, slug), `${step}\n`);
|
|
35
|
+
stampTick(root, slug); // the entry stamp (D64): TICK = TURN; skipped when the ledger is dormant.
|
|
36
|
+
// The wall-clock receipt starts here (research/07 Build 2b): checkpoint stamps
|
|
37
|
+
// landedAt, and the pair becomes the step's duration in the finish report.
|
|
38
|
+
stampStepStat(root, slug, step, 'startedAt', new Date().toISOString());
|
|
35
39
|
process.stdout.write(`plumbbob: building step ${step}. Seam (for orientation; not a lock):\n${parsed.seam.map((p) => ` ${p}`).join('\n')}\n`);
|
|
36
40
|
return 0;
|
|
37
41
|
}
|