@cat-factory/executor-harness 1.43.8 → 1.47.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.
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
5
  import { killChildProcess, spawnDetached } from './process.js';
6
6
  import { redact, secretsToRedact } from './redact.js';
7
7
  function isObject(value) {
@@ -130,6 +130,31 @@ function streamCli(command, args, prompt, opts, env, secrets, onEvent) {
130
130
  * `TodoWrite` tool calls onto subtask progress and the terminal `result` event
131
131
  * onto the summary + usage.
132
132
  */
133
+ /**
134
+ * Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
135
+ * `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
136
+ * expects) plus every resource file at its path within the skill directory. Resource sub-paths
137
+ * were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
138
+ *
139
+ * The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
140
+ * scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
141
+ * or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
142
+ * would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
143
+ * valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
144
+ */
145
+ async function writeNativeSkill(skillsRoot, skill) {
146
+ const dir = join(skillsRoot, skill.name);
147
+ await mkdir(dir, { recursive: true });
148
+ const name = JSON.stringify(skill.name);
149
+ const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '));
150
+ const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`;
151
+ await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8');
152
+ for (const resource of skill.resources) {
153
+ const dest = join(dir, resource.relPath);
154
+ await mkdir(dirname(dest), { recursive: true });
155
+ await writeFile(dest, resource.content, 'utf8');
156
+ }
157
+ }
133
158
  export async function runClaudeCode(opts) {
134
159
  const stats = { toolCalls: 0, assistantChars: 0 };
135
160
  let summary = '';
@@ -219,6 +244,16 @@ export async function runClaudeCode(opts) {
219
244
  hasTrustDialogAccepted: true,
220
245
  }), { mode: 0o600 }).catch(() => { });
221
246
  }
247
+ // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
248
+ // `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
249
+ // config home when present, else the developer's `~/.claude` (ambient/native mode). Best-effort:
250
+ // a write failure must not wedge the run — the prompt still names the skill.
251
+ if (opts.skill) {
252
+ const skillsRoot = configHome
253
+ ? join(configHome, 'skills')
254
+ : join(homedir(), '.claude', 'skills');
255
+ await writeNativeSkill(skillsRoot, opts.skill).catch(() => { });
256
+ }
222
257
  // Anthropic itself authenticates with the subscription OAuth token; a
223
258
  // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
224
259
  // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
package/dist/agent.js CHANGED
@@ -654,6 +654,16 @@ async function runMultiRepoExplore(job, opts) {
654
654
  }, { infraSetupFields: {}, logger, signal: opts.signal });
655
655
  });
656
656
  }
657
+ /**
658
+ * Whether a Ralph iteration ({@link AgentJob.validation} set) landed on a MULTI-REPO job (writable
659
+ * peer repos or read-only reference repos). The post-commit validation command is only wired into
660
+ * the single-repo flow, so a multi-repo run would silently skip it and degenerate the loop into a
661
+ * one-shot with no completion gate — multi-repo ralph is out of scope for v1 (see
662
+ * backend/docs/ralph-loop.md), so {@link runCodingMode} fails loudly on this instead.
663
+ */
664
+ export function ralphUnsupportedOnMultiRepo(job) {
665
+ return Boolean(job.validation) && Boolean(job.peerRepos?.length || job.referenceRepos?.length);
666
+ }
657
667
  /**
658
668
  * Edit-and-push coding, dispatching on job DATA: repo-bootstrap (force-push a fresh history to a
659
669
  * separate target repo), conflict-resolution (merge the base in, resolve, push back), multi-repo
@@ -678,7 +688,19 @@ async function runCodingMode(job, opts) {
678
688
  // all of them. Keyed off job DATA, not the agent kind — set for the implementer's writable
679
689
  // peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
680
690
  // reference repos (`referenceRepos`, cloned but never pushed).
681
- const result = job.peerRepos?.length || job.referenceRepos?.length
691
+ const multiRepo = Boolean(job.peerRepos?.length || job.referenceRepos?.length);
692
+ // Ralph loop (v1): the post-commit validation command is only wired into the single-repo
693
+ // flow, so a multi-repo run would silently skip it and the loop would degenerate into a
694
+ // one-shot with no completion gate. Multi-repo ralph is deliberately out of scope for v1
695
+ // (see backend/docs/ralph-loop.md), so FAIL LOUDLY rather than run a validation-less pass.
696
+ if (ralphUnsupportedOnMultiRepo(job)) {
697
+ return {
698
+ error: 'Ralph loop is not supported on a multi-repo task (connected service repos). ' +
699
+ 'Its validation command runs only in the single primary-repo checkout. ' +
700
+ 'Run the Ralph loop on a task scoped to a single repo.',
701
+ };
702
+ }
703
+ const result = multiRepo
682
704
  ? await runMultiRepoCoding(job, opts)
683
705
  : await runSingleRepoCoding(job, opts);
684
706
  // Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
@@ -700,7 +722,7 @@ async function runCodingMode(job, opts) {
700
722
  */
