@cat-factory/executor-harness 1.56.0 → 1.60.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 CHANGED
@@ -63,9 +63,17 @@ The implementation job (`POST /run`) is the canonical sequence:
63
63
  configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
64
64
  while they fail and the attempt budget remains the agent is re-run with the captured output
65
65
  as its instruction (see [pre-PR validation](../../../docs/initiatives/pre-pr-validation.md)),
66
- 5. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
66
+ 5. **prove the reproduction**, when the job body carries `reproduction` the declared check is
67
+ run against the pre-fix tree and the tree the PR will open from, in two freshly-created
68
+ symmetric `git worktree` checkouts, and only red-then-green is reported as proof (see
69
+ [bugfix reproduction proof](../../../docs/initiatives/bugfix-reproduction-proof.md)). Unlike
70
+ step 4 this NEVER gates the PR: a failed verification is fed back to the agent while budget
71
+ remains, then recorded as `inconclusive`. It runs BEFORE step 4 so validation stays the last
72
+ thing to touch the tree,
73
+ 6. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
67
74
  ONLY if step 4 ended green. A spent budget returns an error result with the validation report
68
- and opens no PR. Absent `validationChecks`, step 4 does not happen at all.
75
+ and opens no PR. Absent `validationChecks` / `reproduction`, steps 4 and 5 do not happen at
76
+ all.
69
77
 
70
78
  Bootstrap differs at the ends — it may start from an empty dir, and **resets
71
79
  history to one commit and force-pushes** the default branch instead of opening a
@@ -133,7 +141,9 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
133
141
  | `src/package-registries.ts` | Private-registry (npm) auth: renders the job's allowlisted entries into an npmrc — the user `~/.npmrc` in a container, a per-job file pointed at by `npm_config_userconfig` for a native job. |
134
142
  | `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
135
143
  | `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
144
+ | `src/captured-command.ts` | The one way the harness runs a declared shell command on its own behalf: `sh -c` with a per-command watchdog, abort handling, conventional exit codes (124/127/130) and a scrub-then-bound output capture. Shared by both pre-PR verification phases so a fix to one cannot miss the other. |
136
145
  | `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic — keyed off the job body, never the agent kind. |
146
+ | `src/reproduction-proof.ts` | Bugfix reproduction proof: runs the job's declared reproduction command against two symmetric fresh worktrees (the pre-fix tree and the final tree) and computes red-then-green from the exit codes, with a repair loop that never fails the run. Generic — keyed off the job body, never the agent kind. |
137
147
  | `src/logger.ts` | Structured logging. |
138
148
 
139
149
  ## Runner lifecycle knobs
@@ -147,6 +157,9 @@ runner):
147
157
  | `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
148
158
  | `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
149
159
  | `VALIDATION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a pre-PR validation check; a timeout counts as a failure (exit 124) so one hung command can't wedge the loop. |
160
+ | `REPRODUCTION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a reproduction-proof setup or check command; a timeout counts as a failure (exit 124). |
161
+ | `REPRODUCTION_HEARTBEAT_MS` | `30000` (30s) | How often the reproduction proof feeds the job inactivity watchdog while it runs commands the agent is not producing output for. |
162
+ | `REPRODUCTION_TOTAL_BUDGET_MS` | `2700000` (45m) | Wall-clock ceiling on the WHOLE proof phase (every attempt, both trees, setup included). Attempts multiply two full tree runs each and the heartbeat above deliberately stops the inactivity watchdog from firing, so this is what bounds the phase. Checked at phase boundaries; exceeding it settles `inconclusive`, never a run failure. |
150
163
  | `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
