@cat-factory/executor-harness 1.50.14 → 1.50.18

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.
@@ -319,21 +319,7 @@ export async function runClaudeCode(opts) {
319
319
  : join(homedir(), '.claude', 'skills');
320
320
  await writeNativeSkill(skillsRoot, opts.skill).catch(() => { });
321
321
  }
322
- // Anthropic itself authenticates with the subscription OAuth token; a
323
- // non-Anthropic Claude-Code vendor (GLM via Z.ai, Kimi via Moonshot, DeepSeek)
324
- // points Claude Code at its Anthropic-compatible endpoint with an auth-token key.
325
- // Ambient mode injects neither — the CLI uses the developer's logged-in `~/.claude`.
326
- const env = opts.ambientAuth
327
- ? {}
328
- : {
329
- CLAUDE_CONFIG_DIR: configHome,
330
- ...(opts.subscriptionBaseUrl
331
- ? {
332
- ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
333
- ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken,
334
- }
335
- : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
336
- };
322
+ const env = buildClaudeEnv(opts, configHome);
337
323
  // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
338
324
  // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
339
325
  // heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
@@ -369,38 +355,7 @@ export async function runClaudeCode(opts) {
369
355
  ...appendArgs,
370
356
  ],
371
357
  }, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
372
- // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
373
- // subagent calls, which carry their own per-turn tokens, are concatenated).
374
- attributeCumulativeUsage(calls, usage);
375
- // Final drain of any subagent transcript writes that landed after the last poll, then
376
- // fold the subagents' usage + per-call telemetry into the run's outcome — their tokens
377
- // never appear on the parent stream, so this is the only place they are accounted.
378
- await subagents?.stop();
379
- const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
380
- const subCalls = subagents?.calls() ?? [];
381
- const mergedCalls = [...calls, ...subCalls];
382
- // INVARIANT (do not "fix" this into a double count): the run total is the parent usage
383
- // PLUS the subagent usage because the two are disjoint sources. The parent `usage` here
384
- // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
385
- // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
386
- // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
387
- // spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
388
- // transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
389
- // sibling parent session transcript (whose usage `result` already totals), so neither
390
- // `calls` nor `usage` can already contain the subagent spend.
391
- const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
392
- ? {
393
- inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
394
- outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
395
- }
396
- : undefined;
397
- return {
398
- summary,
399
- stats,
400
- stderrTail,
401
- ...(mergedUsage ? { usage: mergedUsage } : {}),
402
- ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
403
- };
358
+ return await assembleClaudeOutcome({ summary, stats, stderrTail, calls, usage, subagents });
404
359
  }
405
360
  finally {
406
361
  await subagents?.stop();
@@ -417,6 +372,59 @@ export async function runClaudeCode(opts) {
417
372
  }
418
373
  }
419
374
  }
