@cat-factory/executor-harness 1.135.0 → 1.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/salvage.d.ts CHANGED
@@ -126,6 +126,28 @@ export declare function salvageUntrackedWork(args: {
126
126
  signal?: AbortSignal;
127
127
  bounds?: SalvageBounds;
128
128
  }): Promise<SalvageReport>;
129
+ /**
130
+ * Fold a later salvage pass onto an earlier one, so a run that salvaged TWICE reports what both
131
+ * passes did rather than only the last.
132
+ *
133
+ * A coding run salvages at each point where the next thing to happen reads COMMITS rather than the
134
+ * working tree: once before the pre-PR gates (which is what lets them run at all on a run whose
135
+ * only product is untracked files), and again at the settle, because a gate's repair round runs
136
+ * the agent afresh and can leave new files of its own. On almost every run the second pass is
137
+ * `none` (the first one already committed everything), so this is a cheap way to keep ONE honest
138
+ * report instead of two half-truths.
139
+ *
140
+ * Counts are summed only when BOTH passes committed, because only then are the two sets disjoint.
141
+ * A pass that refused or failed sees the same files again on the next pass, so summing there would
142
+ * double-count the one loss; instead the more significant status wins outright, `failed` over
143
+ * `refused` over `committed`, on the rule that a pass which could NOT keep its files is the fact a
144
+ * human has to act on and must not be hidden by a later pass that found nothing left to do. Ties
145
+ * take the later pass, whose numbers are the current ones.
146
+ *
147
+ * `withheld` is always the union: a credential-bearing file either pass declined to commit has to
148
+ * be named whatever else happened, since naming it is what lets someone rotate what it held.
149
+ */
150
+ export declare function foldSalvageReports(previous: SalvageReport, next: SalvageReport): SalvageReport;
129
151
  /**
130
152
  * How the run that left these files behind ended. It decides what the commit message SAYS, which
131
153
  * is the whole point of marking a salvage: a commit arriving on a branch with no explanation is
@@ -159,6 +181,21 @@ export declare function salvageCommitMessage(fileCount: number, occasion: Salvag
159
181
  * into describing it differently. The caller decides WHERE it goes.
160
182
  */
161
183
  export declare function salvageOnlyNotice(): string;
184
+ /**
185
+ * Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
186
+ *
187
+ * Only the BODY is marked. A title carrying it would follow the PR into every list and
188
+ * notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
189
+ * diff, and the salvage commit's own message elaborates on it there.
190
+ *
191
+ * Lives here rather than beside either caller because BOTH open pull requests off a salvage-only
192
+ * branch: the multi-repo push phase, per leg, and the single-repo one. It was the multi-repo path
193
+ * alone for a while, which meant the same branch shape opened an unmarked PR depending only on how
194
+ * many repositories the run happened to clone.
195
+ */
196
+ export declare function withSalvageOnlyNote<T extends {
197
+ body: string;
198
+ }>(pr: T, salvageOnly: boolean): T;
162
199
  /**
163
200
  * Where a salvage commit ENDED UP, which the salvage itself cannot know: it commits, and someone
164
201
  * else pushes. A commit that was not pushed dies with the container exactly as the uncommitted
@@ -174,7 +211,13 @@ export interface SalvageDelivery {
174
211
  * reports, so the person reading "the run was killed" is told in the same breath what became of
175
212
  * its work: on the branch and reviewed by nobody, still in the container, or never committed.
176
213
  *
214
+ * `occasion` is the SAME fact {@link salvageCommitMessage} is given, and for the same reason: how
215
+ * the run ended is what decides how much to trust the files. A run that was killed left them
216
+ * mid-thought; a run that settled simply never added them, and telling a human a clean run "was
217
+ * aborted" describes a failure that did not happen. The two texts had drifted precisely here,
218
+ * which is why the occasion is now a parameter of both rather than a constant inside one.
219
+ *
177
220
  * `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
178
221
  * ordinary push follows (the settle path), so there is nothing extra to say.
179
222
  */
180
- export declare function describeSalvage(report: SalvageReport, delivery?: SalvageDelivery): string | undefined;
223
+ export declare function describeSalvage(report: SalvageReport, occasion: SalvageOccasion, delivery?: SalvageDelivery): string | undefined;
package/dist/salvage.js CHANGED
@@ -206,6 +206,59 @@ export async function salvageUntrackedWork(args) {
206
206
  return { status: 'failed', ...report, reason };
207
207
  }