151
164
  | `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content — though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
152
165
 
package/dist/agent.js CHANGED
@@ -808,6 +808,10 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
808
808
  // Forwarded straight off the job body — the loop is generic machinery keyed on the data, not
809
809
  // on the agent kind.
810
810
  ...(job.validationChecks ? { validationChecks: job.validationChecks } : {}),
811
+ // Bugfix reproduction proof: the declared command run against the pre-fix and final trees
812
+ // (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
813
+ // like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
814
+ ...(job.reproduction ? { reproduction: job.reproduction } : {}),
811
815
  };
812
816
  }
813
817
  /**
@@ -818,16 +822,20 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
818
822
  */
819
823
  async function runSingleRepoCoding(job, opts) {
820
824
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
821
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, effortReport, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
825
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, reproductionReport, effortReport, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
822
826
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
823
827
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
824
828
  const ralphVerdict = validation ? { ralphVerdict: validation } : {};
825
829
  // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
826
830
  const effort = effortReport ? { effortReport } : {};
827
- // The pre-PR validation report, spread onto every result path below: on the passing path it is
828
- // the captured proof the checkout was green when the PR opened; on the exhausted path it is the
829
- // evidence behind the failure below. Absent when the service configured no checks.
830
- const validationFields = validationReport ? { validationReport } : {};
831
+ // The two PRE-PR VERIFICATION reports, spread onto every result path below. The validation one:
832
+ // on the passing path it is the captured proof the checkout was green when the PR opened; on the
833
+ // exhausted path it is the evidence behind the failure below. The reproduction one is evidence
834
+ // on every path it never gates the PR. Each is absent when its phase was not configured.
835
+ const verificationFields = {
836
+ ...(validationReport ? { validationReport } : {}),
837
+ ...(reproductionReport ? { reproductionReport } : {}),
838
+ };
831
839
  // Pre-PR validation spent its attempt budget with the checkout still red. FAIL the job — do
832
840
  // NOT open a pull request, and do not pretend the push succeeded as a deliverable. The work is
833
841
  // still on the branch (a retry resumes on it); the report carries each failing command's exit
@@ -846,7 +854,7 @@ async function runSingleRepoCoding(job, opts) {
846
854
  failureCause: 'agent',
847
855
  ...(usage ? { usage } : {}),
848
856
  ...(callMetrics ? { callMetrics } : {}),
849
- ...validationFields,
857
+ ...verificationFields,
850
858
  ...effort,
851
859
  };
852
860
  }
@@ -861,7 +869,7 @@ async function runSingleRepoCoding(job, opts) {
861
869
  ...(usage ? { usage } : {}),
862
870
  ...(callMetrics ? { callMetrics } : {}),
863
871
  ...ralphVerdict,
864
- ...validationFields,
872
+ ...verificationFields,
865
873
  ...effort,
866
874
  };
867
875
  }
@@ -874,7 +882,7 @@ async function runSingleRepoCoding(job, opts) {
874
882
  failureCause: 'no-changes',
875
883
  ...(usage ? { usage } : {}),
876
884
  ...(callMetrics ? { callMetrics } : {}),
877
- ...validationFields,
885
+ ...verificationFields,
878
886
  ...effort,
879
887
  };
880
888
  }
@@ -908,7 +916,7 @@ async function runSingleRepoCoding(job, opts) {
908
916
  stats,
909
917
  ...(usage ? { usage } : {}),
910
918
  ...(callMetrics ? { callMetrics } : {}),
911
- ...validationFields,
919
+ ...verificationFields,
912
920
  ...effort,
913
921
  };
914
922
  }
@@ -921,7 +929,7 @@ async function runSingleRepoCoding(job, opts) {
921
929
  failureCause: 'no-changes',
922
930
  ...(usage ? { usage } : {}),
923
931
  ...(callMetrics ? { callMetrics } : {}),
924
- ...validationFields,
932
+ ...verificationFields,
925
933
  ...effort,
926
934
  };
927
935
  }
@@ -934,7 +942,7 @@ async function runSingleRepoCoding(job, opts) {
934
942
  ...(usage ? { usage } : {}),
935
943
  ...(callMetrics ? { callMetrics } : {}),
936
944
  ...ralphVerdict,
937
- ...validationFields,
945
+ ...verificationFields,
938
946
  ...effort,
939
947
  };
940
948
  }
@@ -946,7 +954,7 @@ async function runSingleRepoCoding(job, opts) {
946
954
  ...(usage ? { usage } : {}),
947
955
  ...(callMetrics ? { callMetrics } : {}),
948
956
  ...ralphVerdict,
949
- ...validationFields,
957
+ ...verificationFields,
950
958
  ...effort,
951
959
  };
952
960
  }
@@ -0,0 +1,112 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { killChildProcess, spawnDetached } from './process.js';
3
+ import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
4
+ // The ONE way the harness runs a declared shell command on its own behalf (rather than through
5
+ // the agent) and keeps a bounded, secret-scrubbed record of what it printed.
6
+ //
7
+ // Both pre-PR verification phases need exactly this — the PRE-PR VALIDATION checks
8
+ // (`validation-checks.ts`) and the BUGFIX REPRODUCTION PROOF (`reproduction-proof.ts`) — and they
9
+ // need it to behave IDENTICALLY: same watchdog semantics, same abort handling, same conventional
10
+ // exit codes, same scrub-then-bound pipeline. They were two near-verbatim copies; a fix applied to
11
+ // one of them (a redaction ordering, an exit-code convention) silently missed the other, which is
12
+ // the whole reason this seam exists.
13
+ //
14
+ // Everything is PER-JOB by construction: the command, the cwd and the environment all arrive as
15
+ // arguments and nothing is read from or written to `process.env`/`HOME`. The local NATIVE
16
+ // transport serves every concurrent job from ONE host process, so a global would leak one job's
17
+ // state into a sibling's — and the container path would never catch it.
18
+ /**
19
+ * A little slack kept in the rolling capture buffer ON TOP of {@link MAX_CAPTURED_OUTPUT_CHARS},
20
+ * so scrubbing sees whole secrets.
21
+ *
22
+ * The buffer discards from the FRONT as output arrives, and `redactSecrets` only runs once the
23
+ * command settles. Without the margin a token straddling that rolling cut would already have lost
24
+ * its `KEY=` prefix by scrub time and would survive as an unrecognised partial. Capturing a bit
25
+ * more than we keep, scrubbing, and only THEN bounding to the real limit closes that window; 512
26
+ * chars comfortably exceeds any single credential assignment the rules match.
27
+ */
28
+ const CAPTURE_MARGIN_CHARS = 512;
29
+ /**
30
+ * Run ONE command as `sh -c` in `cwd`, capturing a bounded, secret-scrubbed tail of its combined
31
+ * stdout+stderr. The exit code is the verdict — computed here by the harness, never self-reported
32
+ * by the model, which is the whole point of a programmatic phase. A watchdog kills the process
33
+ * tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
34
+ * from settling.
35
+ *
36
+ * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
37
+ * not a mutated global: the harness spawns this itself rather than through the agent, so without
38
+ * the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
39
+ * had this been staged in `process.env`, against a sibling job's state).
40
+ *
41
+ * `logLabel`/`logFields` shape only the two warnings this runner emits itself (the watchdog kill
42
+ * and a spawn failure); the caller keeps its own start/finish logging, which knows what the
43
+ * command MEANS.
44
+ */
45
+ export async function runCapturedCommand(args) {
46
+ const { cwd, command, timeoutMs, reportTailChars, logLabel, logFields, logger, opts } = args;
47
+ const startedAt = Date.now();
48
+ return new Promise((resolve) => {
49
+ let out = '';
50
+ let settled = false;
51
+ let timedOut = false;
52
+ const child = spawn('sh', ['-c', command], {
53
+ cwd,
54
+ detached: spawnDetached,
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ env: { ...process.env, ...opts.agentEnv },
57
+ });
58
+ // Keep only the tail (plus the scrub margin); guard against unbounded buffering on a chatty
59
+ // command.
60
+ const capture = (chunk) => {
61
+ out = (out + chunk.toString('utf8')).slice(-(MAX_CAPTURED_OUTPUT_CHARS + CAPTURE_MARGIN_CHARS));
62
+ };
63
+ child.stdout?.on('data', capture);
64
+ child.stderr?.on('data', capture);
65
+ const finish = (exitCode) => {
66
+ if (settled)
67
+ return;
68
+ settled = true;
69
+ clearTimeout(timer);
70
+ opts.signal?.removeEventListener('abort', onAbort);
71
+ const trimmed = out.trim();
72
+ // Scrub BEFORE either bound: the pattern rules need a whole assignment to match, so the
73
+ // margin above is trimmed away only once the secrets are already gone.
74
+ const scrubbed = trimmed ? redactSecrets(trimmed).slice(-MAX_CAPTURED_OUTPUT_CHARS) : '';
75
+ resolve({
76
+ exitCode,
77
+ passed: exitCode === 0,
78
+ ...(scrubbed ? { outputTail: boundTail(scrubbed, reportTailChars) } : {}),
79
+ durationMs: Date.now() - startedAt,
80
+ ...(timedOut ? { timedOut: true } : {}),
81
+ ...(scrubbed ? { fullTail: scrubbed } : {}),
82
+ });
83
+ };
84
+ const timer = setTimeout(() => {
85
+ logger.warn(`${logLabel}: command timed out`, { ...logFields, timeoutMs });
86
+ timedOut = true;
87
+ killChildProcess(child, undefined, logger);
88
+ finish(124); // conventional timeout exit code (a non-zero fail)
89
+ }, timeoutMs);
90
+ timer.unref?.();
91
+ const onAbort = () => {
92
+ killChildProcess(child, undefined, logger);
93
+ finish(130); // aborted (a non-zero fail)
94
+ };
95
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
96
+ child.on('error', (err) => {
97
+ logger.warn(`${logLabel}: command failed to spawn`, {
98
+ ...logFields,
99
+ error: err instanceof Error ? err.message : String(err),
100
+ });
101
+ finish(127); // spawn error / command not found (a non-zero fail)
102
+ });
103
+ child.on('close', (code) => finish(code ?? 1));
104
+ });
105
+ }
106
+ /** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
107
+ export function boundTail(scrubbed, maxChars) {
108
+ if (scrubbed.length <= maxChars)
109
+ return scrubbed;
110
+ const trimmed = scrubbed.length - maxChars;
111
+ return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-maxChars)}`;
112
+ }
@@ -3,12 +3,13 @@ import { join } from 'node:path';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { killChildProcess, spawnDetached } from './process.js';
5
5
  import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
