@cat-factory/executor-harness 1.132.3 → 1.134.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.
Files changed (55) hide show
  1. package/README.md +47 -0
  2. package/dist/agent-env.d.ts +17 -0
  3. package/dist/agent-env.js +47 -0
  4. package/dist/agent-runner.d.ts +11 -2
  5. package/dist/agent-runner.js +3 -48
  6. package/dist/agent.d.ts +0 -11
  7. package/dist/agent.js +7 -132
  8. package/dist/captured-command.d.ts +1 -1
  9. package/dist/captured-command.js +3 -2
  10. package/dist/coding-agent.d.ts +35 -0
  11. package/dist/coding-agent.js +213 -41
  12. package/dist/docker-status.d.ts +89 -0
  13. package/dist/docker-status.js +147 -0
  14. package/dist/frontend-infra.js +4 -3
  15. package/dist/git.d.ts +48 -5
  16. package/dist/git.js +93 -26
  17. package/dist/guard-driver.d.ts +71 -0
  18. package/dist/guard-driver.js +171 -0
  19. package/dist/harness-server.js +13 -0
  20. package/dist/infra-standup.d.ts +69 -0
  21. package/dist/infra-standup.js +182 -0
  22. package/dist/job.d.ts +10 -0
  23. package/dist/multi-repo-coding.d.ts +17 -0
  24. package/dist/multi-repo-coding.js +55 -8
  25. package/dist/pi-workspace.d.ts +11 -0
  26. package/dist/pi-workspace.js +47 -0
  27. package/dist/pi.d.ts +8 -0
  28. package/dist/pi.js +16 -9
  29. package/dist/progress-guard.d.ts +56 -10
  30. package/dist/progress-guard.js +84 -22
  31. package/dist/runner.d.ts +1 -1
  32. package/dist/salvage.d.ts +180 -0
  33. package/dist/salvage.js +289 -0
  34. package/dist/workspace-probe.d.ts +85 -0
  35. package/dist/workspace-probe.js +124 -0
  36. package/package.json +4 -4
  37. package/src/agent-env.ts +49 -0
  38. package/src/agent-runner.ts +14 -53
  39. package/src/agent.ts +7 -158
  40. package/src/captured-command.ts +3 -2
  41. package/src/coding-agent.ts +252 -44
  42. package/src/docker-status.ts +201 -0
  43. package/src/frontend-infra.ts +4 -3
  44. package/src/git.ts +104 -26
  45. package/src/guard-driver.ts +203 -0
  46. package/src/harness-server.ts +13 -0
  47. package/src/infra-standup.ts +218 -0
  48. package/src/job.ts +10 -0
  49. package/src/multi-repo-coding.ts +59 -8
  50. package/src/pi-workspace.ts +72 -0
  51. package/src/pi.ts +27 -12
  52. package/src/progress-guard.ts +110 -34
  53. package/src/runner.ts +1 -1
  54. package/src/salvage.ts +407 -0
  55. package/src/workspace-probe.ts +155 -0
@@ -43,8 +43,14 @@ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
43
43
  // broad on purpose: different models/extensions name the same capability differently
44
44
  // (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
45
45
  // and a false "no edits" reading would kill a run that IS making changes. Matched
