plumbbob 0.5.3 → 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 (81) 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 +39 -2
  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/start.js +16 -3
  12. package/dist/verbs/status.js +64 -2
  13. package/node_modules/checkride/CHANGELOG.md +139 -0
  14. package/node_modules/checkride/LICENSE +21 -0
  15. package/node_modules/checkride/README.md +179 -0
  16. package/node_modules/checkride/dist/adapters.d.ts +56 -0
  17. package/node_modules/checkride/dist/adapters.d.ts.map +1 -0
  18. package/node_modules/checkride/dist/adapters.js +219 -0
  19. package/node_modules/checkride/dist/adapters.js.map +1 -0
  20. package/node_modules/checkride/dist/cli.d.ts +29 -0
  21. package/node_modules/checkride/dist/cli.d.ts.map +1 -0
  22. package/node_modules/checkride/dist/cli.js +227 -0
  23. package/node_modules/checkride/dist/cli.js.map +1 -0
  24. package/node_modules/checkride/dist/config.d.ts +74 -0
  25. package/node_modules/checkride/dist/config.d.ts.map +1 -0
  26. package/node_modules/checkride/dist/config.js +126 -0
  27. package/node_modules/checkride/dist/config.js.map +1 -0
  28. package/node_modules/checkride/dist/doctor.d.ts +76 -0
  29. package/node_modules/checkride/dist/doctor.d.ts.map +1 -0
  30. package/node_modules/checkride/dist/doctor.js +299 -0
  31. package/node_modules/checkride/dist/doctor.js.map +1 -0
  32. package/node_modules/checkride/dist/index.d.ts +18 -0
  33. package/node_modules/checkride/dist/index.d.ts.map +1 -0
  34. package/node_modules/checkride/dist/index.js +13 -0
  35. package/node_modules/checkride/dist/index.js.map +1 -0
  36. package/node_modules/checkride/dist/init.d.ts +64 -0
  37. package/node_modules/checkride/dist/init.d.ts.map +1 -0
  38. package/node_modules/checkride/dist/init.js +489 -0
  39. package/node_modules/checkride/dist/init.js.map +1 -0
  40. package/node_modules/checkride/dist/links.d.ts +23 -0
  41. package/node_modules/checkride/dist/links.d.ts.map +1 -0
  42. package/node_modules/checkride/dist/links.js +125 -0
  43. package/node_modules/checkride/dist/links.js.map +1 -0
  44. package/node_modules/checkride/dist/orchestrator.d.ts +96 -0
  45. package/node_modules/checkride/dist/orchestrator.d.ts.map +1 -0
  46. package/node_modules/checkride/dist/orchestrator.js +234 -0
  47. package/node_modules/checkride/dist/orchestrator.js.map +1 -0
  48. package/node_modules/checkride/package.json +76 -0
  49. package/node_modules/checkride/templates/flat/fallow.toml +26 -0
  50. package/node_modules/checkride/templates/flat/pnpm-workspace.yaml +6 -0
  51. package/node_modules/checkride/templates/flat/tsconfig.json +9 -0
  52. package/node_modules/checkride/templates/hybrid/fallow.toml +42 -0
  53. package/node_modules/checkride/templates/hybrid/pnpm-workspace.yaml +6 -0
  54. package/node_modules/checkride/templates/hybrid/tsconfig.json +12 -0
  55. package/node_modules/checkride/templates/monorepo/fallow.toml +44 -0
  56. package/node_modules/checkride/templates/monorepo/pnpm-workspace.yaml +7 -0
  57. package/node_modules/checkride/templates/monorepo/tsconfig.json +7 -0
  58. package/node_modules/checkride/templates/shared/cspell.json +35 -0
  59. package/node_modules/checkride/templates/shared/gitignore +10 -0
  60. package/node_modules/checkride/templates/shared/markdownlint-cli2.jsonc +11 -0
  61. package/node_modules/checkride/templates/shared/npmrc +1 -0
  62. package/node_modules/checkride/templates/shared/oxlintrc.json +33 -0
  63. package/node_modules/checkride/templates/shared/rules/no-class.yml +8 -0
  64. package/node_modules/checkride/templates/shared/rules/no-deep-sibling-import.yml +15 -0
  65. package/node_modules/checkride/templates/shared/rules/no-default-export.yml +9 -0
  66. package/node_modules/checkride/templates/shared/rules/require-js-extension.yml +21 -0
  67. package/node_modules/checkride/templates/shared/sgconfig.yml +2 -0
  68. package/node_modules/checkride/templates/shared/tsconfig.base.json +28 -0
  69. package/node_modules/checkride/templates/shared/vitest.config.ts.template +27 -0
  70. package/package.json +4 -1
  71. package/skills/pb-build/SKILL.md +42 -7
  72. package/skills/pb-finish/SKILL.md +1 -1
  73. package/skills/pb-harvest/SKILL.md +1 -1
  74. package/skills/pb-park/SKILL.md +1 -1
  75. package/skills/pb-plan/SKILL.md +25 -4
  76. package/skills/pb-refine/SKILL.md +1 -1
  77. package/skills/pb-revert/SKILL.md +1 -1
  78. package/skills/pb-spike/SKILL.md +1 -1
  79. package/skills/pb-status/SKILL.md +1 -1
  80. package/skills/pb-step/SKILL.md +10 -2
  81. package/skills/pb-verify/SKILL.md +21 -6
