@cat-factory/executor-harness 1.66.0 → 1.68.0

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/dist/agent.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import { join } from 'node:path';
2
2
  import { tmpdir } from 'node:os';
3
- import { mkdir, mkdtemp, opendir, rm } from 'node:fs/promises';
3
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
7
7
  import { configurePackageRegistries } from './package-registries.js';
8
8
  import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
9
- import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
9
+ import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
10
10
  import { inferVcsProvider, openPullRequest } from './vcs-api.js';
11
11
  import { applyPrDescription } from './pr-description.js';
12
12
  import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
13
13
  import { validationFailureMessage } from './validation-checks.js';
14
+ import { agentCapabilities, mergeEffort } from './agent-shared.js';
15
+ import { runBootstrap } from './bootstrap-mode.js';
14
16
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
15
17
  import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
16
18
  import { log } from './logger.js';
@@ -231,14 +233,6 @@ async function cloneServiceCheckout(dir, job, signal) {
231
233
  });
232
234
  return deriveWorkDir(dir, job.repo.serviceDirectory);
233
235
  }
234
- /**
235
- * Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
236
- * onto its final result. Every container mode routes its result through this so the report reaches
237
- * the backend uniformly. A run that wrote no report passes through unchanged.
238
- */
239
- function mergeEffort(result, effortReport) {
240
- return effortReport ? { ...result, effortReport } : result;
241
- }
242
236
  /** Run one generic agent job end to end, dispatching on `mode`. */
243
237
  export async function handleAgent(job, opts = {}) {
244
238
  // An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
@@ -503,6 +497,7 @@ async function runExploreMode(job, opts) {
503
497
  webSearchProxy: job.webSearch,
504
498
  contextFiles: job.contextFiles,
505
499
  guardLimits: job.guardLimits,
500
+ ...agentCapabilities(job),
506
501
  }, agentOpts);
507
502
  return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
508
503
  }
@@ -690,6 +685,7 @@ async function runMultiRepoExplore(job, opts) {
690
685
  webSearchProxy: job.webSearch,
691
686
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
692
687
  guardLimits: job.guardLimits,
688
+ ...agentCapabilities(job),
693
689
  multiRepo: true,
694
690
  }, opts);
