plumbbob 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +9 -0
  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 +42 -7
  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.1",
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
@@ -144,6 +144,14 @@ hand — or vibe it in another session, or in another harness entirely — and r
144
144
  `/pb-verify` instead: same checks, same pause, same checkpoint. It reads the *diff,
145
145
  not the author*.
146
146
 
147
+ **Bring your own agents.** Because the executor is author-blind, you can plug your
148
+ own in. A **user-authored agent** is any executable that speaks one small JSON
149
+ envelope; drop it under `.plumbbob/agents/`, bind it to a step's `before`, `build`,
150
+ or `after` slot in the build's `harness.json`, and PlumbBob runs it through the same
151
+ pause — with **no way for it to advance the loop** (checkride still gates, you're
152
+ still the clock). The full contract for authors, with a copy-paste example, is in
153
+ [`docs/agents.md`](docs/agents.md).
154
+
147
155
  ## Why "PlumbBob"
148
156
 
149
157
  The name is the method. A plumb bob is a weight on a string — gravity pulls it into a
@@ -203,6 +211,7 @@ Each doc answers one question — in rough reading order for a new user:
203
211
  - *Should I / can I / what about…?* → [`docs/faq.md`](docs/faq.md) — the adoption questions, answered straight.
204
212
  - *What is each method for?* → [`docs/techniques.md`](docs/techniques.md) — steps, seams, the pause, park/harvest, spikes.
205
213
  - *What does each skill do?* → [`docs/skills-reference.md`](docs/skills-reference.md) — all twelve `/pb-*` skills: inputs, effects, when to reach for each.
214
+ - *How do I plug in my own agent?* → [`docs/agents.md`](docs/agents.md) — the subprocess envelope, the manifest, `harness.json`, and a working example.
206
215
  - *How do I install it, exactly?* → [`docs/install.md`](docs/install.md) — the full guide and the agent-neutral roadmap.
207
216
  - *What does the CLI underneath do?* → [`docs/cli-reference.md`](docs/cli-reference.md) — every verb, flag, exit code, and the `.plumbbob/` sidecar.
208
217
  - *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
+ }