@@ -1,13 +1,16 @@
1
- // The strict intent parser. `build <n>` reads the nth step under `## Steps` and
2
- // extracts its seam — the backtick-wrapped paths on the single `seam:` sub-line.
3
- // Strict by design: it refuses precisely on globs, absolute paths, a missing
4
- // step, a missing seam, or more than one seam line, rather than guessing.
1
+ // The intent parser. `build <n>` reads the nth step under `## Steps` and extracts
2
+ // its seam — the backtick-wrapped paths on the single `seam:` sub-line. The seam
3
+ // half is strict by design (it gates git behavior): it refuses precisely on globs,
4
+ // absolute paths, a missing step, a missing seam, or more than one seam line,
5
+ // rather than guessing. The best-effort scrapers below (title, done-when,
6
+ // decisions, constraints) feed an agent's context, not a gate, so they never
7
+ // refuse — they return what they can and report the lines they skipped (D61).
5
8
  const GLOB_CHARS = /[*?[\]{}]/;
6
- export function parseStepSeam(content, step) {
9
+ function sliceStep(content, step) {
7
10
  const lines = content.split('\n');
8
11
  const stepsIdx = lines.findIndex((l) => l.trim() === '## Steps');
9
12
  if (stepsIdx === -1) {
10
- return fail('intent.md has no "## Steps" section.');
13
+ return { ok: false, error: 'intent.md has no "## Steps" section.' };
11
14
  }
12
15
  let sectionEnd = lines.findIndex((l, i) => i > stepsIdx && l.startsWith('## '));
13
16
  if (sectionEnd === -1) {
@@ -22,12 +25,19 @@ export function parseStepSeam(content, step) {
22
25
  }
23
26
  const pos = itemStarts.findIndex((s) => s.n === step);
24
27
  if (pos === -1) {
25
- return fail(`intent.md has no step ${step} under "## Steps".`);
28
+ return { ok: false, error: `intent.md has no step ${step} under "## Steps".` };
26
29
  }
27
30
  const itemStart = itemStarts[pos]?.idx ?? stepsIdx;
28
31
  const nextStart = itemStarts[pos + 1]?.idx;
29
32
  const itemEnd = nextStart === undefined ? sectionEnd : nextStart;
30
- const itemLines = lines.slice(itemStart, itemEnd);
33
+ return { ok: true, itemLines: lines.slice(itemStart, itemEnd) };
34
+ }
35
+ export function parseStepSeam(content, step) {
36
+ const slice = sliceStep(content, step);
37
+ if (!slice.ok) {
38
+ return fail(slice.error);
39
+ }
40
+ const itemLines = slice.itemLines;
31
41
  const seamLines = itemLines
32
42
  .map((l, i) => ({ l, i }))
33
43
  .filter(({ l }) => /^\s*-\s*seam:/.test(l))
@@ -102,6 +112,86 @@ export function scopeDrift(paths, seam) {
102
112
  }
103
113
  return paths.filter((p) => !matchesSeam(p, seam) && !isArtifactPath(p));
104
114
  }
115
+ const DONE_WHEN = /\*\*done when:\*\*/i;
116
+ export function parseStepMeta(content, step) {
117
+ const slice = sliceStep(content, step);
118
+ if (!slice.ok) {
119
+ return { title: '', doneWhen: '' };
120
+ }
121
+ // Gather the opener and its wrapped continuation lines, stopping at the first
122
+ // sub-bullet (`- seam:` and friends), then flatten to one line.
123
+ const collected = [];
124
+ for (let i = 0; i < slice.itemLines.length; i++) {
125
+ const line = slice.itemLines[i] ?? '';
126
+ if (i > 0 && /^\s*-\s/.test(line)) {
127
+ break;
128
+ }
129
+ collected.push(line.trim());
130
+ }
131
+ const body = collected.join(' ').replace(/^\d+\.\s*(?:\[[ xX]\]\s*)?/, '').trim();
132
+ const m = DONE_WHEN.exec(body);
133
+ if (m === null) {
134
+ return { title: stripTrailingDash(body), doneWhen: '' };
135
+ }
136
+ return {
137
+ title: stripTrailingDash(body.slice(0, m.index)),
138
+ doneWhen: body.slice(m.index + m[0].length).trim(),
139
+ };
140
+ }
141
+ // The build title — the first `# ` heading. '' when absent.
142
+ export function parseBuildTitle(content) {
143
+ for (const line of content.split('\n')) {
144
+ const m = /^#\s+(.+)$/.exec(line);
145
+ if (m) {
146
+ return (m[1] ?? '').trim();
147
+ }
148
+ }
149
+ return '';
150
+ }
151
+ export function scrapeBullets(content, heading) {
152
+ const lines = content.split('\n');
153
+ const start = lines.findIndex((l) => l.trim() === heading);
154
+ if (start === -1) {
155
+ return { items: [], skipped: [] };
156
+ }
157
+ let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
158
+ if (end === -1) {
159
+ end = lines.length;
160
+ }
161
+ const items = [];
162
+ const skipped = [];
163
+ let current = null;
164
+ const flush = () => {
165
+ if (current !== null) {
166
+ items.push(current.join(' '));
167
+ current = null;
168
+ }
169
+ };
170
+ for (let i = start + 1; i < end; i++) {
171
+ const line = lines[i] ?? '';
172
+ const bullet = /^-\s+(.*)$/.exec(line);
173
+ if (bullet !== null) {
174
+ flush();
175
+ current = [(bullet[1] ?? '').trim()];
176
+ }
177
+ else if (line.trim() === '') {
178
+ flush();
179
+ }
180
+ else if (current !== null && /^\s+\S/.test(line)) {
181
+ current.push(line.trim());
182
+ }
183
+ else {
184
+ skipped.push(line);
185
+ }
186
+ }
187
+ flush();
188
+ return { items, skipped };
189
+ }
190
+ // Trim a trailing em-dash or hyphen (the `Title —` separator) plus surrounding
191
+ // space, so a title never carries the marker that introduced its done-when.
192
+ function stripTrailingDash(text) {
193
+ return text.replace(/\s*[—-]+\s*$/, '').trim();
194
+ }
105
195
  function fail(error) {
106
196
  return { ok: false, error };
107
197
  }
@@ -53,6 +53,23 @@ export function resolveBoolean(root, key, fallback, flag) {
53
53
  const value = resolveSetting(root, key, flag);
54
54
  return typeof value === 'boolean' ? value : fallback;
55
55
  }
56
+ // Resolve a non-negative integer setting (e.g. `agentTimeout`, D51). A missing
57
+ // rung, or one holding a non-finite / negative / non-integer number, yields the
58
+ // caller's fallback rather than a garbage timeout — 0 means "no timeout", so a
59
+ // broken value must never silently become one.
60
+ export function resolveNumber(root, key, fallback, flag) {
61
+ const value = resolveSetting(root, key, flag);
62
+ return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : fallback;
63
+ }
64
+ // Resolve an object-valued setting (e.g. the `agents` slot-binding defaults, D57)
65
+ // across the same ladder. The first defined, object-typed rung wins; a missing
66
+ // rung, or one holding a non-object / array / scalar, yields {} rather than
67
+ // garbage — mirrors the string/boolean/number resolvers above. No flag rung: the
68
+ // caller (`agent run`) merges these under the per-build harness, not over a flag.
69
+ export function resolveRecord(root, key) {
70
+ const value = resolveSetting(root, key, undefined);
71
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
72
+ }
56
73
  // Read one key from the untracked local overlay ONLY — no project or built-in
57
74
  // fallback. The `activeBuild` cursor lives here and must never resolve from the
58
75
  // tracked settings.json (it is per-worktree state, not a shared default).
@@ -27,8 +27,9 @@ export function buildDir(root, slug) {
27
27
  // Derive a filesystem-safe slug from a build title: lowercased, every run of
28
28
  // non-alphanumerics collapsed to a single hyphen, trimmed of leading/trailing
29
29
  // hyphens. The CLI stays dumb and explicit (D38) — collision handling belongs to
30
- // the caller (`start` refuses rather than silently suffixing `-2`). A title with
31
- // no alphanumerics yields `''`, which the caller must reject or override.
30
+ // the caller (`start` refuses rather than silently suffixing `-2`, and prepends
31
+ // the `YYYY-MM-DD-` date that keeps `listBuilds`' lexical sort chronological). A
32
+ // title with no alphanumerics yields `''`, which the caller must reject or override.
32
33
  export function slugify(title) {
33
34
  return title
34
35
  .toLowerCase()
@@ -125,6 +126,39 @@ export function buildLogPath(root, slug) {
125
126
  export function reportPath(root, slug) {
126
127
  return join(artifactDir(root, slug), 'report.md');
127
128
  }
129
+ // handoff.json is the agent-run ledger (D47): each `agent run` appends its
130
+ // validated envelope here so a later invocation can thread earlier results into
131
+ // the next call's `context[]` — the CLI itself is memoryless between runs, and
132
+ // the file survives context-window compaction where inline stdout does not. It is
133
+ // in-flight control state like STEP/SEAM: untracked (excludeControl), scoped to
134
+ // the current step, cleared when the step checkpoints (`clearHandoff`).
135
+ export function handoffPath(root, slug) {
136
+ return join(artifactDir(root, slug), 'handoff.json');
137
+ }
138
+ // Append one entry to the build's handoff.json, creating the file (as a JSON
139
+ // array) when absent and tolerating a malformed existing file by starting fresh —
140
+ // a corrupt ledger must never wedge a run. Pretty-printed so it stays readable.
141
+ export function appendHandoff(root, slug, entry) {
142
+ const path = handoffPath(root, slug);
143
+ let entries = [];
144
+ try {
145
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
146
+ if (Array.isArray(parsed))
147
+ entries = parsed;
148
+ }
149
+ catch {
150
+ entries = [];
151
+ }
152
+ entries.push(entry);
153
+ mkdirSync(dirname(path), { recursive: true });
154
+ writeFileSync(path, `${JSON.stringify(entries, null, 2)}\n`);
155
+ }
156
+ // Remove the build's handoff.json — the step-scoped ledger is cleared when the
157
+ // step checkpoints (D47), the same beat that clears STEP/SEAM. Absent file is a
158
+ // no-op (`force`).
159
+ export function clearHandoff(root, slug) {
160
+ rmSync(handoffPath(root, slug), { force: true });
161
+ }
128
162
  // A session exists iff STATE exists. Deleting STATE (at finish) is what flips the
129
163
  // repo back to "no session" — so it is the single source of truth for "is there
130
164
  // a session". `start` calls beginSession; `finish` removes the file.
@@ -177,6 +211,9 @@ export function excludeControl(root) {
177
211
  `${DIRNAME}/builds/*/STEP`,
178
212
  `${DIRNAME}/builds/*/SEAM`,
179
213
  `${DIRNAME}/builds/*/SPIKE`,
214
+ // The agent-run handoff ledger (D47) is step-scoped in-flight state, not a
215
+ // tracked artifact — it must never ride a step commit into the PR.
216
+ `${DIRNAME}/builds/*/handoff.json`,
180
217
  // The checkride gate (D32) writes raw tool output to `.check/`; checkpoint's
181
218
  // stageAll must never sweep it into a step commit.
182
219
  '.check/',
@@ -0,0 +1,312 @@
1
+ // `plumbbob agent <subcommand>` — the doorway to user-authored agents (D39/D41).
2
+ // `agent list` walks the two tiers and prints each resolvable agent. `agent run
3
+ // <name> [--step N] [--mode before|build|after]` (D60) composes the StepContext,
4
+ // spawns the manifest command, streams its stderr live, captures and validates
5
+ // the child's envelope, re-emits it on this verb's own stdout (machine) with the
6
+ // human summary on stderr (D46/D47), lands `parked[]` through the build-log (D44),
7
+ // and appends the envelope to the step-scoped handoff ledger (D47). There is no
8
+ // code path here to checkpoint, flip a step, or chain agents — the identity
9
+ // invariant (C6) holds by construction. A thin read-write shell: resolution,
10
+ // composition, and spawn mechanics live in lib/agents.ts.
11
+ import { readFileSync, writeFileSync } from 'node:fs';
12
+ import { join } from 'node:path';
13
+ import { findRepoRoot } from "../lib/git.js";
14
+ import { SLOTS, composeStepContext, formatAgentList, isSlot, listAgents, parseSlotBindings, readHarnessFile, resolveAgent, resolveSlotAgents, runAgent, } from "../lib/agents.js";
15
+ import { appendToSection } from "../lib/buildlog.js";
16
+ import { resolveBoolean, resolveNumber, resolveRecord } from "../lib/settings.js";
17
+ import { appendHandoff, buildFolder, buildLogPath, hasSession, intentPath, resolveBuild, stepPath } from "../lib/sidecar.js";
18
+ export async function agent(cwd, args = []) {
19
+ const [sub, ...rest] = args;
20
+ if (sub === 'list')
21
+ return list(cwd, rest);
22
+ if (sub === 'run')
23
+ return run(cwd, rest);
24
+ const known = 'Available subcommands: list, run.';
25
+ const message = sub === undefined ? `plumbbob agent <subcommand>. ${known}` : `plumbbob: unknown 'agent' subcommand '${sub}'. ${known}`;
26
+ process.stderr.write(`${message}\n`);
27
+ return 1;
28
+ }
29
+ function list(cwd, _args) {
30
+ const root = findRepoRoot(cwd);
31
+ if (root === null) {
32
+ process.stderr.write('plumbbob: not inside a git repository.\n');
33
+ return 1;
34
+ }
35
+ process.stdout.write(`${formatAgentList(listAgents(root))}\n`);
36
+ return 0;
37
+ }
38
+ // `agent run`: with a name (or `--agent` flag) run exactly that agent, failing
39
+ // loud on a miss (D54); with no name run the step's harness-bound agents for the
40
+ // requested slot (D42). Either way this composes the StepContext, spawns, and
41
+ // applies side effects — never advancing the loop.
42
+ async function run(cwd, args) {
43
+ const root = findRepoRoot(cwd);
44
+ if (root === null || !hasSession(root)) {
45
+ process.stderr.write('plumbbob: no active session. Run `plumbbob start "<title>"` first.\n');
46
+ return 1;
47
+ }
48
+ const { build: slug, rest } = resolveBuild(root, args);
49
+ const parsed = parseRunArgs(rest);
50
+ if (typeof parsed === 'string') {
51
+ process.stderr.write(`${parsed}\n`);
52
+ return 1;
53
+ }
54
+ const step = parsed.step ?? readStep(root, slug);
55
+ if (step === null) {
56
+ process.stderr.write('plumbbob: no step to run against — pass --step N, or `plumbbob build N` first.\n');
57
+ return 1;
58
+ }
59
+ // A `--agent <path>` flag still needs a name — it labels an explicit run (the
60
+ // handoff ledger and the human summary key on the name), it does not name one.
61
+ if (parsed.name === undefined && parsed.flagPath !== undefined) {
62
+ process.stderr.write('plumbbob: --agent needs an agent name too, for the run label. Try: plumbbob agent run reviewer --agent ./path --step 3.\n');
63
+ return 1;
64
+ }
65
+ // Explicit ask (a name, above the bindings — D57): run exactly it, fail loud on
66
+ // a miss. No name: resolve the harness bindings for the slot and run them.
67
+ if (parsed.name !== undefined) {
68
+ return runOne(root, slug, step, {
69
+ name: parsed.name,
70
+ flagPath: parsed.flagPath,
71
+ mode: parsed.mode,
72
+ ambient: false,
73
+ });
74
+ }
75
+ return runBound(root, slug, step, parsed.mode);
76
+ }
77
+ async function runOne(root, slug, step, spec) {
78
+ const resolution = resolveAgent(root, spec.name, spec.flagPath !== undefined ? { flagPath: spec.flagPath } : {});
79
+ if (!resolution.ok) {
80
+ return degrade(spec.ambient, `plumbbob: ${resolution.error}`, `plumbbob: bound agent "${spec.name}" did not resolve — ${resolution.error} Skipping (D54 — the loop works without it).`);
81
+ }
82
+ const { manifest, dir } = resolution.agent;
83
+ const resolved = resolveMode(spec.mode, manifest);
84
+ if (!resolved.ok) {
85
+ return degrade(spec.ambient, resolved.error, `plumbbob: bound agent "${spec.name}" — ${resolved.error.replace(/^plumbbob:\s*/, '')} Skipping (D54).`);
86
+ }
87
+ const mode = resolved.mode;
88
+ let intent;
89
+ try {
90
+ intent = readFileSync(intentPath(root, slug), 'utf8');
91
+ }
92
+ catch {
93
+ process.stderr.write('plumbbob: no intent.md for the active build — cannot compose the agent input.\n');
94
+ return 1;
95
+ }
96
+ const composed = composeStepContext({
97
+ intent,
98
+ slug: slug ?? '',
99
+ step,
100
+ mode,
101
+ settings: {
102
+ auto: resolveBoolean(root, 'auto', false),
103
+ agentTimeout: resolveNumber(root, 'agentTimeout', 0),
104
+ },
105
+ });
106
+ if (!composed.ok) {
107
+ process.stderr.write(`plumbbob: ${composed.error}\n`);
108
+ return 1;
109
+ }
110
+ for (const warning of composed.warnings) {
111
+ process.stderr.write(`${warning}\n`);
112
+ }
113
+ const result = await runAgent({
114
+ root,
115
+ command: manifest.command,
116
+ agentDir: dir,
117
+ input: composed.input,
118
+ timeoutSeconds: resolveNumber(root, 'agentTimeout', 0),
119
+ });
120
+ return report(root, slug, spec.name, mode, step, result);
121
+ }
122
+ // No name given: run the harness-bound agents for the requested slot (D42). The
123
+ // bindings merge the ladder — per-step over harness defaults over settings-level
124
+ // defaults (D57). A missing bound agent degrades to a warning (D54); an absent
125
+ // harness, or one that binds nothing to this slot, is a clean no-op. Each bound
126
+ // agent runs in turn; the batch exits 1 if any agent that actually ran failed.
127
+ async function runBound(root, slug, step, modeFlag) {
128
+ if (modeFlag === undefined) {
129
+ process.stderr.write('plumbbob: agent run needs an agent name, or --mode <slot> to run the step\'s bound agents.\n');
130
+ return 1;
131
+ }
132
+ if (!isSlot(modeFlag)) {
133
+ process.stderr.write(`plumbbob: unknown --mode '${modeFlag}' — slots are ${SLOTS.join(', ')}.\n`);
134
+ return 1;
135
+ }
136
+ const harness = readHarnessFile(join(buildFolder(root, slug), 'harness.json'));
137
+ if (harness !== null && !harness.ok) {
138
+ process.stderr.write(`plumbbob: ${harness.error}\n`);
139
+ return 1;
140
+ }
141
+ const settingsDefaults = parseSlotBindings(resolveRecord(root, 'agents'));
142
+ const names = resolveSlotAgents({
143
+ harness: harness?.harness ?? null,
144
+ settingsDefaults,
145
+ step,
146
+ slot: modeFlag,
147
+ });
148
+ if (names.length === 0) {
149
+ process.stderr.write(`plumbbob: no agents bound to the '${modeFlag}' slot for step ${step} — nothing to run.\n`);
150
+ return 0;
151
+ }
152
+ let worst = 0;
153
+ for (const name of names) {
154
+ const code = await runOne(root, slug, step, { name, flagPath: undefined, mode: modeFlag, ambient: true });
155
+ if (code !== 0)
156
+ worst = code;
157
+ }
158
+ return worst;
159
+ }
160
+ // Emit the loud error or the soft warning by whether this run was an explicit ask
161
+ // (name/flag) or an ambient harness binding (D54/D54), and return the matching
162
+ // code — a hard miss stops with 1, a degraded one warns and returns 0 so a batch
163
+ // of bound agents carries on.
164
+ function degrade(ambient, hard, soft) {
165
+ process.stderr.write(`${ambient ? soft : hard}\n`);
166
+ return ambient ? 0 : 1;
167
+ }
168
+ // Split `run`'s argv (with `--build` already stripped by resolveBuild) into the
169
+ // agent name and the value flags. A value flag missing its value, or `--step`
170
+ // given a non-number, is a loud error (returned as a string) rather than a silent
171
+ // default. Unknown `--flags` are ignored — the point is the named agent.
172
+ function parseRunArgs(args) {
173
+ const positionals = [];
174
+ let step;
175
+ let mode;
176
+ let flagPath;
177
+ for (let i = 0; i < args.length; i++) {
178
+ const arg = args[i];
179
+ if (arg === '--step') {
180
+ const value = args[++i];
181
+ if (value === undefined || !/^\d+$/.test(value) || Number(value) < 1) {
182
+ return 'plumbbob: --step needs a positive step number.';
183
+ }
184
+ step = Number(value);
185
+ }
186
+ else if (arg === '--mode') {
187
+ const value = args[++i];
188
+ if (value === undefined)
189
+ return `plumbbob: --mode needs a slot (${SLOTS.join(', ')}).`;
190
+ mode = value;
191
+ }
192
+ else if (arg === '--agent') {
193
+ const value = args[++i];
194
+ if (value === undefined)
195
+ return 'plumbbob: --agent needs a path to an agent directory.';
196
+ flagPath = value;
197
+ }
198
+ else if (!arg.startsWith('--')) {
199
+ positionals.push(arg);
200
+ }
201
+ }
202
+ return { name: positionals[0], step, mode, flagPath };
203
+ }
204
+ // Resolve the slot the agent runs in. An explicit `--mode` must name a real slot
205
+ // AND one the manifest declares — an undeclared slot is refused loud (D54). With
206
+ // no `--mode`, a single-slot agent uses its only slot; a multi-slot agent must be
207
+ // told which one (step 5's harness bindings pick the slot from the lifecycle
208
+ // point; a bare `run` cannot guess).
209
+ function resolveMode(flag, manifest) {
210
+ if (flag !== undefined) {
211
+ if (!isSlot(flag))
212
+ return { ok: false, error: `plumbbob: unknown --mode '${flag}' — slots are ${SLOTS.join(', ')}.` };
213
+ if (!manifest.slots.includes(flag)) {
214
+ return {
215
+ ok: false,
216
+ error: `plumbbob: agent "${manifest.name}" does not declare the '${flag}' slot (it declares ${manifest.slots.join(', ')}).`,
217
+ };
218
+ }
219
+ return { ok: true, mode: flag };
220
+ }
221
+ if (manifest.slots.length === 1)
222
+ return { ok: true, mode: manifest.slots[0] };
223
+ return {
224
+ ok: false,
225
+ error: `plumbbob: agent "${manifest.name}" declares multiple slots (${manifest.slots.join(', ')}) — pass --mode <slot>.`,
226
+ };
227
+ }
228
+ // Map the run outcome to reporting and side effects (D46). A failed run — non-zero
229
+ // exit, out-of-contract stdout, timeout, interrupt, or a shell that never
230
+ // started — reports on stderr and stops with NO side effects (the envelope of a
231
+ // failed child is not authoritative). A clean run lands `parked[]` (D44), records
232
+ // the envelope in the handoff ledger (D47), prints the human summary on stderr,
233
+ // and re-emits the machine envelope on stdout — nothing else, ever (the stream
234
+ // discipline: stdout carries the envelope alone).
235
+ function report(root, slug, name, mode, step, result) {
236
+ if (!result.ok) {
237
+ process.stderr.write(`${failureLine(name, result)}\n`);
238
+ return 1;
239
+ }
240
+ const { envelope } = result;
241
+ applyParked(root, slug, envelope.parked);
242
+ appendHandoff(root, slug, { agent: name, mode, step, envelope });
243
+ process.stderr.write(humanSummary(name, mode, envelope));
244
+ process.stdout.write(`${JSON.stringify(envelope)}\n`);
245
+ return 0;
246
+ }
247
+ function failureLine(name, result) {
248
+ switch (result.reason) {
249
+ case 'exit':
250
+ return `plumbbob: agent "${name}" exited ${result.code} — failed run, stopping. No side effects applied.`;
251
+ case 'contract':
252
+ return `plumbbob: agent "${name}" is out of contract — ${result.error}`;
253
+ case 'timeout':
254
+ return `plumbbob: agent "${name}" timed out after ${result.seconds}s — killed.`;
255
+ case 'interrupted':
256
+ return `plumbbob: interrupted — killed agent "${name}".`;
257
+ case 'spawn':
258
+ return `plumbbob: could not spawn agent "${name}" — ${result.error}`;
259
+ }
260
+ }
261
+ // Land each parked concern through the build-log's Park list (D44 — the agent
262
+ // never writes .plumbbob/ itself). A build-log with no "## Park list" section, or
263
+ // no build-log at all, warns once rather than losing the lines silently.
264
+ function applyParked(root, slug, parked) {
265
+ if (parked.length === 0)
266
+ return;
267
+ const path = buildLogPath(root, slug);
268
+ let content;
269
+ try {
270
+ content = readFileSync(path, 'utf8');
271
+ }
272
+ catch {
273
+ process.stderr.write(`plumbbob: no build-log.md — could not park ${parked.length} line(s) the agent returned.\n`);
274
+ return;
275
+ }
276
+ for (const line of parked) {
277
+ const updated = appendToSection(content, 'Park list', `- [ ] ${line}`);
278
+ if (updated === null) {
279
+ process.stderr.write(`plumbbob: no "## Park list" in build-log.md — could not park: ${line}\n`);
280
+ return;
281
+ }
282
+ content = updated;
283
+ process.stderr.write(`plumbbob: parked — ${line}\n`);
284
+ }
285
+ writeFileSync(path, content);
286
+ }
287
+ // The human-facing summary on stderr (D47): a headline plus, for a halt, the
288
+ // route the skills name (D52) — `blocked` unblocks and re-runs, `drift` sends the
289
+ // plan to /pb-refine. The machine envelope on stdout carries the same status for
290
+ // the calling skill; this is the terminal read.
291
+ function humanSummary(name, mode, envelope) {
292
+ const head = `plumbbob: agent "${name}" (${mode}) — ${envelope.status}: ${envelope.summary}\n`;
293
+ const notes = envelope.notes.length > 0 ? ` notes: ${envelope.notes}\n` : '';
294
+ if (envelope.status === 'blocked') {
295
+ return `${head} blocked — the agent couldn't finish; unblock and re-run.\n${notes}`;
296
+ }
297
+ if (envelope.status === 'drift') {
298
+ return `${head} drift — the plan no longer matches reality; /pb-refine before continuing.\n${notes}`;
299
+ }
300
+ return head;
301
+ }
302
+ // The in-flight step for this build (the STEP marker `build` wrote), or null when
303
+ // none is set — the caller then requires an explicit `--step`.
304
+ function readStep(root, slug) {
305
+ try {
306
+ const raw = readFileSync(stepPath(root, slug), 'utf8').trim();
307
+ return /^\d+$/.test(raw) ? Number(raw) : null;
308
+ }
309
+ catch {
310
+ return null;
311
+ }
312
+ }
@@ -9,7 +9,7 @@
9
9
  // the same way.
10
10
  import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
11
11
  import { commit, findRepoRoot, headSha, isDirty, stageAll, stagePath, stagedPaths, stagedStat } from "../lib/git.js";
12
- import { buildFolder, buildLogPath, checkpointsPath, hasSession, intentPath, seamPath, stepPath } from "../lib/sidecar.js";
12
+ import { buildFolder, buildLogPath, checkpointsPath, clearHandoff, hasSession, intentPath, seamPath, stepPath, } from "../lib/sidecar.js";
13
13
  import { runCheck } from "../lib/check.js";
14
14
  import { markStepDone, parseSteps, parseTitle } from "../lib/orient.js";
15
15
  import { parseStepSeam, scopeDrift } from "../lib/intent.js";
@@ -50,6 +50,7 @@ export async function checkpoint(cwd, args) {
50
50
  logCheckpoint(root, step, sha);
51
51
  rmSync(seamPath(root), { force: true });
52
52
  rmSync(stepPath(root), { force: true });
53
+ clearHandoff(root); // the agent-run ledger is step-scoped (D47) — clear it with the markers.
53
54
  process.stdout.write(`plumbbob: step ${step} checkpointed — ${sha.slice(0, 9)}. Back at the boundary.\n`);
54
55
  return 0;
55
56
  }
@@ -22,11 +22,12 @@
22
22
  // "detected but tool missing" is the footgun this exists for.
23
23
  //
24
24
  // Functional, node builtins plus checkride (C1/C2).
25
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { accessSync, constants, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, writeFileSync } from 'node:fs';
26
26
  import { homedir } from 'node:os';
27
- import { dirname, join } from 'node:path';
27
+ import { dirname, isAbsolute, join } from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { runDoctor } from 'checkride';
30
+ import { listAgents } from "../lib/agents.js";
30
31
  import { marketplacePlumbbob } from "../lib/plugins.js";
31
32
  import { findRepoRoot, gitPath, stagePath } from "../lib/git.js";
32
33
  import { buildDir, excludeControl, listBuilds, sidecarDir, slugify } from "../lib/sidecar.js";
@@ -264,6 +265,96 @@ function sidecarReport(cwd, migrate) {
264
265
  lines.push(' (archive/ + the active session → builds/, config → settings.json; staged, never committed)');
265
266
  return { lines, failed: 1 };
266
267
  }
268
+ // --- agent validation (D48) ---
269
+ // Interpreters recognized as the program of a manifest `command`: when the command
270
+ // is `sh <script>` / `node <script>` / …, the checkable artifact is the script
271
+ // argument (the interpreter reads it — the file need not carry +x), not the
272
+ // interpreter, which we trust to be on PATH.
273
+ const INTERPRETERS = new Set(['sh', 'bash', 'zsh', 'dash', 'node', 'deno', 'bun', 'python', 'python3', 'ruby', 'perl']);
274
+ // Expand PLUMBBOB_AGENT_DIR (D49) in a command and split it into bare tokens
275
+ // (surrounding quotes stripped). A deliberately shallow split — enough for the
276
+ // static command check below, not a shell parser (which is neither possible nor
277
+ // wanted for a best-effort diagnostic).
278
+ function commandTokens(command, agentDir) {
279
+ const expanded = command.replaceAll('${PLUMBBOB_AGENT_DIR}', agentDir).replaceAll('$PLUMBBOB_AGENT_DIR', agentDir);
280
+ return expanded
281
+ .split(/\s+/)
282
+ .filter((t) => t.length > 0)
283
+ .map((t) => t.replace(/^['"]/, '').replace(/['"]$/, ''));
284
+ }
285
+ // Statically check a manifest `command` for the footgun D48 names: a script file
286
+ // that does not exist, or a directly-invoked one missing its +x bit. The command
287
+ // is a shell string spawned via `sh -c` at the repo root (D49), so a relative path
288
+ // resolves against `root` and PLUMBBOB_AGENT_DIR points at the agent's own dir.
289
+ // Best-effort by design — a path-shaped program or an interpreter's script
290
+ // argument is checked; a bare command (a PATH binary, an inline `node -e` program)
291
+ // is trusted, because we cannot prove a shell line broken without running it.
292
+ // Returns the problem string, or null when nothing checkable is wrong.
293
+ function checkCommand(command, agentDir, root) {
294
+ const tokens = commandTokens(command, agentDir);
295
+ if (tokens.length === 0)
296
+ return null;
297
+ const resolve = (p) => (isAbsolute(p) ? p : join(root, p));
298
+ const program = tokens[0];
299
+ // A path-shaped program is invoked directly, so it must exist AND be executable.
300
+ if (program.includes('/')) {
301
+ const path = resolve(program);
302
+ if (!existsSync(path))
303
+ return `command program ${program} does not exist`;
304
+ try {
305
+ accessSync(path, constants.X_OK);
306
+ }
307
+ catch {
308
+ return `command program ${program} is not executable — chmod +x it`;
309
+ }
310
+ return null;
311
+ }
312
+ // An interpreter reads its script argument, so the script need only exist.
313
+ if (INTERPRETERS.has(program)) {
314
+ const script = tokens.slice(1).find((t) => t.includes('/'));
315
+ if (script !== undefined && !existsSync(resolve(script))) {
316
+ return `command script ${script} does not exist`;
317
+ }
318
+ }
319
+ return null;
320
+ }
321
+ // One agent's problem (D48), or null when it validates: a malformed or
322
+ // unsupported-contract manifest surfaces as its resolution error; an
323
+ // otherwise-valid agent is checked for a missing/non-executable command.
324
+ function agentProblem(listing, root) {
325
+ if (!listing.resolution.ok)
326
+ return listing.resolution.error;
327
+ const { manifest, dir } = listing.resolution.agent;
328
+ return checkCommand(manifest.command, dir, root);
329
+ }
330
+ // The agent-validation section (D48): walk every resolvable agent across both
331
+ // tiers (project + personal) and flag a malformed agent.json, an unsupported
332
+ // contract version (both arrive as the listing's parse error), or a
333
+ // missing/non-executable command. A repo with no agents shows no section (like the
334
+ // sidecar one), keeping doctor quiet for the common case; outside a repo there is
335
+ // no project tier to anchor to, so the section is skipped entirely.
336
+ function agentReport(cwd) {
337
+ const root = findRepoRoot(cwd);
338
+ if (root === null)
339
+ return { lines: [], failed: 0 };
340
+ const listings = listAgents(root);
341
+ if (listings.length === 0)
342
+ return { lines: [], failed: 0 };
343
+ const lines = ['', 'plumbbob doctor — agents (D48)'];
344
+ let failed = 0;
345
+ for (const listing of listings) {
346
+ const problem = agentProblem(listing, root);
347
+ if (problem === null) {
348
+ const slots = listing.resolution.ok ? ` [${listing.resolution.agent.manifest.slots.join(', ')}]` : '';
349
+ lines.push(` ✓ ${listing.name} (${listing.origin})${slots}`);
350
+ }
351
+ else {
352
+ lines.push(` ✗ ${listing.name} (${listing.origin}) — ${problem}`);
353
+ failed += 1;
354
+ }
355
+ }
356
+ return { lines, failed };
357
+ }
267
358
  // The check-gate section (D32): a configured `check` setting names the spawn
268
359
  // override and asks nothing more; otherwise checkride's own doctor reports the
269
360
  // slot/adapter table. Only rows checkride marks `required` count as problems —
@@ -319,9 +410,11 @@ export async function doctor(cwd, args = []) {
319
410
  }
320
411
  const sidecar = sidecarReport(cwd, args.includes('--migrate'));
321
412
  out.push(...sidecar.lines);
413
+ const agents = agentReport(cwd);
414
+ out.push(...agents.lines);
322
415
  const gate = await gateReport(cwd);
323
416
  out.push(...gate.lines);
324
- const failed = checks.filter((c) => !c.ok).length + sidecar.failed + gate.failed;
417
+ const failed = checks.filter((c) => !c.ok).length + sidecar.failed + agents.failed + gate.failed;
325
418
  out.push('');
326
419
  out.push(failed === 0
327
420
  ? 'plumbbob: all checks passed. If a skill still misbehaves, restart Claude Code (or /reload-plugins).'