@cat-factory/executor-harness 1.66.0 → 1.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -0
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +72 -11
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +9 -139
- package/dist/bootstrap-mode.js +141 -0
- package/dist/coding-agent.js +85 -67
- package/dist/job.js +6 -75
- package/dist/pi-workspace.js +15 -14
- package/dist/pi.js +24 -16
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +97 -26
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +8 -165
- package/src/bootstrap-mode.ts +174 -0
- package/src/coding-agent.ts +102 -71
- package/src/job.ts +37 -93
- package/src/pi-workspace.ts +30 -20
- package/src/pi.ts +24 -16
package/src/coding-agent.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import { killChildProcess, spawnDetached } from './process.js'
|
|
5
|
-
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
|
|
3
|
+
import { runCapturedCommand } from './captured-command.js'
|
|
6
4
|
import type {
|
|
7
5
|
AgentJob,
|
|
8
6
|
AgentResult,
|
|
@@ -11,6 +9,7 @@ import type {
|
|
|
11
9
|
ReferenceRepoSpec,
|
|
12
10
|
RepoSpec,
|
|
13
11
|
SkillSpec,
|
|
12
|
+
McpServerSpec,
|
|
14
13
|
} from './job.js'
|
|
15
14
|
import {
|
|
16
15
|
branchAheadOfBase,
|
|
@@ -140,12 +139,18 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
140
139
|
*/
|
|
141
140
|
reproduction?: ReproductionSpec
|
|
142
141
|
/**
|
|
143
|
-
*
|
|
144
|
-
* into {@link runAgentInWorkspace}, which installs
|
|
145
|
-
* `CLAUDE_CONFIG_DIR` for a leased-credential
|
|
146
|
-
* else (Pi, codex, and ambient
|
|
142
|
+
* The skills to make available for this run — a `skill` step's pick and/or the running kind's
|
|
143
|
+
* declared playbooks. Threaded into {@link runAgentInWorkspace}, which installs them
|
|
144
|
+
* harness-aware: natively under the ISOLATED `CLAUDE_CONFIG_DIR` for a leased-credential
|
|
145
|
+
* claude-code run, `.cat-context/skill/<name>/` for everything else (Pi, codex, and ambient
|
|
146
|
+
* claude-code, which has no isolated config dir). Absent ⇒ no skills.
|
|
147
147
|
*/
|
|
148
|
-
|
|
148
|
+
skills?: SkillSpec[]
|
|
149
|
+
/**
|
|
150
|
+
* Tool servers (MCP) to wire into the agent CLI for this run. Forwarded verbatim — the backend
|
|
151
|
+
* has already dropped anything this harness cannot serve. Absent ⇒ built-in tools only.
|
|
152
|
+
*/
|
|
153
|
+
mcpServers?: McpServerSpec[]
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
/** The outcome of a coding agent run, before each caller maps it to its own result shape. */
|
|
@@ -179,6 +184,8 @@ export interface CodingAgentOutcome {
|
|
|
179
184
|
exitCode: number
|
|
180
185
|
validationOutputTail?: string
|
|
181
186
|
iteration?: number
|
|
187
|
+
/** The work-branch HEAD the command was judged against (absent when it could not be read). */
|
|
188
|
+
headSha?: string
|
|
182
189
|
}
|
|
183
190
|
/**
|
|
184
191
|
* The pre-PR validation loop's LAST attempt (present only when {@link CodingAgentSpec.validationChecks}
|
|
@@ -350,7 +357,8 @@ export async function runCodingAgent(
|
|
|
350
357
|
webToolsGuidance: spec.webToolsGuidance,
|
|
351
358
|
webSearchProxy: spec.webSearchProxy,
|
|
352
359
|
guardLimits: spec.guardLimits,
|
|
353
|
-
...(spec.
|
|
360
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
361
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
354
362
|
},
|
|
355
363
|
opts,
|
|
356
364
|
)
|
|
@@ -718,7 +726,7 @@ async function finalizeCodingRun(args: {
|
|
|
718
726
|
// Runs regardless of whether this pass pushed — a no-op iteration must still be able
|
|
719
727
|
// to report that the criterion is (already) met. The harness runs it, never the model.
|
|
720
728
|
if (spec.validation) {
|
|
721
|
-
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
|
|
729
|
+
outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts)
|
|
722
730
|
}
|
|
723
731
|
// Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
|
|
724
732
|
// reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
|
|
@@ -796,22 +804,57 @@ function mergeAgentPasses<T extends Awaited<ReturnType<typeof runAgentInWorkspac
|
|
|
796
804
|
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
797
805
|
* Overridable via env for tests; defaults to 15 minutes.
|
|
798
806
|
*/
|
|
799
|
-
function ralphValidationTimeoutMs(): number {
|
|
807
|
+
export function ralphValidationTimeoutMs(): number {
|
|
800
808
|
const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS)
|
|
801
809
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
|
|
802
810
|
}
|
|
803
811
|
|
|
812
|
+
/**
|
|
813
|
+
* How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
|
|
814
|
+
* The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
|
|
815
|
+
* — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
|
|
816
|
+
* events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
|
|
817
|
+
* watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
|
|
818
|
+
* validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
|
|
819
|
+
* a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
|
|
820
|
+
* settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
|
|
821
|
+
* always fed it; this one did not. Overridable via env for tests.
|
|
822
|
+
*/
|
|
823
|
+
export function ralphHeartbeatMs(): number {
|
|
824
|
+
const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS)
|
|
825
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Bound on the validation output tail that crosses the wire. Deliberately smaller than
|
|
830
|
+
* `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
|
|
831
|
+
* the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
|
|
832
|
+
* log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
|
|
833
|
+
*/
|
|
834
|
+
export const RALPH_VALIDATION_TAIL_CHARS = 4_000
|
|
835
|
+
|
|
804
836
|
/**
|
|
805
837
|
* Ralph loop: run the programmatic completion command in the checkout and return its exit
|
|
806
|
-
* code
|
|
807
|
-
* done signal (0 = the criterion is met) — computed
|
|
808
|
-
* by the model, which is the whole point of a
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
838
|
+
* code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
|
|
839
|
+
* The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
|
|
840
|
+
* here by the harness, never self-reported by the model, which is the whole point of a
|
|
841
|
+
* programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
|
|
842
|
+
* trust boundary as the coding agent) — there is no host/backend execution.
|
|
843
|
+
*
|
|
844
|
+
* The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
|
|
845
|
+
* command, rather than the near-verbatim copy this used to be. That copy had drifted in two
|
|
846
|
+
* ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
|
|
847
|
+
* margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
|
|
848
|
+
* an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
|
|
849
|
+
* it published the full 16k capture where both siblings deliberately bound the wire tail.
|
|
850
|
+
*
|
|
851
|
+
* `headSha` is what lets the engine tell a loop that is iterating from one that is merely
|
|
852
|
+
* repeating: two consecutive failing iterations against an unchanged head means the agent
|
|
853
|
+
* committed nothing, and the loop is ended early instead of spending the rest of its budget.
|
|
854
|
+
* Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
|
|
813
855
|
*/
|
|
814
|
-
async function runRalphValidation(
|
|
856
|
+
export async function runRalphValidation(
|
|
857
|
+
repoDir: string,
|
|
815
858
|
cwd: string,
|
|
816
859
|
validation: { command: string; iteration?: number },
|
|
817
860
|
logger: Logger,
|
|
@@ -821,66 +864,50 @@ async function runRalphValidation(
|
|
|
821
864
|
exitCode: number
|
|
822
865
|
validationOutputTail?: string
|
|
823
866
|
iteration?: number
|
|
867
|
+
headSha?: string
|
|
824
868
|
}> {
|
|
825
|
-
const timeoutMs = ralphValidationTimeoutMs()
|
|
826
869
|
logger.info('coding-agent(ralph): running validation command', {
|
|
827
870
|
iteration: validation.iteration,
|
|
828
871
|
})
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
872
|
+
// Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
|
|
873
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs())
|
|
874
|
+
heartbeat.unref?.()
|
|
875
|
+
let captured
|
|
876
|
+
try {
|
|
877
|
+
captured = await runCapturedCommand({
|
|
833
878
|
cwd,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
879
|
+
command: validation.command,
|
|
880
|
+
timeoutMs: ralphValidationTimeoutMs(),
|
|
881
|
+
reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
|
|
882
|
+
logLabel: 'coding-agent(ralph): validation',
|
|
883
|
+
logFields: { iteration: validation.iteration },
|
|
884
|
+
logger,
|
|
885
|
+
opts,
|
|
840
886
|
})
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
opts.signal?.removeEventListener('abort', onAbort)
|
|
852
|
-
const trimmed = out.trim()
|
|
853
|
-
const tail = trimmed ? redactSecrets(trimmed) : undefined
|
|
854
|
-
logger.info('coding-agent(ralph): validation finished', {
|
|
855
|
-
exitCode,
|
|
856
|
-
iteration: validation.iteration,
|
|
857
|
-
})
|
|
858
|
-
resolve({
|
|
859
|
-
validationPassed: exitCode === 0,
|
|
860
|
-
exitCode,
|
|
861
|
-
...(tail ? { validationOutputTail: tail } : {}),
|
|
862
|
-
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
863
|
-
})
|
|
864
|
-
}
|
|
865
|
-
const timer = setTimeout(() => {
|
|
866
|
-
logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs })
|
|
867
|
-
killChildProcess(child, undefined, logger)
|
|
868
|
-
finish(124) // conventional timeout exit code (a non-zero fail)
|
|
869
|
-
}, timeoutMs)
|
|
870
|
-
timer.unref?.()
|
|
871
|
-
const onAbort = (): void => {
|
|
872
|
-
killChildProcess(child, undefined, logger)
|
|
873
|
-
finish(130) // aborted (a non-zero fail)
|
|
874
|
-
}
|
|
875
|
-
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
876
|
-
child.on('error', (err) => {
|
|
877
|
-
logger.warn('coding-agent(ralph): validation command failed to spawn', {
|
|
878
|
-
error: err instanceof Error ? err.message : String(err),
|
|
879
|
-
})
|
|
880
|
-
finish(127) // spawn error / command not found (a non-zero fail)
|
|
887
|
+
} finally {
|
|
888
|
+
clearInterval(heartbeat)
|
|
889
|
+
}
|
|
890
|
+
// The commit the criterion was judged against. Read AFTER the command so a validation that
|
|
891
|
+
// itself commits (a formatter check that rewrites files, say) is attributed to what it left.
|
|
892
|
+
// Best-effort: an unreadable head only costs the engine's no-progress guard, never the
|
|
893
|
+
// verdict — but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
|
|
894
|
+
const headSha = await headCommit(repoDir, opts.signal).catch((err: unknown) => {
|
|
895
|
+
logger.warn('coding-agent(ralph): could not read the work-branch head', {
|
|
896
|
+
error: err instanceof Error ? err.message : String(err),
|
|
881
897
|
})
|
|
882
|
-
|
|
898
|
+
return ''
|
|
899
|
+
})
|
|
900
|
+
logger.info('coding-agent(ralph): validation finished', {
|
|
901
|
+
exitCode: captured.exitCode,
|
|
902
|
+
iteration: validation.iteration,
|
|
883
903
|
})
|
|
904
|
+
return {
|
|
905
|
+
validationPassed: captured.passed,
|
|
906
|
+
exitCode: captured.exitCode,
|
|
907
|
+
...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
|
|
908
|
+
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
909
|
+
...(headSha ? { headSha } : {}),
|
|
910
|
+
}
|
|
884
911
|
}
|
|
885
912
|
|
|
886
913
|
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
@@ -1030,6 +1057,10 @@ export async function runMultiRepoCoding(
|
|
|
1030
1057
|
webSearchProxy: job.webSearch,
|
|
1031
1058
|
guardLimits: job.guardLimits,
|
|
1032
1059
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
1060
|
+
// Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
|
|
1061
|
+
// are properties of the AGENT KIND, not of the checkout layout.
|
|
1062
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
1063
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
1033
1064
|
multiRepo: true,
|
|
1034
1065
|
},
|
|
1035
1066
|
opts,
|
package/src/job.ts
CHANGED
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
type ReproductionReport,
|
|
13
13
|
type ReproductionSpec,
|
|
14
14
|
} from './reproduction-proof.js'
|
|
15
|
+
import {
|
|
16
|
+
parseMcpServerSpecs,
|
|
17
|
+
parseSkillSpecs,
|
|
18
|
+
type McpServerSpec,
|
|
19
|
+
type SkillResourceSpec,
|
|
20
|
+
type SkillSpec,
|
|
21
|
+
} from './agent-capabilities.js'
|
|
22
|
+
|
|
23
|
+
// Re-exported so the job body stays the one import site for a harness handler describing a job.
|
|
24
|
+
export type { McpServerSpec, SkillResourceSpec, SkillSpec }
|
|
15
25
|
|
|
16
26
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
17
27
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
@@ -632,26 +642,6 @@ export interface ContextFileSpec {
|
|
|
632
642
|
content: string
|
|
633
643
|
}
|
|
634
644
|
|
|
635
|
-
/** One materialisable resource file of a skill (repo-sourced Claude Skills). */
|
|
636
|
-
export interface SkillResourceSpec {
|
|
637
|
-
/** Path within the skill directory, e.g. `templates/report.md` (subdirs preserved, no traversal). */
|
|
638
|
-
relPath: string
|
|
639
|
-
content: string
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* A repo-sourced Claude Skill to make available for a `skill` step. Materialised HARNESS-AWARE:
|
|
644
|
-
* `CLAUDE_CONFIG_DIR/skills/<name>/SKILL.md` (+ resources) for the claude-code CLI to load
|
|
645
|
-
* natively, or `.cat-context/skill/<relPath>` for the Pi/codex checkout (their prompt carries the
|
|
646
|
-
* instructions). A dedicated top-level body field (like `packageRegistries`), never a context file.
|
|
647
|
-
*/
|
|
648
|
-
export interface SkillSpec {
|
|
649
|
-
name: string
|
|
650
|
-
description: string
|
|
651
|
-
instructions: string
|
|
652
|
-
resources: SkillResourceSpec[]
|
|
653
|
-
}
|
|
654
|
-
|
|
655
645
|
/** How an explore agent's reply is consumed. */
|
|
656
646
|
export interface AgentOutputSpec {
|
|
657
647
|
/** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
|
|
@@ -739,11 +729,20 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
739
729
|
*/
|
|
740
730
|
packageRegistries?: PackageRegistrySpec[]
|
|
741
731
|
/**
|
|
742
|
-
*
|
|
743
|
-
*
|
|
744
|
-
*
|
|
732
|
+
* The skills to make available for this run (see {@link SkillSpec}) — a `skill` step's picked
|
|
733
|
+
* skill and/or the playbooks the running agent kind declares. Materialised harness-aware before
|
|
734
|
+
* the run: natively into `CLAUDE_CONFIG_DIR/skills/<name>/` for claude-code, or
|
|
735
|
+
* `.cat-context/skill/<name>/<relPath>` for Pi/codex. Absent ⇒ no skills installed.
|
|
736
|
+
*/
|
|
737
|
+
skills?: SkillSpec[]
|
|
738
|
+
/**
|
|
739
|
+
* Tool servers (MCP) to wire into the agent CLI for this run (see {@link McpServerSpec}). The
|
|
740
|
+
* backend has already dropped anything this harness cannot serve, so every entry here is
|
|
741
|
+
* expected to work. SECRET-BEARING (`env`/`headers` carry resolved credentials), so the config
|
|
742
|
+
* files written from it live outside the checkout and are never logged. Absent ⇒ the CLI's
|
|
743
|
+
* built-in tools only.
|
|
745
744
|
*/
|
|
746
|
-
|
|
745
|
+
mcpServers?: McpServerSpec[]
|
|
747
746
|
/**
|
|
748
747
|
* Tester kinds only: sensitive test credentials injected into the run's ENVIRONMENT (out of
|
|
749
748
|
* band) as `{ key, value }` env pairs, so the tester's shell can read `$KEY` without the value
|
|
@@ -942,6 +941,12 @@ export interface AgentResult {
|
|
|
942
941
|
exitCode: number
|
|
943
942
|
validationOutputTail?: string
|
|
944
943
|
iteration?: number
|
|
944
|
+
/**
|
|
945
|
+
* The work-branch HEAD the command was judged against. The engine compares it across
|
|
946
|
+
* consecutive failing iterations to end a loop that has stopped committing anything,
|
|
947
|
+
* instead of spending the rest of its budget re-learning that. Absent when unreadable.
|
|
948
|
+
*/
|
|
949
|
+
headSha?: string
|
|
945
950
|
}
|
|
946
951
|
/**
|
|
947
952
|
* Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
|
|
@@ -1029,71 +1034,6 @@ function parseContextFiles(value: unknown): ContextFileSpec[] {
|
|
|
1029
1034
|
return files
|
|
1030
1035
|
}
|
|
1031
1036
|
|
|
1032
|
-
/**
|
|
1033
|
-
* Sanitize a skill resource's relative path: keep the subdirectory structure (so
|
|
1034
|
-
* `templates/report.md` materialises nested) but reject anything that could escape the skill
|
|
1035
|
-
* directory — absolute paths, `..` traversal, backslashes, empty/dot segments. Returns undefined
|
|
1036
|
-
* for an unsafe path (the resource is then dropped).
|
|
1037
|
-
*/
|
|
1038
|
-
function sanitizeSkillRelPath(value: unknown): string | undefined {
|
|
1039
|
-
if (typeof value !== 'string') return undefined
|
|
1040
|
-
const segments = value.replace(/\\/g, '/').split('/')
|
|
1041
|
-
const clean: string[] = []
|
|
1042
|
-
for (const seg of segments) {
|
|
1043
|
-
if (seg === '' || seg === '.') continue
|
|
1044
|
-
if (seg === '..') return undefined
|
|
1045
|
-
// Same character class as a context-file name, per segment.
|
|
1046
|
-
const c = seg.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1047
|
-
if (!c || c === '.' || c === '..' || c.startsWith('.')) return undefined
|
|
1048
|
-
clean.push(c)
|
|
1049
|
-
}
|
|
1050
|
-
return clean.length ? clean.join('/') : undefined
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
/**
|
|
1054
|
-
* Fallback native-skill directory name when the authored name has no id-safe characters (e.g. a
|
|
1055
|
-
* purely non-ASCII skill name). The name is only a path segment / manifest label, so a safe
|
|
1056
|
-
* default keeps the skill installable rather than dropping it — which, on the claude-code path,
|
|
1057
|
-
* would leave the prompt pointing at a skill that was never installed (a blind run).
|
|
1058
|
-
*/
|
|
1059
|
-
const FALLBACK_SKILL_NAME = 'skill'
|
|
1060
|
-
|
|
1061
|
-
/** A skill's own directory name, sanitized to a safe single path segment (undefined if empty). */
|
|
1062
|
-
function sanitizeSkillName(value: unknown): string | undefined {
|
|
1063
|
-
if (typeof value !== 'string') return undefined
|
|
1064
|
-
const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
1065
|
-
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1066
|
-
if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
|
|
1067
|
-
return cleaned
|
|
1068
|
-
}
|
|
1069
|
-
|
|
1070
|
-
/** Validate the optional `skill` field, or undefined when absent/malformed. */
|
|
1071
|
-
function parseSkillSpec(value: unknown): SkillSpec | undefined {
|
|
1072
|
-
if (typeof value !== 'object' || value === null) return undefined
|
|
1073
|
-
const o = value as Record<string, unknown>
|
|
1074
|
-
const instructions = typeof o.instructions === 'string' ? o.instructions : undefined
|
|
1075
|
-
// No instructions ⇒ there is nothing to run — drop the skill (the prompt still carries the
|
|
1076
|
-
// folded-in directive on the Pi/codex path). An unsafe/empty NAME only affects the install
|
|
1077
|
-
// directory, so fall back to a safe default rather than dropping the whole skill.
|
|
1078
|
-
if (!instructions) return undefined
|
|
1079
|
-
const name = sanitizeSkillName(o.name) ?? FALLBACK_SKILL_NAME
|
|
1080
|
-
const description = typeof o.description === 'string' ? o.description : ''
|
|
1081
|
-
const resources: SkillResourceSpec[] = []
|
|
1082
|
-
if (Array.isArray(o.resources)) {
|
|
1083
|
-
const used = new Set<string>()
|
|
1084
|
-
for (const entry of o.resources) {
|
|
1085
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
1086
|
-
const e = entry as Record<string, unknown>
|
|
1087
|
-
const relPath = sanitizeSkillRelPath(e.relPath)
|
|
1088
|
-
if (!relPath || used.has(relPath)) continue
|
|
1089
|
-
if (typeof e.content !== 'string') continue
|
|
1090
|
-
used.add(relPath)
|
|
1091
|
-
resources.push({ relPath, content: e.content })
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
return { name, description, instructions, resources }
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
1037
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
1098
1038
|
function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
|
|
1099
1039
|
if (typeof value !== 'object' || value === null) return undefined
|
|
@@ -1335,7 +1275,8 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1335
1275
|
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
1336
1276
|
contextFiles: parseContextFiles(o.contextFiles),
|
|
1337
1277
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1338
|
-
|
|
1278
|
+
skills: parseSkillSpecs(o.skills),
|
|
1279
|
+
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
1339
1280
|
testSecrets: parseTestSecrets(o.testSecrets),
|
|
1340
1281
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1341
1282
|
validation: parseValidationSpec(o.validation),
|
|
@@ -1374,7 +1315,8 @@ interface ParsedAgentJobParts {
|
|
|
1374
1315
|
bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
|
|
1375
1316
|
contextFiles: ReturnType<typeof parseContextFiles>
|
|
1376
1317
|
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1377
|
-
|
|
1318
|
+
skills: ReturnType<typeof parseSkillSpecs>
|
|
1319
|
+
mcpServers: ReturnType<typeof parseMcpServerSpecs>
|
|
1378
1320
|
testSecrets: ReturnType<typeof parseTestSecrets>
|
|
1379
1321
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1380
1322
|
validation: ReturnType<typeof parseValidationSpec>
|
|
@@ -1428,7 +1370,8 @@ function assembleAgentJob(
|
|
|
1428
1370
|
bootstrap,
|
|
1429
1371
|
contextFiles,
|
|
1430
1372
|
packageRegistries,
|
|
1431
|
-
|
|
1373
|
+
skills,
|
|
1374
|
+
mcpServers,
|
|
1432
1375
|
testSecrets,
|
|
1433
1376
|
guardLimits,
|
|
1434
1377
|
validation,
|
|
@@ -1452,7 +1395,8 @@ function assembleAgentJob(
|
|
|
1452
1395
|
...(output ? { output } : {}),
|
|
1453
1396
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
1454
1397
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
1455
|
-
...(
|
|
1398
|
+
...(skills ? { skills } : {}),
|
|
1399
|
+
...(mcpServers ? { mcpServers } : {}),
|
|
1456
1400
|
...(testSecrets.length ? { testSecrets } : {}),
|
|
1457
1401
|
...(infra ? { infra } : {}),
|
|
1458
1402
|
...(pr ? { pr } : {}),
|
package/src/pi-workspace.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
|
-
import type { RepoSpec
|
|
4
|
+
import type { RepoSpec } from './job.js'
|
|
5
|
+
import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
|
|
5
6
|
import { readEffortReport } from './effort.js'
|
|
6
7
|
import { log } from './logger.js'
|
|
7
8
|
import {
|
|
@@ -206,12 +207,20 @@ export interface AgentRunSpec {
|
|
|
206
207
|
*/
|
|
207
208
|
contextFiles?: ContextFileInfo[]
|
|
208
209
|
/**
|
|
209
|
-
*
|
|
210
|
-
* the
|
|
211
|
-
*
|
|
212
|
-
* folded-in instructions).
|
|
210
|
+
* The skills to make available for this run — a `skill` step's picked skill and/or the playbooks
|
|
211
|
+
* the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
|
|
212
|
+
* natively into the config dir's `skills/`; for Pi/codex the resource files are materialised
|
|
213
|
+
* under `.cat-context/skill/<name>/` (their prompt already carries the folded-in instructions).
|
|
214
|
+
* Absent ⇒ no skills.
|
|
213
215
|
*/
|
|
214
|
-
|
|
216
|
+
skills?: SkillSpec[]
|
|
217
|
+
/**
|
|
218
|
+
* Tool servers (MCP) to wire into the agent CLI. Served by the subscription harnesses only —
|
|
219
|
+
* Pi has no MCP client, and the BACKEND is what decides that (it drops an unservable server and
|
|
220
|
+
* tells the agent so), which is why this path simply forwards whatever it is given rather than
|
|
221
|
+
* re-deciding. Absent ⇒ the CLI's built-in tools only.
|
|
222
|
+
*/
|
|
223
|
+
mcpServers?: McpServerSpec[]
|
|
215
224
|
/**
|
|
216
225
|
* Enable proxy-backed web search: point the rpiv-web-tools SearXNG provider at the
|
|
217
226
|
* backend's search proxy (`${proxyBaseUrl}/web-search`) with the session token as
|
|
@@ -268,14 +277,14 @@ export async function runAgentInWorkspace(
|
|
|
268
277
|
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
269
278
|
const contextFiles = spec.contextFiles ?? []
|
|
270
279
|
await materializeContextFiles(spec.dir, contextFiles)
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
if (spec.
|
|
278
|
-
await materializeSkillResources(spec.dir, spec.
|
|
280
|
+
// Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
|
|
281
|
+
// there. Everything else reads the checkout, so materialise each skill's resources under
|
|
282
|
+
// `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
|
|
283
|
+
// Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install into (the
|
|
284
|
+
// runner refuses to write a skill into the developer's own `~/.claude`; see `runClaudeCode`).
|
|
285
|
+
// Resource-free skills are a no-op here.
|
|
286
|
+
if (spec.skills?.length && !installsSkillNatively(spec)) {
|
|
287
|
+
await materializeSkillResources(spec.dir, spec.skills)
|
|
279
288
|
}
|
|
280
289
|
|
|
281
290
|
// Subscription harnesses (Claude Code / Codex) authenticate with the leased
|
|
@@ -296,7 +305,8 @@ export async function runAgentInWorkspace(
|
|
|
296
305
|
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
297
306
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
298
307
|
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
299
|
-
...(spec.
|
|
308
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
309
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
300
310
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
301
311
|
signal: opts.signal,
|
|
302
312
|
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
@@ -367,12 +377,12 @@ export async function runAgentInWorkspace(
|
|
|
367
377
|
}
|
|
368
378
|
|
|
369
379
|
/**
|
|
370
|
-
* Whether the claude-code runner will install this run's
|
|
371
|
-
*
|
|
380
|
+
* Whether the claude-code runner will install this run's skills natively (into the CLI's config
|
|
381
|
+
* dir) rather than the caller materialising them into the checkout. True ONLY for a
|
|
372
382
|
* leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
|
|
373
|
-
* uses the developer's own `~/.claude`, which the runner will not write a
|
|
374
|
-
*
|
|
375
|
-
*
|
|
383
|
+
* uses the developer's own `~/.claude`, which the runner will not write a skill into — it would
|
|
384
|
+
* outlive the run in their personal setup, and two concurrent jobs carrying same-named skills
|
|
385
|
+
* would overwrite each other's.
|
|
376
386
|
*/
|
|
377
387
|
export function installsSkillNatively(
|
|
378
388
|
spec: Pick<AgentRunSpec, 'harness' | 'ambientAuth'>,
|
package/src/pi.ts
CHANGED
|
@@ -253,29 +253,37 @@ export async function materializeContextFiles(
|
|
|
253
253
|
}
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
/** Subdirectory of {@link CONTEXT_DIR} where a
|
|
256
|
+
/** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
|
|
257
257
|
export const SKILL_CONTEXT_SUBDIR = 'skill'
|
|
258
258
|
|
|
259
259
|
/**
|
|
260
|
-
* Materialise
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
260
|
+
* Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
|
|
261
|
+
* — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
|
|
262
|
+
* (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
|
|
263
|
+
* skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
|
|
264
|
+
* which keys off ambient auth as well as the harness).
|
|
265
|
+
*
|
|
266
|
+
* Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
|
|
267
|
+
* the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
|
|
268
|
+
* overwrite each other — silently handing the agent the wrong template. The names were sanitized
|
|
269
|
+
* to a single safe path segment at the job boundary, as were the resource sub-paths (no
|
|
270
|
+
* traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
|
|
271
|
+
* `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
|
|
267
272
|
*/
|
|
268
273
|
export async function materializeSkillResources(
|
|
269
274
|
cwd: string,
|
|
270
|
-
|
|
275
|
+
skills: { name: string; resources: { relPath: string; content: string }[] }[],
|
|
271
276
|
): Promise<void> {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
277
|
+
const withResources = skills.filter((s) => s.resources.length)
|
|
278
|
+
if (!withResources.length) return
|
|
279
|
+
for (const skill of withResources) {
|
|
280
|
+
const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name)
|
|
281
|
+
await mkdir(dir, { recursive: true })
|
|
282
|
+
for (const r of skill.resources) {
|
|
283
|
+
const dest = join(dir, r.relPath)
|
|
284
|
+
await mkdir(dirname(dest), { recursive: true })
|
|
285
|
+
await writeFile(dest, r.content, 'utf8')
|
|
286
|
+
}
|
|
279
287
|
}
|
|
280
288
|
const gitRoot = await findGitRoot(cwd)
|
|
281
289
|
if (!gitRoot) return
|