6
- import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
6
+ import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
7
7
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
8
8
  import { EFFORT_REPORT_FILE } from './effort.js';
9
9
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
10
10
  import { log } from './logger.js';
11
11
  import { runValidationLoop, } from './validation-checks.js';
12
+ import { runReproductionLoop, } from './reproduction-proof.js';
12
13
  /**
13
14
  * How often the harness checkpoints the agent's work mid-run by pushing the branch.
14
15
  * A per-run container can be evicted at any moment; pushing the agent's commits
@@ -154,6 +155,55 @@ export async function runCodingAgent(spec, opts = {}) {
154
155
  opts.onPhase?.('agent');
155
156
  logger.info('coding-agent: running agent', { serviceDirectory });
156
157
  let agentRun = await runAgentPass(spec.userPrompt);
158
+ const foldPass = (run) => {
159
+ agentRun = mergeAgentPasses(agentRun, run);
160
+ };
161
+ // The new files the agent left unadded, folded into either loop's repair prompt. Both
162
+ // loops judge state the push will NOT carry unless it is committed — the checks run
163
+ // against the working tree, the proof against committed trees — so an unadded file is
164
+ // exactly the thing to name. A throw degrades to "no warning" inside each loop.
165
+ const listUncommittedNewFiles = () => listUntrackedFiles(workDir, opts.signal);
166
+ // BUGFIX REPRODUCTION PROOF: run the run's declared reproduction command against the
167
+ // pre-fix tree and the tree the PR will open from, and record whether it was red then
168
+ // green. Runs BEFORE the validation loop below, deliberately: validation is the GATE
169
+ // ("only a green checkout opens a PR"), so it has to stay the last thing that touches the
170
+ // tree — otherwise a reproduction repair round could leave the checkout red behind it and
171
+ // the PR would open anyway. Keyed purely off the job body carrying a spec (no agent-kind
172
+ // switch); absent ⇒ a no-op and the flow below is byte-for-byte what it was.
173
+ const reproduction = spec.reproduction;
174
+ let reproductionReport;
175
+ if (reproduction && (await producedWork(dir, spec, baseSha, resumed, opts))) {
176
+ opts.onPhase?.('reproduction');
177
+ reproductionReport = await runReproductionLoop({
178
+ dir,
179
+ baseSha,
180
+ // Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
181
+ // `producedWork` has already committed forgotten tracked edits, and each repair round
182
+ // re-commits before the next read.
183
+ resolveFinalSha: async () => {
184
+ await commitTrackedEdits(dir, spec.commitMessage, signal);
185
+ return headCommit(dir, signal);
186
+ },
187
+ ...(serviceDirectory ? { serviceDirectory } : {}),
188
+ spec: reproduction,
189
+ logger,
190
+ opts,
191
+ runAgentPass,
192
+ onAgentPass: foldPass,
193
+ listUncommittedNewFiles,
194
+ // Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run
195
+ // branched off base, so `baseSha` IS base. Wiring the probe unconditionally would buy
196
+ // an always-empty answer for the price of a fetch — and a fresh clone is shallow, so
197
+ // it could not resolve a merge base to answer with anyway. Lazy inside the loop: it
198
+ // only runs if a tree comes back green.
199
+ ...(resumed
200
+ ? {
201
+ listBaseTreeChanges: () => changedFilesSinceBase(dir, spec.repo.baseBranch, spec.ghToken, baseSha, opts.signal),
202
+ }
203
+ : {}),
204
+ });
205
+ opts.onPhase?.('agent');
206
+ }
157
207
  // PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
158
208
  // they fail and budget remains, hand the captured output back to the agent and run it
159
209
  // again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
@@ -169,17 +219,16 @@ export async function runCodingAgent(spec, opts = {}) {
169
219
  logger,
170
220
  opts,
171
221
  runAgentPass,
172
- onAgentPass: (run) => {
173
- agentRun = mergeAgentPasses(agentRun, run);
174
- },
222
+ onAgentPass: foldPass,
175
223
  // The checks run against the WORKING TREE, but only tracked edits are staged for the
176
224
  // push — so a repair round can go green on a new file the PR would never contain.
177
225
  // Name those files in the next repair prompt so the agent adds them.
178
- listUncommittedNewFiles: () => listUntrackedFiles(workDir, opts.signal),
226
+ listUncommittedNewFiles,
179
227
  });
180
228
  }
181
229
  outcome = await finalizeCodingRun({
182
230
  validationReport,
231
+ reproductionReport,
183
232
  dir,
184
233
  spec,
185
234
  logger,
@@ -314,7 +363,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
314
363
  * {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
315
364
  */
