@cat-factory/executor-harness 1.45.0 → 1.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
- import { tmpdir } from 'node:os';
4
- import { join } from 'node:path';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir, tmpdir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
5
  import { killChildProcess, spawnDetached } from './process.js';
6
6
  import { redact, secretsToRedact } from './redact.js';
7
7
  function isObject(value) {
@@ -130,6 +130,31 @@ function streamCli(command, args, prompt, opts, env, secrets, onEvent) {
130
130
  * `TodoWrite` tool calls onto subtask progress and the terminal `result` event
131
131
  * onto the summary + usage.
132
132
  */
133
+ /**
134
+ * Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
135
+ * `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
136
+ * expects) plus every resource file at its path within the skill directory. Resource sub-paths
137
+ * were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
138
+ *
139
+ * The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
140
+ * scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
141
+ * or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
142
+ * would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
143
+ * valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
144
+ */
145
+ async function writeNativeSkill(skillsRoot, skill) {
146
+ const dir = join(skillsRoot, skill.name);
147
+ await mkdir(dir, { recursive: true });
148
+ const name = JSON.stringify(skill.name);
149
+ const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '));
150
+ const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`;
151
+ await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8');
152
+ for (const resource of skill.resources) {
153
+ const dest = join(dir, resource.relPath);
154
+ await mkdir(dirname(dest), { recursive: true });
155
+ await writeFile(dest, resource.content, 'utf8');
156
+ }
157
+ }
133
158
  export async function runClaudeCode(opts) {
134
159
  const stats = { toolCalls: 0, assistantChars: 0 };
135
160
  let summary = '';
@@ -219,6 +244,16 @@ export async function runClaudeCode(opts) {
219
244
  hasTrustDialogAccepted: true,
220
245
  }), { mode: 0o600 }).catch(() => { });
221
246
  }
247
+ // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
248
+ // `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
249
+ // config home when present, else the developer's `~/.claude` (ambient/native mode). Best-effort:
250
+ // a write failure must not wedge the run — the prompt still names the skill.
251
+ if (opts.skill) {
252
+ const skillsRoot = configHome
253
+ ? join(configHome, 'skills')
254
+ : join(homedir(), '.claude', 'skills');
255
+ await writeNativeSkill(skillsRoot, opts.skill).catch(() => { });
256
+ }
222
257
  // Anthropic itself authenticates with the subscription OAuth token; a
223
258
  // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
224
259
  // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
package/dist/agent.js CHANGED
@@ -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
  ? {
@@ -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 } : {}),
@@ -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.45.0",
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.126.0",
30
- "@cat-factory/spend": "0.12.37"
29
+ "@cat-factory/server": "0.129.0",
30
+ "@cat-factory/spend": "0.12.41"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process'
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
3
- import { tmpdir } from 'node:os'
4
- import { join } from 'node:path'
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
3
+ import { homedir, tmpdir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
5
  import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
6
6
  import { killChildProcess, spawnDetached } from './process.js'
7
7
  import { redact, secretsToRedact } from './redact.js'
@@ -52,6 +52,18 @@ export interface SubscriptionRunOptions {
52
52
  * container.
53
53
  */
54
54
  ambientAuth?: boolean
55
+ /**
56
+ * A repo-sourced Claude Skill to install natively before launch (repo-sourced Claude Skills,
57
+ * slice 2). The claude-code runner writes it to `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md`
58
+ * (+ resource files) so the CLI loads it; the codex runner ignores it (codex reads the
59
+ * checkout's `.cat-context/skill/`, materialised by the caller). Absent ⇒ no skill installed.
60
+ */
61
+ skill?: {
62
+ name: string
63
+ description: string
64
+ instructions: string
65
+ resources: { relPath: string; content: string }[]
66
+ }
55
67
  /** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
56
68
  signal?: AbortSignal
57
69
  /** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
@@ -202,6 +214,35 @@ function streamCli(
202
214
  * `TodoWrite` tool calls onto subtask progress and the terminal `result` event
203
215
  * onto the summary + usage.
204
216
  */
217
+ /**
218
+ * Write a repo-sourced skill as a NATIVE Claude Code skill under `<skillsRoot>/<name>/`: a
219
+ * `SKILL.md` (YAML frontmatter `name`/`description` + the instructions body, the format the CLI
220
+ * expects) plus every resource file at its path within the skill directory. Resource sub-paths
221
+ * were sanitized at the job boundary (no traversal), so nested dirs are created as needed.
222
+ *
223
+ * The frontmatter `name`/`description` values are emitted as JSON-encoded (double-quoted) YAML
224
+ * scalars, not bare plain scalars: an author's description routinely contains `: ` (colon-space)
225
+ * or a leading YAML indicator (`#`, `-`, `[`, `{`, `"`, …), which is invalid as a plain scalar and
226
+ * would make the CLI fail to parse the frontmatter and silently skip the skill. A JSON string is a
227
+ * valid YAML double-quoted scalar, so quoting makes the manifest robust to arbitrary text.
228
+ */
229
+ async function writeNativeSkill(
230
+ skillsRoot: string,
231
+ skill: NonNullable<SubscriptionRunOptions['skill']>,
232
+ ): Promise<void> {
233
+ const dir = join(skillsRoot, skill.name)
234
+ await mkdir(dir, { recursive: true })
235
+ const name = JSON.stringify(skill.name)
236
+ const description = JSON.stringify(skill.description.replace(/\r?\n/g, ' '))
237
+ const frontmatter = `---\nname: ${name}\ndescription: ${description}\n---\n`
238
+ await writeFile(join(dir, 'SKILL.md'), `${frontmatter}\n${skill.instructions}\n`, 'utf8')
239
+ for (const resource of skill.resources) {
240
+ const dest = join(dir, resource.relPath)
241
+ await mkdir(dirname(dest), { recursive: true })
242
+ await writeFile(dest, resource.content, 'utf8')
243
+ }
244
+ }
245
+
205
246
  export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRunOutcome> {
206
247
  const stats: PiRunStats = { toolCalls: 0, assistantChars: 0 }
207
248
  let summary = ''
@@ -297,6 +338,17 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
297
338
  ).catch(() => {})
298
339
  }
299
340
 
341
+ // Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
342
+ // `skills/<name>/` so the CLI discovers and can invoke it. Written to the isolated per-run
343
+ // config home when present, else the developer's `~/.claude` (ambient/native mode). Best-effort:
344
+ // a write failure must not wedge the run — the prompt still names the skill.
345
+ if (opts.skill) {
346
+ const skillsRoot = configHome
347
+ ? join(configHome, 'skills')
348
+ : join(homedir(), '.claude', 'skills')
349
+ await writeNativeSkill(skillsRoot, opts.skill).catch(() => {})
350
+ }
351
+
300
352
  // Anthropic itself authenticates with the subscription OAuth token; a
301
353
  // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
302
354
  // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
package/src/agent.ts CHANGED
@@ -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
  ? {
@@ -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 } : {}),
@@ -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