plumbbob 0.8.17 → 0.9.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 (47) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +56 -44
  3. package/dist/cli-core.js +35 -15
  4. package/dist/lib/agents.js +230 -112
  5. package/dist/lib/buildlog.js +50 -30
  6. package/dist/lib/buildlogsync.js +21 -12
  7. package/dist/lib/check.js +69 -35
  8. package/dist/lib/commitmsg.js +53 -32
  9. package/dist/lib/git.js +86 -31
  10. package/dist/lib/intent.js +91 -31
  11. package/dist/lib/latch.js +70 -39
  12. package/dist/lib/orient.js +78 -39
  13. package/dist/lib/plugins.js +11 -8
  14. package/dist/lib/settings.js +65 -32
  15. package/dist/lib/sidecar.js +320 -150
  16. package/dist/lib/templates.js +17 -6
  17. package/dist/verbs/agent.js +116 -55
  18. package/dist/verbs/build.js +27 -12
  19. package/dist/verbs/check.js +33 -7
  20. package/dist/verbs/checkpoint.js +153 -78
  21. package/dist/verbs/doctor.js +192 -95
  22. package/dist/verbs/finish.js +87 -44
  23. package/dist/verbs/handoff.js +56 -20
  24. package/dist/verbs/init.js +37 -12
  25. package/dist/verbs/park.js +14 -3
  26. package/dist/verbs/revert.js +64 -24
  27. package/dist/verbs/spike.js +89 -30
  28. package/dist/verbs/start.js +72 -38
  29. package/dist/verbs/status.js +44 -17
  30. package/dist/verbs/turn.js +88 -45
  31. package/dist/verbs/use.js +20 -9
  32. package/package.json +2 -2
  33. package/skills/{pb-build → build}/SKILL.md +25 -25
  34. package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
  35. package/skills/{pb-finish → finish}/SKILL.md +18 -5
  36. package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
  37. package/skills/{pb-park → park}/SKILL.md +2 -2
  38. package/skills/{pb-plan → plan}/SKILL.md +11 -11
  39. package/skills/{pb-refine → refine}/SKILL.md +7 -7
  40. package/skills/{pb-revert → revert}/SKILL.md +2 -2
  41. package/skills/{pb-spike → spike}/SKILL.md +4 -4
  42. package/skills/{pb-status → status}/SKILL.md +3 -3
  43. package/skills/{pb-step → step}/SKILL.md +9 -9
  44. package/skills/{pb-verify → verify}/SKILL.md +8 -8
  45. package/templates/build-log.md +7 -7
  46. package/templates/intent.md +8 -8
  47. package/templates/spike-report.md +1 -1
@@ -1,36 +1,54 @@
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.
1
+ // The agent contract. A user-authored agent is anything executable that speaks a
2
+ // versioned JSON envelope — a JSON manifest on disk (`agent.json`), JSON on the
3
+ // child's stdin, JSON on its stdout, prose on its stderr — so the doorway is
4
+ // runtime-agnostic: any language, any framework, one subprocess boundary. This
5
+ // module is the contract's validator, resolver, and runner: it type-narrows the
6
+ // on-disk manifest and the output envelope a child returns (refusing a contract
7
+ // major-version mismatch with an upgrade hint rather than guessing at a shape
8
+ // that is only stable within a major — additions are a minor bump, removals or
9
+ // renames a major), it walks the agent tiers to resolve an agent by name, it
10
+ // composes the step context a child reads on stdin, and it spawns the child.
11
+ // The validators are pure; the resolver reads `agent.json` files off disk with
12
+ // node builtins only. The `agent run` verb (src/verbs/agent.ts) is the thin
13
+ // shell over all of it.
11
14
  import { spawn } from 'node:child_process';
12
15
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
13
16
  import { homedir } from 'node:os';
14
17
  import { join } from 'node:path';
15
18
  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.
19
+ /**
20
+ * The contract major version this plumbbob speaks.
21
+ *
22
+ * A manifest or envelope declaring a different major is refused: within a major
23
+ * the envelope only gains fields, so an older CLI can read a newer minor, but a
24
+ * major gap means the shapes may genuinely disagree.
25
+ */
20
26
  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.
