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.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +46 -17
  3. package/dist/cli-core.js +4 -0
  4. package/dist/lib/agents.js +498 -0
  5. package/dist/lib/intent.js +98 -8
  6. package/dist/lib/settings.js +17 -0
  7. package/dist/lib/sidecar.js +36 -0
  8. package/dist/verbs/agent.js +312 -0
  9. package/dist/verbs/checkpoint.js +2 -1
  10. package/dist/verbs/doctor.js +96 -3
  11. package/dist/verbs/status.js +64 -2
  12. package/node_modules/checkride/CHANGELOG.md +139 -0
  13. package/node_modules/checkride/LICENSE +21 -0
  14. package/node_modules/checkride/README.md +179 -0
  15. package/node_modules/checkride/dist/adapters.d.ts +56 -0
  16. package/node_modules/checkride/dist/adapters.d.ts.map +1 -0
  17. package/node_modules/checkride/dist/adapters.js +219 -0
  18. package/node_modules/checkride/dist/adapters.js.map +1 -0
  19. package/node_modules/checkride/dist/cli.d.ts +29 -0
  20. package/node_modules/checkride/dist/cli.d.ts.map +1 -0
  21. package/node_modules/checkride/dist/cli.js +227 -0
  22. package/node_modules/checkride/dist/cli.js.map +1 -0
  23. package/node_modules/checkride/dist/config.d.ts +74 -0
  24. package/node_modules/checkride/dist/config.d.ts.map +1 -0
  25. package/node_modules/checkride/dist/config.js +126 -0
  26. package/node_modules/checkride/dist/config.js.map +1 -0
  27. package/node_modules/checkride/dist/doctor.d.ts +76 -0
  28. package/node_modules/checkride/dist/doctor.d.ts.map +1 -0
  29. package/node_modules/checkride/dist/doctor.js +299 -0
  30. package/node_modules/checkride/dist/doctor.js.map +1 -0
  31. package/node_modules/checkride/dist/index.d.ts +18 -0
  32. package/node_modules/checkride/dist/index.d.ts.map +1 -0
  33. package/node_modules/checkride/dist/index.js +13 -0
  34. package/node_modules/checkride/dist/index.js.map +1 -0
  35. package/node_modules/checkride/dist/init.d.ts +64 -0
  36. package/node_modules/checkride/dist/init.d.ts.map +1 -0
  37. package/node_modules/checkride/dist/init.js +489 -0
  38. package/node_modules/checkride/dist/init.js.map +1 -0
  39. package/node_modules/checkride/dist/links.d.ts +23 -0
  40. package/node_modules/checkride/dist/links.d.ts.map +1 -0
  41. package/node_modules/checkride/dist/links.js +125 -0
  42. package/node_modules/checkride/dist/links.js.map +1 -0
  43. package/node_modules/checkride/dist/orchestrator.d.ts +96 -0
  44. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -0
  45. package/node_modules/checkride/dist/orchestrator.js +234 -0
  46. package/node_modules/checkride/dist/orchestrator.js.map +1 -0
  47. package/node_modules/checkride/package.json +76 -0
  48. package/node_modules/checkride/templates/flat/fallow.toml +26 -0
  49. package/node_modules/checkride/templates/flat/pnpm-workspace.yaml +6 -0
  50. package/node_modules/checkride/templates/flat/tsconfig.json +9 -0
  51. package/node_modules/checkride/templates/hybrid/fallow.toml +42 -0
  52. package/node_modules/checkride/templates/hybrid/pnpm-workspace.yaml +6 -0
  53. package/node_modules/checkride/templates/hybrid/tsconfig.json +12 -0
  54. package/node_modules/checkride/templates/monorepo/fallow.toml +44 -0
  55. package/node_modules/checkride/templates/monorepo/pnpm-workspace.yaml +7 -0
  56. package/node_modules/checkride/templates/monorepo/tsconfig.json +7 -0
  57. package/node_modules/checkride/templates/shared/cspell.json +35 -0
  58. package/node_modules/checkride/templates/shared/gitignore +10 -0
  59. package/node_modules/checkride/templates/shared/markdownlint-cli2.jsonc +11 -0
  60. package/node_modules/checkride/templates/shared/npmrc +1 -0
  61. package/node_modules/checkride/templates/shared/oxlintrc.json +33 -0
  62. package/node_modules/checkride/templates/shared/rules/no-class.yml +8 -0
  63. package/node_modules/checkride/templates/shared/rules/no-deep-sibling-import.yml +15 -0
  64. package/node_modules/checkride/templates/shared/rules/no-default-export.yml +9 -0
  65. package/node_modules/checkride/templates/shared/rules/require-js-extension.yml +21 -0
  66. package/node_modules/checkride/templates/shared/sgconfig.yml +2 -0
  67. package/node_modules/checkride/templates/shared/tsconfig.base.json +28 -0
  68. package/node_modules/checkride/templates/shared/vitest.config.ts.template +27 -0
  69. package/package.json +4 -1
  70. package/skills/pb-build/SKILL.md +47 -12
  71. package/skills/pb-finish/SKILL.md +1 -1
  72. package/skills/pb-harvest/SKILL.md +1 -1
  73. package/skills/pb-park/SKILL.md +1 -1
  74. package/skills/pb-plan/SKILL.md +25 -4
  75. package/skills/pb-refine/SKILL.md +1 -1
  76. package/skills/pb-revert/SKILL.md +1 -1
  77. package/skills/pb-spike/SKILL.md +1 -1
  78. package/skills/pb-status/SKILL.md +1 -1
  79. package/skills/pb-step/SKILL.md +10 -2
  80. 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.5.4",
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.jpg" alt="A row of plumb bobs of varying shapes hanging from strings" width="600">
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 `.plumbbob/intent.md`. No code is written
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
- | Skill | Does |
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-build` | *(optional)* implement the next planned step, then verify it to the pause — `--auto` self-approves and chains to done |
123
- | `/pb-verify` | the tick check self-review validate **PAUSE** → checkpoint |
124
- | `/pb-park` | capture a mid-build idea without chasing it |
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
- Three power moves round it out `/pb-revert` (recover to a checkpoint), `/pb-spike`
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 optional.** It's one executor, not the loop. Implement a step by
143
- hand or vibe it in another session, or in another harness entirely and run
144
- `/pb-verify` instead: same checks, same pause, same checkpoint. It reads the *diff,
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 handor 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
+ }