208
208
  }
209
+ /**
210
+ * Fold a later salvage pass onto an earlier one, so a run that salvaged TWICE reports what both
211
+ * passes did rather than only the last.
212
+ *
213
+ * A coding run salvages at each point where the next thing to happen reads COMMITS rather than the
214
+ * working tree: once before the pre-PR gates (which is what lets them run at all on a run whose
215
+ * only product is untracked files), and again at the settle, because a gate's repair round runs
216
+ * the agent afresh and can leave new files of its own. On almost every run the second pass is
217
+ * `none` (the first one already committed everything), so this is a cheap way to keep ONE honest
218
+ * report instead of two half-truths.
219
+ *
220
+ * Counts are summed only when BOTH passes committed, because only then are the two sets disjoint.
221
+ * A pass that refused or failed sees the same files again on the next pass, so summing there would
222
+ * double-count the one loss; instead the more significant status wins outright, `failed` over
223
+ * `refused` over `committed`, on the rule that a pass which could NOT keep its files is the fact a
224
+ * human has to act on and must not be hidden by a later pass that found nothing left to do. Ties
225
+ * take the later pass, whose numbers are the current ones.
226
+ *
227
+ * `withheld` is always the union: a credential-bearing file either pass declined to commit has to
228
+ * be named whatever else happened, since naming it is what lets someone rotate what it held.
229
+ */
230
+ export function foldSalvageReports(previous, next) {
231
+ if (next.status === 'none')
232
+ return withWithheld(previous, next);
233
+ if (previous.status === 'none')
234
+ return withWithheld(next, previous);
235
+ if (previous.status === 'committed' && next.status === 'committed') {
236
+ return withWithheld({
237
+ status: 'committed',
238
+ files: [...previous.files, ...next.files].slice(0, REPORTED_PATHS),
239
+ fileCount: previous.fileCount + next.fileCount,
240
+ totalBytes: previous.totalBytes + next.totalBytes,
241
+ ...((next.commitSha ?? previous.commitSha)
242
+ ? { commitSha: next.commitSha ?? previous.commitSha }
243
+ : {}),
244
+ }, previous);
245
+ }
246
+ return SALVAGE_STATUS_RANK[previous.status] > SALVAGE_STATUS_RANK[next.status]
247
+ ? withWithheld(previous, next)
248
+ : withWithheld(next, previous);
249
+ }
250
+ /** Which status a fold keeps when the two passes disagree; see {@link foldSalvageReports}. */
251
+ const SALVAGE_STATUS_RANK = {
252
+ none: 0,
253
+ committed: 1,
254
+ refused: 2,
255
+ failed: 3,
256
+ };
257
+ /** `kept` with `other`'s withheld paths merged in, de-duplicated and order-preserving. */
258
+ function withWithheld(kept, other) {
259
+ const union = [...new Set([...(kept.withheld ?? []), ...(other.withheld ?? [])])];
260
+ return union.length > 0 ? { ...kept, withheld: union } : kept;
261
+ }
209
262
  /** The salvage commit's message: what it is, why it exists, and how much to trust it. */
210
263
  export function salvageCommitMessage(fileCount, occasion) {
211
264
  const noun = fileCount === 1 ? 'file' : 'files';
@@ -240,6 +293,21 @@ export function salvageOnlyNotice() {
240
293
  `not be discarded with the container. Nothing has reviewed them for completeness or ` +
241
294
  `relevance, and some may be scratch work from the agent's task in a sibling repository.`);
242
295
  }
296
+ /**
297
+ * Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
298
+ *
299
+ * Only the BODY is marked. A title carrying it would follow the PR into every list and
300
+ * notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
301
+ * diff, and the salvage commit's own message elaborates on it there.
302
+ *
303
+ * Lives here rather than beside either caller because BOTH open pull requests off a salvage-only
304
+ * branch: the multi-repo push phase, per leg, and the single-repo one. It was the multi-repo path
305
+ * alone for a while, which meant the same branch shape opened an unmarked PR depending only on how
306
+ * many repositories the run happened to clone.
307
+ */
308
+ export function withSalvageOnlyNote(pr, salvageOnly) {
309
+ return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr;
310
+ }
243
311
  /** Total size of `paths` under `dir`; a file that cannot be stat'd counts as zero rather than failing. */