27
+ /**
28
+ * The three lifecycle slots an agent may bind to: `before` (context in),
29
+ * `build` (the diff), `after` (advisory review).
30
+ *
31
+ * Exactly these, never a fourth — "a salient point in the middle" is judgment
32
+ * the host model handles in prose, not a declarative slot.
33
+ */
25
34
  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.
35
+ /**
36
+ * The terminal states an agent reports.
37
+ *
38
+ * `done` = finished. `blocked` = it couldn't finish, so the human unblocks and
39
+ * re-runs. `drift` = it finished but found the plan no longer matches reality,
40
+ * so the route is /plumbbob:refine repair. The two halts need different medicine,
41
+ * which is why they are distinct statuses.
42
+ */
29
43
  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.
44
+ /**
45
+ * Validate a parsed `agent.json`.
46
+ *
47
+ * Contract is checked first (a version mismatch gets an upgrade hint, not a
48
+ * field complaint, since older field checks may not apply across a major).
49
+ * Then: a non-empty `name` and `command`, and a `slots` subset of {before,
50
+ * build, after}. Unknown keys pass through untouched.
51
+ */
34
52
  export function parseManifest(raw) {
35
53
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
36
54
  return manifestFail('agent.json must be a JSON object.');
@@ -62,10 +80,13 @@ export function parseManifest(raw) {
62
80
  },
63
81
  };
64
82
  }
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).
83
+ /**
84
+ * Validate a child's output envelope (already JSON-parsed from its stdout).
85
+ *
86
+ * Contract first (mismatch → upgrade hint), then a `status` in {done, blocked,
87
+ * drift}, a non-empty `summary`, and a well-formed `parked[]`. Optional prose
88
+ * fields default to empty; unknown keys are tolerated.
89
+ */
69
90
  export function parseEnvelope(raw) {
70
91
  if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
71
92
  return envelopeFail('the agent envelope must be a JSON object on stdout.');
@@ -97,15 +118,24 @@ export function parseEnvelope(raw) {
97
118
  },
98
119
  };