695
691
  return mergeEffort(await finalizeExploreResult(job, {
@@ -792,8 +788,8 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
792
788
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
793
789
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
794
790
  ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
795
- // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
796
- ...(job.skill ? { skill: job.skill } : {}),
791
+ // Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
792
+ ...agentCapabilities(job),
797
793
  // Ralph loop: run the completion command after the agent commits and report its verdict.
798
794
  ...(job.validation
799
795
  ? {
@@ -1039,6 +1035,7 @@ async function runConflictResolution(job, opts) {
1039
1035
  sessionToken: job.sessionToken,
1040
1036
  contextFiles: job.contextFiles,
1041
1037
  guardLimits: job.guardLimits,
1038
+ ...agentCapabilities(job),
1042
1039
  }, opts);
1043
1040
  // Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
1044
1041
  // the PR would still be broken. Fail so the engine can retry / notify.
@@ -1115,133 +1112,6 @@ function unresolvedReason(unresolved, stats, stderrTail) {
1115
1112
  `(${unresolved.length} file(s) still conflicted: ${sample}).${cause}` +
1116
1113
  agentOutputTail(stderrTail));
1117
1114
  }
1118
- /**
1119
- * Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
1120
- * the agent adapts it in place per the instructions; without one (`fromScratch`), start from
1121
- * an empty directory → the agent scaffolds the new service. Either way the result's history
1122
- * is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
1123
- * default branch. Diverges from the ordinary coding flow in pushing to a different repo with
1124
- * a reinitialised history rather than a work branch + PR on the cloned repo.
1125
- */
1126
- async function runBootstrap(job, opts) {
1127
- const { signal } = opts;
1128
- const boot = job.bootstrap;
1129
- const fromScratch = boot.fromScratch === true;
1130
- const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
1131
- return withWorkspace('boot', async (dir) => {
1132
- if (!fromScratch) {
1133
- opts.onPhase?.('clone');
1134
- logger.info('agent(bootstrap): cloning reference architecture', {
1135
- reference: `${job.repo.owner}/${job.repo.name}`,
1136
- });
1137
- await cloneRepo({
1138
- repo: { ...job.repo, baseBranch: job.branch },
1139
- ghToken: job.ghToken,
1140
- dir,
1141
- signal,
1142
- });
1143
- }
1144
- else {
1145
- logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
1146
- }
1147
- opts.onPhase?.('agent');
1148
- logger.info('agent(bootstrap): running agent');
1149
- const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
1150
- dir,
1151
- systemPrompt: job.systemPrompt,
1152
- userPrompt: job.userPrompt,
1153
- model: job.model,
1154
- harness: job.harness,
1155
- subscriptionToken: job.subscriptionToken,
1156
- subscriptionBaseUrl: job.subscriptionBaseUrl,
1157
- ambientAuth: job.ambientAuth,
1158
- proxyBaseUrl: job.proxyBaseUrl,
1159
- sessionToken: job.sessionToken,
1160
- guardLimits: job.guardLimits,
1161
- }, opts);
1162
- // Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
1163
- // reached the model), and a force-push would then publish an empty tree — leaving the
1164
- // run "succeeded" but the repo bare. Fail with a structured error (carrying what the
1165
- // agent did) instead of pushing nothing.
1166
- if (!(await producedRepoContent(dir, !fromScratch, signal))) {
1167
- const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
1168
- logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
1169
- return mergeEffort({
1170
- summary,
1171
- stats,
1172
- error,
1173
- failureCause: 'agent',
1174
- ...(usage ? { usage } : {}),
1175
- ...(callMetrics ? { callMetrics } : {}),
1176
- }, effortReport);
1177
- }
1178
- opts.onPhase?.('push');
1179
- logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
1180
- // Bootstrap always resets history to one commit + force-pushes (the fresh history
1181
- // shares no ancestor with whatever boilerplate the new repo was created with).
1182
- await reinitAndPush({
1183
- dir,
1184
- target: boot.target,
1185
- ghToken: job.ghToken,
1186
- message: fromScratch
1187
- ? 'Bootstrap new repository'
1188
- : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
1189
- });
1190
- logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
1191
- return mergeEffort({
1192
- defaultBranch: boot.target.defaultBranch,
1193
- summary,
1194
- stats,
1195
- ...(usage ? { usage } : {}),
1196
- ...(callMetrics ? { callMetrics } : {}),
1197
- }, effortReport);
1198
- });
1199
- }
1200
- /**
1201
- * Whether the bootstrapper actually produced repository content, so a no-op run (the agent
1202
- * never reached the model / never wrote anything) is failed rather than force-pushed as an
1203
- * empty repo. With a reference architecture, "produced content" means the agent changed the
1204
- * clone; scaffolding from scratch, it means at least one file now exists in the working
1205
- * directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
1206
- * never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
1207
- */
1208
- export async function producedRepoContent(dir, hasReference, signal) {
1209
- if (hasReference)
1210
- return hasAgentChanges(dir, signal);
1211
- return containsAnyFile(dir);
1212
- }
1213
- /**
1214
- * Whether `dir` contains at least one regular file anywhere in its tree, walking
1215
- * depth-first and stopping at the FIRST file found — so the cost is bounded by how
1216
- * quickly a file turns up (a scaffold almost always writes a root-level file), not by
1217
- * the size of the produced tree (a full recursive `readdir` would materialise every
1218
- * entry before the check).
1219
- */
1220
- async function containsAnyFile(dir) {
1221
- const handle = await opendir(dir);
1222
- try {
1223
- for await (const entry of handle) {
1224
- if (entry.isFile())
1225
- return true;
1226
- if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
1227
- return true;
1228
- }
1229
- }
1230
- catch {
1231
- // A directory that vanished mid-walk has nothing to contribute.
1232
- }
1233
- return false;
1234
- }
1235
- /** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
1236
- function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
1237
- const what = hasReference
1238
- ? 'made no changes to the reference architecture'
1239
- : 'scaffolded no files';
1240
- const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
1241
- return (`the bootstrapper agent ${what} ` +
1242
- `(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
1243
- agentOutputTail(stderrTail, summary));
1244
- }
1245
1115
  /** Human-readable reason a read-only run produced no usable output. */
1246
1116
  function noOutputReason(stats, stderrTail) {
1247
1117
  const cause = agentNeverActed(stats)
@@ -0,0 +1,141 @@
1
+ import { opendir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { NEVER_ACTED_CAUSE, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
4
+ import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js';
5
+ import { log } from './logger.js';
6
+ import { agentCapabilities, mergeEffort } from './agent-shared.js';
7
+ // ---------------------------------------------------------------------------
8
+ // The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
9
+ // pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
10
+ // cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
11
+ // and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
12
+ // helpers in `agent-shared.ts` with the coding/explore flows.
13
+ // ---------------------------------------------------------------------------
14
+ /**
15
+ * Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
16
+ * the agent adapts it in place per the instructions; without one (`fromScratch`), start from
17
+ * an empty directory → the agent scaffolds the new service. Either way the result's history
18
+ * is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
19
+ * default branch. Diverges from the ordinary coding flow in pushing to a different repo with
20
+ * a reinitialised history rather than a work branch + PR on the cloned repo.
21
+ */
22
+ export async function runBootstrap(job, opts) {
23
+ const { signal } = opts;
24
+ const boot = job.bootstrap;
25
+ const fromScratch = boot.fromScratch === true;
26
+ const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
27
+ return withWorkspace('boot', async (dir) => {
28
+ if (!fromScratch) {
29
+ opts.onPhase?.('clone');
30
+ logger.info('agent(bootstrap): cloning reference architecture', {
31
+ reference: `${job.repo.owner}/${job.repo.name}`,
32
+ });
33
+ await cloneRepo({
34
+ repo: { ...job.repo, baseBranch: job.branch },
35
+ ghToken: job.ghToken,
36
+ dir,
37
+ signal,
38
+ });
39
+ }
40
+ else {
41
+ logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
42
+ }
43
+ opts.onPhase?.('agent');
44
+ logger.info('agent(bootstrap): running agent');
45
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
46
+ dir,
47
+ systemPrompt: job.systemPrompt,
48
+ userPrompt: job.userPrompt,
49
+ model: job.model,
50
+ harness: job.harness,
51
+ subscriptionToken: job.subscriptionToken,
52
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
53
+ ambientAuth: job.ambientAuth,
54
+ proxyBaseUrl: job.proxyBaseUrl,
55
+ sessionToken: job.sessionToken,
56
+ guardLimits: job.guardLimits,
57
+ ...agentCapabilities(job),
58
+ }, opts);
59
+ // Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
60
+ // reached the model), and a force-push would then publish an empty tree — leaving the
61
+ // run "succeeded" but the repo bare. Fail with a structured error (carrying what the
62
+ // agent did) instead of pushing nothing.
63
+ if (!(await producedRepoContent(dir, !fromScratch, signal))) {
64
+ const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
65
+ logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
66
+ return mergeEffort({
67
+ summary,
68
+ stats,
69
+ error,
70
+ failureCause: 'agent',
71
+ ...(usage ? { usage } : {}),
72
+ ...(callMetrics ? { callMetrics } : {}),
73
+ }, effortReport);
74
+ }
75
+ opts.onPhase?.('push');
76
+ logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
77
+ // Bootstrap always resets history to one commit + force-pushes (the fresh history
78
+ // shares no ancestor with whatever boilerplate the new repo was created with).
79
+ await reinitAndPush({
80
+ dir,
81
+ target: boot.target,
82
+ ghToken: job.ghToken,
83
+ message: fromScratch
84
+ ? 'Bootstrap new repository'
85
+ : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
86
+ });
87
+ logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
88
+ return mergeEffort({
89
+ defaultBranch: boot.target.defaultBranch,
90
+ summary,
91
+ stats,
92
+ ...(usage ? { usage } : {}),
93
+ ...(callMetrics ? { callMetrics } : {}),
94
+ }, effortReport);
95
+ });
96
+ }
97
+ /**
98
+ * Whether the bootstrapper actually produced repository content, so a no-op run (the agent
99
+ * never reached the model / never wrote anything) is failed rather than force-pushed as an
100
+ * empty repo. With a reference architecture, "produced content" means the agent changed the
101
+ * clone; scaffolding from scratch, it means at least one file now exists in the working
102
+ * directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
103
+ * never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
104
+ */
105
+ export async function producedRepoContent(dir, hasReference, signal) {
106
+ if (hasReference)
107
+ return hasAgentChanges(dir, signal);
108
+ return containsAnyFile(dir);
109
+ }
110
+ /**
111
+ * Whether `dir` contains at least one regular file anywhere in its tree, walking
112
+ * depth-first and stopping at the FIRST file found — so the cost is bounded by how
113
+ * quickly a file turns up (a scaffold almost always writes a root-level file), not by
114
+ * the size of the produced tree (a full recursive `readdir` would materialise every
115
+ * entry before the check).
116
+ */
117
+ async function containsAnyFile(dir) {
118
+ const handle = await opendir(dir);
119
+ try {
120
+ for await (const entry of handle) {
121
+ if (entry.isFile())
122
+ return true;
123
+ if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
124
+ return true;
125
+ }
126
+ }
127
+ catch {
128
+ // A directory that vanished mid-walk has nothing to contribute.
129
+ }
130
+ return false;
131
+ }
132
+ /** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
133
+ function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
134
+ const what = hasReference
135
+ ? 'made no changes to the reference architecture'
136
+ : 'scaffolded no files';
137
+ const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
138
+ return (`the bootstrapper agent ${what} ` +
139
+ `(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
140
+ agentOutputTail(stderrTail, summary));
141
+ }
@@ -1,8 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { killChildProcess, spawnDetached } from './process.js';
5
- import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
3
+ import { runCapturedCommand } from './captured-command.js';
6
4
  import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
7
5
  import { openPullRequest } from './vcs-api.js';
8
6
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
@@ -153,7 +151,8 @@ export async function runCodingAgent(spec, opts = {}) {
153
151
  webToolsGuidance: spec.webToolsGuidance,
154
152
  webSearchProxy: spec.webSearchProxy,
155
153
  guardLimits: spec.guardLimits,
156
- ...(spec.skill ? { skill: spec.skill } : {}),
154
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
155
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
157
156
  }, opts);
158
157
  let outcome;
159
158
  try {
@@ -457,7 +456,7 @@ async function finalizeCodingRun(args) {
457
456
  // Runs regardless of whether this pass pushed — a no-op iteration must still be able
458
457
  // to report that the criterion is (already) met. The harness runs it, never the model.
459
458
  if (spec.validation) {
460
- outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
459
+ outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts);
461
460
  }
462
461
  // Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
463
462
  // reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
@@ -525,81 +524,96 @@ function mergeAgentPasses(previous, next) {
525
524
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
526
525
  * Overridable via env for tests; defaults to 15 minutes.
527
526
  */
528
- function ralphValidationTimeoutMs() {
527
+ export function ralphValidationTimeoutMs() {
529
528
  const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS);
530
529
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
531
530
  }
531
+ /**
532
+ * How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
533
+ * The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
534
+ * — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
535
+ * events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
536
+ * watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
537
+ * validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
538
+ * a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
539
+ * settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
540
+ * always fed it; this one did not. Overridable via env for tests.
541
+ */
542
+ export function ralphHeartbeatMs() {
543
+ const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS);
544
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
545
+ }
546
+ /**
547
+ * Bound on the validation output tail that crosses the wire. Deliberately smaller than
548
+ * `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
549
+ * the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
550
+ * log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
551
+ */
552
+ export const RALPH_VALIDATION_TAIL_CHARS = 4_000;
532
553
  /**
533
554
  * Ralph loop: run the programmatic completion command in the checkout and return its exit
534
- * code plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
535
- * done signal (0 = the criterion is met) — computed here by the harness, never self-reported
536
- * by the model, which is the whole point of a programmatic exit condition. Runs
537
- * `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
538
- * command counts as a failure so the loop is never blocked), and an aborted run resolves to a
539
- * non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
540
- * boundary as the coding agent) there is no host/backend execution.
555
+ * code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
556
+ * The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
557
+ * here by the harness, never self-reported by the model, which is the whole point of a
558
+ * programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
559
+ * trust boundary as the coding agent) there is no host/backend execution.
560
+ *
561
+ * The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
562
+ * command, rather than the near-verbatim copy this used to be. That copy had drifted in two
563
+ * ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
564
+ * margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
565
+ * an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
566
+ * it published the full 16k capture where both siblings deliberately bound the wire tail.
567
+ *
568
+ * `headSha` is what lets the engine tell a loop that is iterating from one that is merely
569
+ * repeating: two consecutive failing iterations against an unchanged head means the agent
570
+ * committed nothing, and the loop is ended early instead of spending the rest of its budget.
571
+ * Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
541
572
  */
542
- async function runRalphValidation(cwd, validation, logger, opts) {
543
- const timeoutMs = ralphValidationTimeoutMs();
573
+ export async function runRalphValidation(repoDir, cwd, validation, logger, opts) {
544
574
  logger.info('coding-agent(ralph): running validation command', {
545
575
  iteration: validation.iteration,
546
576
  });
547
- return new Promise((resolve) => {
548
- let out = '';
549
- let settled = false;
550
- const child = spawn('sh', ['-c', validation.command], {
577
+ // Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
578
+ const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs());
579
+ heartbeat.unref?.();
580
+ let captured;
581
+ try {
582
+ captured = await runCapturedCommand({
551
583
  cwd,
552
- detached: spawnDetached,
553
- stdio: ['ignore', 'pipe', 'pipe'],
554
- // The job's own env (see `RunOptions.agentEnv`): a validation command typically installs
555
- // before it tests, and this is spawned by the HARNESS rather than the agent, so it does not
556
- // otherwise inherit the job's private-registry npmrc pointer on the native path.
557
- env: { ...process.env, ...opts.agentEnv },
584
+ command: validation.command,
585
+ timeoutMs: ralphValidationTimeoutMs(),
586
+ reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
587
+ logLabel: 'coding-agent(ralph): validation',
588
+ logFields: { iteration: validation.iteration },
589
+ logger,
590
+ opts,
558
591
  });
559
- // Keep only the tail; guard against unbounded buffering on a chatty command.
560
- const capture = (chunk) => {
561
- out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
562
- };
563
- child.stdout?.on('data', capture);
564
- child.stderr?.on('data', capture);
565
- const finish = (exitCode) => {
566
- if (settled)
567
- return;
568
- settled = true;
569
- clearTimeout(timer);
570
- opts.signal?.removeEventListener('abort', onAbort);
571
- const trimmed = out.trim();
572
- const tail = trimmed ? redactSecrets(trimmed) : undefined;
573
- logger.info('coding-agent(ralph): validation finished', {
574
- exitCode,
575
- iteration: validation.iteration,
576
- });
577
- resolve({
578
- validationPassed: exitCode === 0,
579
- exitCode,
580
- ...(tail ? { validationOutputTail: tail } : {}),
581
- ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
582
- });
583
- };
584
- const timer = setTimeout(() => {
585
- logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs });
586
- killChildProcess(child, undefined, logger);
587
- finish(124); // conventional timeout exit code (a non-zero fail)
588
- }, timeoutMs);
589
- timer.unref?.();
590
- const onAbort = () => {
591
- killChildProcess(child, undefined, logger);
592
- finish(130); // aborted (a non-zero fail)
593
- };
594
- opts.signal?.addEventListener('abort', onAbort, { once: true });
595
- child.on('error', (err) => {
596
- logger.warn('coding-agent(ralph): validation command failed to spawn', {
597
- error: err instanceof Error ? err.message : String(err),
598
- });
599
- finish(127); // spawn error / command not found (a non-zero fail)
592
+ }
593
+ finally {
594
+ clearInterval(heartbeat);
595
+ }
596
+ // The commit the criterion was judged against. Read AFTER the command so a validation that
597
+ // itself commits (a formatter check that rewrites files, say) is attributed to what it left.
598
+ // Best-effort: an unreadable head only costs the engine's no-progress guard, never the
599
+ // verdict — but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
600
+ const headSha = await headCommit(repoDir, opts.signal).catch((err) => {
601
+ logger.warn('coding-agent(ralph): could not read the work-branch head', {
602
+ error: err instanceof Error ? err.message : String(err),
600
603
  });
601
- child.on('close', (code) => finish(code ?? 1));
604
+ return '';
602
605
  });
606
+ logger.info('coding-agent(ralph): validation finished', {
607
+ exitCode: captured.exitCode,
608
+ iteration: validation.iteration,
609
+ });
610
+ return {
611
+ validationPassed: captured.passed,
612
+ exitCode: captured.exitCode,
613
+ ...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
614
+ ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
615
+ ...(headSha ? { headSha } : {}),
616
+ };
603
617
  }
604
618
  /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
605
619
  export function safeDirSegment(value) {
@@ -707,6 +721,10 @@ export async function runMultiRepoCoding(job, opts = {}) {
707
721
  webSearchProxy: job.webSearch,
708
722
  guardLimits: job.guardLimits,
709
723
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
724
+ // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
725
+ // are properties of the AGENT KIND, not of the checkout layout.
726
+ ...(job.skills?.length ? { skills: job.skills } : {}),
727
+ ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
710
728
  multiRepo: true,
711
729
  }, opts);
712
730
  // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
package/dist/job.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parseValidationChecksSpec, } from './validation-checks.js';
2
2
  import { parseReproductionSpec, } from './reproduction-proof.js';
3
+ import { parseMcpServerSpecs, parseSkillSpecs, } from './agent-capabilities.js';
3
4
  function str(value, path) {
4
5
  if (typeof value !== 'string' || value.length === 0) {
5
6
  throw new Error(`Invalid job: '${path}' must be a non-empty string`);
@@ -411,78 +412,6 @@ function parseContextFiles(value) {
411
412
  }
412
413
  return files;
413
414
  }
414
- /**
415
- * Sanitize a skill resource's relative path: keep the subdirectory structure (so
416
- * `templates/report.md` materialises nested) but reject anything that could escape the skill
417
- * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
418
- * for an unsafe path (the resource is then dropped).
419
- */
420
- function sanitizeSkillRelPath(value) {
421
- if (typeof value !== 'string')
422
- return undefined;
423
- const segments = value.replace(/\\/g, '/').split('/');
424
- const clean = [];
425
- for (const seg of segments) {
426
- if (seg === '' || seg === '.')
427
- continue;
428
- if (seg === '..')
429
- return undefined;
430
- // Same character class as a context-file name, per segment.
431
- const c = seg.replace(/[^A-Za-z0-9._-]/g, '');
432
- if (!c || c === '.' || c === '..' || c.startsWith('.'))
433
- return undefined;
434
- clean.push(c);
435
- }
436
- return clean.length ? clean.join('/') : undefined;
437
- }
438
- /**
439
- * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
440
- * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
441
- * default keeps the skill installable rather than dropping it — which, on the claude-code path,
442
- * would leave the prompt pointing at a skill that was never installed (a blind run).
443
- */
444
- const FALLBACK_SKILL_NAME = 'skill';
445
- /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
446
- function sanitizeSkillName(value) {
447
- if (typeof value !== 'string')
448
- return undefined;
449
- const base = value.replace(/\\/g, '/').split('/').pop() ?? '';
450
- const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '');
451
- if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.'))
452
- return undefined;
453
- return cleaned;
454
- }
455
- /** Validate the optional `skill` field, or undefined when absent/malformed. */
456
- function parseSkillSpec(value) {
457
- if (typeof value !== 'object' || value === null)
458
- return undefined;
459
- const o = value;
460
- const instructions = typeof o.instructions === 'string' ? o.instructions : undefined;
461
- // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
462
- // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
463
- // directory, so fall back to a safe default rather than dropping the whole skill.
464
- if (!instructions)
465
- return undefined;
466
- const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME;
467
- const description = typeof o.description === 'string' ? o.description : '';
468
- const resources = [];
469
- if (Array.isArray(o.resources)) {
470
- const used = new Set();
471
- for (const entry of o.resources) {
472
- if (typeof entry !== 'object' || entry === null)
473
- continue;
474
- const e = entry;
475
- const relPath = sanitizeSkillRelPath(e.relPath);
476
- if (!relPath || used.has(relPath))
477
- continue;
478
- if (typeof e.content !== 'string')
479
- continue;
480
- used.add(relPath);
481
- resources.push({ relPath, content: e.content });
482
- }
483
- }
484
- return { name, description, instructions, resources };
485
- }
486
415
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
487
416
  function parseAgentInfraSpec(value) {
488
417
  if (typeof value !== 'object' || value === null)
@@ -669,7 +598,8 @@ export function parseAgentJob(input) {
669
598
  bootstrap: parseAgentBootstrapSpec(o.bootstrap),
670
599
  contextFiles: parseContextFiles(o.contextFiles),
671
600
  packageRegistries: parsePackageRegistries(o.packageRegistries),
672
- skill: parseSkillSpec(o.skill),
601
+ skills: parseSkillSpecs(o.skills),
602
+ mcpServers: parseMcpServerSpecs(o.mcpServers),
673
603
  testSecrets: parseTestSecrets(o.testSecrets),
674
604
  guardLimits: parseGuardLimits(o.guardLimits),
675
605
  validation: parseValidationSpec(o.validation),
@@ -731,7 +661,7 @@ function parseAgentPrSpec(raw) {
731
661
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
732
662
  */
733
663
  function assembleAgentJob(o, mode, agentField, parts) {
734
- const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
664
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
735
665
  const repo = (o.repo ?? {});
736
666
  return {
737
667
  jobId: str(o.jobId, 'jobId'),
@@ -748,7 +678,8 @@ function assembleAgentJob(o, mode, agentField, parts) {
748
678
  ...(output ? { output } : {}),
749
679
  ...(contextFiles.length ? { contextFiles } : {}),
750
680
  ...(packageRegistries.length ? { packageRegistries } : {}),
751
- ...(skill ? { skill } : {}),
681
+ ...(skills ? { skills } : {}),
682
+ ...(mcpServers ? { mcpServers } : {}),
752
683
  ...(testSecrets.length ? { testSecrets } : {}),
753
684
  ...(infra ? { infra } : {}),
754
685
  ...(pr ? { pr } : {}),