46
- // case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
47
- // recognised here — broaden or move to a working-tree signal if that becomes common.
46
+ // case-insensitively.
47
+ //
48
+ // A file written purely through `bash` (a heredoc, `sed -i`, `node -e`) is NOT recognised here,
49
+ // and deliberately so: this set answers "did the model call a tool we already know edits files",
50
+ // which is a cheap SUFFICIENT condition and never a necessary one. The necessary one is the
51
+ // working tree itself, which is what the no-edit bound now actually decides on: see the
52
+ // `needs-workspace-evidence` verdict and {@link ProgressGuard.noteWorkspaceMutation}. A hit here
53
+ // still satisfies the bound outright, so the common case never pays for a probe.
48
54
  const FILE_EDIT_TOOLS = new Set([
49
55
  'edit',
50
56
  'write',
@@ -170,11 +176,15 @@ export function mergeGuardLimits(base, overrides) {
170
176
  };
171
177
  }
172
178
  /**
173
- * Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
174
- * reason the moment a run has plainly stopped making progress, so the harness can
175
- * kill Pi early instead of letting it burn the whole budget (and then surface a
176
- * useful failure instead of a generic "no file changes"). Pure and incremental so
177
- * it can be unit-tested over a fixed event sequence.
179
+ * Live anti-rabbithole guard: fed each streamed tool-call signal, it returns a {@link
180
+ * ProgressVerdict} the moment a run has plainly stopped making progress, so the harness can kill
181
+ * the CLI early instead of letting it burn the whole budget (and then surface a useful failure
182
+ * instead of a generic "no file changes").
183
+ *
184
+ * PURE, SYNCHRONOUS and INCREMENTAL, so it can be unit-tested over a fixed event sequence: it
185
+ * spawns nothing and reads nothing off disk. The one bound that needs evidence from outside the
186
+ * stream says so in its verdict and lets the caller fetch it, then reports the answer back
187
+ * through {@link noteWorkspaceMutation} / {@link rearmNoEditBound}.
178
188
  */
179
189
  export class ProgressGuard {
180
190
  limits;
@@ -185,13 +195,18 @@ export class ProgressGuard {
185
195
  consecutiveWebCalls = 0;
186
196
  consecutiveMcpCalls = 0;
187
197
  consecutiveNonActionCalls = 0;
198
+ // Set when the no-edit bound has been reported as `needs-workspace-evidence` and the caller's
199
+ // probe has not answered yet. It suppresses a second report: the bound is a threshold, so every
200
+ // action call past it would otherwise re-raise the same unanswered question and the caller would
201
+ // probe git once per tool call. Cleared by whichever answer comes back.
202
+ awaitingWorkspaceEvidence = false;
188
203
  constructor(limits,
189
204
  /** When false (assess-only runs like the merger), the no-edit bound is skipped. */
190
205
  expectsEdits = true) {
191
206
  this.limits = limits;
192
207
  this.expectsEdits = expectsEdits;
193
208
  }
194
- /** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
209
+ /** Feed one parsed Pi event; returns a {@link ProgressVerdict} when the run is in trouble, else null. */
195
210
  observe(event) {
196
211
  const tool = toolCallSignal(event);
197
212
  if (!tool)
@@ -199,8 +214,35 @@ export class ProgressGuard {
199
214
  return this.observeSignal(tool);
200
215
  }
201
216
  /**
202
- * Feed one already-parsed tool-call signal (name + error flag), returning a diagnostic reason
203
- * when the run should abort, else null. Split out of {@link observe} so a caller whose stream
217
+ * Record that the run HAS changed the repository, however it did it. Satisfies the no-edit
218
+ * bound permanently, exactly as a recognised edit-tool call does, matching that bound's
219
+ * existing semantics: it guards a run only UNTIL its first edit, because an agent that has
220
+ * changed the tree has demonstrably started the work.
221
+ *
222
+ * Called by the driver when a workspace probe answers a `needs-workspace-evidence` verdict
223
+ * positively. Idempotent, and cheap enough that a caller who probes for other reasons may also
224
+ * report through it.
225
+ */
226
+ noteWorkspaceMutation() {
227
+ this.edits++;
228
+ this.awaitingWorkspaceEvidence = false;
229
+ }
230
+ /**
231
+ * Re-arm the no-edit bound after a probe that could answer NEITHER way (it threw). The bound
232
+ * becomes trippable again once another `maxToolCallsWithoutEdit` action calls have gone by,
233
+ * rather than the run being killed on a git failure or left permanently unguarded by one.
234
+ *
235
+ * Failing open here is the deliberate half: killing a productive run is the expensive error,
236
+ * and the streak bounds, the inactivity watchdog and the job's wall-clock cap all still hold
237
+ * the run in the meantime.
238
+ */
239
+ rearmNoEditBound() {
240
+ this.toolCalls = 0;
241
+ this.awaitingWorkspaceEvidence = false;
242
+ }
243
+ /**
244
+ * Feed one already-parsed tool-call signal (name + error flag), returning a {@link
245
+ * ProgressVerdict} when a bound is reached, else null. Split out of {@link observe} so a caller whose stream
204
246
  * is NOT Pi's `tool_execution_end` envelope — the claude-code runner, which correlates a
205
247
  * `tool_use` block's name with its `tool_result`'s `is_error` — can drive the SAME guard logic
206
248
  * without synthesising a fake Pi event.
@@ -211,8 +253,11 @@ export class ProgressGuard {
211
253
  // isn't wedged in a failing-op loop), so it's updated before the planning skip.
212
254
  this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0;
213
255
  if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
214
- return (`no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
215
- `retrying a failing operation rather than making progress. Aborting.`);
256
+ return {
257
+ kind: 'abort',
258
+ reason: `no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
259
+ `retrying a failing operation rather than making progress. Aborting.`,
260
+ };
216
261
  }
217
262
  // Web search/fetch loop: web tools are read-only (they don't count toward the
218
263
  // no-edit bound), so guard them separately — an uninterrupted streak of them is a
@@ -221,8 +266,11 @@ export class ProgressGuard {
221
266
  this.consecutiveWebCalls++;
222
267
  const webCap = this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls;
223
268
  if (this.consecutiveWebCalls >= webCap) {
224
- return (`no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
225
- `any other action — the agent is stuck researching instead of doing the work. Aborting.`);
269
+ return {
270
+ kind: 'abort',
271
+ reason: `no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
272
+ `any other action — the agent is stuck researching instead of doing the work. Aborting.`,
273
+ };
226
274
  }
227
275
  }
228
276
  else {
@@ -235,9 +283,12 @@ export class ProgressGuard {
235
283
  this.consecutiveMcpCalls++;
236
284
  const mcpCap = this.limits.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls;
237
285
  if (this.consecutiveMcpCalls >= mcpCap) {
238
- return (`no progress: ${this.consecutiveMcpCalls} consecutive tool-server (MCP) calls without ` +
239
- `any other action. The agent is stuck querying its tools instead of doing the work. ` +
240
- `Aborting.`);
286
+ return {
287
+ kind: 'abort',
288
+ reason: `no progress: ${this.consecutiveMcpCalls} consecutive tool-server (MCP) calls without ` +
289
+ `any other action. The agent is stuck querying its tools instead of doing the work. ` +
290
+ `Aborting.`,
291
+ };
241
292
  }
242
293
  }
243
294
  else {
@@ -264,9 +315,12 @@ export class ProgressGuard {
264
315
  const nonActionCap = this.limits.maxConsecutiveNonActionCalls ??
265
316
  DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls;
266
317
  if (this.consecutiveNonActionCalls >= nonActionCap) {
267
- return (`no progress: ${this.consecutiveNonActionCalls} consecutive read-only calls (searching, ` +
268
- `reading, tool-server lookups, subagent dispatches) with no action call between them. ` +
269
- `The agent is cycling through research instead of doing the work. Aborting.`);
318
+ return {
319
+ kind: 'abort',
320
+ reason: `no progress: ${this.consecutiveNonActionCalls} consecutive read-only calls (searching, ` +
321
+ `reading, tool-server lookups, subagent dispatches) with no action call between them. ` +
322
+ `The agent is cycling through research instead of doing the work. Aborting.`,
323
+ };
270
324
  }
271
325
  return null;
272
326
  }
@@ -274,11 +328,19 @@ export class ProgressGuard {
274
328
  this.toolCalls++;
275
329
  if (FILE_EDIT_TOOLS.has(name))
276
330
  this.edits++;
331
+ // PROVISIONAL, not settled: the tool names say no recognised edit tool was called, which is
332
+ // not the same fact as "the repository is unchanged". The caller answers that from the
333
+ // working tree and reports back; until it does, the question is not re-raised.
277
334
  if (this.expectsEdits &&
278
335
  this.edits === 0 &&
336
+ !this.awaitingWorkspaceEvidence &&
279
337
  this.toolCalls >= this.limits.maxToolCallsWithoutEdit) {
280
- return (`no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
281
- `probing the environment without implementing anything. Aborting before it burns the whole run.`);
338
+ this.awaitingWorkspaceEvidence = true;
339
+ return {
340
+ kind: 'needs-workspace-evidence',
341
+ reason: `no progress: ${this.toolCalls} tool calls and no recognised file edit — the agent may be ` +
342
+ `exploring or probing the environment without implementing anything.`,
343
+ };
282
344
  }
283
345
  return null;
284
346
  }
package/dist/runner.d.ts CHANGED
@@ -99,7 +99,7 @@ export interface RunOptions {
99
99
  log?: Logger;
100
100
  /**
101
101
  * Extra environment for the agent's child process, scoped to THIS job. The CLI is spawned with
102
- * `{...process.env, ...agentEnv}`, so these reach the agent and every shell tool it spawns.
102
+ * `agentChildEnv(agentEnv)`, so these reach the agent and every shell tool it spawns.
103
103
  *
104
104
  * This is the seam for anything per-job that would otherwise be written to a process- or
105
105
  * HOME-global (the tester's secrets, a private-registry npmrc pointer). Those globals are only
@@ -0,0 +1,180 @@
1
+ import type { Logger } from './logger.js';
2
+ /**
3
+ * Directory and file names never salvaged. A greenfield checkout may not have a `.gitignore` yet
4
+ * (the agent had not written one when it was killed), and git only excludes what a `.gitignore`
5
+ * tells it to, so without this a blanket salvage would commit `node_modules` into the PR.
6
+ *
7
+ * Matched against every SEGMENT of a path, so `packages/api/node_modules/x` is caught as surely as
8
+ * a root-level one. Deliberately a short list of the unambiguous ones: a cleverer heuristic starts
9
+ * discarding the deliverable, and a `dist/` that genuinely belonged in a commit is a far cheaper
10
+ * miss than a `node_modules/` that did not.
11
+ */
12
+ export declare const SALVAGE_DENIED_SEGMENTS: readonly string[];
13
+ /** Suffixes never salvaged: run output, not source. */
14
+ export declare const SALVAGE_DENIED_SUFFIXES: readonly string[];
15
+ /**
16
+ * Basenames and suffixes that carry CREDENTIALS, withheld from every salvage.
17
+ *
18
+ * The deny-list above trades a cheap miss (a `dist/` that belonged in a commit) against an
19
+ * expensive one (`node_modules/` in a PR). For a secret that trade INVERTS: a private key or a
20
+ * populated `.env` pushed to a branch is a disclosure that outlives the run, cannot be taken back
21
+ * by deleting the commit, and forces a rotation. Missing a file is recoverable; leaking one is not.
22
+ *
23
+ * This exists for the same reason the deny-list does: on the greenfield case the salvage was
24
+ * written for, the agent was killed before it wrote a `.gitignore`, so git excludes nothing and
25
+ * the harness is the only thing standing between an agent-authored key and the pull request.
26
+ *
27
+ * Unlike a junk path, a withheld secret is REPORTED (see {@link SalvageReport.withheld}): the file
28
+ * is real work that did not land, and whoever reads the run has to decide whether to re-create it
29
+ * or, if it holds a live credential, to rotate it.
30
+ */
31
+ export declare const SALVAGE_SECRET_BASENAMES: readonly string[];
32
+ /**
33
+ * Suffixes that mark a key store or an environment file, withheld for the reason above.
34
+ *
35
+ * `.env` is here as well as in {@link isSecretBearingName}'s own `.env` / `.env.*` test, so that
36
+ * `prod.env` and `local.env` are caught alongside `.env` and `.env.production`. The sample
37
+ * allow-list is unaffected: `.env.example` ends in `.example`, not in `.env`.
38
+ */
39
+ export declare const SALVAGE_SECRET_SUFFIXES: readonly string[];
40
+ /** Path segments that are credential or state stores rather than source. */
41
+ export declare const SALVAGE_SECRET_SEGMENTS: readonly string[];
42
+ /**
43
+ * The `.env` files that carry no secret and ARE the deliverable: the checked-in sample every
44
+ * scaffold ships so a reader knows which variables the service wants.
45
+ *
46
+ * An allow-list rather than a cleverer rule, because the two are the same shape and only the
47
+ * convention tells them apart. `.env` and every other `.env.<something>` is withheld: a scaffolded
48
+ * `.env.local` or `.env.production` is exactly where a real key ends up.
49
+ */
50
+ export declare const SALVAGE_ENV_SAMPLE_BASENAMES: readonly string[];
51
+ /** How much may be salvaged before the whole salvage is refused. */
52
+ export interface SalvageBounds {
53
+ maxFiles: number;
54
+ maxBytes: number;
55
+ }
56
+ /**
57
+ * The default bounds. Generous enough for a scaffolded service (the run this was written for left
58
+ * about twenty source files) and far below anything that looks like a build output or a dependency
59
+ * tree that slipped past the deny-list.
60
+ */
61
+ export declare const DEFAULT_SALVAGE_BOUNDS: SalvageBounds;
62
+ /** What the salvage did, carried onto the run outcome so a human is told rather than left to infer. */
63
+ export interface SalvageReport {
64
+ /**
65
+ * `none`: nothing was left uncommitted. `committed`: the files below are in `commitSha`.
66
+ * `refused`: there was work but it exceeded the bounds, so NOTHING was committed — a truncated
67
+ * salvage is worse than none, because a half-committed tree reads as a complete one.
68
+ * `failed`: the commit itself could not be made; the paths are named so the loss is on the record.
69
+ */
70
+ status: 'none' | 'committed' | 'refused' | 'failed';
71
+ /** The salvaged (or would-be salvaged) paths, capped for the log/wire; `fileCount` is the truth. */
72
+ files: string[];
73
+ fileCount: number;
74
+ totalBytes: number;
75
+ commitSha?: string;
76
+ /** Why a `refused`/`failed` salvage did not land. */
77
+ reason?: string;
78
+ /**
79
+ * Secret-bearing paths the salvage refused to commit, whatever its `status` (a run with nothing
80
+ * else to salvage still reports them, as `none`). Named rather than counted: the point is that
81
+ * someone can look at the file and decide whether it held a live credential.
82
+ */
83
+ withheld?: string[];
84
+ }
85
+ /**
86
+ * What the salvage does with one path.
87
+ *
88
+ * Three outcomes, not two, because the reasons for withholding a file are not the same fact. A
89
+ * `skip` is expected and uninteresting: nobody wants `node_modules` in a PR, and saying so would
90
+ * be noise on every run. A `secret` is a decision someone has to know about — the file was real
91
+ * work, it did not land, and it may hold a live credential that now needs rotating.
92
+ */
93
+ export type SalvageDisposition = 'salvage' | 'skip' | 'secret';
94
+ /** What the salvage would do with `path`: keep it, drop it quietly, or withhold it as a secret. */
95
+ export declare function classifySalvagePath(path: string): SalvageDisposition;
96
+ /**
97
+ * Split the untracked paths into what the salvage commits and what it withholds as secret-bearing.
98
+ *
99
+ * The secret check runs BEFORE the junk one, so a key under a denied directory is still counted as
100
+ * withheld rather than swallowed as junk: the point of the count is telling someone a credential
101
+ * may have been created, and where it happened to sit does not change that.
102
+ */
103
+ export declare function partitionSalvageCandidates(paths: readonly string[]): {
104
+ candidates: string[];
105
+ withheld: string[];
106
+ };
107
+ /**
108
+ * Commit the new, untracked, non-ignored files the agent left behind in `dir`.
109
+ *
110
+ * Bounded by FILE COUNT and TOTAL BYTES, and over either bound it salvages NOTHING and says so:
111
+ * committing a prefix would produce a tree that looks complete and is not, which is the one
112
+ * outcome worse than the loss this exists to prevent.
113
+ *
114
+ * The message names the salvage as a salvage. A commit that arrives on a branch with no
115
+ * explanation is indistinguishable from work the agent chose to make, and this work was chosen by
116
+ * nobody — the run was killed with it still on the floor.
117
+ *
118
+ * CODING MODE ONLY: the caller decides that. A read-only kind has no branch to carry a commit and
119
+ * must never be given one.
120
+ */
121
+ export declare function salvageUntrackedWork(args: {
122
+ dir: string;
123
+ /** How the run ended, which is what the commit message has to state. */
124
+ occasion: SalvageOccasion;
125
+ logger: Logger;
126
+ signal?: AbortSignal;
127
+ bounds?: SalvageBounds;
128
+ }): Promise<SalvageReport>;
129
+ /**
130
+ * How the run that left these files behind ended. It decides what the commit message SAYS, which
131
+ * is the whole point of marking a salvage: a commit arriving on a branch with no explanation is
132
+ * indistinguishable from work the agent chose to make and someone chose to keep.
133
+ */
134
+ export type SalvageOccasion =
135
+ /** The run was killed mid-flight (guard, watchdog, eviction); `cause` is what killed it. */
136
+ {
137
+ kind: 'aborted';
138
+ cause: string;
139
+ }
140
+ /** The agent finished but never added its own new files. */
141
+ | {
142
+ kind: 'settled';
143
+ };
144
+ /** The salvage commit's message: what it is, why it exists, and how much to trust it. */
145
+ export declare function salvageCommitMessage(fileCount: number, occasion: SalvageOccasion): string;
146
+ /**
147
+ * The banner for a pull request whose ENTIRE content is a salvage.
148
+ *
149
+ * A branch the agent never committed to, which exists only because the harness swept up the
150
+ * untracked files left in that checkout, is not a change anyone proposed. It is still worth
151
+ * opening (dropping it is the loss this whole module exists to prevent, and a peer repository in a
152
+ * multi-repo run is where a cross-service change most easily goes missing), but its reviewer has
153
+ * to be told that before reading it as a considered contribution: the agent may have been building
154
+ * there, or it may have left scratch work behind while working on a sibling repository, and
155
+ * nothing in the diff distinguishes the two.
156
+ *
157
+ * Lives here with {@link salvageCommitMessage} and {@link describeSalvage} because all three are
158
+ * the same job — saying what a salvage is to whoever finds it — and the three had better not drift
159
+ * into describing it differently. The caller decides WHERE it goes.
160
+ */
161
+ export declare function salvageOnlyNotice(): string;
162
+ /**
163
+ * Where a salvage commit ENDED UP, which the salvage itself cannot know: it commits, and someone
164
+ * else pushes. A commit that was not pushed dies with the container exactly as the uncommitted
165
+ * files would have, so a note that does not say so describes a rescue that did not happen.
166
+ */
167
+ export interface SalvageDelivery {
168
+ pushed: boolean;
169
+ /** Why the push did not land, when it did not. */
170
+ reason?: string;
171
+ }
172
+ /**
173
+ * What a human can act on, in one or two sentences. Joined onto the failure an aborted run
174
+ * reports, so the person reading "the run was killed" is told in the same breath what became of
175
+ * its work: on the branch and reviewed by nobody, still in the container, or never committed.
176
+ *
177
+ * `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
178
+ * ordinary push follows (the settle path), so there is nothing extra to say.
179
+ */
180
+ export declare function describeSalvage(report: SalvageReport, delivery?: SalvageDelivery): string | undefined;
@@ -0,0 +1,289 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { commitPaths, listUntrackedFiles } from './git.js';
4
+ import { HARNESS_SENTINEL_FILES } from './workspace-probe.js';
5
+ // Recovering the work an aborted run left in the tree. `commitTrackedEdits` is a safety net for
6
+ // forgotten edits to files git ALREADY tracks, so a NEW file the agent created and never committed
7
+ // was found, warned about, and dropped. On a greenfield task every file is new, which made that
8
+ // warning the whole deliverable going in the bin: a run that built, tested and verified a service
9
+ // through `bash` heredocs was killed by the progress guard and lost all of it.
10
+ //
11
+ // Observable is not recovered. This makes the salvage real, under guardrails, and MARKED — a
12
+ // salvage commit is evidence from an interrupted run, never work anyone should read as reviewed.
13
+ /**
14
+ * Directory and file names never salvaged. A greenfield checkout may not have a `.gitignore` yet
15
+ * (the agent had not written one when it was killed), and git only excludes what a `.gitignore`
16
+ * tells it to, so without this a blanket salvage would commit `node_modules` into the PR.
17
+ *
18
+ * Matched against every SEGMENT of a path, so `packages/api/node_modules/x` is caught as surely as
19
+ * a root-level one. Deliberately a short list of the unambiguous ones: a cleverer heuristic starts
20
+ * discarding the deliverable, and a `dist/` that genuinely belonged in a commit is a far cheaper
21
+ * miss than a `node_modules/` that did not.
22
+ */
23
+ export const SALVAGE_DENIED_SEGMENTS = [
24
+ 'node_modules',
25
+ 'dist',
26
+ 'build',
27
+ 'coverage',
28
+ '.venv',
29
+ '__pycache__',
30
+ 'target',
31
+ 'vendor',
32
+ '.git',
33
+ ];
34
+ /** Suffixes never salvaged: run output, not source. */
35
+ export const SALVAGE_DENIED_SUFFIXES = ['.log'];
36
+ /**
37
+ * Basenames and suffixes that carry CREDENTIALS, withheld from every salvage.
38
+ *
39
+ * The deny-list above trades a cheap miss (a `dist/` that belonged in a commit) against an
40
+ * expensive one (`node_modules/` in a PR). For a secret that trade INVERTS: a private key or a
41
+ * populated `.env` pushed to a branch is a disclosure that outlives the run, cannot be taken back
42
+ * by deleting the commit, and forces a rotation. Missing a file is recoverable; leaking one is not.
43
+ *
44
+ * This exists for the same reason the deny-list does: on the greenfield case the salvage was
45
+ * written for, the agent was killed before it wrote a `.gitignore`, so git excludes nothing and
46
+ * the harness is the only thing standing between an agent-authored key and the pull request.
47
+ *
48
+ * Unlike a junk path, a withheld secret is REPORTED (see {@link SalvageReport.withheld}): the file
49
+ * is real work that did not land, and whoever reads the run has to decide whether to re-create it
50
+ * or, if it holds a live credential, to rotate it.
51
+ */
52
+ export const SALVAGE_SECRET_BASENAMES = [
53
+ '.netrc',
54
+ '.npmrc',
55
+ '.pypirc',
56
+ 'credentials',
57
+ 'id_dsa',
58
+ 'id_ecdsa',
59
+ 'id_ed25519',
60
+ 'id_rsa',
61
+ 'secrets.json',
62
+ 'secrets.yaml',
63
+ 'secrets.yml',
64
+ ];
65
+ /**
66
+ * Suffixes that mark a key store or an environment file, withheld for the reason above.
67
+ *
68
+ * `.env` is here as well as in {@link isSecretBearingName}'s own `.env` / `.env.*` test, so that
69
+ * `prod.env` and `local.env` are caught alongside `.env` and `.env.production`. The sample
70
+ * allow-list is unaffected: `.env.example` ends in `.example`, not in `.env`.
71
+ */
72
+ export const SALVAGE_SECRET_SUFFIXES = [
73
+ '.env',
74
+ '.jks',
75
+ '.key',
76
+ '.keystore',
77
+ '.p12',
78
+ '.pem',
79
+ '.pfx',
80
+ ];
81
+ /** Path segments that are credential or state stores rather than source. */
82
+ export const SALVAGE_SECRET_SEGMENTS = ['.aws', '.gnupg', '.ssh', '.terraform'];
83
+ /**
84
+ * The `.env` files that carry no secret and ARE the deliverable: the checked-in sample every
85
+ * scaffold ships so a reader knows which variables the service wants.
86
+ *
87
+ * An allow-list rather than a cleverer rule, because the two are the same shape and only the
88
+ * convention tells them apart. `.env` and every other `.env.<something>` is withheld: a scaffolded
89
+ * `.env.local` or `.env.production` is exactly where a real key ends up.
90
+ */
91
+ export const SALVAGE_ENV_SAMPLE_BASENAMES = [
92
+ '.env.defaults',
93
+ '.env.dist',
94
+ '.env.example',
95
+ '.env.sample',
96
+ '.env.template',
97
+ ];
98
+ /** Whether `basename` is a credential-bearing file the salvage must never commit. */
99
+ function isSecretBearingName(basename) {
100
+ const lower = basename.toLowerCase();
101
+ if (SALVAGE_ENV_SAMPLE_BASENAMES.includes(lower))
102
+ return false;
103
+ if (lower === '.env' || lower.startsWith('.env.'))
104
+ return true;
105
+ if (SALVAGE_SECRET_BASENAMES.includes(lower))
106
+ return true;
107
+ return SALVAGE_SECRET_SUFFIXES.some((suffix) => lower.endsWith(suffix));
108
+ }
109
+ /**
110
+ * The default bounds. Generous enough for a scaffolded service (the run this was written for left
111
+ * about twenty source files) and far below anything that looks like a build output or a dependency
112
+ * tree that slipped past the deny-list.
113
+ */
114
+ export const DEFAULT_SALVAGE_BOUNDS = { maxFiles: 200, maxBytes: 5_000_000 };
115
+ /** How many paths a report quotes. The count carries the rest; a report is a summary, not a manifest. */
116
+ const REPORTED_PATHS = 20;
117
+ /** What the salvage would do with `path`: keep it, drop it quietly, or withhold it as a secret. */
118
+ export function classifySalvagePath(path) {
119
+ const segments = path.split('/');
120
+ const basename = segments[segments.length - 1] ?? '';
121
+ if (isSecretBearingName(basename))
122
+ return 'secret';
123
+ if (segments.some((segment) => SALVAGE_SECRET_SEGMENTS.includes(segment)))
124
+ return 'secret';
125
+ if (HARNESS_SENTINEL_FILES.includes(basename))
126
+ return 'skip';
127
+ if (SALVAGE_DENIED_SUFFIXES.some((suffix) => basename.endsWith(suffix)))
128
+ return 'skip';
129
+ if (segments.some((segment) => SALVAGE_DENIED_SEGMENTS.includes(segment)))
130
+ return 'skip';
131
+ return 'salvage';
132
+ }
133
+ /**
134
+ * Split the untracked paths into what the salvage commits and what it withholds as secret-bearing.
135
+ *
136
+ * The secret check runs BEFORE the junk one, so a key under a denied directory is still counted as
137
+ * withheld rather than swallowed as junk: the point of the count is telling someone a credential
138
+ * may have been created, and where it happened to sit does not change that.
139
+ */
140
+ export function partitionSalvageCandidates(paths) {
141
+ const candidates = [];
142
+ const withheld = [];
143
+ for (const path of paths) {
144
+ const disposition = classifySalvagePath(path);
145
+ if (disposition === 'salvage')
146
+ candidates.push(path);
147
+ else if (disposition === 'secret')
148
+ withheld.push(path);
149
+ }
150
+ return { candidates, withheld };
151
+ }
152
+ /**
153
+ * Commit the new, untracked, non-ignored files the agent left behind in `dir`.
154
+ *
155
+ * Bounded by FILE COUNT and TOTAL BYTES, and over either bound it salvages NOTHING and says so:
156
+ * committing a prefix would produce a tree that looks complete and is not, which is the one
157
+ * outcome worse than the loss this exists to prevent.
158
+ *
159
+ * The message names the salvage as a salvage. A commit that arrives on a branch with no
160
+ * explanation is indistinguishable from work the agent chose to make, and this work was chosen by
161
+ * nobody — the run was killed with it still on the floor.
162
+ *
163
+ * CODING MODE ONLY: the caller decides that. A read-only kind has no branch to carry a commit and
164
+ * must never be given one.
165
+ */
166
+ export async function salvageUntrackedWork(args) {
167
+ const bounds = args.bounds ?? DEFAULT_SALVAGE_BOUNDS;
168
+ const { candidates, withheld } = partitionSalvageCandidates(await listUntrackedFiles(args.dir, args.signal));
169
+ if (withheld.length > 0) {
170
+ args.logger.warn('salvage: withheld secret-bearing files from the commit', { withheld });
171
+ }
172
+ const secrets = withheld.length > 0 ? { withheld } : {};
173
+ if (candidates.length === 0) {
174
+ return { status: 'none', files: [], fileCount: 0, totalBytes: 0, ...secrets };
175
+ }
176
+ const totalBytes = await measure(args.dir, candidates);
177
+ const report = {
178
+ files: candidates.slice(0, REPORTED_PATHS),
179
+ fileCount: candidates.length,
180
+ totalBytes,
181
+ ...secrets,
182
+ };
183
+ if (candidates.length > bounds.maxFiles || totalBytes > bounds.maxBytes) {
184
+ const reason = `${candidates.length} uncommitted new files totalling ${totalBytes} bytes exceed the salvage ` +
185
+ `bounds (${bounds.maxFiles} files / ${bounds.maxBytes} bytes), so none were committed — a ` +
186
+ `partial salvage would read as a complete change.`;
187
+ args.logger.warn('salvage: refused, over bounds', { ...report, reason });
188
+ return { status: 'refused', ...report, reason };
189
+ }
190
+ try {
191
+ const commitSha = await commitPaths(args.dir, candidates, salvageCommitMessage(candidates.length, args.occasion), args.signal);
192
+ if (!commitSha)
193
+ return { status: 'none', files: [], fileCount: 0, totalBytes: 0, ...secrets };
194
+ args.logger.warn('salvage: committed the new files the agent left untracked', {
195
+ ...report,
196
+ commitSha,
197
+ });
198
+ return { status: 'committed', ...report, commitSha };
199
+ }
200
+ catch (error) {
201
+ const reason = error instanceof Error ? error.message : String(error);
202
+ args.logger.error('salvage: could not commit the files the agent left behind', {
203
+ ...report,
204
+ reason,
205
+ });
206
+ return { status: 'failed', ...report, reason };
207
+ }
208
+ }
209
+ /** The salvage commit's message: what it is, why it exists, and how much to trust it. */
210
+ export function salvageCommitMessage(fileCount, occasion) {
211
+ const noun = fileCount === 1 ? 'file' : 'files';
212
+ if (occasion.kind === 'settled') {
213
+ return (`chore: commit ${fileCount} new ${noun} the agent left untracked\n\n` +
214
+ `The agent created these files and finished without committing them. The harness committed ` +
215
+ `them so they reach the pull request rather than being discarded with the container.`);
216
+ }
217
+ return (`chore: salvage ${fileCount} uncommitted ${noun} from an aborted agent run\n\n` +
218
+ `This run was ABORTED (${occasion.cause}) with these files created and never committed. The ` +
219
+ `harness committed them so the work is not lost. They are NOT a reviewed change: nothing ` +
220
+ `checked that they are complete or consistent, and the run had not said it was finished.`);
221
+ }
222
+ /**
223
+ * The banner for a pull request whose ENTIRE content is a salvage.
224
+ *
225
+ * A branch the agent never committed to, which exists only because the harness swept up the
226
+ * untracked files left in that checkout, is not a change anyone proposed. It is still worth
227
+ * opening (dropping it is the loss this whole module exists to prevent, and a peer repository in a
228
+ * multi-repo run is where a cross-service change most easily goes missing), but its reviewer has
229
+ * to be told that before reading it as a considered contribution: the agent may have been building
230
+ * there, or it may have left scratch work behind while working on a sibling repository, and
231
+ * nothing in the diff distinguishes the two.
232
+ *
233
+ * Lives here with {@link salvageCommitMessage} and {@link describeSalvage} because all three are
234
+ * the same job — saying what a salvage is to whoever finds it — and the three had better not drift
235
+ * into describing it differently. The caller decides WHERE it goes.
236
+ */
237
+ export function salvageOnlyNotice() {
238
+ return (`> **This branch is a salvage.** The agent committed nothing to this repository; everything ` +
239
+ `here is new files it left uncommitted in the checkout, swept up by the harness so they would ` +
240
+ `not be discarded with the container. Nothing has reviewed them for completeness or ` +
241
+ `relevance, and some may be scratch work from the agent's task in a sibling repository.`);
242
+ }
243
+ /** Total size of `paths` under `dir`; a file that cannot be stat'd counts as zero rather than failing. */
244
+ async function measure(dir, paths) {
245
+ const sizes = await Promise.all(paths.map((path) => stat(join(dir, path)).then((info) => info.size, () => 0)));
246
+ return sizes.reduce((total, size) => total + size, 0);
247
+ }
248
+ /**
249
+ * What a human can act on, in one or two sentences. Joined onto the failure an aborted run
250
+ * reports, so the person reading "the run was killed" is told in the same breath what became of
251
+ * its work: on the branch and reviewed by nobody, still in the container, or never committed.
252
+ *
253
+ * `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
254
+ * ordinary push follows (the settle path), so there is nothing extra to say.
255
+ */
256
+ export function describeSalvage(report, delivery) {
257
+ const parts = [describeOutcome(report, delivery), describeWithheld(report)].filter((part) => part !== undefined);
258
+ return parts.length > 0 ? parts.join(' ') : undefined;
259
+ }
260
+ /** The fate of the files the salvage DID try to keep. */
261
+ function describeOutcome(report, delivery) {
262
+ switch (report.status) {
263
+ case 'none':
264
+ return undefined;
265
+ case 'committed': {
266
+ const landed = delivery && !delivery.pushed
267
+ ? `commit ${report.commitSha ?? 'unknown'}, which could NOT be pushed ` +
268
+ `(${delivery.reason ?? 'the push failed'}) and so is lost with the container`
269
+ : `commit ${report.commitSha ?? 'unknown'}`;
270
+ 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.`);
272
+ }
273
+ case 'refused':
274
+ return `Uncommitted new files were NOT salvaged: ${report.reason ?? 'over the salvage bounds'}`;
275
+ case 'failed':
276
+ return (`${report.fileCount} uncommitted new file(s) were left behind and could NOT be salvaged: ` +
277
+ `${report.reason ?? 'the commit failed'}`);
278
+ }
279
+ }
280
+ /** The secret-bearing files the salvage refused, named so a live credential can be rotated. */
281
+ function describeWithheld(report) {
282
+ const withheld = report.withheld ?? [];
283
+ if (withheld.length === 0)
284
+ return undefined;
285
+ const shown = withheld.slice(0, REPORTED_PATHS).join(', ');
286
+ const rest = withheld.length > REPORTED_PATHS ? ` (and ${withheld.length - REPORTED_PATHS} more)` : '';
287
+ return (`${withheld.length} file(s) that look credential-bearing were withheld from the salvage and ` +
288
+ `are NOT on the branch: ${shown}${rest}. Re-create them, and rotate anything real they held.`);
289
+ }