701
723
  async function runSingleRepoCoding(job, opts) {
702
724
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
703
- const { summary, stats, stderrTail, pushed, usage, callMetrics } = await runCodingAgent({
725
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } = await runCodingAgent({
704
726
  kind: 'agent',
705
727
  jobId: job.jobId,
706
728
  repo: job.repo,
@@ -724,7 +746,23 @@ async function runSingleRepoCoding(job, opts) {
724
746
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
725
747
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
726
748
  ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
749
+ // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
750
+ ...(job.skill ? { skill: job.skill } : {}),
751
+ // Ralph loop: run the completion command after the agent commits and report its verdict.
752
+ ...(job.validation
753
+ ? {
754
+ validation: {
755
+ command: job.validation.command,
756
+ ...(job.validation.iteration !== undefined
757
+ ? { iteration: job.validation.iteration }
758
+ : {}),
759
+ },
760
+ }
761
+ : {}),
727
762
  }, opts);
763
+ // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
764
+ // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
765
+ const ralphVerdict = validation ? { ralphVerdict: validation } : {};
728
766
  if (!pushed) {
729
767
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
730
768
  if (job.noChangesIsError === false) {
@@ -735,6 +773,7 @@ async function runSingleRepoCoding(job, opts) {
735
773
  stats,
736
774
  ...(usage ? { usage } : {}),
737
775
  ...(callMetrics ? { callMetrics } : {}),
776
+ ...ralphVerdict,
738
777
  };
739
778
  }
740
779
  return {
@@ -799,6 +838,7 @@ async function runSingleRepoCoding(job, opts) {
799
838
  stats,
800
839
  ...(usage ? { usage } : {}),
801
840
  ...(callMetrics ? { callMetrics } : {}),
841
+ ...ralphVerdict,
802
842
  };
803
843
  }
804
844
  return {
@@ -808,6 +848,7 @@ async function runSingleRepoCoding(job, opts) {
808
848
  stats,
809
849
  ...(usage ? { usage } : {}),
810
850
  ...(callMetrics ? { callMetrics } : {}),
851
+ ...ralphVerdict,
811
852
  };
812
853
  }
813
854
  /**
@@ -1,5 +1,8 @@
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
6
  import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
4
7
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
5
8
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
@@ -227,6 +230,7 @@ export async function runCodingAgent(spec, opts = {}) {
227
230
  webToolsGuidance: spec.webToolsGuidance,
228
231
  webSearchProxy: spec.webSearchProxy,
229
232
  guardLimits: spec.guardLimits,
233
+ ...(spec.skill ? { skill: spec.skill } : {}),
230
234
  }, opts);
231
235
  // Stop tailing the follow-up sentinel and flush any items written after the last
232
236
  // tick, so a fast final burst still reaches the job view before the run is recorded.
@@ -299,6 +303,13 @@ export async function runCodingAgent(spec, opts = {}) {
299
303
  ...(callMetrics ? { callMetrics } : {}),
300
304
  };
301
305
  }
306
+ // Ralph loop: run the programmatic completion command against the pushed/committed
307
+ // state and attach its verdict (exit code = the loop's authoritative done signal).
308
+ // Runs regardless of whether this pass pushed — a no-op iteration must still be able
309
+ // to report that the criterion is (already) met. The harness runs it, never the model.
310
+ if (spec.validation) {
311
+ outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
312
+ }
302
313
  }
303
314
  finally {
304
315
  // Safety net for the throw path (the happy path already cleared these above).
@@ -309,6 +320,83 @@ export async function runCodingAgent(spec, opts = {}) {
309
320
  return outcome;
310
321
  });
311
322
  }
323
+ /**
324
+ * The Ralph-loop validation watchdog: the longest a completion command may run before it is
325
+ * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
326
+ * Overridable via env for tests; defaults to 15 minutes.
327
+ */
328
+ function ralphValidationTimeoutMs() {
329
+ const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS);
330
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
331
+ }
332
+ /**
333
+ * Ralph loop: run the programmatic completion command in the checkout and return its exit
334
+ * code plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
335
+ * done signal (0 = the criterion is met) — computed here by the harness, never self-reported
336
+ * by the model, which is the whole point of a programmatic exit condition. Runs
337
+ * `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
338
+ * command counts as a failure so the loop is never blocked), and an aborted run resolves to a
339
+ * non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
340
+ * boundary as the coding agent) — there is no host/backend execution.
341
+ */
342
+ async function runRalphValidation(cwd, validation, logger, opts) {
343
+ const timeoutMs = ralphValidationTimeoutMs();
344
+ logger.info('coding-agent(ralph): running validation command', {
345
+ iteration: validation.iteration,
346
+ });
347
+ return new Promise((resolve) => {
348
+ let out = '';
349
+ let settled = false;
350
+ const child = spawn('sh', ['-c', validation.command], {
351
+ cwd,
352
+ detached: spawnDetached,
353
+ stdio: ['ignore', 'pipe', 'pipe'],
354
+ });
355
+ // Keep only the tail; guard against unbounded buffering on a chatty command.
356
+ const capture = (chunk) => {
357
+ out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
358
+ };
359
+ child.stdout?.on('data', capture);
360
+ child.stderr?.on('data', capture);
361
+ const finish = (exitCode) => {
362
+ if (settled)
363
+ return;
364
+ settled = true;
365
+ clearTimeout(timer);
366
+ opts.signal?.removeEventListener('abort', onAbort);
367
+ const trimmed = out.trim();
368
+ const tail = trimmed ? redactSecrets(trimmed) : undefined;
369
+ logger.info('coding-agent(ralph): validation finished', {
370
+ exitCode,
371
+ iteration: validation.iteration,
372
+ });
373
+ resolve({
374
+ validationPassed: exitCode === 0,
375
+ exitCode,
376
+ ...(tail ? { validationOutputTail: tail } : {}),
377
+ ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
378
+ });
379
+ };
380
+ const timer = setTimeout(() => {
381
+ logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs });
382
+ killChildProcess(child, undefined, logger);
383
+ finish(124); // conventional timeout exit code (a non-zero fail)
384
+ }, timeoutMs);
385
+ timer.unref?.();
386
+ const onAbort = () => {
387
+ killChildProcess(child, undefined, logger);
388
+ finish(130); // aborted (a non-zero fail)
389
+ };
390
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
391
+ child.on('error', (err) => {
392
+ logger.warn('coding-agent(ralph): validation command failed to spawn', {
393
+ error: err instanceof Error ? err.message : String(err),
394
+ });
395
+ finish(127); // spawn error / command not found (a non-zero fail)
396
+ });
397
+ child.on('close', (code) => finish(code ?? 1));
398
+ });
399
+ }
312
400
  /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
313
401
  export function safeDirSegment(value) {
314
402
  return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
package/dist/job.js CHANGED
@@ -45,6 +45,27 @@ function parseGuardLimits(value) {
45
45
  spec.maxConsecutiveWebCalls = web;
46
46
  return Object.keys(spec).length > 0 ? spec : undefined;
47
47
  }
48
+ /**
49
+ * Parse the optional Ralph-loop validation spec. Requires a non-empty `command` string (the
50
+ * completion criterion the harness runs); `progressPath`/`iteration` are optional metadata.
51
+ * Returns undefined when absent or malformed (a coding run then behaves like any other — no
52
+ * post-commit validation). See {@link ValidationSpec}.
53
+ */
54
+ function parseValidationSpec(value) {
55
+ if (typeof value !== 'object' || value === null)
56
+ return undefined;
57
+ const o = value;
58
+ if (typeof o.command !== 'string' || o.command.trim() === '')
59
+ return undefined;
60
+ const iteration = posInt(o.iteration);
61
+ return {
62
+ command: o.command,
63
+ ...(typeof o.progressPath === 'string' && o.progressPath
64
+ ? { progressPath: o.progressPath }
65
+ : {}),
66
+ ...(iteration !== undefined ? { iteration } : {}),
67
+ };
68
+ }
48
69
  /**
49
70
  * Parse the shared per-job auth fields, validating per harness: a subscription
50
71
  * harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
@@ -388,6 +409,78 @@ function parseContextFiles(value) {
388
409
  }
389
410
  return files;
390
411
  }
412
+ /**
413
+ * Sanitize a skill resource's relative path: keep the subdirectory structure (so
414
+ * `templates/report.md` materialises nested) but reject anything that could escape the skill
415
+ * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
416
+ * for an unsafe path (the resource is then dropped).
417
+ */
418
+ function sanitizeSkillRelPath(value) {
419
+ if (typeof value !== 'string')
420
+ return undefined;
421
+ const segments = value.replace(/\\/g, '/').split('/');
422
+ const clean = [];
423
+ for (const seg of segments) {
424
+ if (seg === '' || seg === '.')
425
+ continue;
426
+ if (seg === '..')
427
+ return undefined;
428
+ // Same character class as a context-file name, per segment.
429
+ const c = seg.replace(/[^A-Za-z0-9._-]/g, '');
430
+ if (!c || c === '.' || c === '..' || c.startsWith('.'))
431
+ return undefined;
432
+ clean.push(c);
433
+ }
434
+ return clean.length ? clean.join('/') : undefined;
435
+ }
436
+ /**
437
+ * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
438
+ * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
439
+ * default keeps the skill installable rather than dropping it — which, on the claude-code path,
440
+ * would leave the prompt pointing at a skill that was never installed (a blind run).
441
+ */
442
+ const FALLBACK_SKILL_NAME = 'skill';
443
+ /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
444
+ function sanitizeSkillName(value) {
445
+ if (typeof value !== 'string')
446
+ return undefined;
447
+ const base = value.replace(/\\/g, '/').split('/').pop() ?? '';
448
+ const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '');
449
+ if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.'))
450
+ return undefined;
451
+ return cleaned;
452
+ }
453
+ /** Validate the optional `skill` field, or undefined when absent/malformed. */
454
+ function parseSkillSpec(value) {
455
+ if (typeof value !== 'object' || value === null)
456
+ return undefined;
457
+ const o = value;
458
+ const instructions = typeof o.instructions === 'string' ? o.instructions : undefined;
459
+ // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
460
+ // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
461
+ // directory, so fall back to a safe default rather than dropping the whole skill.
462
+ if (!instructions)
463
+ return undefined;
464
+ const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME;
465
+ const description = typeof o.description === 'string' ? o.description : '';
466
+ const resources = [];
467
+ if (Array.isArray(o.resources)) {
468
+ const used = new Set();
469
+ for (const entry of o.resources) {
470
+ if (typeof entry !== 'object' || entry === null)
471
+ continue;
472
+ const e = entry;
473
+ const relPath = sanitizeSkillRelPath(e.relPath);
474
+ if (!relPath || used.has(relPath))
475
+ continue;
476
+ if (typeof e.content !== 'string')
477
+ continue;
478
+ used.add(relPath);
479
+ resources.push({ relPath, content: e.content });
480
+ }
481
+ }
482
+ return { name, description, instructions, resources };
483
+ }
391
484
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
392
485
  function parseAgentInfraSpec(value) {
393
486
  if (typeof value !== 'object' || value === null)
@@ -587,8 +680,10 @@ export function parseAgentJob(input) {
587
680
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
588
681
  const contextFiles = parseContextFiles(o.contextFiles);
589
682
  const packageRegistries = parsePackageRegistries(o.packageRegistries);
683
+ const skill = parseSkillSpec(o.skill);
590
684
  const testSecrets = parseTestSecrets(o.testSecrets);
591
685
  const guardLimits = parseGuardLimits(o.guardLimits);
686
+ const validation = parseValidationSpec(o.validation);
592
687
  const job = {
593
688
  jobId: str(o.jobId, 'jobId'),
594
689
  mode,
@@ -608,6 +703,7 @@ export function parseAgentJob(input) {
608
703
  ...(output ? { output } : {}),
609
704
  ...(contextFiles.length ? { contextFiles } : {}),
610
705
  ...(packageRegistries.length ? { packageRegistries } : {}),
706
+ ...(skill ? { skill } : {}),
611
707
  ...(testSecrets.length ? { testSecrets } : {}),
612
708
  ...(infra ? { infra } : {}),
613
709
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
@@ -623,6 +719,7 @@ export function parseAgentJob(input) {
623
719
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
624
720
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
625
721
  ...(guardLimits ? { guardLimits } : {}),
722
+ ...(validation ? { validation } : {}),
626
723
  };
627
724
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
628
725
  if (job.githubApiBase)
@@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { log } from './logger.js';
5
- import { CONTEXT_DIR, materializeContextFiles, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
5
+ import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
6
6
  import { runSubscriptionHarness } from './agent-runner.js';
7
7
  // The thin base every container agent shares: an ephemeral working directory, and
8
8
  // one Pi run inside it driven by the harness-written context. The agents differ in
@@ -117,6 +117,13 @@ export async function runAgentInWorkspace(spec, opts = {}) {
117
117
  // harness paths; kept out of the agent's commits via a local git exclude entry.
118
118
  const contextFiles = spec.contextFiles ?? [];
119
119
  await materializeContextFiles(spec.dir, contextFiles);
120
+ // Repo-sourced skill (slice 2): claude-code installs it natively (written by the runner into the
121
+ // config dir), so it reads from there. Every other harness (Pi/codex) reads the checkout, so
122
+ // materialise the skill's resources under `.cat-context/skill/` (its instructions are folded
123
+ // into the prompt by the backend). A resource-free skill is a no-op here.
124
+ if (spec.skill && spec.harness !== 'claude-code') {
125
+ await materializeSkillResources(spec.dir, spec.skill);
126
+ }
120
127
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
121
128
  // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
122
129
  // system prompt is passed straight to the CLI; everything around this (clone,
@@ -135,6 +142,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
135
142
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
136
143
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
137
144
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
145
+ ...(spec.skill ? { skill: spec.skill } : {}),
138
146
  signal: opts.signal,
139
147
  onActivity: opts.onActivity,
140
148
  onProgress: opts.onProgress,
package/dist/pi.js CHANGED
@@ -206,6 +206,36 @@ export async function materializeContextFiles(cwd, files) {
206
206
  // No writable .git/info; the files simply stay untracked (still not auto-added on most flows).
207
207
  }
208
208
  }
209
+ /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
210
+ export const SKILL_CONTEXT_SUBDIR = 'skill';
211
+ /**
212
+ * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
213
+ * (repo-sourced Claude Skills, slice 2) — the Pi/codex path, whose agents read the checkout rather
214
+ * than a native `~/.claude/skills` dir (the skill's instructions are folded into their prompt by
215
+ * the backend). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
216
+ * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
217
+ * exclude entry. A skill with no resource bodies is a no-op.
218
+ */
219
+ export async function materializeSkillResources(cwd, skill) {
220
+ if (!skill.resources.length)
221
+ return;
222
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR);
223
+ await mkdir(dir, { recursive: true });
224
+ for (const r of skill.resources) {
225
+ const dest = join(dir, r.relPath);
226
+ await mkdir(dirname(dest), { recursive: true });
227
+ await writeFile(dest, r.content, 'utf8');
228
+ }
229
+ const gitRoot = await findGitRoot(cwd);
230
+ if (!gitRoot)
231
+ return;
232
+ try {
233
+ await appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8');
234
+ }
235
+ catch {
236
+ // No writable .git/info; the files simply stay untracked.
237
+ }
238
+ }
209
239
  /** Walk up from `dir` (bounded) to the directory containing a `.git` folder, or null. */
210
240
  async function findGitRoot(dir) {
211
241
  let current = dir;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.43.8",
3
+ "version": "1.47.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.29",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.122.0",
30
- "@cat-factory/spend": "0.12.34"
29
+ "@cat-factory/server": "0.129.0",
30
+ "@cat-factory/spend": "0.12.41"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
5
  import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
6
6
  import { killChildProcess, spawnDetached } from './process.js'
7
7
  import { redact, secretsToRedact } from './redact.js'
@@ -52,6 +52,18 @@ export interface SubscriptionRunOptions {
52
52
  * container.
53
53
  */
54
54
  ambientAuth?: boolean
55
+ /**
56
+ * A repo-sourced Claude Skill to install natively before launch (repo-sourced Claude Skills,
57
+ * slice 2). The claude-code runner writes it to `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md`
58
+ * (+ resource files) so the CLI loads it; the codex runner ignores it (codex reads the
59
+ * checkout's `.cat-context/skill/`, materialised by the caller). Absent ⇒ no skill installed.
60
+ */
61
+ skill?: {
62
+ name: string
63
+ description: string
64
+ instructions: string
65
+ resources: { relPath: string; content: string }[]
66
+ }
55
67
  /** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
56
68
  signal?: AbortSignal
57
69
  /** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
@@ -202,6 +214,35 @@ function streamCli(
202
214
  * `TodoWrite` tool calls onto subtask progress and the terminal `result` event
203
215
  * onto the summary + usage.
204
216
  */
217
+ /**
218
+ * Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
219
+ * `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
220
+ * expects) plus every resource file at its path within the skill directory. Resource sub-paths
221
+ * were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
222
+ *
223
+ * The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
224
+ * scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
225
+ * or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
226
+ * would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
227
+ * valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
228
+ */
229
+ async function writeNativeSkill(
230
+ skillsRoot: string,
231
+ skill: NonNullable<SubscriptionRunOptions['skill']>,
232
+ ): Promise<void> {
233
+ const dir = join(skillsRoot, skill.name)
234
+ await mkdir(dir, { recursive: true })
235
+ const name = JSON.stringify(skill.name)
236
+ const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '))
237
+ const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`
238
+ await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8')
239
+ for (const resource of skill.resources) {
240
+ const dest = join(dir, resource.relPath)
241
+ await mkdir(dirname(dest), { recursive: true })
242
+ await writeFile(dest, resource.content, 'utf8')
243
+ }
244
+ }
245
+
205
246
  export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
206
247
  const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
207
248
  let summary = ''
@@ -297,6 +338,17 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
297
338
  ).catch(() => {})
298
339
  }
299
340
 
341
+ // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
342
+ // `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
343
+ // config home when present, else the developer's `~/.claude` (ambient/native mode). Best-effort:
344
+ // a write failure must not wedge the run — the prompt still names the skill.
345
+ if (opts.skill) {
346
+ const skillsRoot = configHome
347
+ ? join(configHome, 'skills')
348
+ : join(homedir(), '.claude', 'skills')
349
+ await writeNativeSkill(skillsRoot, opts.skill).catch(() => {})
350
+ }
351
+
300
352
  // Anthropic itself authenticates with the subscription OAuth token; a
301
353
  // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
302
354
  // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
package/src/agent.ts CHANGED
@@ -797,6 +797,19 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
797
797
  })
798
798
  }
799
799
 
800
+ /**
801
+ * Whether a Ralph iteration ({@link AgentJob.validation} set) landed on a MULTI-REPO job (writable
802
+ * peer repos or read-only reference repos). The post-commit validation command is only wired into
803
+ * the single-repo flow, so a multi-repo run would silently skip it and degenerate the loop into a
804
+ * one-shot with no completion gate — multi-repo ralph is out of scope for v1 (see
805
+ * backend/docs/ralph-loop.md), so {@link runCodingMode} fails loudly on this instead.
806
+ */
807
+ export function ralphUnsupportedOnMultiRepo(
808
+ job: Pick<AgentJob, 'validation' | 'peerRepos' | 'referenceRepos'>,
809
+ ): boolean {
810
+ return Boolean(job.validation) && Boolean(job.peerRepos?.length || job.referenceRepos?.length)
811
+ }
812
+
800
813
  /**
801
814
  * Edit-and-push coding, dispatching on job DATA: repo-bootstrap (force-push a fresh history to a
802
815
  * separate target repo), conflict-resolution (merge the base in, resolve, push back), multi-repo
@@ -819,10 +832,22 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
819
832
  // all of them. Keyed off job DATA, not the agent kind — set for the implementer's writable
820
833
  // peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
821
834
  // reference repos (`referenceRepos`, cloned but never pushed).
822
- const result =
823
- job.peerRepos?.length || job.referenceRepos?.length
824
- ? await runMultiRepoCoding(job, opts)
825
- : await runSingleRepoCoding(job, opts)
835
+ const multiRepo = Boolean(job.peerRepos?.length || job.referenceRepos?.length)
836
+ // Ralph loop (v1): the post-commit validation command is only wired into the single-repo
837
+ // flow, so a multi-repo run would silently skip it and the loop would degenerate into a
838
+ // one-shot with no completion gate. Multi-repo ralph is deliberately out of scope for v1
839
+ // (see backend/docs/ralph-loop.md), so FAIL LOUDLY rather than run a validation-less pass.
840
+ if (ralphUnsupportedOnMultiRepo(job)) {
841
+ return {
842
+ error:
843
+ 'Ralph loop is not supported on a multi-repo task (connected service repos). ' +
844
+ 'Its validation command runs only in the single primary-repo checkout. ' +
845
+ 'Run the Ralph loop on a task scoped to a single repo.',
846
+ }
847
+ }
848
+ const result = multiRepo
849
+ ? await runMultiRepoCoding(job, opts)
850
+ : await runSingleRepoCoding(job, opts)
826
851
 
827
852
  // Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
828
853
  // backend post-completion resolver records the outcome. Skipped on a failed run (its `error`
@@ -843,34 +868,51 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
843
868
  */
844
869
  async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
845
870
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch
846
- const { summary, stats, stderrTail, pushed, usage, callMetrics } = await runCodingAgent(
847
- {
848
- kind: 'agent',
849
- jobId: job.jobId,
850
- repo: job.repo,
851
- cloneBranch: job.branch,
852
- ...(job.newBranch ? { newBranch: job.newBranch } : {}),
853
- pushBranch,
854
- ghToken: job.ghToken,
855
- systemPrompt: job.systemPrompt,
856
- userPrompt: job.userPrompt,
857
- model: job.model,
858
- harness: job.harness,
859
- subscriptionToken: job.subscriptionToken,
860
- subscriptionBaseUrl: job.subscriptionBaseUrl,
861
- ambientAuth: job.ambientAuth,
862
- proxyBaseUrl: job.proxyBaseUrl,
863
- sessionToken: job.sessionToken,
864
- commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
865
- webToolsGuidance: job.webToolsGuidance,
866
- webSearchProxy: job.webSearch,
867
- guardLimits: job.guardLimits,
868
- ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
869
- ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
870
- ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
871
- },
872
- opts,
873
- )
871
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } =
872
+ await runCodingAgent(
873
+ {
874
+ kind: 'agent',
875
+ jobId: job.jobId,
876
+ repo: job.repo,
877
+ cloneBranch: job.branch,
878
+ ...(job.newBranch ? { newBranch: job.newBranch } : {}),
879
+ pushBranch,
880
+ ghToken: job.ghToken,
881
+ systemPrompt: job.systemPrompt,
882
+ userPrompt: job.userPrompt,
883
+ model: job.model,
884
+ harness: job.harness,
885
+ subscriptionToken: job.subscriptionToken,
886
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
887
+ ambientAuth: job.ambientAuth,
888
+ proxyBaseUrl: job.proxyBaseUrl,
889
+ sessionToken: job.sessionToken,
890
+ commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
891
+ webToolsGuidance: job.webToolsGuidance,
892
+ webSearchProxy: job.webSearch,
893
+ guardLimits: job.guardLimits,
894
+ ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
895
+ ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
896
+ ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
897
+ // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
898
+ ...(job.skill ? { skill: job.skill } : {}),
899
+ // Ralph loop: run the completion command after the agent commits and report its verdict.
900
+ ...(job.validation
901
+ ? {
902
+ validation: {
903
+ command: job.validation.command,
904
+ ...(job.validation.iteration !== undefined
905
+ ? { iteration: job.validation.iteration }
906
+ : {}),
907
+ },
908
+ }
909
+ : {}),
910
+ },
911
+ opts,
912
+ )
913
+ // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
914
+ // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
915
+ const ralphVerdict = validation ? { ralphVerdict: validation } : {}
874
916
 
875
917
  if (!pushed) {
876
918
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
@@ -882,6 +924,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
882
924
  stats,
883
925
  ...(usage ? { usage } : {}),
884
926
  ...(callMetrics ? { callMetrics } : {}),
927
+ ...ralphVerdict,
885
928
  }
886
929
  }
887
930
  return {
@@ -951,6 +994,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
951
994
  stats,
952
995
  ...(usage ? { usage } : {}),
953
996
  ...(callMetrics ? { callMetrics } : {}),
997
+ ...ralphVerdict,
954
998
  }
955
999
  }
956
1000
  return {
@@ -960,6 +1004,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
960
1004
  stats,
961
1005
  ...(usage ? { usage } : {}),
962
1006
  ...(callMetrics ? { callMetrics } : {}),
1007
+ ...ralphVerdict,
963
1008
  }
964
1009
  }
965
1010
 
@@ -1,5 +1,8 @@
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
6
  import type {
4
7
  AgentJob,
5
8
  AgentResult,
@@ -7,6 +10,7 @@ import type {
7
10
  PeerRepoSpec,
8
11
  ReferenceRepoSpec,
9
12
  RepoSpec,
13
+ SkillSpec,
10
14
  } from './job.js'
11
15
  import {
12
16
  branchAheadOfBase,
@@ -36,7 +40,7 @@ import {
36
40
  } from './pi-workspace.js'
37
41
  import type { ProgressGuardLimits } from './pi.js'
38
42
  import type { RunOptions } from './runner.js'
39
- import { log } from './logger.js'
43
+ import { log, type Logger } from './logger.js'
40
44
 
41
45
  // The shared skeleton for the container coding agents that clone a repo, run Pi
42
46
  // against it and push the result on a branch. The implementation (`/run`) and
@@ -92,6 +96,18 @@ export interface CodingAgentSpec extends HarnessAuthFields {
92
96
  * them. Best-effort per branch. Absent/empty ⇒ none fetched.
93
97
  */
94
98
  referenceBranches?: string[]
99
+ /**
100
+ * Ralph loop: run this programmatic completion command in the checkout AFTER the agent
101
+ * commits + pushes, capturing its exit code + a bounded output tail (the loop's exit
102
+ * condition — computed by the harness, never the model). Absent for every non-`ralph` run.
103
+ */
104
+ validation?: { command: string; iteration?: number }
105
+ /**
106
+ * A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
107
+ * into {@link runAgentInWorkspace}, which installs it harness-aware (native `~/.claude/skills`
108
+ * for claude-code, `.cat-context/skill/` for Pi/codex). Absent ⇒ no skill.
109
+ */
110
+ skill?: SkillSpec
95
111
  }
96
112
 
97
113
  /** The outcome of a coding agent run, before each caller maps it to its own result shape. */
@@ -107,6 +123,17 @@ export interface CodingAgentOutcome {
107
123
  usage?: { inputTokens: number; outputTokens: number }
108
124
  /** Per-model-call telemetry from a subscription harness's CLI stream (absent for Pi). */
109
125
  callMetrics?: HarnessCallMetric[]
126
+ /**
127
+ * Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
128
+ * exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
129
+ * was set. The exit code is the loop's authoritative completion signal.
130
+ */
131
+ validation?: {
132
+ validationPassed: boolean
133
+ exitCode: number
134
+ validationOutputTail?: string
135
+ iteration?: number
136
+ }
110
137
  }
111
138
 
112
139
  /**
@@ -350,6 +377,7 @@ export async function runCodingAgent(
350
377
  webToolsGuidance: spec.webToolsGuidance,
351
378
  webSearchProxy: spec.webSearchProxy,
352
379
  guardLimits: spec.guardLimits,
380
+ ...(spec.skill ? { skill: spec.skill } : {}),
353
381
  },
354
382
  opts,
355
383
  )
@@ -425,6 +453,14 @@ export async function runCodingAgent(
425
453
  ...(callMetrics ? { callMetrics } : {}),
426
454
  }
427
455
  }
456
+
457
+ // Ralph loop: run the programmatic completion command against the pushed/committed
458
+ // state and attach its verdict (exit code = the loop's authoritative done signal).
459
+ // Runs regardless of whether this pass pushed — a no-op iteration must still be able
460
+ // to report that the criterion is (already) met. The harness runs it, never the model.
461
+ if (spec.validation) {
462
+ outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
463
+ }
428
464
  } finally {
429
465
  // Safety net for the throw path (the happy path already cleared these above).
430
466
  clearInterval(checkpoint)
@@ -435,6 +471,94 @@ export async function runCodingAgent(
435
471
  )
436
472
  }
437
473
 
474
+ /**
475
+ * The Ralph-loop validation watchdog: the longest a completion command may run before it is
476
+ * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
477
+ * Overridable via env for tests; defaults to 15 minutes.
478
+ */
479
+ function ralphValidationTimeoutMs(): number {
480
+ const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS)
481
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
482
+ }
483
+
484
+ /**
485
+ * Ralph loop: run the programmatic completion command in the checkout and return its exit
486
+ * code plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
487
+ * done signal (0 = the criterion is met) — computed here by the harness, never self-reported
488
+ * by the model, which is the whole point of a programmatic exit condition. Runs
489
+ * `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
490
+ * command counts as a failure so the loop is never blocked), and an aborted run resolves to a
491
+ * non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
492
+ * boundary as the coding agent) — there is no host/backend execution.
493
+ */
494
+ async function runRalphValidation(
495
+ cwd: string,
496
+ validation: { command: string; iteration?: number },
497
+ logger: Logger,
498
+ opts: RunOptions,
499
+ ): Promise<{
500
+ validationPassed: boolean
501
+ exitCode: number
502
+ validationOutputTail?: string
503
+ iteration?: number
504
+ }> {
505
+ const timeoutMs = ralphValidationTimeoutMs()
506
+ logger.info('coding-agent(ralph): running validation command', {
507
+ iteration: validation.iteration,
508
+ })
509
+ return new Promise((resolve) => {
510
+ let out = ''
511
+ let settled = false
512
+ const child = spawn('sh', ['-c', validation.command], {
513
+ cwd,
514
+ detached: spawnDetached,
515
+ stdio: ['ignore', 'pipe', 'pipe'],
516
+ })
517
+ // Keep only the tail; guard against unbounded buffering on a chatty command.
518
+ const capture = (chunk: Buffer): void => {
519
+ out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS)
520
+ }
521
+ child.stdout?.on('data', capture)
522
+ child.stderr?.on('data', capture)
523
+ const finish = (exitCode: number): void => {
524
+ if (settled) return
525
+ settled = true
526
+ clearTimeout(timer)
527
+ opts.signal?.removeEventListener('abort', onAbort)
528
+ const trimmed = out.trim()
529
+ const tail = trimmed ? redactSecrets(trimmed) : undefined
530
+ logger.info('coding-agent(ralph): validation finished', {
531
+ exitCode,
532
+ iteration: validation.iteration,
533
+ })
534
+ resolve({
535
+ validationPassed: exitCode === 0,
536
+ exitCode,
537
+ ...(tail ? { validationOutputTail: tail } : {}),
538
+ ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
539
+ })
540
+ }
541
+ const timer = setTimeout(() => {
542
+ logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs })
543
+ killChildProcess(child, undefined, logger)
544
+ finish(124) // conventional timeout exit code (a non-zero fail)
545
+ }, timeoutMs)
546
+ timer.unref?.()
547
+ const onAbort = (): void => {
548
+ killChildProcess(child, undefined, logger)
549
+ finish(130) // aborted (a non-zero fail)
550
+ }
551
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
552
+ child.on('error', (err) => {
553
+ logger.warn('coding-agent(ralph): validation command failed to spawn', {
554
+ error: err instanceof Error ? err.message : String(err),
555
+ })
556
+ finish(127) // spawn error / command not found (a non-zero fail)
557
+ })
558
+ child.on('close', (code) => finish(code ?? 1))
559
+ })
560
+ }
561
+
438
562
  /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
