plumbbob 0.8.18 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +50 -44
- package/dist/cli-core.js +35 -15
- package/dist/lib/agents.js +230 -112
- package/dist/lib/buildlog.js +50 -30
- package/dist/lib/buildlogsync.js +21 -12
- package/dist/lib/check.js +69 -35
- package/dist/lib/commitmsg.js +53 -32
- package/dist/lib/git.js +86 -31
- package/dist/lib/intent.js +91 -31
- package/dist/lib/latch.js +70 -39
- package/dist/lib/orient.js +78 -39
- package/dist/lib/plugins.js +11 -8
- package/dist/lib/settings.js +65 -32
- package/dist/lib/sidecar.js +320 -150
- package/dist/lib/templates.js +17 -6
- package/dist/verbs/agent.js +116 -55
- package/dist/verbs/build.js +27 -12
- package/dist/verbs/check.js +33 -7
- package/dist/verbs/checkpoint.js +153 -78
- package/dist/verbs/doctor.js +192 -95
- package/dist/verbs/finish.js +86 -45
- package/dist/verbs/handoff.js +56 -20
- package/dist/verbs/init.js +37 -12
- package/dist/verbs/park.js +14 -3
- package/dist/verbs/revert.js +64 -24
- package/dist/verbs/spike.js +89 -30
- package/dist/verbs/start.js +72 -38
- package/dist/verbs/status.js +44 -17
- package/dist/verbs/turn.js +88 -45
- package/dist/verbs/use.js +20 -9
- package/package.json +1 -1
- package/skills/{pb-build → build}/SKILL.md +25 -25
- package/skills/{pb-doctor → doctor}/SKILL.md +1 -1
- package/skills/{pb-finish → finish}/SKILL.md +3 -3
- package/skills/{pb-harvest → harvest}/SKILL.md +4 -4
- package/skills/{pb-park → park}/SKILL.md +2 -2
- package/skills/{pb-plan → plan}/SKILL.md +11 -11
- package/skills/{pb-refine → refine}/SKILL.md +7 -7
- package/skills/{pb-revert → revert}/SKILL.md +2 -2
- package/skills/{pb-spike → spike}/SKILL.md +4 -4
- package/skills/{pb-status → status}/SKILL.md +3 -3
- package/skills/{pb-step → step}/SKILL.md +9 -9
- package/skills/{pb-verify → verify}/SKILL.md +8 -8
- package/templates/build-log.md +7 -7
- package/templates/intent.md +8 -8
- package/templates/spike-report.md +1 -1
package/dist/lib/agents.js
CHANGED
|
@@ -1,36 +1,54 @@
|
|
|
1
|
-
// The agent contract
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// major
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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 {
|