244
312
  async function measure(dir, paths) {
245
313
  const sizes = await Promise.all(paths.map((path) => stat(join(dir, path)).then((info) => info.size, () => 0)));
@@ -250,15 +318,21 @@ async function measure(dir, paths) {
250
318
  * reports, so the person reading "the run was killed" is told in the same breath what became of
251
319
  * its work: on the branch and reviewed by nobody, still in the container, or never committed.
252
320
  *
321
+ * `occasion` is the SAME fact {@link salvageCommitMessage} is given, and for the same reason: how
322
+ * the run ended is what decides how much to trust the files. A run that was killed left them
323
+ * mid-thought; a run that settled simply never added them, and telling a human a clean run "was
324
+ * aborted" describes a failure that did not happen. The two texts had drifted precisely here,
325
+ * which is why the occasion is now a parameter of both rather than a constant inside one.
326
+ *
253
327
  * `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
254
328
  * ordinary push follows (the settle path), so there is nothing extra to say.
255
329
  */
256
- export function describeSalvage(report, delivery) {
257
- const parts = [describeOutcome(report, delivery), describeWithheld(report)].filter((part) => part !== undefined);
330
+ export function describeSalvage(report, occasion, delivery) {
331
+ const parts = [describeOutcome(report, occasion, delivery), describeWithheld(report)].filter((part) => part !== undefined);
258
332
  return parts.length > 0 ? parts.join(' ') : undefined;
259
333
  }
260
334
  /** The fate of the files the salvage DID try to keep. */
261
- function describeOutcome(report, delivery) {
335
+ function describeOutcome(report, occasion, delivery) {
262
336
  switch (report.status) {
263
337
  case 'none':
264
338
  return undefined;
@@ -267,8 +341,11 @@ function describeOutcome(report, delivery) {
267
341
  ? `commit ${report.commitSha ?? 'unknown'}, which could NOT be pushed ` +
268
342
  `(${delivery.reason ?? 'the push failed'}) and so is lost with the container`
269
343
  : `commit ${report.commitSha ?? 'unknown'}`;
344
+ const why = occasion.kind === 'aborted'
345
+ ? 'this run was aborted'
346
+ : 'the agent finished without committing them';
270
347
  return (`${report.fileCount} uncommitted new file(s) the agent left behind were salvaged into ` +
271
- `${landed}; this run was aborted, so review them before trusting them.`);
348
+ `${landed}; ${why}, so review them before trusting them.`);
272
349
  }
273
350
  case 'refused':
274
351
  return `Uncommitted new files were NOT salvaged: ${report.reason ?? 'over the salvage bounds'}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.135.0",
3
+ "version": "1.139.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,7 +26,7 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@cat-factory/kernel": "0.321.0",
29
- "@cat-factory/server": "0.306.5",
29
+ "@cat-factory/server": "0.306.7",
30
30
  "@cat-factory/spend": "0.16.17",
31
31
  "@hono/node-server": "^2.1.1",
32
32
  "@types/node": "^26.2.0",
@@ -683,6 +683,17 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
683
683
  // The built-in tools this run declares, named ONCE: the same list rides `--tools` and the
684
684
  // `--allowedTools` re-grant, which is additive rather than inert (see `claudeAllowedToolPatterns`).
685
685
  const tools = CLAUDE_TOOL_SET
686
+ // ...but `--tools` itself is withheld from an ambient run, whose `claude` is the developer's own
687
+ // rather than this image's pinned one: an unrecognised FLAG fails the whole run, where an
688
+ // unrecognised tool NAME is merely dropped. See `claudeCliArgs`. The re-grant is unaffected
689
+ // (`--allowedTools` long predates this), so a tool-server run still unlocks what it wires.
690
+ const declareTools = opts.ambientAuth !== true
691
+ const declaredTools = declareTools ? tools : []
692
+ if (!declareTools) {
693
+ opts.log?.info(
694
+ 'claude-code: taking the CLI’s default tool surface (ambient CLI, version unknown)',
695
+ )
696
+ }
686
697
 
687
698
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : []
688
699
  const capture = openClaudeCallCapture(opts, { prompt, folded, secrets })
@@ -730,7 +741,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
730
741
  reportToolServerStartup(event, opts.onToolServers)
731
742
  // The same startup event answers what the CLI granted of what we asked for; a capability it
732
743
  // named no tool for is a silent capability loss otherwise (see `assertClaudeToolsCurrent`).
733
- assertClaudeToolsCurrent(event, tools, opts.log)
744
+ assertClaudeToolsCurrent(event, declaredTools, opts.log)
734
745
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
735
746
  // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
736
747
  // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
@@ -820,7 +831,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
820
831
  const { stderrTail } = await streamCli(
821
832
  {
822
833
  command: 'claude',
823
- args: claudeCliArgs({ model: opts.model, tools, mcpArgs: home.mcpArgs, appendArgs }),
834
+ args: claudeCliArgs({
835
+ model: opts.model,
836
+ tools,
837
+ declareTools,
838
+ mcpArgs: home.mcpArgs,
839
+ appendArgs,
840
+ }),
824
841
  },
825
842
  prompt,
826
843
  { ...opts, signal: runSignal },
package/src/agent.ts CHANGED
@@ -31,12 +31,14 @@ import {
31
31
  import { inferVcsProvider, openPullRequest } from './vcs-api.js'
32
32
  import type { PiRunStats, RunDiagnostics } from './pi-reduction.js'
33
33
  import { applyPrDescription } from './pr-description.js'
34
+ import { withSalvageOnlyNote } from './salvage.js'
34
35
  import { makeDirClaimer } from './checkout-dir.js'
35
36
  import { noChangesReason, runCodingAgent } from './coding-agent.js'
36
37
  import { runMultiRepoCoding } from './multi-repo-coding.js'
37
38
  import { validationFailureMessage } from './validation-checks.js'
38
39
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
39
40
  import { agentCapabilities, mergeEffort } from './agent-shared.js'
41
+ import { appendEnvironmentInventory } from './environment-inventory.js'
40
42
  import { runBootstrap } from './bootstrap-mode.js'
41
43
  import {
42
44
  acquireRepoCheckout,
@@ -176,9 +178,28 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
176
178
  ...artifactUploadEnv(job.artifactUpload),
177
179
  })
178
180
  if (job.mode === 'preview') return await runPreviewMode(job, scoped)
179
- return job.mode === 'coding'
180
- ? await runCodingMode(job, scoped)
181
- : await runExploreMode(job, scoped)
181
+ // THE composition point for the environment inventory (see `environment-inventory.ts`): the
182
+ // machine is probed ONCE here, before any mode branches, and the result is folded onto the
183
+ // job's own system prompt. Every mode, every repair round and all three agent CLIs read that
184
+ // one field, so none of them can end up without the block and none can carry it twice.
185
+ // `preview` returns above because it runs no agent at all, so there is no prompt to fold onto.
186
+ //
187
+ // This sits on the critical path AHEAD of the clone, which is the cost of having one
188
+ // composition point instead of one per mode (each mode owns its own clone, so there is no
189
+ // single post-clone place to put this). The pass is sized for that: everything in it runs
190
+ // concurrently, every probe is a call that answers in milliseconds or is wedged, and the one
191
+ // deliberate wait is a single short retry for a daemon that is still starting.
192
+
193
+ const staged: AgentJob = {
194
+ ...job,
195
+ systemPrompt: await appendEnvironmentInventory(job.systemPrompt, {
196
+ ...(opts.signal ? { signal: opts.signal } : {}),
197
+ ...(opts.log ? { log: opts.log } : {}),
198
+ }),
199
+ }
200
+ return staged.mode === 'coding'
201
+ ? await runCodingMode(staged, scoped)
202
+ : await runExploreMode(staged, scoped)
182
203
  } finally {
183
204
  if (scopeDir) await rm(scopeDir, { recursive: true, force: true }).catch(() => {})
184
205
  }
@@ -925,6 +946,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
925
946
  reproductionReport,
926
947
  effortReport,
927
948
  prDescription,
949
+ salvageOnly,
928
950
  } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
929
951
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
930
952
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
@@ -1000,8 +1022,10 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1000
1022
  ghToken: job.ghToken,
1001
1023
  head: pushBranch,
1002
1024
  base: job.repo.baseBranch,
1003
- // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text.
1004
- pr: applyPrDescription(job.pr, prDescription),
1025
+ // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text,
1026
+ // and a branch that is nothing but salvage says so above whichever body won: the agent
1027
+ // committed nothing here, so no briefing on it describes a change anyone proposed.
1028
+ pr: withSalvageOnlyNote(applyPrDescription(job.pr, prDescription), salvageOnly === true),
1005
1029
  // A resumed run's PR is already open, so refresh it rather than lose the briefing to the
1006
1030
  // duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
1007
1031
  ...(prDescription ? { refreshExisting: true } : {}),
package/src/claude-cli.ts CHANGED
@@ -141,6 +141,18 @@ export function claudeCliArgs(opts: {
141
141
  model: string
142
142
  /** The built-in tools this run asks for; see {@link CLAUDE_TOOL_SET}. */
143
143
  tools: readonly string[]
144
+ /**
145
+ * Whether to DECLARE that set with `--tools`, or take whatever the CLI defaults to.
146
+ *
147
+ * False for an `ambientAuth` run, which is the one case where the CLI is not this image's. A
148
+ * name the build does not carry is dropped silently, which is what makes {@link CLAUDE_TOOL_SET}
149
+ * safe to be over-inclusive, but that rule is about tool NAMES. An unrecognised FLAG is a
150
+ * different failure: the CLI exits before the run starts. Everywhere else the image pins the
151
+ * version and the flag is measured against it; on a developer's own machine the harness knows
152
+ * neither which `claude` is on the PATH nor how old it is, and the cost of guessing wrong is
153
+ * every local native run, not a thinner tool surface on one.
154
+ */
155
+ declareTools: boolean
144
156
  /** `--mcp-config` + `--strict-mcp-config` + any `--allowedTools`; empty when no server is wired. */
145
157
  mcpArgs: readonly string[]
146
158
  /** `--append-system-prompt <prompt>`, or empty when the prompt was folded into stdin. */
@@ -160,8 +172,7 @@ export function claudeCliArgs(opts: {
160
172
  '--model',
161
173
  opts.model,
162
174
  // Declared rather than defaulted: see this module's header for what the default set costs.
163
- '--tools',
164
- opts.tools.join(','),
175
+ ...(opts.declareTools ? ['--tools', opts.tools.join(',')] : []),
165
176
  ...opts.mcpArgs,
166
177
  ...opts.appendArgs,
167
178
  ]
@@ -180,6 +191,11 @@ export function claudeCliArgs(opts: {
180
191
  * Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
181
192
  * disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
182
193
  * job and not to stay silent (which reads exactly like a satisfied request).
194
+ *
195
+ * `requested` is what the argv actually DECLARED, so an ambient run (which declares nothing, see
196
+ * {@link claudeCliArgs}) passes none and the line says the surface is the CLI's own default. The
197
+ * floor is still read back there: "the default set carries no search tool" and "we asked for one
198
+ * and did not get it" are both worth a line, and they are not the same fact or the same fix.
183
199
  */
184
200
  export function assertClaudeToolsCurrent(
185
201
  event: Record<string, unknown>,
@@ -192,7 +208,7 @@ export function assertClaudeToolsCurrent(
192
208
  const cliVersion = version ? { cliVersion: version } : {}
193
209
  if (!Array.isArray(event.tools)) {
194
210
  log.warn('claude-code announced no tool list, so this run has an unverified tool surface', {
195
- requestedTools: [...requested],
211
+ ...(requested.length > 0 ? { requestedTools: [...requested] } : { toolsDeclared: false }),
196
212
  ...cliVersion,
197
213
  })
198
214
  return
@@ -202,7 +218,7 @@ export function assertClaudeToolsCurrent(
202
218
  (c) => c.capability,
203
219
  )
204
220
  const fields = {
205
- requestedTools: [...requested],
221
+ ...(requested.length > 0 ? { requestedTools: [...requested] } : { toolsDeclared: false }),
206
222
  grantedTools: [...granted].sort(),
207
223
  ...cliVersion,
208
224
  }