@cat-factory/executor-harness 1.50.12 → 1.50.16

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.
@@ -5,7 +5,7 @@ import { dirname, join } from 'node:path';
5
5
  import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
6
6
  import { killChildProcess, spawnDetached } from './process.js';
7
7
  import { redact, secretsToRedact } from './redact.js';
8
- import { createSliceTracker, startSubagentWatcher } from './subagents.js';
8
+ import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js';
9
9
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
10
10
  import { retainSessionTranscripts } from './transcript-retention.js';
11
11
  /**
@@ -219,17 +219,19 @@ export async function runClaudeCode(opts) {
219
219
  { role: 'user', content: opts.userPrompt },
220
220
  ];
221
221
  const calls = [];
222
- // ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
223
- // their terminal tool_results (both DO appear here — only a subagent's intermediate
224
- // turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
225
- // slice-derived progress is the fallback for the parallel-subagent shape that writes no
226
- // parent plan (the pr-reviewer failure this fixes).
222
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
223
+ // sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
224
+ // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
225
+ // progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
226
+ // shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
227
+ // update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
228
+ // and never marks it done, which used to gate the slice signal off and pin progress at 0%.
227
229
  const sliceTracker = createSliceTracker();
228
- let sawTodoPlan = false;
229
- const emitSliceProgress = () => {
230
- if (sawTodoPlan || !opts.onProgress)
230
+ let lastTodo;
231
+ const emitProgress = () => {
232
+ if (!opts.onProgress)
231
233
  return;
232
- const progress = sliceTracker.progress();
234
+ const progress = pickProgress(lastTodo, sliceTracker.progress());
233
235
  if (progress)
234
236
  opts.onProgress(progress);
235
237
  };
@@ -242,19 +244,14 @@ export async function runClaudeCode(opts) {
242
244
  stats.assistantChars += text.length;
243
245
  stats.toolCalls += toolUses;
244
246
  for (const block of content) {
245
- if (isObject(block) &&
246
- block.type === 'tool_use' &&
247
- block.name === 'TodoWrite' &&
248
- opts.onProgress) {
247
+ if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
249
248
  const progress = todosToProgress(block.input?.todos);
250
- if (progress) {
251
- sawTodoPlan = true;
252
- opts.onProgress(progress);
253
- }
249
+ if (progress)
250
+ lastTodo = progress;
254
251
  }
255
252
  }
256
253
  sliceTracker.onAssistant(content);
257
- emitSliceProgress();
254
+ emitProgress();
258
255
  // Record this call BEFORE appending its turn: the prompt is the history that
259
256
  // produced this response. The append-only array keeps each call's prompt a strict
260
257
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
@@ -277,7 +274,7 @@ export async function runClaudeCode(opts) {
277
274
  const content = event.message.content;
278
275
  if (Array.isArray(content)) {
279
276
  sliceTracker.onUser(content);
280
- emitSliceProgress();
277
+ emitProgress();
281
278
  messages.push({ role: 'tool', content });
282
279
  }
283
280
  }
@@ -337,13 +334,16 @@ export async function runClaudeCode(opts) {
337
334
  }
338
335
  : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken }),
339
336
  };
340
- // ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
341
- // transcripts (under the isolated config home) so a parallel-subagent review keeps the
342
- // inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
343
- // token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
344
- // watch. Best-effort a missing/renamed transcript layout just yields no extra signal.
337
+ // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
338
+ // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
339
+ // heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
340
+ // lifted into the run's telemetry. The CLI writes them per-session under
341
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/*.jsonl`, so we watch the
342
+ // `projects` tree and let the watcher discover the `subagents/` dir (the session uuid isn't
343
+ // known up front). Ambient mode has no isolated home to watch. Best-effort — a
344
+ // missing/renamed transcript layout just yields no extra signal.
345
345
  const subagents = configHome
346
- ? startSubagentWatcher(join(configHome, 'subagents'), {
346
+ ? startSubagentWatcher(join(configHome, 'projects'), {
347
347
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
348
348
  secrets,
349
349
  model: opts.model,
@@ -384,9 +384,10 @@ export async function runClaudeCode(opts) {
384
384
  // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
385
385
  // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
386
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 `subagents/*.jsonl` transcripts (a
388
- // directory distinct from the parent's `projects/` session transcript), which the watcher
389
- // reads and nothing else does so neither `calls` nor `usage` can already contain them.
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.
390
391
  const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
391
392
  ? {
392
393
  inputTokens: (usage?.inputTokens ?? 0) + subUsage.inputTokens,
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
  /**
@@ -744,7 +752,7 @@ async function runCodingMode(job, opts) {
744
752
  */
