@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.
- package/dist/agent-runner.js +38 -3
- package/dist/agent.js +43 -2
- package/dist/coding-agent.js +88 -0
- package/dist/job.js +97 -0
- package/dist/pi-workspace.js +9 -1
- package/dist/pi.js +30 -0
- package/package.json +3 -3
- package/src/agent-runner.ts +55 -3
- package/src/agent.ts +77 -32
- package/src/coding-agent.ts +125 -1
- package/src/job.ts +149 -0
- package/src/pi-workspace.ts +17 -1
- package/src/pi.ts +32 -0
package/dist/agent-runner.js
CHANGED
|
@@ -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
|
|
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
|
/**
|
package/dist/coding-agent.js
CHANGED
|
@@ -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)
|
package/dist/pi-workspace.js
CHANGED
|
@@ -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.
|
|
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.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
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",
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
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 } =
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
|
package/src/coding-agent.ts
CHANGED
|
@@ -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')
|
package/src/pi-workspace.ts
CHANGED
|
@@ -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
|