439
563
  export function safeDirSegment(value: string): string {
440
564
  return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
package/src/job.ts CHANGED
@@ -153,6 +153,26 @@ function parseGuardLimits(value: unknown): GuardLimitsSpec | undefined {
153
153
  return Object.keys(spec).length > 0 ? spec : undefined
154
154
  }
155
155
 
156
+ /**
157
+ * Parse the optional Ralph-loop validation spec. Requires a non-empty `command` string (the
158
+ * completion criterion the harness runs); `progressPath`/`iteration` are optional metadata.
159
+ * Returns undefined when absent or malformed (a coding run then behaves like any other — no
160
+ * post-commit validation). See {@link ValidationSpec}.
161
+ */
162
+ function parseValidationSpec(value: unknown): ValidationSpec | undefined {
163
+ if (typeof value !== 'object' || value === null) return undefined
164
+ const o = value as Record<string, unknown>
165
+ if (typeof o.command !== 'string' || o.command.trim() === '') return undefined
166
+ const iteration = posInt(o.iteration)
167
+ return {
168
+ command: o.command,
169
+ ...(typeof o.progressPath === 'string' && o.progressPath
170
+ ? { progressPath: o.progressPath }
171
+ : {}),
172
+ ...(iteration !== undefined ? { iteration } : {}),
173
+ }
174
+ }
175
+
156
176
  /**
157
177
  * Parse the shared per-job auth fields, validating per harness: a subscription
158
178
  * harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
@@ -601,6 +621,26 @@ export interface ContextFileSpec {
601
621
  content: string
602
622
  }
603
623
 
624
+ /** One materialisable resource file of a skill (repo-sourced Claude Skills). */
625
+ export interface SkillResourceSpec {
626
+ /** Path within the skill directory, e.g. `templates/report.md` (subdirs preserved, no traversal). */
627
+ relPath: string
628
+ content: string
629
+ }
630
+
631
+ /**
632
+ * A repo-sourced Claude Skill to make available for a `skill` step. Materialised HARNESS-AWARE:
633
+ * `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resources) for the claude-code CLI to load
634
+ * natively, or `.cat-context/skill/<relPath>` for the Pi/codex checkout (their prompt carries the
635
+ * instructions). A dedicated top-level body field (like `packageRegistries`), never a context file.
636
+ */
637
+ export interface SkillSpec {
638
+ name: string
639
+ description: string
640
+ instructions: string
641
+ resources: SkillResourceSpec[]
642
+ }
643
+
604
644
  /** How an explore agent's reply is consumed. */
605
645
  export interface AgentOutputSpec {
606
646
  /** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
@@ -627,6 +667,23 @@ export interface AgentOutputSpec {
627
667
  * RUNNING — no agent runs and the serve is deliberately not torn down when the job returns
628
668
  * (see {@link AgentResult.preview}).
629
669
  */
670
+ /**
671
+ * Coding mode (Ralph loop): the programmatic completion criterion. After the coding agent
672
+ * commits + pushes, the harness runs {@link command} in the checkout and reports its exit
673
+ * code back on {@link AgentResult.ralphVerdict} — exit 0 means the loop is done. This is the
674
+ * whole point of a Ralph loop's exit condition being a REAL check: the harness runs it, not
675
+ * the model. The command runs only inside the sandboxed run container (same trust boundary
676
+ * as the coding agent). Absent for every non-`ralph` coding run.
677
+ */
678
+ export interface ValidationSpec {
679
+ /** The shell command the harness runs against the checkout (exit 0 = the criterion is met). */
680
+ command: string
681
+ /** Repo-relative progress-log path the agent maintains (informational; the harness doesn't write it). */
682
+ progressPath?: string
683
+ /** 1-based iteration number, echoed back on the verdict for the engine's attempt log. */
684
+ iteration?: number
685
+ }
686
+
630
687
  export interface AgentJob extends HarnessAuthFields {
631
688
  jobId: string
632
689
  mode: AgentMode
@@ -670,6 +727,12 @@ export interface AgentJob extends HarnessAuthFields {
670
727
  * job on a reused container is removed.
671
728
  */
672
729
  packageRegistries?: PackageRegistrySpec[]
730
+ /**
731
+ * A repo-sourced Claude Skill to make available for a `skill` step (see {@link SkillSpec}).
732
+ * Materialised harness-aware before the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/`
733
+ * for claude-code, or `.cat-context/skill/<relPath>` for Pi/codex. Absent ⇒ no skill installed.
734
+ */
735
+ skill?: SkillSpec
673
736
  /**
674
737
  * Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
675
738
  * band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
@@ -753,6 +816,11 @@ export interface AgentJob extends HarnessAuthFields {
753
816
  * killed for a kind's normal working pattern. Absent ⇒ env/default for all knobs.
754
817
  */
755
818
  guardLimits?: GuardLimitsSpec
819
+ /**
820
+ * Coding mode (Ralph loop): the programmatic completion command the harness runs after the
821
+ * agent commits + pushes. Present only for a `ralph` iteration. See {@link ValidationSpec}.
822
+ */
823
+ validation?: ValidationSpec
756
824
  }
757
825
 
758
826
  /** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
@@ -809,6 +877,18 @@ export interface AgentResult {
809
877
  pushed?: boolean
810
878
  prUrl?: string
811
879
  branch?: string
880
+ /**
881
+ * Coding mode (Ralph loop): the harness-computed verdict of the post-commit validation
882
+ * command — whether it exited 0, its exit code, and a bounded, redacted output tail. The
883
+ * engine reads this (never a model self-report) to decide whether the loop is done or must
884
+ * iterate again. Present only for a `ralph` iteration ({@link AgentJob.validation} set).
885
+ */
886
+ ralphVerdict?: {
887
+ validationPassed: boolean
888
+ exitCode: number
889
+ validationOutputTail?: string
890
+ iteration?: number
891
+ }
812
892
  /**
813
893
  * Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
814
894
  * repo the run actually changed (service-connections phase 3). Beside the own-service
@@ -889,6 +969,71 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
889
969
  return files
890
970
  }
891
971
 
972
+ /**
973
+ * Sanitize a skill resource's relative path: keep the subdirectory structure (so
974
+ * `templates/report.md` materialises nested) but reject anything that could escape the skill
975
+ * directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
976
+ * for an unsafe path (the resource is then dropped).
977
+ */
978
+ function sanitizeSkillRelPath(value: unknown): string | undefined {
979
+ if (typeof value !== 'string') return undefined
980
+ const segments = value.replace(/\\/g, '/').split('/')
981
+ const clean: string[] = []
982
+ for (const seg of segments) {
983
+ if (seg === '' || seg === '.') continue
984
+ if (seg === '..') return undefined
985
+ // Same character class as a context-file name, per segment.
986
+ const c = seg.replace(/[^A-Za-z0-9._-]/g, '')
987
+ if (!c || c === '.' || c === '..' || c.startsWith('.')) return undefined
988
+ clean.push(c)
989
+ }
990
+ return clean.length ? clean.join('/') : undefined
991
+ }
992
+
993
+ /**
994
+ * Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
995
+ * purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
996
+ * default keeps the skill installable rather than dropping it — which, on the claude-code path,
997
+ * would leave the prompt pointing at a skill that was never installed (a blind run).
998
+ */
999
+ const FALLBACK_SKILL_NAME = 'skill'
1000
+
1001
+ /** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
1002
+ function sanitizeSkillName(value: unknown): string | undefined {
1003
+ if (typeof value !== 'string') return undefined
1004
+ const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
1005
+ const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
1006
+ if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
1007
+ return cleaned
1008
+ }
1009
+
1010
+ /** Validate the optional `skill` field, or undefined when absent/malformed. */
1011
+ function parseSkillSpec(value: unknown): SkillSpec | undefined {
1012
+ if (typeof value !== 'object' || value === null) return undefined
1013
+ const o = value as Record<string, unknown>
1014
+ const instructions = typeof o.instructions === 'string' ? o.instructions : undefined
1015
+ // No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
1016
+ // folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
1017
+ // directory, so fall back to a safe default rather than dropping the whole skill.
1018
+ if (!instructions) return undefined
1019
+ const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME
1020
+ const description = typeof o.description === 'string' ? o.description : ''
1021
+ const resources: SkillResourceSpec[] = []
1022
+ if (Array.isArray(o.resources)) {
1023
+ const used = new Set<string>()
1024
+ for (const entry of o.resources) {
1025
+ if (typeof entry !== 'object' || entry === null) continue
1026
+ const e = entry as Record<string, unknown>
1027
+ const relPath = sanitizeSkillRelPath(e.relPath)
1028
+ if (!relPath || used.has(relPath)) continue
1029
+ if (typeof e.content !== 'string') continue
1030
+ used.add(relPath)
1031
+ resources.push({ relPath, content: e.content })
1032
+ }
1033
+ }
1034
+ return { name, description, instructions, resources }
1035
+ }
1036
+
892
1037
  /** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
893
1038
  function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
894
1039
  if (typeof value !== 'object' || value === null) return undefined
@@ -1128,8 +1273,10 @@ export function parseAgentJob(input: unknown): AgentJob {
1128
1273
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
1129
1274
  const contextFiles = parseContextFiles(o.contextFiles)
1130
1275
  const packageRegistries = parsePackageRegistries(o.packageRegistries)
1276
+ const skill = parseSkillSpec(o.skill)
1131
1277
  const testSecrets = parseTestSecrets(o.testSecrets)
1132
1278
  const guardLimits = parseGuardLimits(o.guardLimits)
1279
+ const validation = parseValidationSpec(o.validation)
1133
1280
  const job: AgentJob = {
1134
1281
  jobId: str(o.jobId, 'jobId'),
1135
1282
  mode,
@@ -1149,6 +1296,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1149
1296
  ...(output ? { output } : {}),
1150
1297
  ...(contextFiles.length ? { contextFiles } : {}),
1151
1298
  ...(packageRegistries.length ? { packageRegistries } : {}),
1299
+ ...(skill ? { skill } : {}),
1152
1300
  ...(testSecrets.length ? { testSecrets } : {}),
1153
1301
  ...(infra ? { infra } : {}),
1154
1302
  ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
@@ -1164,6 +1312,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1164
1312
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
1165
1313
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
1166
1314
  ...(guardLimits ? { guardLimits } : {}),
1315
+ ...(validation ? { validation } : {}),
1167
1316
  }
1168
1317
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
1169
1318
  if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
@@ -1,7 +1,7 @@
1
1
  import { mkdir, mkdtemp, rm } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
- import type { RepoSpec } from './job.js'
4
+ import type { RepoSpec, SkillSpec } from './job.js'
5
5
  import { log } from './logger.js'
6
6
  import {
7
7
  type ContextFileInfo,
@@ -11,6 +11,7 @@ import {
11
11
  type RunDiagnostics,
12
12
  CONTEXT_DIR,
13
13
  materializeContextFiles,
14
+ materializeSkillResources,
14
15
  mergeGuardLimits,
15
16
  progressGuardLimitsFromEnv,
16
17
  runPi,
@@ -201,6 +202,13 @@ export interface AgentRunSpec {
201
202
  * from AGENTS.md, so the agent reads them on demand. Absent ⇒ none.
202
203
  */
203
204
  contextFiles?: ContextFileInfo[]
205
+ /**
206
+ * A repo-sourced Claude Skill to make available for this run (slice 2). Installed HARNESS-AWARE:
207
+ * the claude-code runner writes it natively into the config dir's `skills/`; for Pi/codex the
208
+ * resource files are materialised under `.cat-context/skill/` (their prompt already carries the
209
+ * folded-in instructions). Absent ⇒ no skill.
210
+ */
211
+ skill?: SkillSpec
204
212
  /**
205
213
  * Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
206
214
  * backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
@@ -232,6 +240,13 @@ export async function runAgentInWorkspace(
232
240
  // harness paths; kept out of the agent's commits via a local git exclude entry.
233
241
  const contextFiles = spec.contextFiles ?? []
234
242
  await materializeContextFiles(spec.dir, contextFiles)
243
+ // Repo-sourced skill (slice 2): claude-code installs it natively (written by the runner into the
244
+ // config dir), so it reads from there. Every other harness (Pi/codex) reads the checkout, so
245
+ // materialise the skill's resources under `.cat-context/skill/` (its instructions are folded
246
+ // into the prompt by the backend). A resource-free skill is a no-op here.
247
+ if (spec.skill && spec.harness !== 'claude-code') {
248
+ await materializeSkillResources(spec.dir, spec.skill)
249
+ }
235
250
 
236
251
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
237
252
  // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
@@ -251,6 +266,7 @@ export async function runAgentInWorkspace(
251
266
  ...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
252
267
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
253
268
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
269
+ ...(spec.skill ? { skill: spec.skill } : {}),
254
270
  signal: opts.signal,
255
271
  onActivity: opts.onActivity,
256
272
  onProgress: opts.onProgress,
package/src/pi.ts CHANGED
@@ -244,6 +244,38 @@ export async function materializeContextFiles(
244
244
  }
245
245
  }
246
246
 
247
+ /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
248
+ export const SKILL_CONTEXT_SUBDIR = 'skill'
249
+
250
+ /**
251
+ * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
252
+ * (repo-sourced Claude Skills, slice 2) — the Pi/codex path, whose agents read the checkout rather
253
+ * than a native `~/.claude/skills` dir (the skill's instructions are folded into their prompt by
254
+ * the backend). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
255
+ * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
256
+ * exclude entry. A skill with no resource bodies is a no-op.
257
+ */
258
+ export async function materializeSkillResources(
259
+ cwd: string,
260
+ skill: { resources: { relPath: string; content: string }[] },
261
+ ): Promise<void> {
262
+ if (!skill.resources.length) return
263
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR)
264
+ await mkdir(dir, { recursive: true })
265
+ for (const r of skill.resources) {
266
+ const dest = join(dir, r.relPath)
267
+ await mkdir(dirname(dest), { recursive: true })
268
+ await writeFile(dest, r.content, 'utf8')
269
+ }
270
+ const gitRoot = await findGitRoot(cwd)
271
+ if (!gitRoot) return
272
+ try {
273
+ await appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8')
274
+ } catch {
275
+ // No writable .git/info; the files simply stay untracked.
276
+ }
277
+ }
278
+
247
279
  /** Walk up from `dir` (bounded) to the directory containing a `.git` folder, or null. */
248
280
  async function findGitRoot(dir: string): Promise<string | null> {
249
281
  let current = dir