99
120
  }
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.
121
+ /**
122
+ * True when a string names one of the three slots.
123
+ *
124
+ * Exported for the `agent run` verb's `--mode` refusal: a mode the manifest
125
+ * doesn't declare is refused loud — the user who typed it asked for that slot
126
+ * specifically.
127
+ */
102
128
  export function isSlot(value) {
103
129
  return SLOTS.includes(value);
104
130
  }
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.
131
+ /**
132
+ * Validate the shared `contract` field.
133
+ *
134
+ * A missing / non-integer contract is malformed; a present-but-different major
135
+ * is a version mismatch and gets an upgrade hint pointing at whichever side is
136
+ * behind. Returns an error string, or null when the contract is the supported
137
+ * major.
138
+ */
109
139
  function checkContract(value, what) {
110
140
  if (typeof value !== 'number' || !Number.isInteger(value)) {
111
141
  return `${what} needs an integer "contract" version — this plumbbob speaks contract ${CONTRACT_VERSION}.`;
@@ -117,9 +147,12 @@ function checkContract(value, what) {
117
147
  }
118
148
  return null;
119
149
  }
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.
150
+ /**
151
+ * Narrow the manifest's `slots` to the subset it declares.
152
+ *
153
+ * Must be a non-empty array drawn from SLOTS, with strangers refused and
154
+ * duplicates collapsed. Returns the narrowed slots, or an error string.
155
+ */
123
156
  function parseSlots(value) {
124
157
  if (!Array.isArray(value) || value.length === 0) {
125
158
  return `agent.json needs a non-empty "slots" array drawn from ${SLOTS.join(', ')}.`;
@@ -133,10 +166,14 @@ function parseSlots(value) {
133
166
  }
134
167
  return [...seen];
135
168
  }
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.
169
+ /**
170
+ * Narrow the envelope's optional `parked[]` (absent = none).
171
+ *
172
+ * Present, it must be an array of non-blank strings — each becomes a park line
173
+ * the CLI lands through the park verb. A malformed list is refused rather than
174
+ * silently dropped: losing a parked concern is a quiet data loss. Returns the
175
+ * trimmed lines or an error string.
176
+ */
140
177
  function parseParked(value) {
141
178
  if (value === undefined || value === null) {
142
179
  return [];
@@ -153,39 +190,66 @@ function parseParked(value) {
153
190
  }
154
191
  return parked;
155
192
  }
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.
193
+ /**
194
+ * Coerce an optional prose field (description, when, body, notes) to a string.
195
+ *
196
+ * Absent and blank read the same to the host model, and a non-string value is
197
+ * coerced away rather than refused, because these are advisory prose, not
198
+ * gates.
199
+ */
159
200
  function asString(value) {
160
201
  return typeof value === 'string' ? value : '';
161
202
  }
203
+ /**
204
+ * True when a string names one of the envelope statuses.
205
+ */
162
206
  function isStatus(value) {
163
207
  return ENVELOPE_STATUSES.includes(value);
164
208
  }
209
+ /**
210
+ * Wrap an error string as a failed manifest parse.
211
+ */
165
212
  function manifestFail(error) {
166
213
  return { ok: false, error };
167
214
  }
215
+ /**
216
+ * Wrap an error string as a failed envelope parse.
217
+ */
168
218
  function envelopeFail(error) {
169
219
  return { ok: false, error };
170
220
  }
221
+ /**
222
+ * The project agents tier — `.plumbbob/agents/` under the repo root.
223
+ */
171
224
  export function projectAgentsDir(root) {
172
225
  return join(root, '.plumbbob', 'agents');
173
226
  }
227
+ /**
228
+ * The personal agents tier — `~/.plumbbob/agents/`, the user's own library.
229
+ */
174
230
  export function personalAgentsDir(home) {
175
231
  return join(home, '.plumbbob', 'agents');
176
232
  }
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.
233
+ /**
234
+ * The home dir the personal tier resolves under.
235
+ *
236
+ * Callers (verbs) pass nothing and get the environment's HOME (matching
237
+ * doctor.ts); tests pass an explicit dir to exercise an HOME-overridden
238
+ * personal library without touching process.env.
239
+ */
180
240
  function agentsHome(home) {
181
241
  return home ?? process.env.HOME ?? homedir();
182
242
  }
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.
243
+ /**
244
+ * Resolve an agent by name across the tiers, first hit wins.
245
+ *
246
+ * A `flagPath` (from `--agent <path>`) points straight at an agent directory
247
+ * and takes top priority. A tier "hits" when it holds an `agent.json`: a
248
+ * malformed one there is surfaced as an error (project still shadows
249
+ * personal), never silently skipped in favor of a different agent — an
250
+ * explicit ask fails loud. Returns the not-found error only when no tier holds
251
+ * a manifest at all.
252
+ */
189
253
  export function resolveAgent(root, name, opts = {}) {
190
254
  if (opts.flagPath !== undefined) {
191
255
  const hit = loadAgentDir(opts.flagPath, 'flag');
@@ -206,10 +270,13 @@ export function resolveAgent(root, name, opts = {}) {
206
270
  error: `no agent named "${name}" — looked in ${projectAgentsDir(root)} and ${personalAgentsDir(home)}.`,
207
271
  };
208
272
  }
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.
273
+ /**
274
+ * Every resolvable agent across both tiers, sorted by name.
275
+ *
276
+ * Project shadows personal: a name defined in both resolves — and lists — from
277
+ * project. Each entry carries its origin and resolution; a malformed manifest
278
+ * lands as an errored resolution rather than crashing the walk.
279
+ */
213
280
  export function listAgents(root, opts = {}) {
214
281
  const home = agentsHome(opts.home);
215
282
  const tiers = [
@@ -229,9 +296,13 @@ export function listAgents(root, opts = {}) {
229
296
  resolution: resolveAgent(root, name, { home }),
230
297
  }));
231
298
  }
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.
299
+ /**
300
+ * Render `agent list`'s output.
301
+ *
302
+ * Pure so the verb stays a thin read-write shell and the formatting is
303
+ * unit-tested here. Prints name, origin, slots, and description per agent; a
304
+ * malformed manifest gets a `✗ … invalid:` line.
305
+ */
235
306
  export function formatAgentList(listings) {
236
307
  if (listings.length === 0) {
237
308
  return 'plumbbob: no agents. Add one under .plumbbob/agents/<name>/ or ~/.plumbbob/agents/<name>/.';
@@ -248,10 +319,14 @@ export function formatAgentList(listings) {
248
319
  }
249
320
  return lines.join('\n');
250
321
  }
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.
322
+ /**
323
+ * Load and validate the `agent.json` in one agent directory.
324
+ *
325
+ * Returns null when the directory holds no manifest (a tier miss, so
326
+ * resolution falls through), an errored resolution when the manifest is
327
+ * present but unreadable/malformed, else the resolved agent. A missing file
328
+ * (ENOENT throw) is the miss signal — one read, no TOCTOU race.
329
+ */
255
330
  function loadAgentDir(dir, origin) {
256
331
  const file = join(dir, 'agent.json');
257
332
  let raw;
@@ -273,9 +348,12 @@ function loadAgentDir(dir, origin) {
273
348
  ? { ok: true, agent: { manifest: result.manifest, origin, dir } }
274
349
  : { ok: false, error: `${file}: ${result.error}` };
275
350
  }
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.
351
+ /**
352
+ * The names of subdirectories under an agents tier that carry an `agent.json`.
353
+ *
354
+ * Filtering here means `listAgents` never lists a bare folder that resolution
355
+ * would then miss. An absent tier directory yields no names.
356
+ */
279
357
  function agentDirNames(dir) {
280
358
  try {
281
359
  return readdirSync(dir, { withFileTypes: true })
@@ -286,11 +364,15 @@ function agentDirNames(dir) {
286
364
  return [];
287
365
  }
288
366
  }
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).
367
+ /**
368
+ * Compose the StepContext for one step.
369
+ *
370
+ * The strict seam parse is the only refusal path — a missing step or
371
+ * unparseable seam gates git behavior, so it fails loud; everything else is
372
+ * best-effort and lands whatever it can, returning `warnings` (skipped-bullet
373
+ * lines) for the caller to print on stderr rather than writing there itself —
374
+ * this module stays pure, the verb does the IO.
375
+ */
294
376
  export function composeStepContext(params) {
295
377
  const seam = parseStepSeam(params.intent, params.step);
296
378
  if (!seam.ok) {
@@ -318,15 +400,22 @@ export function composeStepContext(params) {
318
400
  warnings,
319
401
  };
320
402
  }
403
+ /**
404
+ * Format the skipped-line warnings for one scraped intent.md section.
405
+ */
321
406
  function skipWarnings(heading, skipped) {
322
407
  return skipped.map((line) => `intent.md: skipped a non-bullet line under ${heading} — ${JSON.stringify(line.trim())}`);
323
408
  }
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.
409
+ /**
410
+ * Validate a parsed harness.json.
411
+ *
412
+ * Contract first (a major mismatch gets the same upgrade hint as the manifest
413
+ * and envelope). Structure is strict — a `steps` that is not an object, a
414
+ * non-numeric step key, or a step entry that is not an object is the author's
415
+ * error and is refused loud. Slot *contents* stay lenient: a slot value may be
416
+ * one name or a list, and blanks/non-strings drop rather than refuse, because
417
+ * bindings feed a spawn, not a git-gating parse.
418
+ */
330
419
  function parseHarness(raw) {
331
420
  if (!isObject(raw)) {
332
421
  return { ok: false, error: 'harness.json must be a JSON object.' };
@@ -353,12 +442,16 @@ function parseHarness(raw) {
353
442
  }
354
443
  return { ok: true, harness: { contract: CONTRACT_VERSION, defaults, steps } };
355
444
  }
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.
445
+ /**
446
+ * Read and validate a harness.json at `path`.
447
+ *
448
+ * The caller builds the path from the build folder — this module stays
449
+ * ignorant of the sidecar layout, taking the full path. Returns null when the
450
+ * file is absent (a build with no bound agents — a clean no-op); an errored
451
+ * parse when present-but-broken (invalid JSON or a malformed structure the
452
+ * author must fix); else the validated bindings. Shared so every reader
453
+ * (`agent run`, `status`) parses the harness the same way.
454
+ */
362
455
  export function readHarnessFile(path) {
363
456
  let raw;
364
457
  try {
@@ -376,12 +469,16 @@ export function readHarnessFile(path) {
376
469
  }
377
470
  return parseHarness(parsed);
378
471
  }
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.
472
+ /**
473
+ * Narrow a raw slot→agents object to the slots it actually binds.
474
+ *
475
+ * Accepts a harness `defaults` block, a per-step entry, or the settings-level
476
+ * defaults. A slot value may be a single agent name or a list; blanks and
477
+ * non-strings drop. Only the three real slots are read — `note` and any
478
+ * stranger key are ignored here. A slot present but naming no valid agent
479
+ * still counts as bound-to-nothing (an explicit override to none), so callers
480
+ * get clean replace semantics up the ladder.
481
+ */
385
482
  export function parseSlotBindings(raw) {
386
483
  if (!isObject(raw))
387
484
  return {};
@@ -392,12 +489,16 @@ export function parseSlotBindings(raw) {
392
489
  }
393
490
  return out;
394
491
  }
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.
492
+ /**
493
+ * The agents bound to one slot for one step, merged down the bindings ladder.
494
+ *
495
+ * A per-step slot entry overrides the harness `defaults`, which override the
496
+ * settings-level defaults — the first level that names the slot wins, replace
497
+ * not append, the same most-specific-wins shape as the settings ladder. The
498
+ * `--agent` flag and an explicit name sit above all of this, but the verb
499
+ * takes the single-agent path for those and never reaches here. Returns []
500
+ * when no level binds the slot: nothing to run, a clean no-op.
501
+ */
401
502
  export function resolveSlotAgents(params) {
402
503
  const perStep = params.harness?.steps.get(params.step)?.bindings;
403
504
  if (perStep && params.slot in perStep)
@@ -409,23 +510,36 @@ export function resolveSlotAgents(params) {
409
510
  return params.settingsDefaults[params.slot] ?? [];
410
511
  return [];
411
512
  }
513
+ /**
514
+ * Coerce a slot value — one agent name or a list — to a trimmed name list,
515
+ * dropping blanks and non-strings.
516
+ */
412
517
  function asNameList(value) {
413
518
  const items = Array.isArray(value) ? value : [value];
414
519
  return items.filter((v) => typeof v === 'string' && v.trim().length > 0).map((v) => v.trim());
415
520
  }
521
+ /**
522
+ * True when a value is a plain JSON object — not null, not an array.
523
+ */
416
524
  function isObject(value) {
417
525
  return typeof value === 'object' && value !== null && !Array.isArray(value);
418
526
  }
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`.
527
+ /**
528
+ * Spawn an agent's `command` and settle with the run's outcome.
529
+ *
530
+ * The command runs via the shell (`sh -c` on POSIX) at the repo root, so a
531
+ * build-slot agent's repo-relative seam edits resolve; the agent's own
532
+ * directory rides in `PLUMBBOB_AGENT_DIR` so a root-cwd agent can still reach
533
+ * its files. The composed StepContext is delivered as JSON on stdin. Stdout is
534
+ * piped and captured (the envelope); stderr is inherited so the child's prose
535
+ * streams live to the terminal — production (narrating) never collides with
536
+ * consumption (one structured result at the pause). A SIGINT while the child
537
+ * runs kills it and reports `interrupted` rather than orphaning it — the human
538
+ * is present, Ctrl-C works. A positive `timeoutSeconds` arms a kill timer
539
+ * (0 = off; a timeout is the user's explicit opt-in). Async `spawn`, not
540
+ * `spawnSync`, so the parent stays live to interrupt gracefully. Never
541
+ * rejects — every failure mode is a resolved `AgentRunResult`.
542
+ */
429
543
  export function runAgent(params) {
430
544
  return new Promise((resolve) => {
431
545
  const child = spawn(params.command, {
@@ -446,7 +560,7 @@ export function runAgent(params) {
446
560
  process.off('SIGINT', onSigint);
447
561
  resolve(result);
448
562
  };
449
- // Kill the child and report, rather than let a Ctrl-C orphan it (D58). The
563
+ // Kill the child and report, rather than let a Ctrl-C orphan it. The
450
564
  // SIGKILL escalation covers a child that ignores the interrupt.
451
565
  function onSigint() {
452
566
  child.kill('SIGKILL');
@@ -482,10 +596,14 @@ export function runAgent(params) {
482
596
  child.stdin.end();
483
597
  });
484
598
  }
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.
599
+ /**
600
+ * Parse the child's captured stdout into a validated envelope.
601
+ *
602
+ * JSON first (a non-JSON stdout is out of contract — the envelope must be one
603
+ * JSON object), then the envelope validator, which carries the
604
+ * contract-mismatch hint. Kept private — `runAgent` is the only caller and the
605
+ * verb reads the union, not this.
606
+ */
489
607
  function parseChildEnvelope(stdout) {
490
608
  let raw;
491
609
  try {