316
365
  async function finalizeCodingRun(args) {
317
- const { validationReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
366
+ const { validationReport, reproductionReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
318
367
  const { signal } = opts;
319
368
  const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
320
369
  let outcome;
@@ -403,6 +452,10 @@ async function finalizeCodingRun(args) {
403
452
  // backend to record on the step.
404
453
  if (validationReport)
405
454
  outcome.validationReport = validationReport;
455
+ // The reproduction proof: attached to EVERY outcome, including a no-op or an `inconclusive`
456
+ // verdict. It is evidence about the change, not a gate on it — see the loop's D6 note.
457
+ if (reproductionReport)
458
+ outcome.reproductionReport = reproductionReport;
406
459
  return outcome;
407
460
  }
408
461
  /**
package/dist/git.js CHANGED
@@ -509,6 +509,40 @@ export async function branchAheadOfBase(dir, baseBranch, ghToken, signal) {
509
509
  return undefined;
510
510
  }
511
511
  }
512
+ /**
513
+ * The files `commitish` changes relative to its merge base with the PR base branch — i.e.
514
+ * everything the work branch has added on top of base, `git diff --name-only <base>...<commitish>`.
515
+ *
516
+ * The BUGFIX REPRODUCTION PROOF uses this to answer the one question that decides whether a GREEN
517
+ * pre-fix tree means anything: does that tree ALREADY carry non-test work committed on this
518
+ * branch? A resumed run's `baseSha` is whatever the branch tip was when this pass started, which
519
+ * in the designed flow is the reproduction step's test commit — but after an eviction it is this
520
+ * same coder step's own interrupted work, fix included. Reporting "the check passed before your
521
+ * change, so it does not demonstrate the defect" in that case is simply false.
522
+ *
523
+ * `undefined` means "could not determine" (a shallow clone with no reachable merge base, a fetch
524
+ * failure, an unknown ref), never an empty list: the caller must degrade to its prior behaviour
525
+ * rather than read a failed probe as "the tree is clean".
526
+ *
527
+ * NUL-delimited so a path containing a newline (legal in git) cannot split into two entries.
528
+ */
529
+ export async function changedFilesSinceBase(dir, baseBranch, ghToken, commitish, signal) {
530
+ try {
531
+ await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
532
+ cwd: dir,
533
+ signal,
534
+ env: await authEnv(ghToken),
535
+ });
536
+ const out = await git(['diff', '--name-only', '-z', `refs/cat-factory/base...${commitish}`], {
537
+ cwd: dir,
538
+ signal,
539
+ });
540
+ return out.split('\0').filter((p) => p !== '');
541
+ }
542
+ catch {
543
+ return undefined;
544
+ }
545
+ }
512
546
  /**
513
547
  * Whether the checked-out branch has a real, examinable diff against
514
548
  * `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
@@ -565,6 +599,80 @@ export async function hasAgentChanges(dir, signal) {
565
599
  export async function headCommit(dir, signal) {
566
600
  return (await git(['rev-parse', 'HEAD'], { cwd: dir, signal })).trim();
567
601
  }
602
+ /**
603
+ * Add a DETACHED worktree of `commitish` at `worktreePath`, sharing `dir`'s object database.
604
+ *
605
+ * The bugfix reproduction proof runs the declared check against two trees of the SAME clone (the
606
+ * pre-fix tree and the final tree), so a worktree is the only mechanism that gets both without a
607
+ * second clone, a second fetch, or disturbing the agent's own checkout — which must stay exactly
608
+ * as the agent left it, since the push and the PR come off it.
609
+ *
610
+ * `--detach` (rather than a branch) is deliberate: a worktree that claimed a branch would collide
611
+ * with the work branch checked out in `dir`, and nothing here ever commits.
612
+ *
613
+ * `worktreePath` is expected to live OUTSIDE the checkout (a per-job temp root), so the worktree's
614
+ * `.git` pointer file can never be swept into the agent's commit by a broad `git add -A`.
615
+ */
616
+ export async function addWorktree(dir, worktreePath, commitish, signal) {
617
+ await git(['worktree', 'add', '--detach', worktreePath, commitish], { cwd: dir, signal });
618
+ }
619
+ /**
620
+ * Remove a worktree previously added by {@link addWorktree} and prune the stale administrative
621
+ * entry, never throwing: teardown is bookkeeping, and a run whose PROOF succeeded must not fail
622
+ * because a temp directory could not be cleaned up. The caller still deletes the temp root, so a
623
+ * failure here leaks only a `.git/worktrees/<name>` record inside a container that is about to be
624
+ * destroyed anyway.
625
+ */
626
+ export async function removeWorktree(dir, worktreePath, signal) {
627
+ try {
628
+ await git(['worktree', 'remove', '--force', worktreePath], { cwd: dir, signal });
629
+ }
630
+ catch {
631
+ // Fall through to the prune, which cleans up the record even when the directory is gone.
632
+ }
633
+ try {
634
+ await git(['worktree', 'prune'], { cwd: dir, signal });
635
+ }
636
+ catch {
637
+ // Best-effort by design (see the doc comment).
638
+ }
639
+ }
640
+ /**
641
+ * Which of `paths` actually exist in `commitish`'s tree. Used by the reproduction proof to tell a
642
+ * DECLARED test file that was committed from one that only ever existed as an untracked working-
643
+ * tree file: the proof runs against committed trees, so an unadded test is invisible to it — and
644
+ * equally invisible to the push, which is the point worth telling the agent about rather than
645
+ * reporting a verdict computed without the reproduction in it.
646
+ *
647
+ * Returns the input order/spelling of the paths that matched, so the caller can diff against its
648
+ * declared list to name the missing ones verbatim.
649
+ */
650
+ export async function pathsPresentAtCommit(dir, commitish, paths, signal) {
651
+ if (paths.length === 0)
652
+ return [];
653
+ const out = await git(['ls-tree', '-r', '--name-only', '-z', commitish, '--', ...paths], {
654
+ cwd: dir,
655
+ signal,
656
+ });
657
+ // NUL-delimited so a path containing a newline (legal in git) can't split into two entries.
658
+ const present = new Set(out.split('\0').filter((p) => p !== ''));
659
+ return paths.filter((p) => present.has(p));
660
+ }
661
+ /**
662
+ * Check `paths` out of `commitish` into `dir`'s working tree (and index), leaving every other file
663
+ * untouched.
664
+ *
665
+ * This is how the reproduction's declared TEST files are placed onto the pre-fix worktree, and the
666
+ * narrowness is the whole safety property: a whole-tree checkout would drag the FIX across too and
667
+ * green the base, manufacturing a "the test does not capture the defect" verdict out of a
668
+ * perfectly good reproduction. Only the paths the caller has already sanitized are passed, and
669
+ * `--` stops any of them being read as a revision.
670
+ */
671
+ export async function checkoutPathsFrom(dir, commitish, paths, signal) {
672
+ if (paths.length === 0)
673
+ return;
674
+ await git(['checkout', commitish, '--', ...paths], { cwd: dir, signal });
675
+ }
568
676
  /** Stage everything and commit; returns false when there was nothing to commit. */
569
677
  export async function commitAll(dir, message, signal) {
570
678
  await git(['add', '-A'], { cwd: dir, signal });
package/dist/job.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { parseValidationChecksSpec, } from './validation-checks.js';
2
+ import { parseReproductionSpec, } from './reproduction-proof.js';
1
3
  function str(value, path) {
2
4
  if (typeof value !== 'string' || value.length === 0) {
3
5
  throw new Error(`Invalid job: '${path}' must be a non-empty string`);
@@ -66,39 +68,6 @@ function parseValidationSpec(value) {
66
68
  ...(iteration !== undefined ? { iteration } : {}),
67
69
  };
68
70
  }
69
- /**
70
- * Parse the optional PRE-PR VALIDATION CHECKS spec (see
71
- * docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
72
- * the repair-round budget. Every entry needs a non-empty command; entries without one are
73
- * dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
74
- * body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
75
- * failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
76
- * can't make a container loop forever.
77
- */
78
- function parseValidationChecksSpec(value) {
79
- if (typeof value !== 'object' || value === null)
80
- return undefined;
81
- const o = value;
82
- if (!Array.isArray(o.checks))
83
- return undefined;
84
- const checks = [];
85
- for (const raw of o.checks) {
86
- if (typeof raw !== 'object' || raw === null)
87
- continue;
88
- const c = raw;
89
- if (typeof c.command !== 'string' || c.command.trim() === '')
90
- continue;
91
- const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command;
92
- checks.push({ label, command: c.command });
93
- }
94
- if (checks.length === 0)
95
- return undefined;
96
- const parsed = posInt(o.maxAttempts);
97
- return {
98
- checks,
99
- maxAttempts: Math.min(parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS, VALIDATION_MAX_ATTEMPTS_CEILING),
100
- };
101
- }
102
71
  /**
103
72
  * Parse the shared per-job auth fields, validating per harness: a subscription
104
73
  * harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
@@ -386,18 +355,6 @@ export function parseTestSecrets(value) {
386
355
  }
387
356
  return entries;
388
357
  }
389
- /**
390
- * The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
391
- * default it applies when the body omits one.
392
- *
393
- * DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
394
- * in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
395
- * cannot import them. Keep the two in step: the API validates writes against the contracts
396
- * values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
397
- * was allowed to save, with nothing to flag the mismatch.
398
- */
399
- export const VALIDATION_MAX_ATTEMPTS_CEILING = 10;
400
- export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3;
401
358
  /** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
402
359
  function parseAgentBootstrapSpec(value) {
403
360
  if (typeof value !== 'object' || value === null)
@@ -717,6 +674,7 @@ export function parseAgentJob(input) {
717
674
  guardLimits: parseGuardLimits(o.guardLimits),
718
675
  validation: parseValidationSpec(o.validation),
719
676
  validationChecks: parseValidationChecksSpec(o.validationChecks),
677
+ reproduction: parseReproductionSpec(o.reproduction),
720
678
  reviewPrNumber: posInt(o.reviewPrNumber),
721
679
  });
722
680
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
@@ -773,7 +731,7 @@ function parseAgentPrSpec(raw) {
773
731
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
774
732
  */
775
733
  function assembleAgentJob(o, mode, agentField, parts) {
776
- const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reviewPrNumber, } = parts;
734
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
777
735
  const repo = (o.repo ?? {});
778
736
  return {
779
737
  jobId: str(o.jobId, 'jobId'),
@@ -801,6 +759,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
801
759
  ...(guardLimits ? { guardLimits } : {}),
802
760
  ...(validation ? { validation } : {}),
803
761
  ...(validationChecks ? { validationChecks } : {}),
762
+ ...(reproduction ? { reproduction } : {}),
804
763
  };
805
764
  }
806
765
  /**