@cat-factory/executor-harness 1.45.0 → 1.48.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-runner.js +38 -3
- package/dist/agent.js +2 -0
- package/dist/coding-agent.js +1 -0
- package/dist/job.js +74 -0
- package/dist/pi-workspace.js +9 -1
- package/dist/pi.js +30 -0
- package/package.json +5 -5
- package/src/agent-runner.ts +55 -3
- package/src/agent.ts +2 -0
- package/src/coding-agent.ts +8 -0
- package/src/job.ts +93 -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
|
@@ -746,6 +746,8 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
746
746
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
747
747
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
748
748
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
749
|
+
// Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
|
|
750
|
+
...(job.skill ? { skill: job.skill } : {}),
|
|
749
751
|
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
750
752
|
...(job.validation
|
|
751
753
|
? {
|
package/dist/coding-agent.js
CHANGED
|
@@ -230,6 +230,7 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
230
230
|
webToolsGuidance: spec.webToolsGuidance,
|
|
231
231
|
webSearchProxy: spec.webSearchProxy,
|
|
232
232
|
guardLimits: spec.guardLimits,
|
|
233
|
+
...(spec.skill ? { skill: spec.skill } : {}),
|
|
233
234
|
}, opts);
|
|
234
235
|
// Stop tailing the follow-up sentinel and flush any items written after the last
|
|
235
236
|
// tick, so a fast final burst still reaches the job view before the run is recorded.
|
package/dist/job.js
CHANGED
|
@@ -409,6 +409,78 @@ function parseContextFiles(value) {
|
|
|
409
409
|
}
|
|
410
410
|
return files;
|
|
411
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
|
+
}
|
|
412
484
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
413
485
|
function parseAgentInfraSpec(value) {
|
|
414
486
|
if (typeof value !== 'object' || value === null)
|
|
@@ -608,6 +680,7 @@ export function parseAgentJob(input) {
|
|
|
608
680
|
const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
|
|
609
681
|
const contextFiles = parseContextFiles(o.contextFiles);
|
|
610
682
|
const packageRegistries = parsePackageRegistries(o.packageRegistries);
|
|
683
|
+
const skill = parseSkillSpec(o.skill);
|
|
611
684
|
const testSecrets = parseTestSecrets(o.testSecrets);
|
|
612
685
|
const guardLimits = parseGuardLimits(o.guardLimits);
|
|
613
686
|
const validation = parseValidationSpec(o.validation);
|
|
@@ -630,6 +703,7 @@ export function parseAgentJob(input) {
|
|
|
630
703
|
...(output ? { output } : {}),
|
|
631
704
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
632
705
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
706
|
+
...(skill ? { skill } : {}),
|
|
633
707
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
634
708
|
...(infra ? { infra } : {}),
|
|
635
709
|
...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
|
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.48.1",
|
|
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",
|
|
@@ -21,13 +21,13 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"@hono/node-server": "^2.0.
|
|
24
|
+
"@hono/node-server": "^2.0.10",
|
|
25
25
|
"@types/node": "^26.1.1",
|
|
26
|
-
"hono": "^4.12.
|
|
26
|
+
"hono": "^4.12.30",
|
|
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.137.2",
|
|
30
|
+
"@cat-factory/spend": "0.12.52"
|
|
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
|
@@ -894,6 +894,8 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
894
894
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
895
895
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
896
896
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
897
|
+
// Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
|
|
898
|
+
...(job.skill ? { skill: job.skill } : {}),
|
|
897
899
|
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
898
900
|
...(job.validation
|
|
899
901
|
? {
|
package/src/coding-agent.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
PeerRepoSpec,
|
|
11
11
|
ReferenceRepoSpec,
|
|
12
12
|
RepoSpec,
|
|
13
|
+
SkillSpec,
|
|
13
14
|
} from './job.js'
|
|
14
15
|
import {
|
|
15
16
|
branchAheadOfBase,
|
|
@@ -101,6 +102,12 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
101
102
|
* condition — computed by the harness, never the model). Absent for every non-`ralph` run.
|
|
102
103
|
*/
|
|
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
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
/** The outcome of a coding agent run, before each caller maps it to its own result shape. */
|
|
@@ -370,6 +377,7 @@ export async function runCodingAgent(
|
|
|
370
377
|
webToolsGuidance: spec.webToolsGuidance,
|
|
371
378
|
webSearchProxy: spec.webSearchProxy,
|
|
372
379
|
guardLimits: spec.guardLimits,
|
|
380
|
+
...(spec.skill ? { skill: spec.skill } : {}),
|
|
373
381
|
},
|
|
374
382
|
opts,
|
|
375
383
|
)
|
package/src/job.ts
CHANGED
|
@@ -621,6 +621,26 @@ export interface ContextFileSpec {
|
|
|
621
621
|
content: string
|
|
622
622
|
}
|
|
623
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
|
+
|
|
624
644
|
/** How an explore agent's reply is consumed. */
|
|
625
645
|
export interface AgentOutputSpec {
|
|
626
646
|
/** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
|
|
@@ -707,6 +727,12 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
707
727
|
* job on a reused container is removed.
|
|
708
728
|
*/
|
|
709
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
|
|
710
736
|
/**
|
|
711
737
|
* Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
|
|
712
738
|
* band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
|
|
@@ -943,6 +969,71 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
|
|
|
943
969
|
return files
|
|
944
970
|
}
|
|
945
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
|
+
|
|
946
1037
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
947
1038
|
function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
|
|
948
1039
|
if (typeof value !== 'object' || value === null) return undefined
|
|
@@ -1182,6 +1273,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1182
1273
|
const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
|
|
1183
1274
|
const contextFiles = parseContextFiles(o.contextFiles)
|
|
1184
1275
|
const packageRegistries = parsePackageRegistries(o.packageRegistries)
|
|
1276
|
+
const skill = parseSkillSpec(o.skill)
|
|
1185
1277
|
const testSecrets = parseTestSecrets(o.testSecrets)
|
|
1186
1278
|
const guardLimits = parseGuardLimits(o.guardLimits)
|
|
1187
1279
|
const validation = parseValidationSpec(o.validation)
|
|
@@ -1204,6 +1296,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1204
1296
|
...(output ? { output } : {}),
|
|
1205
1297
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
1206
1298
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
1299
|
+
...(skill ? { skill } : {}),
|
|
1207
1300
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
1208
1301
|
...(infra ? { infra } : {}),
|
|
1209
1302
|
...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
|
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
|