745
753
  async function runSingleRepoCoding(job, opts) {
746
754
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
747
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } = await runCodingAgent({
755
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } = await runCodingAgent({
748
756
  kind: 'agent',
749
757
  jobId: job.jobId,
750
758
  repo: job.repo,
@@ -785,6 +793,8 @@ async function runSingleRepoCoding(job, opts) {
785
793
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
786
794
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
787
795
  const ralphVerdict = validation ? { ralphVerdict: validation } : {};
796
+ // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
797
+ const effort = effortReport ? { effortReport } : {};
788
798
  if (!pushed) {
789
799
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
790
800
  if (job.noChangesIsError === false) {
@@ -796,6 +806,7 @@ async function runSingleRepoCoding(job, opts) {
796
806
  ...(usage ? { usage } : {}),
797
807
  ...(callMetrics ? { callMetrics } : {}),
798
808
  ...ralphVerdict,
809
+ ...effort,
799
810
  };
800
811
  }
801
812
  return {
@@ -807,6 +818,7 @@ async function runSingleRepoCoding(job, opts) {
807
818
  failureCause: 'no-changes',
808
819
  ...(usage ? { usage } : {}),
809
820
  ...(callMetrics ? { callMetrics } : {}),
821
+ ...effort,
810
822
  };
811
823
  }
812
824
  // Changes are on the branch. Open a PR only when the job asked for one.
@@ -839,6 +851,7 @@ async function runSingleRepoCoding(job, opts) {
839
851
  stats,
840
852
  ...(usage ? { usage } : {}),
841
853
  ...(callMetrics ? { callMetrics } : {}),
854
+ ...effort,
842
855
  };
843
856
  }
844
857
  return {
@@ -850,6 +863,7 @@ async function runSingleRepoCoding(job, opts) {
850
863
  failureCause: 'no-changes',
851
864
  ...(usage ? { usage } : {}),
852
865
  ...(callMetrics ? { callMetrics } : {}),
866
+ ...effort,
853
867
  };
854
868
  }
855
869
  return {
@@ -861,6 +875,7 @@ async function runSingleRepoCoding(job, opts) {
861
875
  ...(usage ? { usage } : {}),
862
876
  ...(callMetrics ? { callMetrics } : {}),
863
877
  ...ralphVerdict,
878
+ ...effort,
864
879
  };
865
880
  }
866
881
  return {
@@ -871,6 +886,7 @@ async function runSingleRepoCoding(job, opts) {
871
886
  ...(usage ? { usage } : {}),
872
887
  ...(callMetrics ? { callMetrics } : {}),
873
888
  ...ralphVerdict,
889
+ ...effort,
874
890
  };
875
891
  }
876
892
  /**
@@ -935,7 +951,7 @@ async function runConflictResolution(job, opts) {
935
951
  logger.info('agent(conflict): resolving conflicts with agent', { conflicted });
936
952
  const diff = await conflictDiff(dir, conflicted, signal);
937
953
  const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt);
938
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
954
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
939
955
  dir,
940
956
  systemPrompt: job.systemPrompt,
941
957
  userPrompt,
@@ -956,7 +972,7 @@ async function runConflictResolution(job, opts) {
956
972
  logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
957
973
  unresolved: unresolved.length,
958
974
  });
959
- return {
975
+ return mergeEffort({
960
976
  pushed: false,
961
977
  branch: job.branch,
962
978
  summary,
@@ -965,21 +981,21 @@ async function runConflictResolution(job, opts) {
965
981
  failureCause: 'agent',
966
982
  ...(usage ? { usage } : {}),
967
983
  ...(callMetrics ? { callMetrics } : {}),
968
- };
984
+ }, effortReport);
969
985
  }
970
986
  // Complete the merge commit with the agent's resolution staged, then push.
971
987
  await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal);
972
988
  opts.onPhase?.('push');
973
989
  logger.info('agent(conflict): pushing resolved branch', { ...stats });
974
990
  await pushBranch(dir, job.branch, job.ghToken, signal);
975
- return {
991
+ return mergeEffort({
976
992
  pushed: true,
977
993
  branch: job.branch,
978
994
  summary,
979
995
  stats,
980
996
  ...(usage ? { usage } : {}),
981
997
  ...(callMetrics ? { callMetrics } : {}),
982
- };
998
+ }, effortReport);
983
999
  });
984
1000
  }
985
1001
  /**
@@ -1055,7 +1071,7 @@ async function runBootstrap(job, opts) {
1055
1071
  }
1056
1072
  opts.onPhase?.('agent');
1057
1073
  logger.info('agent(bootstrap): running agent');
1058
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
1074
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
1059
1075
  dir,
1060
1076
  systemPrompt: job.systemPrompt,
1061
1077
  userPrompt: job.userPrompt,
@@ -1075,14 +1091,14 @@ async function runBootstrap(job, opts) {
1075
1091
  if (!(await producedRepoContent(dir, !fromScratch, signal))) {
1076
1092
  const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
1077
1093
  logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
1078
- return {
1094
+ return mergeEffort({
1079
1095
  summary,
1080
1096
  stats,
1081
1097
  error,
1082
1098
  failureCause: 'agent',
1083
1099
  ...(usage ? { usage } : {}),
1084
1100
  ...(callMetrics ? { callMetrics } : {}),
1085
- };
1101
+ }, effortReport);
1086
1102
  }
1087
1103
  opts.onPhase?.('push');
1088
1104
  logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
@@ -1097,13 +1113,13 @@ async function runBootstrap(job, opts) {
1097
1113
  : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
1098
1114
  });
1099
1115
  logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
1100
- return {
1116
+ return mergeEffort({
1101
1117
  defaultBranch: boot.target.defaultBranch,
1102
1118
  summary,
1103
1119
  stats,
1104
1120
  ...(usage ? { usage } : {}),
1105
1121
  ...(callMetrics ? { callMetrics } : {}),
1106
- };
1122
+ }, effortReport);
1107
1123
  });
1108
1124
  }
1109
1125
  /**
@@ -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
+ }
@@ -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
package/dist/subagents.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import { createReadStream } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { basename, join } from 'node:path';
4
4
  import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
5
5
  export function createSliceTracker() {
6
6
  // Insertion-ordered so the progress `items` render in dispatch order.
@@ -54,21 +54,84 @@ export function createSliceTracker() {
54
54
  },
55
55
  };
56
56
  }
57
+ /**
58
+ * Reconcile the two redundant views of the same slice work into the one to surface
59
+ * (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
60
+ * written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
61
+ * sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
62
+ * slice tracker (the CLI writes the plan once and never marks it done, while the parallel
63
+ * `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
64
+ * slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
65
+ * at 0%. So prefer whichever view is further along: more `completed`, then more
66
+ * `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
67
+ * `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
68
+ * todo plan. Pure + total; returns whichever single input is present when only one is.
69
+ */
70
+ export function pickProgress(todo, slice) {
71
+ if (!todo)
72
+ return slice;
73
+ if (!slice)
74
+ return todo;
75
+ if (slice.completed !== todo.completed)
76
+ return slice.completed > todo.completed ? slice : todo;
77
+ if (slice.inProgress !== todo.inProgress)
78
+ return slice.inProgress > todo.inProgress ? slice : todo;
79
+ if (slice.total !== todo.total)
80
+ return slice.total > todo.total ? slice : todo;
81
+ return todo;
82
+ }
57
83
  // ---------------------------------------------------------------------------
58
84
  // Subagent transcript watcher (heartbeat + usage) (D3)
59
85
  // ---------------------------------------------------------------------------
60
86
  /** Default poll cadence for the transcript directory; well under the git timeout margin. */
61
87
  const DEFAULT_POLL_MS = 3_000;
62
88
  /**
63
- * Start watching `dir` (the CLI's `<configHome>/subagents`) for `*.jsonl` transcripts,
64
- * tailing each file by byte offset. New content feeds `onActivity` (heartbeat) and each
65
- * assistant turn carrying usage is lifted into a {@link HarnessCallMetric} + summed into
66
- * the cumulative usage. Best-effort throughout: the directory may not exist yet (created
67
- * lazily by the CLI), a file may be mid-write, and the line/usage shape may change across
68
- * CLI versions every such case is swallowed so the watcher can only ever ADD signal,
69
- * never break the run.
89
+ * Recursively collect every `*.jsonl` file that lives inside a `subagents/` directory
90
+ * anywhere under `root` (the CLI's `<configHome>/projects` tree). The Claude CLI writes
91
+ * each parallel `Task` subagent's transcript to
92
+ * `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`; the
93
+ * session-uuid dir isn't known before the CLI mints it, so we DISCOVER the `subagents/`
94
+ * dir by walking rather than guessing its path (ADR 0027 Defect A). Files NOT under a
95
+ * `subagents/` dir — critically the parent's own `<session-uuid>.jsonl` session transcript,
96
+ * whose per-turn usage the terminal `result` event already totals — are deliberately
97
+ * excluded: reading them would double-count the parent. `root` itself counts as inside a
98
+ * `subagents/` dir when its own basename is `subagents` (so passing the leaf dir works too).
99
+ * Best-effort: an unreadable directory is skipped, never thrown.
100
+ */
101
+ async function findSubagentTranscripts(root) {
102
+ const out = [];
103
+ const walk = async (dir, inSubagents) => {
104
+ let entries;
105
+ try {
106
+ entries = await readdir(dir, { withFileTypes: true });
107
+ }
108
+ catch {
109
+ return; // dir not created yet (or vanished) — try again next tick
110
+ }
111
+ for (const entry of entries) {
112
+ const full = join(dir, entry.name);
113
+ if (entry.isDirectory()) {
114
+ await walk(full, inSubagents || entry.name === 'subagents');
115
+ }
116
+ else if (inSubagents && entry.isFile() && entry.name.endsWith('.jsonl')) {
117
+ out.push(full);
118
+ }
119
+ }
120
+ };
121
+ await walk(root, basename(root) === 'subagents');
122
+ return out;
123
+ }
124
+ /**
125
+ * Start watching `root` (the CLI's `<configHome>/projects` tree) for subagent `*.jsonl`
126
+ * transcripts — any file under a `subagents/` directory beneath it (see
127
+ * {@link findSubagentTranscripts}) — tailing each file by byte offset. New content feeds
128
+ * `onActivity` (heartbeat) and each assistant turn carrying usage is lifted into a
129
+ * {@link HarnessCallMetric} + summed into the cumulative usage. Best-effort throughout: the
130
+ * tree may not exist yet (created lazily by the CLI), a file may be mid-write, and the
131
+ * line/usage shape may change across CLI versions — every such case is swallowed so the
132
+ * watcher can only ever ADD signal, never break the run.
70
133
  */
71
- export function startSubagentWatcher(dir, opts) {
134
+ export function startSubagentWatcher(root, opts) {
72
135
  const secrets = opts.secrets ?? [];
73
136
  const offsets = new Map();
74
137
  const calls = [];
@@ -151,16 +214,8 @@ export function startSubagentWatcher(dir, opts) {
151
214
  return;
152
215
  polling = true;
153
216
  try {
154
- let entries;
155
- try {
156
- entries = (await readdir(dir)).filter((n) => n.endsWith('.jsonl'));
157
- }
158
- catch {
159
- return; // dir not created yet (or vanished) — try again next tick
160
- }
161
217
  let grew = false;
162
- for (const name of entries) {
163
- const path = join(dir, name);
218
+ for (const path of await findSubagentTranscripts(root)) {
164
219
  let size;
165
220
  try {
166
221
  size = (await stat(path)).size;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.50.12",
3
+ "version": "1.50.16",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.141.2",
30
- "@cat-factory/spend": "0.12.73"
29
+ "@cat-factory/server": "0.142.0",
30
+ "@cat-factory/spend": "0.12.74"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",