375
+ /**
376
+ * Build the child-process env for the `claude` CLI: an isolated config home plus subscription
377
+ * auth (Anthropic OAuth token, or an Anthropic-compatible base URL + auth token for a
378
+ * non-Anthropic Claude-Code vendor like GLM/Kimi/DeepSeek), or an empty env in ambient mode
379
+ * (the developer's own logged-in `~/.claude` is used). Extracted from {@link runClaudeCode} to
380
+ * keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
381
+ */
382
+ function buildClaudeEnv(opts, configHome) {
383
+ if (opts.ambientAuth)
384
+ return {};
385
+ return {
386
+ CLAUDE_CONFIG_DIR: configHome,
387
+ ...(opts.subscriptionBaseUrl
388
+ ? {
389
+ ANTHROPIC_BASE_URL: opts.subscriptionBaseUrl,
390
+ ANTHROPIC_AUTH_TOKEN: opts.subscriptionToken,
391
+ }
392
+ : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
393
+ };
394
+ }
395
+ /**
396
+ * Merge the parent-loop telemetry with the subagents' out-of-band usage + per-call metrics into
397
+ * the run outcome. INVARIANT (do not "fix" this into a double count): the run total is the parent
398
+ * usage PLUS the subagent usage because the two are disjoint sources — the parent `usage` (the
399
+ * terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
400
+ * exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
401
+ * {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
402
+ */
403
+ async function assembleClaudeOutcome(args) {
404
+ const { summary, stats, stderrTail, calls, usage, subagents } = args;
405
+ // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
406
+ // subagent calls, which carry their own per-turn tokens, are concatenated).
407
+ attributeCumulativeUsage(calls, usage);
408
+ // Final drain of any subagent transcript writes that landed after the last poll, then
409
+ // fold the subagents' usage + per-call telemetry into the run's outcome.
410
+ await subagents?.stop();
411
+ const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
412
+ const subCalls = subagents?.calls() ?? [];
413
+ const mergedCalls = [...calls, ...subCalls];
414
+ const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
415
+ ? {
416
+ inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
417
+ outputTokens: (usage?.outputTokens ?? 0) + subUsage.outputTokens,
418
+ }
419
+ : undefined;
420
+ return {
421
+ summary,
422
+ stats,
423
+ stderrTail,
424
+ ...(mergedUsage ? { usage: mergedUsage } : {}),
425
+ ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
426
+ };
427
+ }
420
428
  /** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
421
429
  function todosToProgress(todos) {
422
430
  if (!Array.isArray(todos))
package/dist/agent.js CHANGED
@@ -228,6 +228,14 @@ async function cloneServiceCheckout(dir, job, signal) {
228
228
  });
229
229
  return deriveWorkDir(dir, job.repo.serviceDirectory);
230
230
  }
231
+ /**
232
+ * Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
233
+ * onto its final result. Every container mode routes its result through this so the report reaches
234
+ * the backend uniformly. A run that wrote no report passes through unchanged.
235
+ */
236
+ function mergeEffort(result, effortReport) {
237
+ return effortReport ? { ...result, effortReport } : result;
238
+ }
231
239
  /** Run one generic agent job end to end, dispatching on `mode`. */
232
240
  export async function handleAgent(job, opts = {}) {
233
241
  // Private-registry auth first, before any mode runs: every mode with a checkout may
@@ -457,7 +465,7 @@ async function runExploreMode(job, opts) {
457
465
  try {
458
466
  opts.onPhase?.('agent');
459
467
  logger.info('agent(explore): running agent', { serviceDirectory });
460
- const { summary, stats, stderrTail, usage, callMetrics, diagnostics: runDiag, } = await runAgentInWorkspace({
468
+ const { summary, stats, stderrTail, usage, callMetrics, diagnostics: runDiag, effortReport, } = await runAgentInWorkspace({
461
469
  dir: workDir,
462
470
  systemPrompt: job.systemPrompt,
463
471
  userPrompt,
@@ -477,7 +485,7 @@ async function runExploreMode(job, opts) {
477
485
  contextFiles: job.contextFiles,
478
486
  guardLimits: job.guardLimits,
479
487
  }, opts);
480
- return await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal });
488
+ return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
481
489
  }
482
490
  finally {
483
491
  restoreSecrets();
@@ -666,14 +674,14 @@ async function runMultiRepoExplore(job, opts) {
666
674
  guardLimits: job.guardLimits,
667
675
  multiRepo: true,
668
676
  }, opts);
669
- return finalizeExploreResult(job, {
677
+ return mergeEffort(await finalizeExploreResult(job, {
670
678
  summary: run.summary,
671
679
  stats: run.stats,
672
680
  stderrTail: run.stderrTail,
673
681
  usage: run.usage,
674
682
  callMetrics: run.callMetrics,
675
683
  runDiag: run.diagnostics,
676
- }, { infraSetupFields: {}, logger, signal: opts.signal });
684
+ }, { infraSetupFields: {}, logger, signal: opts.signal }), run.effortReport);
677
685
  });
678
686
  }
679
687
  /**
@@ -737,14 +745,12 @@ async function runCodingMode(job, opts) {
737
745
  return result;
738
746
  }
739
747
  /**
740
- * The ordinary single-repo coding flow: clone `branch` (or resume `newBranch`), run the agent,
741
- * commit + push to `pushBranch`, and open `pr` when one is set and the run produced changes. A
742
- * no-op is a failure for the implementer (`noChangesIsError` default) and a non-fatal no-op for
743
- * the in-place fixers (and for a seed-only kind like `repro-test`).
748
+ * Assemble the {@link runCodingAgent} spec for the ordinary single-repo coding flow. Extracted
749
+ * from {@link runSingleRepoCoding} so the many optional-field spreads don't inflate that
750
+ * function's cyclomatic complexity; the mapping is a straight field copy off `job`.
744
751
  */
745
- async function runSingleRepoCoding(job, opts) {
746
- const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
747
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } = await runCodingAgent({
752
+ function buildSingleRepoCodingSpec(job, pushBranch) {
753
+ return {
748
754
  kind: 'agent',
749
755
  jobId: job.jobId,
750
756
  repo: job.repo,
@@ -781,10 +787,22 @@ async function runSingleRepoCoding(job, opts) {
781
787
  },
782
788
  }
783
789
  : {}),
784
- }, opts);
790
+ };
791
+ }
792
+ /**
793
+ * The ordinary single-repo coding flow: clone `branch` (or resume `newBranch`), run the agent,
794
+ * commit + push to `pushBranch`, and open `pr` when one is set and the run produced changes. A
795
+ * no-op is a failure for the implementer (`noChangesIsError` default) and a non-fatal no-op for
796
+ * the in-place fixers (and for a seed-only kind like `repro-test`).
797
+ */
798
+ async function runSingleRepoCoding(job, opts) {
799
+ const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
800
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
785
801
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
786
802
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
787
803
  const ralphVerdict = validation ? { ralphVerdict: validation } : {};
804
+ // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
805
+ const effort = effortReport ? { effortReport } : {};
788
806
  if (!pushed) {
789
807
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
790
808
  if (job.noChangesIsError === false) {
@@ -796,6 +814,7 @@ async function runSingleRepoCoding(job, opts) {
796
814
  ...(usage ? { usage } : {}),
797
815
  ...(callMetrics ? { callMetrics } : {}),
798
816
  ...ralphVerdict,
817
+ ...effort,
799
818
  };
800
819
  }
801
820
  return {
@@ -807,6 +826,7 @@ async function runSingleRepoCoding(job, opts) {
807
826
  failureCause: 'no-changes',
808
827
  ...(usage ? { usage } : {}),
809
828
  ...(callMetrics ? { callMetrics } : {}),
829
+ ...effort,
810
830
  };
811
831
  }
812
832
  // Changes are on the branch. Open a PR only when the job asked for one.
@@ -839,6 +859,7 @@ async function runSingleRepoCoding(job, opts) {
839
859
  stats,
840
860
  ...(usage ? { usage } : {}),
841
861
  ...(callMetrics ? { callMetrics } : {}),
862
+ ...effort,
842
863
  };
843
864
  }
844
865
  return {
@@ -850,6 +871,7 @@ async function runSingleRepoCoding(job, opts) {
850
871
  failureCause: 'no-changes',
851
872
  ...(usage ? { usage } : {}),
852
873
  ...(callMetrics ? { callMetrics } : {}),
874
+ ...effort,
853
875
  };
854
876
  }
855
877
  return {
@@ -861,6 +883,7 @@ async function runSingleRepoCoding(job, opts) {
861
883
  ...(usage ? { usage } : {}),
862
884
  ...(callMetrics ? { callMetrics } : {}),
863
885
  ...ralphVerdict,
886
+ ...effort,
864
887
  };
865
888
  }
866
889
  return {
@@ -871,6 +894,7 @@ async function runSingleRepoCoding(job, opts) {
871
894
  ...(usage ? { usage } : {}),
872
895
  ...(callMetrics ? { callMetrics } : {}),
873
896
  ...ralphVerdict,
897
+ ...effort,
874
898
  };
875
899
  }
876
900
  /**
@@ -935,7 +959,7 @@ async function runConflictResolution(job, opts) {
935
959
  logger.info('agent(conflict): resolving conflicts with agent', { conflicted });
936
960
  const diff = await conflictDiff(dir, conflicted, signal);
937
961
  const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt);
938
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
962
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
939
963
  dir,
940
964
  systemPrompt: job.systemPrompt,
941
965
  userPrompt,
@@ -956,7 +980,7 @@ async function runConflictResolution(job, opts) {
956
980
  logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
957
981
  unresolved: unresolved.length,
958
982
  });
959
- return {
983
+ return mergeEffort({
960
984
  pushed: false,
961
985
  branch: job.branch,
962
986
  summary,
@@ -965,21 +989,21 @@ async function runConflictResolution(job, opts) {
965
989
  failureCause: 'agent',
966
990
  ...(usage ? { usage } : {}),
967
991
  ...(callMetrics ? { callMetrics } : {}),
968
- };
992
+ }, effortReport);
969
993
  }
970
994
  // Complete the merge commit with the agent's resolution staged, then push.
971
995
  await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal);
972
996
  opts.onPhase?.('push');
973
997
  logger.info('agent(conflict): pushing resolved branch', { ...stats });
974
998
  await pushBranch(dir, job.branch, job.ghToken, signal);
975
- return {
999
+ return mergeEffort({
976
1000
  pushed: true,
977
1001
  branch: job.branch,
978
1002
  summary,
979
1003
  stats,
980
1004
  ...(usage ? { usage } : {}),
981
1005
  ...(callMetrics ? { callMetrics } : {}),
982
- };
1006
+ }, effortReport);
983
1007
  });
984
1008
  }
985
1009
  /**
@@ -1055,7 +1079,7 @@ async function runBootstrap(job, opts) {
1055
1079
  }
1056
1080
  opts.onPhase?.('agent');
1057
1081
  logger.info('agent(bootstrap): running agent');
1058
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
1082
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
1059
1083
  dir,
1060
1084
  systemPrompt: job.systemPrompt,
1061
1085
  userPrompt: job.userPrompt,
@@ -1075,14 +1099,14 @@ async function runBootstrap(job, opts) {
1075
1099
  if (!(await producedRepoContent(dir, !fromScratch, signal))) {
1076
1100
  const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
1077
1101
  logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
1078
- return {
1102
+ return mergeEffort({
1079
1103
  summary,
1080
1104
  stats,
1081
1105
  error,
1082
1106
  failureCause: 'agent',
1083
1107
  ...(usage ? { usage } : {}),
1084
1108
  ...(callMetrics ? { callMetrics } : {}),
1085
- };
1109
+ }, effortReport);
1086
1110
  }
1087
1111
  opts.onPhase?.('push');
1088
1112
  logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
@@ -1097,13 +1121,13 @@ async function runBootstrap(job, opts) {
1097
1121
  : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
1098
1122
  });
1099
1123
  logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
1100
- return {
1124
+ return mergeEffort({
1101
1125
  defaultBranch: boot.target.defaultBranch,
1102
1126
  summary,
1103
1127
  stats,
1104
1128
  ...(usage ? { usage } : {}),
1105
1129
  ...(callMetrics ? { callMetrics } : {}),
1106
- };
1130
+ }, effortReport);
1107
1131
  });
1108
1132
  }
1109
1133
  /**
@@ -5,6 +5,7 @@ import { killChildProcess, spawnDetached } from './process.js';
5
5
  import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
6
6
  import { branchAheadOfBase, 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
+ import { EFFORT_REPORT_FILE } from './effort.js';
8
9
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
9
10
  import { log } from './logger.js';
10
11
  /**
@@ -105,6 +106,13 @@ export async function runCodingAgent(spec, opts = {}) {
105
106
  const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir;
106
107
  if (serviceDirectory)
107
108
  await mkdir(workDir, { recursive: true });
109
+ // Every container agent is asked to write its effort self-assessment to `.cat-effort.json`
110
+ // in its cwd (the backend appends EFFORT_REPORT_GUIDANCE to every container prompt). Locally
111
+ // exclude it from git — exactly like the follow-ups sentinel below — so the agent's own
112
+ // `git add` can never stage it into the PR. `readEffortReport` also removes it after the run,
113
+ // but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
114
+ // filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
115
+ await excludeFromGit(dir, EFFORT_REPORT_FILE, signal);
108
116
  // Follow-up companion: tail the Coder's sentinel file and stream new items out on the
109
117
  // job view. Locally exclude it from git first so the agent's own `git add` can never
110
118
  // stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
@@ -278,7 +286,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
278
286
  async function finalizeCodingRun(args) {
279
287
  const { dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
280
288
  const { signal } = opts;
281
- const { summary, stats, stderrTail, usage, callMetrics } = agentRun;
289
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
282
290
  let outcome;
283
291
  // Stop tailing the follow-up sentinel and flush any items written after the last
284
292
  // tick, so a fast final burst still reaches the job view before the run is recorded.
@@ -335,6 +343,7 @@ async function finalizeCodingRun(args) {
335
343
  ...(stderrTail ? { stderrTail } : {}),
336
344
  ...(usage ? { usage } : {}),
337
345
  ...(callMetrics ? { callMetrics } : {}),
346
+ ...(effortReport ? { effortReport } : {}),
338
347
  };
339
348
  }
340
349
  else {
@@ -349,6 +358,7 @@ async function finalizeCodingRun(args) {
349
358
  ...(stderrTail ? { stderrTail } : {}),
350
359
  ...(usage ? { usage } : {}),
351
360
  ...(callMetrics ? { callMetrics } : {}),
361
+ ...(effortReport ? { effortReport } : {}),
352
362
  };
353
363
  }
354
364
  // Ralph loop: run the programmatic completion command against the pushed/committed
@@ -528,7 +538,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
528
538
  // note + the backend system-prompt section explain the layout.
529
539
  opts.onPhase?.('agent');
530
540
  logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) });
531
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
541
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
532
542
  dir: root,
533
543
  systemPrompt: job.systemPrompt,
534
544
  userPrompt: job.userPrompt,
@@ -560,6 +570,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
560
570
  stats,
561
571
  ...(usage ? { usage } : {}),
562
572
  ...(callMetrics ? { callMetrics } : {}),
573
+ ...(effortReport ? { effortReport } : {}),
563
574
  };
564
575
  }
565
576
  return {
@@ -571,6 +582,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
571
582
  failureCause: 'no-changes',
572
583
  ...(usage ? { usage } : {}),
573
584
  ...(callMetrics ? { callMetrics } : {}),
585
+ ...(effortReport ? { effortReport } : {}),
574
586
  };
575
587
  }
576
588
  logger.info('multi-repo: complete', {
@@ -587,6 +599,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
587
599
  stats,
588
600
  ...(usage ? { usage } : {}),
589
601
  ...(callMetrics ? { callMetrics } : {}),
602
+ ...(effortReport ? { effortReport } : {}),
590
603
  };
591
604
  });
592
605
  }
package/dist/effort.js ADDED
@@ -0,0 +1,84 @@
1
+ import { readFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ // ---------------------------------------------------------------------------
4
+ // The agent effort self-assessment side channel. Every container agent is asked
5
+ // (via the backend-composed system prompt) to end its run by writing a short JSON
6
+ // self-assessment — how hard the work was, what reduced its effectiveness, the key
7
+ // obstacles — to a sentinel file in its working directory. The harness reads it after
8
+ // the agent finishes, removes it (so it never lands in a commit), and forwards it on
9
+ // the job result; the backend records it on the step and surfaces it in run details.
10
+ //
11
+ // The filename is kept in sync with `EFFORT_REPORT_FILE` in `@cat-factory/agents`
12
+ // (the executor-harness has no dependency on that package), exactly like CONTEXT_DIR
13
+ // and the follow-ups sentinel. The shape mirrors the contracts `AgentEffortReport`.
14
+ // ---------------------------------------------------------------------------
15
+ /** The sentinel file the agent writes its effort self-assessment to (relative to its cwd). */
16
+ export const EFFORT_REPORT_FILE = '.cat-effort.json';
17
+ /**
18
+ * Read + parse + REMOVE the agent's effort sentinel file from `cwd`. Lenient: returns undefined
19
+ * when the file is absent (the agent wrote none), unreadable, not JSON, or carries nothing
20
+ * meaningful. Never throws — a malformed self-report must never fail an otherwise-good run.
21
+ */
22
+ export async function readEffortReport(cwd) {
23
+ const path = join(cwd, EFFORT_REPORT_FILE);
24
+ let raw;
25
+ try {
26
+ raw = await readFile(path, 'utf8');
27
+ }
28
+ catch {
29
+ return undefined; // no report written — the common case
30
+ }
31
+ // Remove it so it never lands in a commit (defence in depth; the backend also excludes it).
32
+ await rm(path, { force: true }).catch(() => { });
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(raw);
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ return coerceEffort(parsed);
41
+ }
42
+ /** Coerce arbitrary parsed JSON into a clean {@link EffortReport}, or undefined when it carries nothing. */
43
+ function coerceEffort(value) {
44
+ if (typeof value !== 'object' || value === null)
45
+ return undefined;
46
+ const o = value;
47
+ const report = { difficulty: clampDifficulty(o.difficulty) };
48
+ if (typeof o.summary === 'string' && o.summary.trim()) {
49
+ report.summary = o.summary.trim().slice(0, 2000);
50
+ }
51
+ if (typeof o.reducedEffectiveness === 'string' && o.reducedEffectiveness.trim()) {
52
+ report.reducedEffectiveness = o.reducedEffectiveness.trim().slice(0, 2000);
53
+ }
54
+ if (Array.isArray(o.obstacles)) {
55
+ const obstacles = o.obstacles
56
+ .filter((x) => typeof x === 'string' && x.trim().length > 0)
57
+ .map((x) => x.trim().slice(0, 500))
58
+ .slice(0, 20);
59
+ if (obstacles.length)
60
+ report.obstacles = obstacles;
61
+ }
62
+ // Nothing beyond a defaulted difficulty ⇒ the agent didn't really report anything; drop it so
63
+ // run details don't show an empty "5/10, no detail" card for a stray/blank file.
64
+ if (report.summary === undefined &&
65
+ report.reducedEffectiveness === undefined &&
66
+ report.obstacles === undefined &&
67
+ !isFiniteNumber(o.difficulty)) {
68
+ return undefined;
69
+ }
70
+ return report;
71
+ }
72
+ function clampDifficulty(v) {
73
+ const n = isFiniteNumber(v)
74
+ ? v
75
+ : typeof v === 'string' && v.trim() !== ''
76
+ ? Number(v)
77
+ : Number.NaN;
78
+ if (!Number.isFinite(n))
79
+ return 5;
80
+ return Math.min(10, Math.max(1, Math.round(n)));
81
+ }
82
+ function isFiniteNumber(v) {
83
+ return typeof v === 'number' && Number.isFinite(v);
84
+ }
package/dist/job.js CHANGED
@@ -555,6 +555,24 @@ function isReservedEnvName(key) {
555
555
  const lower = key.toLowerCase();
556
556
  return RESERVED_ENV_PREFIXES.some((p) => lower.startsWith(p));
557
557
  }
558
+ /**
559
+ * Collect only string→string entries from a raw `env` bag. A non-string value is dropped so a
560
+ * malformed binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved
561
+ * names that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
562
+ * dropped too: they are spread over `process.env` at build time, so a binding named `PATH` would
563
+ * replace it with a URL and the build would no longer find its tools. Extracted from the infra
564
+ * parsers to keep their cyclomatic complexity down.
565
+ */
566
+ function parseInfraEnv(raw) {
567
+ const env = {};
568
+ if (typeof raw === 'object' && raw !== null) {
569
+ for (const [key, val] of Object.entries(raw)) {
570
+ if (key && !isReservedEnvName(key) && typeof val === 'string')
571
+ env[key] = val;
572
+ }
573
+ }
574
+ return env;
575
+ }
558
576
  /** Parse the frontend UI-test infra spec (`kind: 'frontend'`), tolerating missing knobs. */
559
577
  function parseFrontendInfraSpec(o) {
560
578
  const packageManager = o.packageManager === 'pnpm' || o.packageManager === 'npm' || o.packageManager === 'yarn'
@@ -562,18 +580,7 @@ function parseFrontendInfraSpec(o) {
562
580
  : undefined;
563
581
  const serveMode = o.serveMode === 'static' || o.serveMode === 'command' ? o.serveMode : undefined;
564
582
  const envInjection = o.envInjection === 'build' || o.envInjection === 'runtime' ? o.envInjection : undefined;
565
- // Only string→string entries survive; a non-string value is dropped so a malformed
566
- // binding can't inject `[object Object]` (or undefined) as an upstream URL. Reserved names
567
- // that would break the toolchain or enable injection (PATH, NODE_OPTIONS, LD_PRELOAD, …) are
568
- // dropped too: they are spread over `process.env` at build time, so a binding named `PATH`
569
- // would replace it with a URL and the build would no longer find its tools.
570
- const env = {};
571
- if (typeof o.env === 'object' && o.env !== null) {
572
- for (const [key, val] of Object.entries(o.env)) {
573
- if (key && !isReservedEnvName(key) && typeof val === 'string')
574
- env[key] = val;
575
- }
576
- }
583
+ const env = parseInfraEnv(o.env);
577
584
  const servePort = port(o.servePort);
578
585
  const wiremockPort = port(o.wiremockPort);
579
586
  // The app's monorepo subdirectory becomes the install/build/serve cwd, so it goes through the
@@ -732,11 +739,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
732
739
  ghToken: str(o.ghToken, 'ghToken'),
733
740
  repo: parseRepoSpec(repo),
734
741
  branch: str(o.branch, 'branch'),
735
- ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
736
- ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
737
- ...(o.webSearch === true ? { webSearch: true } : {}),
738
- ...(o.full === true ? { full: true } : {}),
739
- ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
742
+ ...collectOptionalRequestFields(o),
740
743
  ...(bootstrap ? { bootstrap } : {}),
741
744
  ...(output ? { output } : {}),
742
745
  ...(contextFiles.length ? { contextFiles } : {}),
@@ -744,20 +747,35 @@ function assembleAgentJob(o, mode, agentField, parts) {
744
747
  ...(skill ? { skill } : {}),
745
748
  ...(testSecrets.length ? { testSecrets } : {}),
746
749
  ...(infra ? { infra } : {}),
747
- ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
748
- ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
749
- ...(typeof o.commitMessage === 'string' && o.commitMessage
750
- ? { commitMessage: o.commitMessage }
751
- : {}),
752
750
  ...(pr ? { pr } : {}),
753
751
  ...(peerRepos.length ? { peerRepos } : {}),
754
752
  ...(referenceRepos.length ? { referenceRepos } : {}),
755
753
  ...(referenceBranches.length ? { referenceBranches } : {}),
756
754
  ...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
755
+ ...(guardLimits ? { guardLimits } : {}),
756
+ ...(validation ? { validation } : {}),
757
+ };
758
+ }
759
+ /**
760
+ * The optional {@link AgentJob} fields read directly off the request `o` (booleans + trimmed
761
+ * strings). Extracted from {@link assembleAgentJob} to keep its cyclomatic complexity down; every
762
+ * key is unique so grouping the conditional spreads is behaviour-neutral (spread order is
763
+ * irrelevant with no colliding keys).
764
+ */
765
+ function collectOptionalRequestFields(o) {
766
+ return {
767
+ ...(typeof o.githubApiBase === 'string' ? { githubApiBase: o.githubApiBase } : {}),
768
+ ...(typeof o.webToolsGuidance === 'string' ? { webToolsGuidance: o.webToolsGuidance } : {}),
769
+ ...(o.webSearch === true ? { webSearch: true } : {}),
770
+ ...(o.full === true ? { full: true } : {}),
771
+ ...(typeof o.mergeBase === 'string' && o.mergeBase ? { mergeBase: o.mergeBase } : {}),
772
+ ...(typeof o.newBranch === 'string' && o.newBranch ? { newBranch: o.newBranch } : {}),
773
+ ...(typeof o.pushBranch === 'string' && o.pushBranch ? { pushBranch: o.pushBranch } : {}),
774
+ ...(typeof o.commitMessage === 'string' && o.commitMessage
775
+ ? { commitMessage: o.commitMessage }
776
+ : {}),
757
777
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
758
778
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
759
779
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
760
- ...(guardLimits ? { guardLimits } : {}),
761
- ...(validation ? { validation } : {}),
762
780
  };
763
781
  }
@@ -1,6 +1,7 @@
1
1
  import { mkdir, mkdtemp, rm } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { readEffortReport } from './effort.js';
4
5
  import { log } from './logger.js';
5
6
  import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, mergeGuardLimits, progressGuardLimitsFromEnv, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
6
7
  import { runSubscriptionHarness } from './agent-runner.js';
@@ -134,7 +135,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
134
135
  if (!spec.ambientAuth && !spec.subscriptionToken) {
135
136
  throw new Error(`The ${spec.harness} harness requires a subscription token`);
136
137
  }
137
- return runSubscriptionHarness(spec.harness, {
138
+ const subOutcome = await runSubscriptionHarness(spec.harness, {
138
139
  cwd: spec.dir,
139
140
  model: spec.model,
140
141
  systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles),
@@ -148,6 +149,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
148
149
  onProgress: opts.onProgress,
149
150
  ...(opts.log ? { log: opts.log } : {}),
150
151
  });
152
+ return withEffortReport(spec.dir, subOutcome);
151
153
  }
152
154
  if (!spec.proxyBaseUrl || !spec.sessionToken) {
153
155
  throw new Error('The Pi harness requires proxyBaseUrl and sessionToken');
@@ -178,7 +180,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
178
180
  });
179
181
  await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
180
182
  const { signal, onActivity, onProgress, onSpan } = opts;
181
- return runPi({
183
+ const piOutcome = await runPi({
182
184
  cwd: spec.dir,
183
185
  model: spec.model,
184
186
  userPrompt: spec.userPrompt,
@@ -193,6 +195,16 @@ export async function runAgentInWorkspace(spec, opts = {}) {
193
195
  guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
194
196
  extraEnv,
195
197
  });
198
+ return withEffortReport(spec.dir, piOutcome);
199
+ }
200
+ /**
201
+ * Lift the agent's effort self-assessment off its sentinel file in `dir` and fold it onto the
202
+ * run outcome. Shared by both harness paths so EVERY container agent's effort report is captured
203
+ * in one place. Never throws (a bad/absent report just yields no `effortReport`).
204
+ */
205
+ async function withEffortReport(dir, outcome) {
206
+ const effortReport = await readEffortReport(dir);
207
+ return effortReport ? { ...outcome, effortReport } : outcome;
196
208
  }
197
209
  /**
198
210
  * Append a pointer to the materialised linked context onto a subscription harness's