@cat-factory/executor-harness 1.100.0 → 1.104.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 +27 -0
- package/dist/agent-shared.d.ts +7 -5
- package/dist/agent-shared.js +6 -4
- package/dist/agent.js +2 -1
- package/dist/checkout-dir.d.ts +33 -0
- package/dist/checkout-dir.js +56 -0
- package/dist/coding-agent.d.ts +9 -14
- package/dist/coding-agent.js +8 -19
- package/dist/context-manifests.d.ts +69 -0
- package/dist/context-manifests.js +110 -0
- package/dist/job.d.ts +9 -12
- package/dist/job.js +4 -40
- package/dist/pi-workspace.d.ts +7 -1
- package/dist/pi-workspace.js +18 -1
- package/dist/pi.d.ts +20 -0
- package/dist/pi.js +21 -13
- package/dist/reference-screenshots.d.ts +71 -0
- package/dist/reference-screenshots.js +261 -0
- package/package.json +4 -4
- package/src/agent-shared.ts +14 -5
- package/src/agent.ts +2 -6
- package/src/checkout-dir.ts +62 -0
- package/src/coding-agent.ts +17 -21
- package/src/context-manifests.ts +156 -0
- package/src/job.ts +22 -48
- package/src/pi-workspace.ts +25 -2
- package/src/pi.ts +29 -11
- package/src/reference-screenshots.ts +295 -0
package/src/coding-agent.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { runCapturedCommand } from './captured-command.js'
|
|
4
|
+
import { makeDirClaimer } from './checkout-dir.js'
|
|
4
5
|
import type {
|
|
5
6
|
AgentJob,
|
|
6
7
|
AgentResult,
|
|
7
8
|
HarnessAuthFields,
|
|
8
9
|
PeerRepoSpec,
|
|
9
10
|
ReferenceRepoSpec,
|
|
11
|
+
ReferenceScreenshotsSpec,
|
|
10
12
|
RepoSpec,
|
|
11
13
|
SkillSpec,
|
|
12
14
|
McpServerSpec,
|
|
@@ -179,6 +181,14 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
179
181
|
* has already dropped anything this harness cannot serve. Absent ⇒ built-in tools only.
|
|
180
182
|
*/
|
|
181
183
|
mcpServers?: McpServerSpec[]
|
|
184
|
+
/**
|
|
185
|
+
* The task's reference design images, downloaded into `.cat-context/reference-screenshots/`
|
|
186
|
+
* before the agent's first turn. Carried on the coding path as well as the explore one because
|
|
187
|
+
* what earns a run its references is the KIND's declared `ui` image, and a deployment's own
|
|
188
|
+
* UI-facing kind may well be a coding one, and nothing here switches on which built-in it is.
|
|
189
|
+
* Absent ⇒ none (the normal case).
|
|
190
|
+
*/
|
|
191
|
+
referenceScreenshots?: ReferenceScreenshotsSpec
|
|
182
192
|
}
|
|
183
193
|
|
|
184
194
|
/** The outcome of a coding agent run, before each caller maps it to its own result shape. */
|
|
@@ -457,6 +467,9 @@ export async function runCodingAgent(
|
|
|
457
467
|
guardLimits: spec.guardLimits,
|
|
458
468
|
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
459
469
|
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
470
|
+
...(spec.referenceScreenshots
|
|
471
|
+
? { referenceScreenshots: spec.referenceScreenshots }
|
|
472
|
+
: {}),
|
|
460
473
|
},
|
|
461
474
|
opts,
|
|
462
475
|
)
|
|
@@ -1018,25 +1031,6 @@ export async function runRalphValidation(
|
|
|
1018
1031
|
}
|
|
1019
1032
|
}
|
|
1020
1033
|
|
|
1021
|
-
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
1022
|
-
export function safeDirSegment(value: string): string {
|
|
1023
|
-
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
/**
|
|
1027
|
-
* A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
|
|
1028
|
-
* repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
|
|
1029
|
-
* — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
|
|
1030
|
-
* `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
|
|
1031
|
-
* the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
|
|
1032
|
-
* backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
|
|
1033
|
-
* (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
|
|
1034
|
-
* independently, so a divergent rule would point the agent at a directory that does not exist.
|
|
1035
|
-
*/
|
|
1036
|
-
export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
|
|
1037
|
-
return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
1034
|
/** One repository participating in a multi-repo run: where to clone it + what to do after. */
|
|
1041
1035
|
interface RepoLeg {
|
|
1042
1036
|
repo: RepoSpec
|
|
@@ -1086,8 +1080,9 @@ export async function runMultiRepoCoding(
|
|
|
1086
1080
|
const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
|
|
1087
1081
|
const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
1088
1082
|
|
|
1089
|
-
// Assign the sibling directory per repo via the shared deterministic allocator
|
|
1090
|
-
// matching the backend prompt's `siblingCheckoutDir`), shared with the
|
|
1083
|
+
// Assign the sibling directory per repo via the shared deterministic allocator
|
|
1084
|
+
// (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
|
|
1085
|
+
// read-only explore fan-out.
|
|
1091
1086
|
const claimDir = makeDirClaimer()
|
|
1092
1087
|
const legs: RepoLeg[] = [
|
|
1093
1088
|
{
|
|
@@ -1207,6 +1202,7 @@ export async function runMultiRepoCoding(
|
|
|
1207
1202
|
// are properties of the AGENT KIND, not of the checkout layout.
|
|
1208
1203
|
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
1209
1204
|
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
1205
|
+
...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
|
|
1210
1206
|
multiRepo: true,
|
|
1211
1207
|
},
|
|
1212
1208
|
opts,
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The two manifests of FILES the backend stages into a checkout before the agent's first turn:
|
|
3
|
+
// the linked-context documents it materialises under CONTEXT_DIR, and the reference design images
|
|
4
|
+
// it has the harness download beside them.
|
|
5
|
+
//
|
|
6
|
+
// One module because they are the same kind of thing parsed the same defensive way, and because
|
|
7
|
+
// they share the basename rule below: both name files the container writes into a directory it
|
|
8
|
+
// then points the agent at, so both are held to a value that cannot escape it or clobber a repo
|
|
9
|
+
// file. Split out of `job.ts`, which parses everything else a job body carries.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A linked-context file the backend prepared (requirements / RFC / PRD / tracker issue)
|
|
14
|
+
* for the harness to materialise under CONTEXT_DIR in the checkout, so the agent can read
|
|
15
|
+
* it on demand. The harness can't reach Jira/GitHub itself, so all such context is fetched
|
|
16
|
+
* and shipped here up front. `path` is sanitised to a safe basename on parse.
|
|
17
|
+
*/
|
|
18
|
+
export interface ContextFileSpec {
|
|
19
|
+
path: string
|
|
20
|
+
title: string
|
|
21
|
+
url: string
|
|
22
|
+
content: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The REFERENCE DESIGN IMAGES the backend holds for this task, for the harness to download into
|
|
27
|
+
* `.cat-context/reference-screenshots/` before the agent runs: the directory a UI tester's prompt
|
|
28
|
+
* names and, until now, nothing wrote.
|
|
29
|
+
*
|
|
30
|
+
* A manifest rather than the bytes: a design frame is a full-page PNG, and a job body is JSON
|
|
31
|
+
* that crosses every transport and is persisted with the dispatch. The bytes come back over the
|
|
32
|
+
* SAME container session token the run already holds (`GET ${url}/<artifactId>`), so this needs
|
|
33
|
+
* no extra credential and no publicly reachable URL.
|
|
34
|
+
*
|
|
35
|
+
* `view` is what the backend's gate pairs on, and `fileName` is the name the BACKEND chose for it,
|
|
36
|
+
* never derived here. The file name is how the agent learns the view name, so deriving it in the
|
|
37
|
+
* container would let a harness image the deployment has not rolled out yet rename every view a
|
|
38
|
+
* run reports, and the pairing would come apart with nothing failing.
|
|
39
|
+
*/
|
|
40
|
+
export interface ReferenceScreenshotsSpec {
|
|
41
|
+
/** Base URL of the reference download route; the artifact id is appended as a path segment. */
|
|
42
|
+
url: string
|
|
43
|
+
/** The run's container session token (the same one the LLM proxy is called with). */
|
|
44
|
+
token: string
|
|
45
|
+
files: ReferenceScreenshotSpec[]
|
|
46
|
+
/**
|
|
47
|
+
* View names the task holds a reference for that this job was NOT sent a file for, because the
|
|
48
|
+
* set was capped. Stated to the agent beside the transfers that failed: from where it stands
|
|
49
|
+
* both are a view to capture with no image to compare against.
|
|
50
|
+
*
|
|
51
|
+
* Two producers, and they mean the same thing here: the BACKEND's own ceiling (the number that
|
|
52
|
+
* should ever actually bind, chosen where the precedence between an upload and a design frame
|
|
53
|
+
* is known), and this parser's backstop against a body claiming more files than any real set
|
|
54
|
+
* has. A drop with no entry here is the bug this field exists to prevent.
|
|
55
|
+
*/
|
|
56
|
+
omitted: string[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One reference image in a {@link ReferenceScreenshotsSpec}. `fileName` is sanitised on parse. */
|
|
60
|
+
export interface ReferenceScreenshotSpec {
|
|
61
|
+
artifactId: string
|
|
62
|
+
fileName: string
|
|
63
|
+
view: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sanitise a body-supplied context filename to a safe basename within CONTEXT_DIR:
|
|
68
|
+
* strip any directory part, allow only `[A-Za-z0-9._-]`, and reject empties / dotfiles
|
|
69
|
+
* / `..` so a hostile value can't escape the directory or clobber repo files.
|
|
70
|
+
*/
|
|
71
|
+
export function sanitizeContextFileName(value: unknown): string | undefined {
|
|
72
|
+
if (typeof value !== 'string') return undefined
|
|
73
|
+
const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
74
|
+
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
|
|
75
|
+
if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
|
|
76
|
+
return cleaned
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Parse the linked-context files, dropping any malformed/unsafe entry. */
|
|
80
|
+
export function parseContextFiles(value: unknown): ContextFileSpec[] {
|
|
81
|
+
if (!Array.isArray(value)) return []
|
|
82
|
+
const files: ContextFileSpec[] = []
|
|
83
|
+
const used = new Set<string>()
|
|
84
|
+
for (const entry of value) {
|
|
85
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
86
|
+
const e = entry as Record<string, unknown>
|
|
87
|
+
const path = sanitizeContextFileName(e.path)
|
|
88
|
+
if (!path || used.has(path)) continue
|
|
89
|
+
if (typeof e.content !== 'string') continue
|
|
90
|
+
used.add(path)
|
|
91
|
+
files.push({
|
|
92
|
+
path,
|
|
93
|
+
title: typeof e.title === 'string' ? e.title : path,
|
|
94
|
+
url: typeof e.url === 'string' ? e.url : '',
|
|
95
|
+
content: e.content,
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
return files
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How many reference images one job may be handed. The backend caps the set it sends well below
|
|
103
|
+
* this, so this is the harness's own backstop against a malformed or hostile body turning the
|
|
104
|
+
* pre-run setup into an unbounded download, never the ceiling a real run meets.
|
|
105
|
+
*
|
|
106
|
+
* Hitting it is REPORTED rather than silently obeyed: an entry past the ceiling is dropped from
|
|
107
|
+
* `files` and its view named on `omitted`, so an agent facing a truncated set is still told which
|
|
108
|
+
* views to capture. A cap that shortened the list and said nothing would be indistinguishable, on
|
|
109
|
+
* disk and in the prompt, from a design that simply has no such screen.
|
|
110
|
+
*/
|
|
111
|
+
const MAX_REFERENCE_SCREENSHOTS = 40
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Parse the reference-design manifest, or undefined when absent/unusable.
|
|
115
|
+
*
|
|
116
|
+
* The whole manifest is dropped when its transport half is unusable (no absolute http(s) URL, no
|
|
117
|
+
* token): every file would fail the same way, and one stated cause beats N identical ones. An
|
|
118
|
+
* individual entry is dropped only when it cannot name a file safely: the same basename
|
|
119
|
+
* sanitisation every context file gets, so a hostile `fileName` can neither escape the directory
|
|
120
|
+
* nor clobber a repo file.
|
|
121
|
+
*/
|
|
122
|
+
export function parseReferenceScreenshots(value: unknown): ReferenceScreenshotsSpec | undefined {
|
|
123
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
124
|
+
const o = value as Record<string, unknown>
|
|
125
|
+
const url = typeof o.url === 'string' ? o.url.trim() : ''
|
|
126
|
+
const token = typeof o.token === 'string' ? o.token : ''
|
|
127
|
+
if (!url || !token || !/^https?:\/\//i.test(url)) return undefined
|
|
128
|
+
if (!Array.isArray(o.files)) return undefined
|
|
129
|
+
const files: ReferenceScreenshotSpec[] = []
|
|
130
|
+
// The backend's own dropped views come first; anything this parser drops joins them below.
|
|
131
|
+
const omitted = Array.isArray(o.omitted)
|
|
132
|
+
? o.omitted.filter((view): view is string => typeof view === 'string' && view.length > 0)
|
|
133
|
+
: []
|
|
134
|
+
const used = new Set<string>()
|
|
135
|
+
for (const entry of o.files) {
|
|
136
|
+
if (typeof entry !== 'object' || entry === null) continue
|
|
137
|
+
const e = entry as Record<string, unknown>
|
|
138
|
+
const fileName = sanitizeContextFileName(e.fileName)
|
|
139
|
+
const artifactId = typeof e.artifactId === 'string' ? e.artifactId.trim() : ''
|
|
140
|
+
// The id becomes a path segment on the download URL, so it is held to the shape the platform
|
|
141
|
+
// mints rather than encoded and hoped for: anything else cannot be a real artifact anyway.
|
|
142
|
+
if (!fileName || used.has(fileName) || !/^[A-Za-z0-9_-]{1,64}$/.test(artifactId)) continue
|
|
143
|
+
const view = typeof e.view === 'string' ? e.view : fileName
|
|
144
|
+
// Past the backstop the entry is NAMED, not dropped: it stays a view the agent must capture.
|
|
145
|
+
// Checked here rather than at the top of the loop so a malformed entry is refused on its own
|
|
146
|
+
// terms (it names no usable view to report) instead of being counted against the ceiling.
|
|
147
|
+
if (files.length >= MAX_REFERENCE_SCREENSHOTS) {
|
|
148
|
+
omitted.push(view)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
used.add(fileName)
|
|
152
|
+
files.push({ artifactId, fileName, view })
|
|
153
|
+
}
|
|
154
|
+
if (!files.length && !omitted.length) return undefined
|
|
155
|
+
return { url: url.replace(/\/+$/, ''), token, files, omitted }
|
|
156
|
+
}
|
package/src/job.ts
CHANGED
|
@@ -22,6 +22,13 @@ import {
|
|
|
22
22
|
type SkillSpec,
|
|
23
23
|
} from './agent-capabilities.js'
|
|
24
24
|
import { type TestSecretSpec, parseInfraEnv, parseSecretEnvPairs, str } from './job-env.js'
|
|
25
|
+
import {
|
|
26
|
+
parseContextFiles,
|
|
27
|
+
parseReferenceScreenshots,
|
|
28
|
+
type ContextFileSpec,
|
|
29
|
+
type ReferenceScreenshotSpec,
|
|
30
|
+
type ReferenceScreenshotsSpec,
|
|
31
|
+
} from './context-manifests.js'
|
|
25
32
|
|
|
26
33
|
// Re-exported so a handler describing a job keeps ONE import site (the env-pair shape is a job
|
|
27
34
|
// body field like any other; only its VALIDATION moved out).
|
|
@@ -30,6 +37,10 @@ export type { TestSecretSpec }
|
|
|
30
37
|
// Re-exported so the job body stays the one import site for a harness handler describing a job.
|
|
31
38
|
export type { McpServerSpec, SkillResourceSpec, SkillSpec }
|
|
32
39
|
|
|
40
|
+
// Same rule for the two staged-file manifests: their shapes and their defensive parsing moved to
|
|
41
|
+
// `context-manifests.ts`, but they remain job body fields, so this stays the import site.
|
|
42
|
+
export type { ContextFileSpec, ReferenceScreenshotSpec, ReferenceScreenshotsSpec }
|
|
43
|
+
|
|
33
44
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
34
45
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
35
46
|
// `ghToken`, `sessionToken` and `subscriptionToken` are secrets: they are
|
|
@@ -610,19 +621,6 @@ export interface AgentBootstrapSpec {
|
|
|
610
621
|
fromScratch?: boolean
|
|
611
622
|
}
|
|
612
623
|
|
|
613
|
-
/**
|
|
614
|
-
* A linked-context file the backend prepared (requirements / RFC / PRD / tracker issue)
|
|
615
|
-
* for the harness to materialise under CONTEXT_DIR in the checkout, so the agent can read
|
|
616
|
-
* it on demand. The harness can't reach Jira/GitHub itself, so all such context is fetched
|
|
617
|
-
* and shipped here up front. `path` is sanitised to a safe basename on parse.
|
|
618
|
-
*/
|
|
619
|
-
export interface ContextFileSpec {
|
|
620
|
-
path: string
|
|
621
|
-
title: string
|
|
622
|
-
url: string
|
|
623
|
-
content: string
|
|
624
|
-
}
|
|
625
|
-
|
|
626
624
|
/** How an explore agent's reply is consumed. */
|
|
627
625
|
export interface AgentOutputSpec {
|
|
628
626
|
/** `prose` keeps the reply text; `structured` parses (and optionally repairs) it to JSON. */
|
|
@@ -712,6 +710,13 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
712
710
|
* The agent reads them on demand; they are kept out of any commit. Absent ⇒ none.
|
|
713
711
|
*/
|
|
714
712
|
contextFiles?: ContextFileSpec[]
|
|
713
|
+
/**
|
|
714
|
+
* The task's reference design images, downloaded into `.cat-context/reference-screenshots/`
|
|
715
|
+
* before the agent runs (see {@link ReferenceScreenshotsSpec}). Sent only for a kind that
|
|
716
|
+
* CAPTURES views and only when the task actually has references, so absent is the normal case
|
|
717
|
+
* and means the agent names its own views.
|
|
718
|
+
*/
|
|
719
|
+
referenceScreenshots?: ReferenceScreenshotsSpec
|
|
715
720
|
/**
|
|
716
721
|
* Private package-registry auth (npm private orgs, GitHub Packages), rendered into
|
|
717
722
|
* `~/.npmrc` before the run so the checkout's installs — the agent's own and the
|
|
@@ -1015,41 +1020,6 @@ function parseAgentBootstrapSpec(value: unknown): AgentBootstrapSpec | undefined
|
|
|
1015
1020
|
}
|
|
1016
1021
|
}
|
|
1017
1022
|
|
|
1018
|
-
/**
|
|
1019
|
-
* Sanitise a body-supplied context filename to a safe basename within CONTEXT_DIR:
|
|
1020
|
-
* strip any directory part, allow only `[A-Za-z0-9._-]`, and reject empties / dotfiles
|
|
1021
|
-
* / `..` so a hostile value can't escape the directory or clobber repo files.
|
|
1022
|
-
*/
|
|
1023
|
-
function sanitizeContextFileName(value: unknown): string | undefined {
|
|
1024
|
-
if (typeof value !== 'string') return undefined
|
|
1025
|
-
const base = value.replace(/\\/g, '/').split('/').pop() ?? ''
|
|
1026
|
-
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, '')
|
|
1027
|
-
if (!cleaned || cleaned === '.' || cleaned === '..' || cleaned.startsWith('.')) return undefined
|
|
1028
|
-
return cleaned
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
/** Parse the linked-context files, dropping any malformed/unsafe entry. */
|
|
1032
|
-
function parseContextFiles(value: unknown): ContextFileSpec[] {
|
|
1033
|
-
if (!Array.isArray(value)) return []
|
|
1034
|
-
const files: ContextFileSpec[] = []
|
|
1035
|
-
const used = new Set<string>()
|
|
1036
|
-
for (const entry of value) {
|
|
1037
|
-
if (typeof entry !== 'object' || entry === null) continue
|
|
1038
|
-
const e = entry as Record<string, unknown>
|
|
1039
|
-
const path = sanitizeContextFileName(e.path)
|
|
1040
|
-
if (!path || used.has(path)) continue
|
|
1041
|
-
if (typeof e.content !== 'string') continue
|
|
1042
|
-
used.add(path)
|
|
1043
|
-
files.push({
|
|
1044
|
-
path,
|
|
1045
|
-
title: typeof e.title === 'string' ? e.title : path,
|
|
1046
|
-
url: typeof e.url === 'string' ? e.url : '',
|
|
1047
|
-
content: e.content,
|
|
1048
|
-
})
|
|
1049
|
-
}
|
|
1050
|
-
return files
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
1023
|
/** Parse the explore-mode infra stand-up spec, or undefined when absent/unrecognised. */
|
|
1054
1024
|
function parseAgentInfraSpec(value: unknown): AgentInfraSpec | undefined {
|
|
1055
1025
|
if (typeof value !== 'object' || value === null) return undefined
|
|
@@ -1230,6 +1200,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1230
1200
|
referenceBranches: parseReferenceBranches(o.referenceBranches),
|
|
1231
1201
|
bootstrap: parseAgentBootstrapSpec(o.bootstrap),
|
|
1232
1202
|
contextFiles: parseContextFiles(o.contextFiles),
|
|
1203
|
+
referenceScreenshots: parseReferenceScreenshots(o.referenceScreenshots),
|
|
1233
1204
|
packageRegistries: parsePackageRegistries(o.packageRegistries),
|
|
1234
1205
|
skills: parseSkillSpecs(o.skills),
|
|
1235
1206
|
mcpServers: parseMcpServerSpecs(o.mcpServers),
|
|
@@ -1272,6 +1243,7 @@ interface ParsedAgentJobParts {
|
|
|
1272
1243
|
referenceBranches: ReturnType<typeof parseReferenceBranches>
|
|
1273
1244
|
bootstrap: ReturnType<typeof parseAgentBootstrapSpec>
|
|
1274
1245
|
contextFiles: ReturnType<typeof parseContextFiles>
|
|
1246
|
+
referenceScreenshots: ReturnType<typeof parseReferenceScreenshots>
|
|
1275
1247
|
packageRegistries: ReturnType<typeof parsePackageRegistries>
|
|
1276
1248
|
skills: ReturnType<typeof parseSkillSpecs>
|
|
1277
1249
|
mcpServers: ReturnType<typeof parseMcpServerSpecs>
|
|
@@ -1329,6 +1301,7 @@ function assembleAgentJob(
|
|
|
1329
1301
|
referenceBranches,
|
|
1330
1302
|
bootstrap,
|
|
1331
1303
|
contextFiles,
|
|
1304
|
+
referenceScreenshots,
|
|
1332
1305
|
packageRegistries,
|
|
1333
1306
|
skills,
|
|
1334
1307
|
mcpServers,
|
|
@@ -1356,6 +1329,7 @@ function assembleAgentJob(
|
|
|
1356
1329
|
...(bootstrap ? { bootstrap } : {}),
|
|
1357
1330
|
...(output ? { output } : {}),
|
|
1358
1331
|
...(contextFiles.length ? { contextFiles } : {}),
|
|
1332
|
+
...(referenceScreenshots ? { referenceScreenshots } : {}),
|
|
1359
1333
|
...(packageRegistries.length ? { packageRegistries } : {}),
|
|
1360
1334
|
...(skills ? { skills } : {}),
|
|
1361
1335
|
...(mcpServers ? { mcpServers } : {}),
|
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 } from './job.js'
|
|
4
|
+
import type { RepoSpec, ReferenceScreenshotsSpec } from './job.js'
|
|
5
|
+
import { deliverReferenceScreenshots } from './reference-screenshots.js'
|
|
5
6
|
import type { McpServerSpec, SkillSpec } from './agent-capabilities.js'
|
|
6
7
|
import { readEffortReport } from './effort.js'
|
|
7
8
|
import { log } from './logger.js'
|
|
@@ -212,6 +213,12 @@ export interface AgentRunSpec {
|
|
|
212
213
|
* from AGENTS.md, so the agent reads them on demand. Absent ⇒ none.
|
|
213
214
|
*/
|
|
214
215
|
contextFiles?: ContextFileInfo[]
|
|
216
|
+
/**
|
|
217
|
+
* The task's reference design images. Downloaded into `.cat-context/reference-screenshots/`
|
|
218
|
+
* before the run and named in the agent's prompt, so a capturing agent can compare against them
|
|
219
|
+
* and use their view names. Absent ⇒ nothing is downloaded and nothing is said.
|
|
220
|
+
*/
|
|
221
|
+
referenceScreenshots?: ReferenceScreenshotsSpec
|
|
215
222
|
/**
|
|
216
223
|
* The skills to make available for this run — a `skill` step's picked skill and/or the playbooks
|
|
217
224
|
* the running agent kind declares. Installed HARNESS-AWARE: the claude-code runner writes them
|
|
@@ -283,6 +290,21 @@ export async function runAgentInWorkspace(
|
|
|
283
290
|
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
284
291
|
const contextFiles = spec.contextFiles ?? []
|
|
285
292
|
await materializeContextFiles(spec.dir, contextFiles)
|
|
293
|
+
// The task's reference designs, fetched into `.cat-context/reference-screenshots/` for the kinds
|
|
294
|
+
// that capture views. Delivered here (beside the linked context, before either harness path
|
|
295
|
+
// branches) so the Pi and subscription runs are handed the SAME directory and the SAME view
|
|
296
|
+
// names; a per-path copy is how one of them would end up silently without it.
|
|
297
|
+
//
|
|
298
|
+
// This runs once per PASS, not once per job: a coding flow re-enters its workspace for every
|
|
299
|
+
// repair round. That is safe because the delivery is idempotent over the checkout (a file
|
|
300
|
+
// already on disk is counted, never re-fetched), so a later round costs a stat per reference and
|
|
301
|
+
// cannot report a view an earlier round successfully delivered as absent. A view that MISSED is
|
|
302
|
+
// retried, which is the behaviour worth having: the next round is a fresh chance at a blob
|
|
303
|
+
// backend that was briefly down.
|
|
304
|
+
const referenceGuidance = await deliverReferenceScreenshots(spec.dir, spec.referenceScreenshots, {
|
|
305
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
306
|
+
log: opts.log ?? log,
|
|
307
|
+
})
|
|
286
308
|
// Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
|
|
287
309
|
// there. Everything else reads the checkout, so materialise each skill's resources under
|
|
288
310
|
// `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
|
|
@@ -306,7 +328,7 @@ export async function runAgentInWorkspace(
|
|
|
306
328
|
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
307
329
|
cwd: spec.dir,
|
|
308
330
|
model: spec.model,
|
|
309
|
-
systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles)
|
|
331
|
+
systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${referenceGuidance}`,
|
|
310
332
|
userPrompt: spec.userPrompt,
|
|
311
333
|
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
312
334
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
@@ -376,6 +398,7 @@ export async function runAgentInWorkspace(
|
|
|
376
398
|
serviceDirectory: spec.serviceDirectory,
|
|
377
399
|
contextFiles,
|
|
378
400
|
hasBlueprints,
|
|
401
|
+
...(referenceGuidance ? { referenceGuidance } : {}),
|
|
379
402
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
380
403
|
})
|
|
381
404
|
// Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
|
package/src/pi.ts
CHANGED
|
@@ -218,6 +218,13 @@ export async function writeAgentsContext(
|
|
|
218
218
|
* every turn) pointing at files that don't exist. Absent/false ⇒ the note is omitted.
|
|
219
219
|
*/
|
|
220
220
|
hasBlueprints?: boolean
|
|
221
|
+
/**
|
|
222
|
+
* The reference-design block composed by `referenceScreenshotGuidance`: which files the run
|
|
223
|
+
* was handed and which it could not fetch. Composed by the caller (it is the only side that
|
|
224
|
+
* knows what actually landed on disk) and appended verbatim. Absent/'' ⇒ nothing is said,
|
|
225
|
+
* which is the normal case: only a capturing kind is sent references at all.
|
|
226
|
+
*/
|
|
227
|
+
referenceGuidance?: string
|
|
221
228
|
} = {},
|
|
222
229
|
): Promise<void> {
|
|
223
230
|
const dir = join(homedir(), '.pi', 'agent')
|
|
@@ -246,9 +253,13 @@ export async function writeAgentsContext(
|
|
|
246
253
|
// (see the note above `writeAgentsContext`): it comes solely from the backend `spec-aware`
|
|
247
254
|
// trait, so a spec-aware run no longer carries it twice.
|
|
248
255
|
const blueprint = opts.hasBlueprints ? BLUEPRINT_GUIDANCE : ''
|
|
256
|
+
// The reference designs the harness downloaded for a capturing kind, listed with their view
|
|
257
|
+
// names (and the ones that could not be fetched). Last, beside the linked-context list it is the
|
|
258
|
+
// sibling of: both point the agent at files already on disk.
|
|
259
|
+
const references = opts.referenceGuidance ?? ''
|
|
249
260
|
await writeFile(
|
|
250
261
|
join(dir, 'AGENTS.md'),
|
|
251
|
-
`${systemPrompt}${blueprint}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}`,
|
|
262
|
+
`${systemPrompt}${blueprint}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}${references}`,
|
|
252
263
|
'utf8',
|
|
253
264
|
)
|
|
254
265
|
}
|
|
@@ -312,9 +323,22 @@ export async function materializeContextFiles(
|
|
|
312
323
|
const dir = join(cwd, CONTEXT_DIR)
|
|
313
324
|
await mkdir(dir, { recursive: true })
|
|
314
325
|
for (const f of files) await writeFile(join(dir, f.path), f.content, 'utf8')
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
326
|
+
await excludeContextDir(cwd)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Add the LOCAL git exclude entry for {@link CONTEXT_DIR}, so nothing the harness materialises
|
|
331
|
+
* there can be committed into the agent's PR by a `git add -A`.
|
|
332
|
+
*
|
|
333
|
+
* The exclude pattern has no leading slash, so it matches `.cat-context/` at any depth, covering
|
|
334
|
+
* the monorepo case where cwd is a service subdirectory below the repo root. Best-effort: a
|
|
335
|
+
* scaffold-from-scratch checkout has no `.git` yet, and the files then simply stay untracked.
|
|
336
|
+
*
|
|
337
|
+
* One helper rather than a copy per materialiser: every writer into that directory owes the same
|
|
338
|
+
* exclude, and a new one that forgot it would leak the platform's own files into a customer's
|
|
339
|
+
* repository with nothing failing.
|
|
340
|
+
*/
|
|
341
|
+
export async function excludeContextDir(cwd: string): Promise<void> {
|
|
318
342
|
const gitRoot = await findGitRoot(cwd)
|
|
319
343
|
if (!gitRoot) return
|
|
320
344
|
try {
|
|
@@ -356,13 +380,7 @@ export async function materializeSkillResources(
|
|
|
356
380
|
await writeFile(dest, r.content, 'utf8')
|
|
357
381
|
}
|
|
358
382
|
}
|
|
359
|
-
|
|
360
|
-
if (!gitRoot) return
|
|
361
|
-
try {
|
|
362
|
-
await appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8')
|
|
363
|
-
} catch {
|
|
364
|
-
// No writable .git/info; the files simply stay untracked.
|
|
365
|
-
}
|
|
383
|
+
await excludeContextDir(cwd)
|
|
366
384
|
}
|
|
367
385
|
|
|
368
386
|
/** Walk up from `dir` (bounded) to the directory containing a `.git` folder, or null. */
|