plumbbob 0.5.4 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +46 -17
- package/dist/cli-core.js +4 -0
- package/dist/lib/agents.js +498 -0
- package/dist/lib/intent.js +98 -8
- package/dist/lib/settings.js +17 -0
- package/dist/lib/sidecar.js +36 -0
- package/dist/verbs/agent.js +312 -0
- package/dist/verbs/checkpoint.js +2 -1
- package/dist/verbs/doctor.js +96 -3
- package/dist/verbs/status.js +64 -2
- package/node_modules/checkride/CHANGELOG.md +139 -0
- package/node_modules/checkride/LICENSE +21 -0
- package/node_modules/checkride/README.md +179 -0
- package/node_modules/checkride/dist/adapters.d.ts +56 -0
- package/node_modules/checkride/dist/adapters.d.ts.map +1 -0
- package/node_modules/checkride/dist/adapters.js +219 -0
- package/node_modules/checkride/dist/adapters.js.map +1 -0
- package/node_modules/checkride/dist/cli.d.ts +29 -0
- package/node_modules/checkride/dist/cli.d.ts.map +1 -0
- package/node_modules/checkride/dist/cli.js +227 -0
- package/node_modules/checkride/dist/cli.js.map +1 -0
- package/node_modules/checkride/dist/config.d.ts +74 -0
- package/node_modules/checkride/dist/config.d.ts.map +1 -0
- package/node_modules/checkride/dist/config.js +126 -0
- package/node_modules/checkride/dist/config.js.map +1 -0
- package/node_modules/checkride/dist/doctor.d.ts +76 -0
- package/node_modules/checkride/dist/doctor.d.ts.map +1 -0
- package/node_modules/checkride/dist/doctor.js +299 -0
- package/node_modules/checkride/dist/doctor.js.map +1 -0
- package/node_modules/checkride/dist/index.d.ts +18 -0
- package/node_modules/checkride/dist/index.d.ts.map +1 -0
- package/node_modules/checkride/dist/index.js +13 -0
- package/node_modules/checkride/dist/index.js.map +1 -0
- package/node_modules/checkride/dist/init.d.ts +64 -0
- package/node_modules/checkride/dist/init.d.ts.map +1 -0
- package/node_modules/checkride/dist/init.js +489 -0
- package/node_modules/checkride/dist/init.js.map +1 -0
- package/node_modules/checkride/dist/links.d.ts +23 -0
- package/node_modules/checkride/dist/links.d.ts.map +1 -0
- package/node_modules/checkride/dist/links.js +125 -0
- package/node_modules/checkride/dist/links.js.map +1 -0
- package/node_modules/checkride/dist/orchestrator.d.ts +96 -0
- package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -0
- package/node_modules/checkride/dist/orchestrator.js +234 -0
- package/node_modules/checkride/dist/orchestrator.js.map +1 -0
- package/node_modules/checkride/package.json +76 -0
- package/node_modules/checkride/templates/flat/fallow.toml +26 -0
- package/node_modules/checkride/templates/flat/pnpm-workspace.yaml +6 -0
- package/node_modules/checkride/templates/flat/tsconfig.json +9 -0
- package/node_modules/checkride/templates/hybrid/fallow.toml +42 -0
- package/node_modules/checkride/templates/hybrid/pnpm-workspace.yaml +6 -0
- package/node_modules/checkride/templates/hybrid/tsconfig.json +12 -0
- package/node_modules/checkride/templates/monorepo/fallow.toml +44 -0
- package/node_modules/checkride/templates/monorepo/pnpm-workspace.yaml +7 -0
- package/node_modules/checkride/templates/monorepo/tsconfig.json +7 -0
- package/node_modules/checkride/templates/shared/cspell.json +35 -0
- package/node_modules/checkride/templates/shared/gitignore +10 -0
- package/node_modules/checkride/templates/shared/markdownlint-cli2.jsonc +11 -0
- package/node_modules/checkride/templates/shared/npmrc +1 -0
- package/node_modules/checkride/templates/shared/oxlintrc.json +33 -0
- package/node_modules/checkride/templates/shared/rules/no-class.yml +8 -0
- package/node_modules/checkride/templates/shared/rules/no-deep-sibling-import.yml +15 -0
- package/node_modules/checkride/templates/shared/rules/no-default-export.yml +9 -0
- package/node_modules/checkride/templates/shared/rules/require-js-extension.yml +21 -0
- package/node_modules/checkride/templates/shared/sgconfig.yml +2 -0
- package/node_modules/checkride/templates/shared/tsconfig.base.json +28 -0
- package/node_modules/checkride/templates/shared/vitest.config.ts.template +27 -0
- package/package.json +4 -1
- package/skills/pb-build/SKILL.md +47 -12
- package/skills/pb-finish/SKILL.md +1 -1
- package/skills/pb-harvest/SKILL.md +1 -1
- package/skills/pb-park/SKILL.md +1 -1
- package/skills/pb-plan/SKILL.md +25 -4
- package/skills/pb-refine/SKILL.md +1 -1
- package/skills/pb-revert/SKILL.md +1 -1
- package/skills/pb-spike/SKILL.md +1 -1
- package/skills/pb-status/SKILL.md +1 -1
- package/skills/pb-step/SKILL.md +10 -2
- package/skills/pb-verify/SKILL.md +21 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plumbbob",
|
|
3
3
|
"displayName": "PlumbBob",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.3",
|
|
5
5
|
"description": "Attention-first build process — driver skills + a CLI that keep you the decider; guidance, not enforcement.",
|
|
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# PlumbBob
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<img src="hero.
|
|
4
|
+
<img src="hero.svg" alt="A row of plumb bobs of varying shapes hanging from strings" width="600">
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
PlumbBob is a Claude Code plugin — twelve `/pb-*` skills and a small CLI — that runs
|
|
@@ -80,7 +80,7 @@ In Claude Code, inside any git repo with a clean tree:
|
|
|
80
80
|
|
|
81
81
|
1. **Plan.** Fire `/pb-plan` and give it whatever you have — nothing (it interviews
|
|
82
82
|
you), a rough line (`/pb-plan rate-limit POST /login, 5/min/IP, return 429`), or a
|
|
83
|
-
path to a spec file. Together you fill
|
|
83
|
+
path to a spec file. Together you fill the build's `intent.md`. No code is written
|
|
84
84
|
yet.
|
|
85
85
|
2. **Build.** Fire `/pb-build`. It implements the next undone step, runs the
|
|
86
86
|
heavy check gate, reviews its own diff against the plan, and stops:
|
|
@@ -115,21 +115,40 @@ every move, and `/pb-status` always names your next one. (Claude Code namespaces
|
|
|
115
115
|
under the plugin — the real command is `/plumbbob:pb-plan`; for readability these docs
|
|
116
116
|
write the short form `/pb-plan`.)
|
|
117
117
|
|
|
118
|
-
|
|
119
|
-
|
|
118
|
+
**The happy path** — the three moves every session makes; many sessions need
|
|
119
|
+
nothing else:
|
|
120
|
+
|
|
121
|
+
| Skill <img alt="" width="110" height="1"> | Does |
|
|
122
|
+
|------------------------------------|------|
|
|
120
123
|
| `/pb-plan` | plan the whole goal — open the session and author intent's Frame, Decisions, Constraints, **and all Steps** |
|
|
124
|
+
| `/pb-build` | implement the next planned step, then verify it to the pause — `--auto` self-approves and chains to done |
|
|
125
|
+
| `/pb-finish` | finish up — write the report, make the final commit, clear for a fresh goal |
|
|
126
|
+
|
|
127
|
+
**Plan-shaping moves** — optional, for when the plan needs work mid-flight:
|
|
128
|
+
|
|
129
|
+
| Skill <img alt="" width="110" height="1"> | Does |
|
|
130
|
+
|------------------------------------|------|
|
|
121
131
|
| `/pb-step` | revise/sharpen the next step (empty input auto-syncs it to reality) |
|
|
122
|
-
| `/pb-
|
|
123
|
-
| `/pb-
|
|
124
|
-
|
|
132
|
+
| `/pb-refine` | attack the frame for holes, or repair a drifted plan |
|
|
133
|
+
| `/pb-spike` | throwaway worktree experiment for a fork the plan can't settle |
|
|
134
|
+
|
|
135
|
+
**Helpers** — orient, verify, recover, diagnose:
|
|
136
|
+
|
|
137
|
+
| Skill <img alt="" width="110" height="1"> | Does |
|
|
138
|
+
|------------------------------------|------|
|
|
125
139
|
| `/pb-status` | orient — where you are, the next step's done-when and seam, and the next move |
|
|
140
|
+
| `/pb-verify` | the tick, standalone — check → self-review → validate → **PAUSE** → checkpoint, for a diff `/pb-build` didn't write |
|
|
141
|
+
| `/pb-revert` | recover — `git reset --hard` to a recorded checkpoint |
|
|
142
|
+
| `/pb-doctor` | check the install from inside a session |
|
|
143
|
+
|
|
144
|
+
**Capture** — the park/harvest loop for mid-build ideas:
|
|
145
|
+
|
|
146
|
+
| Skill <img alt="" width="110" height="1"> | Does |
|
|
147
|
+
|------------------------------------|------|
|
|
148
|
+
| `/pb-park` | capture a mid-build idea without chasing it |
|
|
126
149
|
| `/pb-harvest` | triage parked ideas between steps (blocker / tangent / pivot) |
|
|
127
|
-
| `/pb-finish` | finish up — write the report, make the final commit, clear for a fresh goal |
|
|
128
150
|
|
|
129
|
-
|
|
130
|
-
(throwaway worktree experiment for a fork the plan can't settle), and `/pb-refine`
|
|
131
|
-
(attack the frame for holes or repair a drifted plan) — plus `/pb-doctor` to check
|
|
132
|
-
your install. All twelve, with inputs and effects, are in
|
|
151
|
+
All twelve, with inputs and effects, are in
|
|
133
152
|
[`docs/skills-reference.md`](docs/skills-reference.md).
|
|
134
153
|
|
|
135
154
|
Under the skills ships a lean `plumbbob` CLI (the mechanical verbs the
|
|
@@ -139,10 +158,18 @@ flow), and a `.plumbbob/` sidecar you can open and edit by hand at any time —
|
|
|
139
158
|
that rides the branch into the PR, plus an untracked control plane (`settings.local.json`,
|
|
140
159
|
the session sentinel, the in-flight markers).
|
|
141
160
|
|
|
142
|
-
**`/pb-build` is
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
not the author*.
|
|
161
|
+
**`/pb-build` is the default engine, not the only one.** It's one executor — the
|
|
162
|
+
loop doesn't care who writes the code. Implement a step by hand — or vibe it in
|
|
163
|
+
another session, or in another harness entirely — and run `/pb-verify` instead:
|
|
164
|
+
same checks, same pause, same checkpoint. It reads the *diff, not the author*.
|
|
165
|
+
|
|
166
|
+
**Bring your own agents.** Because the executor is author-blind, you can plug your
|
|
167
|
+
own in. A **user-authored agent** is any executable that speaks one small JSON
|
|
168
|
+
envelope; drop it under `.plumbbob/agents/`, bind it to a step's `before`, `build`,
|
|
169
|
+
or `after` slot in the build's `harness.json`, and PlumbBob runs it through the same
|
|
170
|
+
pause — with **no way for it to advance the loop** (checkride still gates, you're
|
|
171
|
+
still the clock). The full contract for authors, with a copy-paste example, is in
|
|
172
|
+
[`docs/agents.md`](docs/agents.md).
|
|
146
173
|
|
|
147
174
|
## Why "PlumbBob"
|
|
148
175
|
|
|
@@ -186,7 +213,7 @@ actually learning is the method: decisions before code, one verified step at a
|
|
|
186
213
|
time, capture instead of chase. That transfers to whatever tool wins. If
|
|
187
214
|
something better ships, you walk away with your archives and your habits intact.
|
|
188
215
|
|
|
189
|
-
**…admit the pause is unenforceable?** It is — on purpose (D10/D13 in
|
|
216
|
+
**…admit the pause is unenforceable?** It is — on purpose ([D10](docs/decisions.md#d10)/[D13](docs/decisions.md#d13) in
|
|
190
217
|
[`docs/decisions.md`](docs/decisions.md)). A hard lock buys ritual, not control:
|
|
191
218
|
a determined model routes around it. So PlumbBob enforces deterministically where
|
|
192
219
|
it can — `checkpoint` refuses on a red check, `revert` restores only recorded
|
|
@@ -203,6 +230,8 @@ Each doc answers one question — in rough reading order for a new user:
|
|
|
203
230
|
- *Should I / can I / what about…?* → [`docs/faq.md`](docs/faq.md) — the adoption questions, answered straight.
|
|
204
231
|
- *What is each method for?* → [`docs/techniques.md`](docs/techniques.md) — steps, seams, the pause, park/harvest, spikes.
|
|
205
232
|
- *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md) — all twelve `/pb-*` skills: inputs, effects, when to reach for each.
|
|
233
|
+
- *How do I plug in my own agent?* → [`docs/agents.md`](docs/agents.md) — the subprocess envelope, the manifest, `harness.json`, and working examples (including a local-model reviewer via Ollama).
|
|
234
|
+
- *How do I get a local model reviewing my steps?* → [`docs/local-model-review.md`](docs/local-model-review.md) — the ollama-reviewer example walked end to end, install to every-pause review.
|
|
206
235
|
- *How do I install it, exactly?* → [`docs/install.md`](docs/install.md) — the full guide and the agent-neutral roadmap.
|
|
207
236
|
- *What does the CLI underneath do?* → [`docs/cli-reference.md`](docs/cli-reference.md) — every verb, flag, exit code, and the `.plumbbob/` sidecar.
|
|
208
237
|
- *Something's broken.* → [`docs/troubleshooting.md`](docs/troubleshooting.md) — fixes for the common snags.
|
package/dist/cli-core.js
CHANGED
|
@@ -17,6 +17,7 @@ import { use } from "./verbs/use.js";
|
|
|
17
17
|
import { finish } from "./verbs/finish.js";
|
|
18
18
|
import { init } from "./verbs/init.js";
|
|
19
19
|
import { doctor } from "./verbs/doctor.js";
|
|
20
|
+
import { agent } from "./verbs/agent.js";
|
|
20
21
|
const VERBS = [
|
|
21
22
|
{ name: 'start', summary: 'scaffold .plumbbob/; open the session; record the baseline commit' },
|
|
22
23
|
{ name: 'status', summary: 'print the orientation dashboard, or NO ACTIVE SESSION' },
|
|
@@ -30,6 +31,7 @@ const VERBS = [
|
|
|
30
31
|
{ name: 'finish', summary: 'close-out: report + final commit (no gate), clear the control state, close the session' },
|
|
31
32
|
{ name: 'init', summary: 'link plumbbob into Claude Code as an in-place plugin (~/.claude/skills/plumbbob); --uninstall to undo' },
|
|
32
33
|
{ name: 'doctor', summary: 'diagnose the plugin link + detect a legacy flat sidecar; doctor --migrate moves it into builds/ (staged, not committed)' },
|
|
34
|
+
{ name: 'agent', summary: 'agent list | run <name> [--step N] [--mode before|build|after]: list or spawn a user-authored agent' },
|
|
33
35
|
];
|
|
34
36
|
// PlumbBob (D1/D10/D13): the deciding/executing boundary is a pause, not a lock,
|
|
35
37
|
// so there is nothing to defend — every verb runs the same whether a human or the
|
|
@@ -82,6 +84,8 @@ async function dispatch(verb, cwd, rest) {
|
|
|
82
84
|
return init(rest);
|
|
83
85
|
case 'doctor':
|
|
84
86
|
return doctor(cwd, rest);
|
|
87
|
+
case 'agent':
|
|
88
|
+
return agent(cwd, rest);
|
|
85
89
|
default:
|
|
86
90
|
process.stderr.write(`plumbbob: unknown verb '${verb}'. Run 'plumbbob help' for the verb table.\n`);
|
|
87
91
|
return 1;
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
// The agent contract (D39/D46): a user-authored agent is anything executable that
|
|
2
|
+
// speaks a versioned JSON envelope — a JSON manifest on disk (`agent.json`), JSON
|
|
3
|
+
// on the child's stdin, JSON on its stdout, prose on its stderr. This module is
|
|
4
|
+
// the contract's validator AND resolver: it type-narrows the on-disk manifest and
|
|
5
|
+
// the output envelope a child returns (refusing a contract major-version mismatch
|
|
6
|
+
// with an upgrade hint rather than guessing at a shape that is only stable within a
|
|
7
|
+
// major — C7: additions are minor, removals/renames are major), and it walks the
|
|
8
|
+
// two agent tiers to resolve an agent by name. The validators are pure; the
|
|
9
|
+
// resolver reads `agent.json` files off disk (node builtins only, C2). Invocation
|
|
10
|
+
// (step 4) spawns on top of these.
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { parseBuildTitle, parseStepMeta, parseStepSeam, scrapeBullets } from "./intent.js";
|
|
16
|
+
// The contract major version this plumbbob speaks. A manifest or envelope
|
|
17
|
+
// declaring a different major is refused (D46): within a major the envelope only
|
|
18
|
+
// gains fields, so an older CLI can read a newer minor, but a major gap means the
|
|
19
|
+
// shapes may genuinely disagree.
|
|
20
|
+
export const CONTRACT_VERSION = 1;
|
|
21
|
+
// The three lifecycle slots an agent may bind to (D43): `before` (context in),
|
|
22
|
+
// `build` (the diff), `after` (advisory review). Exactly these, never a fourth —
|
|
23
|
+
// "a salient point in the middle" is judgment the host model handles in prose,
|
|
24
|
+
// not a declarative slot.
|
|
25
|
+
export const SLOTS = ['before', 'build', 'after'];
|
|
26
|
+
// The terminal states an agent reports (D52). `done` = finished; `blocked` = it
|
|
27
|
+
// couldn't finish, so the human unblocks and re-runs; `drift` = it finished but
|
|
28
|
+
// found the plan no longer matches reality, so the route is /pb-refine repair.
|
|
29
|
+
export const ENVELOPE_STATUSES = ['done', 'blocked', 'drift'];
|
|
30
|
+
// Validate a parsed `agent.json`. Contract is checked first (a version mismatch
|
|
31
|
+
// gets an upgrade hint, not a field complaint, since older field checks may not
|
|
32
|
+
// apply across a major). Then: a non-empty `name` and `command`, and a `slots`
|
|
33
|
+
// subset of {before, build, after}. Unknown keys pass through untouched.
|
|
34
|
+
export function parseManifest(raw) {
|
|
35
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
36
|
+
return manifestFail('agent.json must be a JSON object.');
|
|
37
|
+
}
|
|
38
|
+
const obj = raw;
|
|
39
|
+
const versionError = checkContract(obj.contract, 'agent.json');
|
|
40
|
+
if (versionError !== null) {
|
|
41
|
+
return manifestFail(versionError);
|
|
42
|
+
}
|
|
43
|
+
if (typeof obj.name !== 'string' || obj.name.trim().length === 0) {
|
|
44
|
+
return manifestFail('agent.json needs a non-empty "name" string.');
|
|
45
|
+
}
|
|
46
|
+
if (typeof obj.command !== 'string' || obj.command.trim().length === 0) {
|
|
47
|
+
return manifestFail('agent.json needs a non-empty "command" string — the shell command plumbbob spawns.');
|
|
48
|
+
}
|
|
49
|
+
const slots = parseSlots(obj.slots);
|
|
50
|
+
if (typeof slots === 'string') {
|
|
51
|
+
return manifestFail(slots);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
ok: true,
|
|
55
|
+
manifest: {
|
|
56
|
+
contract: CONTRACT_VERSION,
|
|
57
|
+
name: obj.name.trim(),
|
|
58
|
+
command: obj.command,
|
|
59
|
+
slots,
|
|
60
|
+
description: asString(obj.description),
|
|
61
|
+
when: asString(obj.when),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
// Validate a child's output envelope (already JSON-parsed from its stdout).
|
|
66
|
+
// Contract first (mismatch → upgrade hint), then a `status` in {done, blocked,
|
|
67
|
+
// drift}, a non-empty `summary`, and a well-formed `parked[]`. Optional prose
|
|
68
|
+
// fields default to empty; unknown keys are tolerated (C7).
|
|
69
|
+
export function parseEnvelope(raw) {
|
|
70
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
71
|
+
return envelopeFail('the agent envelope must be a JSON object on stdout.');
|
|
72
|
+
}
|
|
73
|
+
const obj = raw;
|
|
74
|
+
const versionError = checkContract(obj.contract, 'the agent envelope');
|
|
75
|
+
if (versionError !== null) {
|
|
76
|
+
return envelopeFail(versionError);
|
|
77
|
+
}
|
|
78
|
+
if (typeof obj.status !== 'string' || !isStatus(obj.status)) {
|
|
79
|
+
return envelopeFail(`the agent envelope needs a "status" of ${ENVELOPE_STATUSES.join(', ')}.`);
|
|
80
|
+
}
|
|
81
|
+
if (typeof obj.summary !== 'string' || obj.summary.trim().length === 0) {
|
|
82
|
+
return envelopeFail('the agent envelope needs a non-empty "summary" string.');
|
|
83
|
+
}
|
|
84
|
+
const parked = parseParked(obj.parked);
|
|
85
|
+
if (typeof parked === 'string') {
|
|
86
|
+
return envelopeFail(parked);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
envelope: {
|
|
91
|
+
contract: CONTRACT_VERSION,
|
|
92
|
+
status: obj.status,
|
|
93
|
+
summary: obj.summary,
|
|
94
|
+
body: asString(obj.body),
|
|
95
|
+
parked,
|
|
96
|
+
notes: asString(obj.notes),
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
// True when a string names one of the three slots. Exported for step 4's
|
|
101
|
+
// `--mode` refusal (D54): a mode the manifest doesn't declare is refused loud.
|
|
102
|
+
export function isSlot(value) {
|
|
103
|
+
return SLOTS.includes(value);
|
|
104
|
+
}
|
|
105
|
+
// Validate the shared `contract` field. A missing / non-integer contract is
|
|
106
|
+
// malformed; a present-but-different major is a version mismatch (D46) and gets an
|
|
107
|
+
// upgrade hint pointing at whichever side is behind. Returns an error string, or
|
|
108
|
+
// null when the contract is the supported major.
|
|
109
|
+
function checkContract(value, what) {
|
|
110
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
111
|
+
return `${what} needs an integer "contract" version — this plumbbob speaks contract ${CONTRACT_VERSION}.`;
|
|
112
|
+
}
|
|
113
|
+
if (value !== CONTRACT_VERSION) {
|
|
114
|
+
const behind = value > CONTRACT_VERSION ? 'plumbbob CLI' : 'agent';
|
|
115
|
+
return (`${what} speaks contract ${value}, but this plumbbob speaks contract ${CONTRACT_VERSION}. ` +
|
|
116
|
+
`Upgrade the ${behind} to match — the envelope is only stable within a major version.`);
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
// Narrow the manifest's `slots` to the subset it declares. Must be a non-empty
|
|
121
|
+
// array drawn from SLOTS, with strangers refused and duplicates collapsed.
|
|
122
|
+
// Returns the narrowed slots, or an error string.
|
|
123
|
+
function parseSlots(value) {
|
|
124
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
125
|
+
return `agent.json needs a non-empty "slots" array drawn from ${SLOTS.join(', ')}.`;
|
|
126
|
+
}
|
|
127
|
+
const seen = new Set();
|
|
128
|
+
for (const entry of value) {
|
|
129
|
+
if (typeof entry !== 'string' || !isSlot(entry)) {
|
|
130
|
+
return `agent.json "slots" has an invalid entry ${JSON.stringify(entry)} — allowed slots are ${SLOTS.join(', ')}.`;
|
|
131
|
+
}
|
|
132
|
+
seen.add(entry);
|
|
133
|
+
}
|
|
134
|
+
return [...seen];
|
|
135
|
+
}
|
|
136
|
+
// `parked[]` is optional (absent = none). Present, it must be an array of
|
|
137
|
+
// non-blank strings — each becomes a park line the CLI lands through the park
|
|
138
|
+
// verb (D44). A malformed list is refused rather than silently dropped: losing a
|
|
139
|
+
// parked concern is a quiet data loss. Returns the trimmed lines or an error.
|
|
140
|
+
function parseParked(value) {
|
|
141
|
+
if (value === undefined || value === null) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(value)) {
|
|
145
|
+
return 'the agent envelope\'s "parked" must be an array of strings.';
|
|
146
|
+
}
|
|
147
|
+
const parked = [];
|
|
148
|
+
for (const entry of value) {
|
|
149
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) {
|
|
150
|
+
return 'the agent envelope\'s "parked" must contain only non-empty strings.';
|
|
151
|
+
}
|
|
152
|
+
parked.push(entry.trim());
|
|
153
|
+
}
|
|
154
|
+
return parked;
|
|
155
|
+
}
|
|
156
|
+
// Optional prose fields (description, when, body, notes) default to empty —
|
|
157
|
+
// absent and blank read the same to the host model, and a non-string value is
|
|
158
|
+
// coerced away rather than refused, because these are advisory prose, not gates.
|
|
159
|
+
function asString(value) {
|
|
160
|
+
return typeof value === 'string' ? value : '';
|
|
161
|
+
}
|
|
162
|
+
function isStatus(value) {
|
|
163
|
+
return ENVELOPE_STATUSES.includes(value);
|
|
164
|
+
}
|
|
165
|
+
function manifestFail(error) {
|
|
166
|
+
return { ok: false, error };
|
|
167
|
+
}
|
|
168
|
+
function envelopeFail(error) {
|
|
169
|
+
return { ok: false, error };
|
|
170
|
+
}
|
|
171
|
+
export function projectAgentsDir(root) {
|
|
172
|
+
return join(root, '.plumbbob', 'agents');
|
|
173
|
+
}
|
|
174
|
+
export function personalAgentsDir(home) {
|
|
175
|
+
return join(home, '.plumbbob', 'agents');
|
|
176
|
+
}
|
|
177
|
+
// The home dir the personal tier resolves under. Callers (verbs) pass nothing and
|
|
178
|
+
// get the environment's HOME (matching doctor.ts); tests pass an explicit dir to
|
|
179
|
+
// exercise an HOME-overridden personal library without touching process.env.
|
|
180
|
+
function agentsHome(home) {
|
|
181
|
+
return home ?? process.env.HOME ?? homedir();
|
|
182
|
+
}
|
|
183
|
+
// Resolve an agent by name across the tiers (D41), first hit wins. A `flagPath`
|
|
184
|
+
// (from `--agent <path>`) points straight at an agent directory and takes top
|
|
185
|
+
// priority. A tier "hits" when it holds an `agent.json`: a malformed one there is
|
|
186
|
+
// surfaced as an error (project still shadows personal — D54's fail-loud), never
|
|
187
|
+
// silently skipped in favor of a different agent. Returns the not-found error only
|
|
188
|
+
// when no tier holds a manifest at all.
|
|
189
|
+
export function resolveAgent(root, name, opts = {}) {
|
|
190
|
+
if (opts.flagPath !== undefined) {
|
|
191
|
+
const hit = loadAgentDir(opts.flagPath, 'flag');
|
|
192
|
+
return hit ?? { ok: false, error: `no agent.json at --agent path ${opts.flagPath}` };
|
|
193
|
+
}
|
|
194
|
+
const home = agentsHome(opts.home);
|
|
195
|
+
const tiers = [
|
|
196
|
+
[join(projectAgentsDir(root), name), 'project'],
|
|
197
|
+
[join(personalAgentsDir(home), name), 'personal'],
|
|
198
|
+
];
|
|
199
|
+
for (const [dir, origin] of tiers) {
|
|
200
|
+
const hit = loadAgentDir(dir, origin);
|
|
201
|
+
if (hit !== null)
|
|
202
|
+
return hit;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
ok: false,
|
|
206
|
+
error: `no agent named "${name}" — looked in ${projectAgentsDir(root)} and ${personalAgentsDir(home)}.`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
// Every resolvable agent across both tiers, sorted by name, project shadowing
|
|
210
|
+
// personal (a name defined in both resolves — and lists — from project). Each
|
|
211
|
+
// entry carries its origin and resolution; a malformed manifest lands as an errored
|
|
212
|
+
// resolution rather than crashing the walk.
|
|
213
|
+
export function listAgents(root, opts = {}) {
|
|
214
|
+
const home = agentsHome(opts.home);
|
|
215
|
+
const tiers = [
|
|
216
|
+
[projectAgentsDir(root), 'project'],
|
|
217
|
+
[personalAgentsDir(home), 'personal'],
|
|
218
|
+
];
|
|
219
|
+
const origins = new Map();
|
|
220
|
+
for (const [dir, origin] of tiers) {
|
|
221
|
+
for (const agentName of agentDirNames(dir)) {
|
|
222
|
+
if (!origins.has(agentName))
|
|
223
|
+
origins.set(agentName, origin);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return [...origins.keys()].sort().map((name) => ({
|
|
227
|
+
name,
|
|
228
|
+
origin: origins.get(name),
|
|
229
|
+
resolution: resolveAgent(root, name, { home }),
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
// Render `agent list`'s output. Pure so the verb stays a thin read-write shell and
|
|
233
|
+
// the formatting is unit-tested here. Prints name, origin, slots, and description
|
|
234
|
+
// per agent (D41's done-when); a malformed manifest gets a `✗ … invalid:` line.
|
|
235
|
+
export function formatAgentList(listings) {
|
|
236
|
+
if (listings.length === 0) {
|
|
237
|
+
return 'plumbbob: no agents. Add one under .plumbbob/agents/<name>/ or ~/.plumbbob/agents/<name>/.';
|
|
238
|
+
}
|
|
239
|
+
const lines = ['plumbbob agents:'];
|
|
240
|
+
for (const listing of listings) {
|
|
241
|
+
if (!listing.resolution.ok) {
|
|
242
|
+
lines.push(` ✗ ${listing.name} (${listing.origin}) — invalid: ${listing.resolution.error}`);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const { manifest } = listing.resolution.agent;
|
|
246
|
+
const description = manifest.description.length > 0 ? ` — ${manifest.description}` : '';
|
|
247
|
+
lines.push(` ${listing.name} (${listing.origin}) [${manifest.slots.join(', ')}]${description}`);
|
|
248
|
+
}
|
|
249
|
+
return lines.join('\n');
|
|
250
|
+
}
|
|
251
|
+
// Load and validate the `agent.json` in one agent directory. Returns null when the
|
|
252
|
+
// directory holds no manifest (a tier miss, so resolution falls through), an errored
|
|
253
|
+
// resolution when the manifest is present but unreadable/malformed, else the resolved
|
|
254
|
+
// agent. A missing file (ENOENT throw) is the miss signal — one read, no TOCTOU race.
|
|
255
|
+
function loadAgentDir(dir, origin) {
|
|
256
|
+
const file = join(dir, 'agent.json');
|
|
257
|
+
let raw;
|
|
258
|
+
try {
|
|
259
|
+
raw = readFileSync(file, 'utf8');
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
let parsed;
|
|
265
|
+
try {
|
|
266
|
+
parsed = JSON.parse(raw);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
return { ok: false, error: `${file} is not valid JSON.` };
|
|
270
|
+
}
|
|
271
|
+
const result = parseManifest(parsed);
|
|
272
|
+
return result.ok
|
|
273
|
+
? { ok: true, agent: { manifest: result.manifest, origin, dir } }
|
|
274
|
+
: { ok: false, error: `${file}: ${result.error}` };
|
|
275
|
+
}
|
|
276
|
+
// The names of subdirectories under an agents tier that actually carry an
|
|
277
|
+
// `agent.json` — so `listAgents` never lists a bare folder that resolution would
|
|
278
|
+
// then miss. An absent tier directory yields no names.
|
|
279
|
+
function agentDirNames(dir) {
|
|
280
|
+
try {
|
|
281
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
282
|
+
.filter((entry) => entry.isDirectory() && existsSync(join(dir, entry.name, 'agent.json')))
|
|
283
|
+
.map((entry) => entry.name);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return [];
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Compose the StepContext for one step. The strict seam parse is the only refusal
|
|
290
|
+
// path — a missing step or unparseable seam gates git behavior, so it fails loud;
|
|
291
|
+
// everything else is best-effort and lands whatever it can, returning `warnings`
|
|
292
|
+
// (skipped-bullet lines) for the caller to print on stderr rather than writing
|
|
293
|
+
// there itself (this module stays pure — the verb does the IO).
|
|
294
|
+
export function composeStepContext(params) {
|
|
295
|
+
const seam = parseStepSeam(params.intent, params.step);
|
|
296
|
+
if (!seam.ok) {
|
|
297
|
+
return { ok: false, error: seam.error };
|
|
298
|
+
}
|
|
299
|
+
const meta = parseStepMeta(params.intent, params.step);
|
|
300
|
+
const decisions = scrapeBullets(params.intent, '## Decisions');
|
|
301
|
+
const constraints = scrapeBullets(params.intent, '## Constraints');
|
|
302
|
+
const warnings = [
|
|
303
|
+
...skipWarnings('## Decisions', decisions.skipped),
|
|
304
|
+
...skipWarnings('## Constraints', constraints.skipped),
|
|
305
|
+
];
|
|
306
|
+
return {
|
|
307
|
+
ok: true,
|
|
308
|
+
input: {
|
|
309
|
+
contract: CONTRACT_VERSION,
|
|
310
|
+
mode: params.mode,
|
|
311
|
+
build: { slug: params.slug, title: parseBuildTitle(params.intent) },
|
|
312
|
+
step: { n: params.step, title: meta.title, doneWhen: meta.doneWhen, seam: seam.seam },
|
|
313
|
+
decisions: decisions.items,
|
|
314
|
+
constraints: constraints.items,
|
|
315
|
+
context: params.context ?? [],
|
|
316
|
+
settings: params.settings ?? {},
|
|
317
|
+
},
|
|
318
|
+
warnings,
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
function skipWarnings(heading, skipped) {
|
|
322
|
+
return skipped.map((line) => `intent.md: skipped a non-bullet line under ${heading} — ${JSON.stringify(line.trim())}`);
|
|
323
|
+
}
|
|
324
|
+
// Validate a parsed harness.json. Contract first (a major mismatch gets the same
|
|
325
|
+
// upgrade hint as the manifest/envelope, D46). Structure is strict — a `steps` that
|
|
326
|
+
// is not an object, a non-numeric step key, or a step entry that is not an object
|
|
327
|
+
// is the author's error and is refused loud. Slot *contents* stay lenient (D61): a
|
|
328
|
+
// slot value may be one name or a list, and blanks/non-strings drop rather than
|
|
329
|
+
// refuse, because bindings feed a spawn, not a git-gating parse.
|
|
330
|
+
export function parseHarness(raw) {
|
|
331
|
+
if (!isObject(raw)) {
|
|
332
|
+
return { ok: false, error: 'harness.json must be a JSON object.' };
|
|
333
|
+
}
|
|
334
|
+
const versionError = checkContract(raw.contract, 'harness.json');
|
|
335
|
+
if (versionError !== null) {
|
|
336
|
+
return { ok: false, error: versionError };
|
|
337
|
+
}
|
|
338
|
+
const defaults = parseSlotBindings(raw.defaults);
|
|
339
|
+
const steps = new Map();
|
|
340
|
+
if (raw.steps !== undefined) {
|
|
341
|
+
if (!isObject(raw.steps)) {
|
|
342
|
+
return { ok: false, error: 'harness.json "steps" must be an object keyed by step number.' };
|
|
343
|
+
}
|
|
344
|
+
for (const [key, value] of Object.entries(raw.steps)) {
|
|
345
|
+
if (!/^\d+$/.test(key)) {
|
|
346
|
+
return { ok: false, error: `harness.json "steps" key ${JSON.stringify(key)} must be a step number.` };
|
|
347
|
+
}
|
|
348
|
+
if (!isObject(value)) {
|
|
349
|
+
return { ok: false, error: `harness.json step ${key} must be an object of slot bindings.` };
|
|
350
|
+
}
|
|
351
|
+
steps.set(Number(key), { bindings: parseSlotBindings(value), note: asString(value.note) });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return { ok: true, harness: { contract: CONTRACT_VERSION, defaults, steps } };
|
|
355
|
+
}
|
|
356
|
+
// Read and validate a harness.json at `path`. The caller builds the path from the
|
|
357
|
+
// build folder — this module stays ignorant of the sidecar layout, taking the full
|
|
358
|
+
// path. null when the file is absent (a build with no bound agents — a clean
|
|
359
|
+
// no-op); an errored parse when present-but-broken (invalid JSON or a malformed
|
|
360
|
+
// structure the author must fix); else the validated bindings. Shared so every
|
|
361
|
+
// reader (`agent run`, `status`) parses the harness the same way.
|
|
362
|
+
export function readHarnessFile(path) {
|
|
363
|
+
let raw;
|
|
364
|
+
try {
|
|
365
|
+
raw = readFileSync(path, 'utf8');
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
let parsed;
|
|
371
|
+
try {
|
|
372
|
+
parsed = JSON.parse(raw);
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return { ok: false, error: `${path} is not valid JSON.` };
|
|
376
|
+
}
|
|
377
|
+
return parseHarness(parsed);
|
|
378
|
+
}
|
|
379
|
+
// Narrow a raw slot→agents object (a harness `defaults`, a per-step entry, or the
|
|
380
|
+
// settings-level defaults, D57) to the slots it actually binds. A slot value may be
|
|
381
|
+
// a single agent name or a list; blanks and non-strings drop. Only the three real
|
|
382
|
+
// slots are read — `note` and any stranger key are ignored here. A slot present but
|
|
383
|
+
// naming no valid agent still counts as bound-to-nothing (an explicit override to
|
|
384
|
+
// none), so callers get clean replace semantics up the ladder.
|
|
385
|
+
export function parseSlotBindings(raw) {
|
|
386
|
+
if (!isObject(raw))
|
|
387
|
+
return {};
|
|
388
|
+
const out = {};
|
|
389
|
+
for (const slot of SLOTS) {
|
|
390
|
+
if (slot in raw)
|
|
391
|
+
out[slot] = asNameList(raw[slot]);
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
// The agents bound to one slot for one step, merging the ladder (D57): a per-step
|
|
396
|
+
// slot entry overrides the harness `defaults`, which override the settings-level
|
|
397
|
+
// defaults — the first level that names the slot wins (replace, not append). The
|
|
398
|
+
// `--agent` flag and an explicit name sit above all of this, but the verb takes the
|
|
399
|
+
// single-agent path for those and never reaches here. Returns [] when no level
|
|
400
|
+
// binds the slot: nothing to run, a clean no-op.
|
|
401
|
+
export function resolveSlotAgents(params) {
|
|
402
|
+
const perStep = params.harness?.steps.get(params.step)?.bindings;
|
|
403
|
+
if (perStep && params.slot in perStep)
|
|
404
|
+
return perStep[params.slot] ?? [];
|
|
405
|
+
const defaults = params.harness?.defaults;
|
|
406
|
+
if (defaults && params.slot in defaults)
|
|
407
|
+
return defaults[params.slot] ?? [];
|
|
408
|
+
if (params.slot in params.settingsDefaults)
|
|
409
|
+
return params.settingsDefaults[params.slot] ?? [];
|
|
410
|
+
return [];
|
|
411
|
+
}
|
|
412
|
+
function asNameList(value) {
|
|
413
|
+
const items = Array.isArray(value) ? value : [value];
|
|
414
|
+
return items.filter((v) => typeof v === 'string' && v.trim().length > 0).map((v) => v.trim());
|
|
415
|
+
}
|
|
416
|
+
function isObject(value) {
|
|
417
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
418
|
+
}
|
|
419
|
+
// Spawn an agent's `command` via the shell (D49: `sh -c` on POSIX) at the repo
|
|
420
|
+
// root, with the composed StepContext delivered as JSON on stdin. Stdout is piped
|
|
421
|
+
// and captured (the envelope); stderr is inherited so the child's prose streams
|
|
422
|
+
// live to the terminal (D46 — production narrates, consumption stays structured);
|
|
423
|
+
// the agent's own directory rides in `PLUMBBOB_AGENT_DIR` (D49) so a root-cwd
|
|
424
|
+
// agent can still reach its files. A SIGINT while the child runs kills it and
|
|
425
|
+
// reports `interrupted` rather than orphaning it (D58); a positive `timeoutSeconds`
|
|
426
|
+
// arms a kill timer (D51, 0 = off). Async `spawn`, not `spawnSync` (D60), so the
|
|
427
|
+
// parent stays live to interrupt gracefully. Never rejects — every failure mode is
|
|
428
|
+
// a resolved `AgentRunResult`.
|
|
429
|
+
export function runAgent(params) {
|
|
430
|
+
return new Promise((resolve) => {
|
|
431
|
+
const child = spawn(params.command, {
|
|
432
|
+
cwd: params.root,
|
|
433
|
+
shell: true,
|
|
434
|
+
env: { ...process.env, PLUMBBOB_AGENT_DIR: params.agentDir },
|
|
435
|
+
stdio: ['pipe', 'pipe', 'inherit'],
|
|
436
|
+
});
|
|
437
|
+
let stdout = '';
|
|
438
|
+
let settled = false;
|
|
439
|
+
let timer = null;
|
|
440
|
+
const finish = (result) => {
|
|
441
|
+
if (settled)
|
|
442
|
+
return;
|
|
443
|
+
settled = true;
|
|
444
|
+
if (timer !== null)
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
process.off('SIGINT', onSigint);
|
|
447
|
+
resolve(result);
|
|
448
|
+
};
|
|
449
|
+
// Kill the child and report, rather than let a Ctrl-C orphan it (D58). The
|
|
450
|
+
// SIGKILL escalation covers a child that ignores the interrupt.
|
|
451
|
+
function onSigint() {
|
|
452
|
+
child.kill('SIGKILL');
|
|
453
|
+
finish({ ok: false, reason: 'interrupted' });
|
|
454
|
+
}
|
|
455
|
+
process.on('SIGINT', onSigint);
|
|
456
|
+
if (params.timeoutSeconds > 0) {
|
|
457
|
+
timer = setTimeout(() => {
|
|
458
|
+
child.kill('SIGKILL');
|
|
459
|
+
finish({ ok: false, reason: 'timeout', seconds: params.timeoutSeconds });
|
|
460
|
+
}, params.timeoutSeconds * 1000);
|
|
461
|
+
}
|
|
462
|
+
child.stdout.on('data', (chunk) => {
|
|
463
|
+
stdout += chunk.toString();
|
|
464
|
+
});
|
|
465
|
+
// A shell that never launched (e.g. `sh` itself missing) — distinct from a
|
|
466
|
+
// command that ran and exited non-zero, which arrives on `close`.
|
|
467
|
+
child.on('error', (err) => {
|
|
468
|
+
finish({ ok: false, reason: 'spawn', error: err.message });
|
|
469
|
+
});
|
|
470
|
+
child.on('close', (code) => {
|
|
471
|
+
if (code !== 0) {
|
|
472
|
+
finish({ ok: false, reason: 'exit', code: code ?? 1, stdout });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const parsed = parseChildEnvelope(stdout);
|
|
476
|
+
finish(parsed.ok
|
|
477
|
+
? { ok: true, envelope: parsed.envelope, stdout }
|
|
478
|
+
: { ok: false, reason: 'contract', error: parsed.error, stdout });
|
|
479
|
+
});
|
|
480
|
+
// Deliver the StepContext and close stdin so a child reading to EOF proceeds.
|
|
481
|
+
child.stdin.write(JSON.stringify(params.input));
|
|
482
|
+
child.stdin.end();
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
// Parse the child's captured stdout into a validated envelope: JSON first (a
|
|
486
|
+
// non-JSON stdout is out of contract, D46), then the envelope validator (which
|
|
487
|
+
// carries the contract-mismatch hint). Kept private — `runAgent` is the only
|
|
488
|
+
// caller and the verb reads the union, not this.
|
|
489
|
+
function parseChildEnvelope(stdout) {
|
|
490
|
+
let raw;
|
|
491
|
+
try {
|
|
492
|
+
raw = JSON.parse(stdout);
|
|
493
|
+
}
|
|
494
|
+
catch {
|
|
495
|
+
return { ok: false, error: 'the agent wrote non-JSON to stdout — the envelope must be a single JSON object.' };
|
|
496
|
+
}
|
|
497
|
+
return parseEnvelope(raw);
|
|
498
|
+
}
|