@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.
@@ -13,7 +13,7 @@ import type { Logger } from './logger.js'
13
13
  import type { HarnessCallMetric, PiRunOutcome, PiRunStats, TodoProgress } from './pi.js'
14
14
  import { killChildProcess, spawnDetached } from './process.js'
15
15
  import { redact, secretsToRedact } from './redact.js'
16
- import { createSliceTracker, startSubagentWatcher } from './subagents.js'
16
+ import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js'
17
17
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
18
18
  import { retainSessionTranscripts } from './transcript-retention.js'
19
19
 
@@ -325,16 +325,18 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
325
325
  ]
326
326
  const calls: HarnessCallMetric[] = []
327
327
 
328
- // ADR 0026 D2.1: derive slice progress from the parent stream's `Task` dispatches +
329
- // their terminal tool_results (both DO appear here — only a subagent's intermediate
330
- // turns don't). A real parent TodoWrite plan, when the agent writes one, wins; the
331
- // slice-derived progress is the fallback for the parallel-subagent shape that writes no
332
- // parent plan (the pr-reviewer failure this fixes).
328
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
329
+ // sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
330
+ // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
331
+ // progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
332
+ // shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
333
+ // update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
334
+ // and never marks it done, which used to gate the slice signal off and pin progress at 0%.
333
335
  const sliceTracker = createSliceTracker()
334
- let sawTodoPlan = false
335
- const emitSliceProgress = (): void => {
336
- if (sawTodoPlan || !opts.onProgress) return
337
- const progress = sliceTracker.progress()
336
+ let lastTodo: TodoProgress | undefined
337
+ const emitProgress = (): void => {
338
+ if (!opts.onProgress) return
339
+ const progress = pickProgress(lastTodo, sliceTracker.progress())
338
340
  if (progress) opts.onProgress(progress)
339
341
  }
340
342
 
@@ -347,21 +349,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
347
349
  stats.assistantChars += text.length
348
350
  stats.toolCalls += toolUses
349
351
  for (const block of content) {
350
- if (
351
- isObject(block) &&
352
- block.type === 'tool_use' &&
353
- block.name === 'TodoWrite' &&
354
- opts.onProgress
355
- ) {
352
+ if (isObject(block) && block.type === 'tool_use' && block.name === 'TodoWrite') {
356
353
  const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
357
- if (progress) {
358
- sawTodoPlan = true
359
- opts.onProgress(progress)
360
- }
354
+ if (progress) lastTodo = progress
361
355
  }
362
356
  }
363
357
  sliceTracker.onAssistant(content)
364
- emitSliceProgress()
358
+ emitProgress()
365
359
  // Record this call BEFORE appending its turn: the prompt is the history that
366
360
  // produced this response. The append-only array keeps each call's prompt a strict
367
361
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
@@ -383,7 +377,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
383
377
  const content = (event.message as Record<string, unknown>).content
384
378
  if (Array.isArray(content)) {
385
379
  sliceTracker.onUser(content)
386
- emitSliceProgress()
380
+ emitProgress()
387
381
  messages.push({ role: 'tool', content })
388
382
  }
389
383
  } else if (type === 'result') {
@@ -446,13 +440,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
446
440
  : { CLAUDE_CODE_OAUTH_TOKEN: opts.subscriptionToken! }),
447
441
  }
448
442
 
449
- // ADR 0026 D2.1/D3: while the run is live, tail the CLI's `subagents/*.jsonl`
450
- // transcripts (under the isolated config home) so a parallel-subagent review keeps the
451
- // inactivity heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible
452
- // token spend is lifted into the run's telemetry. Ambient mode has no isolated home to
453
- // watch. Best-effort a missing/renamed transcript layout just yields no extra signal.
443
+ // ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
444
+ // subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
445
+ // heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
446
+ // lifted into the run's telemetry. The CLI writes them per-session under
447
+ // `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/*.jsonl`, so we watch the
448
+ // `projects` tree and let the watcher discover the `subagents/` dir (the session uuid isn't
449
+ // known up front). Ambient mode has no isolated home to watch. Best-effort — a
450
+ // missing/renamed transcript layout just yields no extra signal.
454
451
  const subagents = configHome
455
- ? startSubagentWatcher(join(configHome, 'subagents'), {
452
+ ? startSubagentWatcher(join(configHome, 'projects'), {
456
453
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
457
454
  secrets,
458
455
  model: opts.model,
@@ -502,9 +499,10 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
502
499
  // is the terminal `result` event's cumulative, which covers ONLY the parent loop — the
503
500
  // ADR 0026 incident is itself the proof: a heavily subagent-parallelised review reported
504
501
  // ~0 tokens, i.e. the parent stream (and its `result` total) never included the subagent
505
- // spend. The subagent tokens live exclusively in the `subagents/*.jsonl` transcripts (a
506
- // directory distinct from the parent's `projects/` session transcript), which the watcher
507
- // reads and nothing else does so neither `calls` nor `usage` can already contain them.
502
+ // spend. The subagent tokens live exclusively in the per-session `subagents/*.jsonl`
503
+ // transcripts, which the watcher reads and nothing else does; it deliberately EXCLUDES the
504
+ // sibling parent session transcript (whose usage `result` already totals), so neither
505
+ // `calls` nor `usage` can already contain the subagent spend.
508
506
  const mergedUsage =
509
507
  usage || subUsage.inputTokens || subUsage.outputTokens
510
508
  ? {
package/src/agent.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  unmergedPaths,
32
32
  } from './git.js'
33
33
  import type { PiRunStats, RunDiagnostics } from './pi.js'
34
+ import type { EffortReport } from './effort.js'
34
35
  import {
35
36
  makeDirClaimer,
36
37
  noChangesReason,
@@ -312,6 +313,15 @@ async function cloneServiceCheckout(
312
313
  return deriveWorkDir(dir, job.repo.serviceDirectory)
313
314
  }
314
315
 
316
+ /**
317
+ * Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
318
+ * onto its final result. Every container mode routes its result through this so the report reaches
319
+ * the backend uniformly. A run that wrote no report passes through unchanged.
320
+ */
321
+ function mergeEffort(result: AgentResult, effortReport: EffortReport | undefined): AgentResult {
322
+ return effortReport ? { ...result, effortReport } : result
323
+ }
324
+
315
325
  /** Run one generic agent job end to end, dispatching on `mode`. */
316
326
  export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise<AgentResult> {
317
327
  // Private-registry auth first, before any mode runs: every mode with a checkout may
@@ -558,6 +568,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
558
568
  usage,
559
569
  callMetrics,
560
570
  diagnostics: runDiag,
571
+ effortReport,
561
572
  } = await runAgentInWorkspace(
562
573
  {
563
574
  dir: workDir,
@@ -582,10 +593,13 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
582
593
  opts,
583
594
  )
584
595
 
585
- return await finalizeExploreResult(
586
- job,
587
- { summary, stats, stderrTail, usage, callMetrics, runDiag },
588
- { infra, infraSetupFields, logger, signal: opts.signal },
596
+ return mergeEffort(
597
+ await finalizeExploreResult(
598
+ job,
599
+ { summary, stats, stderrTail, usage, callMetrics, runDiag },
600
+ { infra, infraSetupFields, logger, signal: opts.signal },
601
+ ),
602
+ effortReport,
589
603
  )
590
604
  } finally {
591
605
  restoreSecrets()
@@ -808,17 +822,20 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
808
822
  },
809
823
  opts,
810
824
  )
811
- return finalizeExploreResult(
812
- job,
813
- {
814
- summary: run.summary,
815
- stats: run.stats,
816
- stderrTail: run.stderrTail,
817
- usage: run.usage,
818
- callMetrics: run.callMetrics,
819
- runDiag: run.diagnostics,
820
- },
821
- { infraSetupFields: {}, logger, signal: opts.signal },
825
+ return mergeEffort(
826
+ await finalizeExploreResult(
827
+ job,
828
+ {
829
+ summary: run.summary,
830
+ stats: run.stats,
831
+ stderrTail: run.stderrTail,
832
+ usage: run.usage,
833
+ callMetrics: run.callMetrics,
834
+ runDiag: run.diagnostics,
835
+ },
836
+ { infraSetupFields: {}, logger, signal: opts.signal },
837
+ ),
838
+ run.effortReport,
822
839
  )
823
840
  })
824
841
  }
@@ -894,7 +911,7 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
894
911
  */
895
912
  async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
896
913
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch
897
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } =
914
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } =
898
915
  await runCodingAgent(
899
916
  {
900
917
  kind: 'agent',
@@ -939,6 +956,8 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
939
956
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
940
957
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
941
958
  const ralphVerdict = validation ? { ralphVerdict: validation } : {}
959
+ // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
960
+ const effort = effortReport ? { effortReport } : {}
942
961
 
943
962
  if (!pushed) {
944
963
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
@@ -951,6 +970,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
951
970
  ...(usage ? { usage } : {}),
952
971
  ...(callMetrics ? { callMetrics } : {}),
953
972
  ...ralphVerdict,
973
+ ...effort,
954
974
  }
955
975
  }
956
976
  return {
@@ -962,6 +982,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
962
982
  failureCause: 'no-changes',
963
983
  ...(usage ? { usage } : {}),
964
984
  ...(callMetrics ? { callMetrics } : {}),
985
+ ...effort,
965
986
  }
966
987
  }
967
988
 
@@ -995,6 +1016,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
995
1016
  stats,
996
1017
  ...(usage ? { usage } : {}),
997
1018
  ...(callMetrics ? { callMetrics } : {}),
1019
+ ...effort,
998
1020
  }
999
1021
  }
1000
1022
  return {
@@ -1010,6 +1032,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1010
1032
  failureCause: 'no-changes',
1011
1033
  ...(usage ? { usage } : {}),
1012
1034
  ...(callMetrics ? { callMetrics } : {}),
1035
+ ...effort,
1013
1036
  }
1014
1037
  }
1015
1038
  return {
@@ -1021,6 +1044,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1021
1044
  ...(usage ? { usage } : {}),
1022
1045
  ...(callMetrics ? { callMetrics } : {}),
1023
1046
  ...ralphVerdict,
1047
+ ...effort,
1024
1048
  }
1025
1049
  }
1026
1050
  return {
@@ -1031,6 +1055,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1031
1055
  ...(usage ? { usage } : {}),
1032
1056
  ...(callMetrics ? { callMetrics } : {}),
1033
1057
  ...ralphVerdict,
1058
+ ...effort,
1034
1059
  }
1035
1060
  }
1036
1061
 
@@ -1100,23 +1125,24 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
1100
1125
  const diff = await conflictDiff(dir, conflicted, signal)
1101
1126
  const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt)
1102
1127
 
1103
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace(
1104
- {
1105
- dir,
1106
- systemPrompt: job.systemPrompt,
1107
- userPrompt,
1108
- model: job.model,
1109
- harness: job.harness,
1110
- subscriptionToken: job.subscriptionToken,
1111
- subscriptionBaseUrl: job.subscriptionBaseUrl,
1112
- ambientAuth: job.ambientAuth,
1113
- proxyBaseUrl: job.proxyBaseUrl,
1114
- sessionToken: job.sessionToken,
1115
- contextFiles: job.contextFiles,
1116
- guardLimits: job.guardLimits,
1117
- },
1118
- opts,
1119
- )
1128
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
1129
+ await runAgentInWorkspace(
1130
+ {
1131
+ dir,
1132
+ systemPrompt: job.systemPrompt,
1133
+ userPrompt,
1134
+ model: job.model,
1135
+ harness: job.harness,
1136
+ subscriptionToken: job.subscriptionToken,
1137
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
1138
+ ambientAuth: job.ambientAuth,
1139
+ proxyBaseUrl: job.proxyBaseUrl,
1140
+ sessionToken: job.sessionToken,
1141
+ contextFiles: job.contextFiles,
1142
+ guardLimits: job.guardLimits,
1143
+ },
1144
+ opts,
1145
+ )
1120
1146
 
1121
1147
  // Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
1122
1148
  // the PR would still be broken. Fail so the engine can retry / notify.
@@ -1125,30 +1151,36 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
1125
1151
  logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
1126
1152
  unresolved: unresolved.length,
1127
1153
  })
1128
- return {
1129
- pushed: false,
1130
- branch: job.branch,
1131
- summary,
1132
- stats,
1133
- error: unresolvedReason(unresolved, stats, stderrTail),
1134
- failureCause: 'agent',
1135
- ...(usage ? { usage } : {}),
1136
- ...(callMetrics ? { callMetrics } : {}),
1137
- }
1154
+ return mergeEffort(
1155
+ {
1156
+ pushed: false,
1157
+ branch: job.branch,
1158
+ summary,
1159
+ stats,
1160
+ error: unresolvedReason(unresolved, stats, stderrTail),
1161
+ failureCause: 'agent',
1162
+ ...(usage ? { usage } : {}),
1163
+ ...(callMetrics ? { callMetrics } : {}),
1164
+ },
1165
+ effortReport,
1166
+ )
1138
1167
  }
1139
1168
  // Complete the merge commit with the agent's resolution staged, then push.
1140
1169
  await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal)
1141
1170
  opts.onPhase?.('push')
1142
1171
  logger.info('agent(conflict): pushing resolved branch', { ...stats })
1143
1172
  await pushBranch(dir, job.branch, job.ghToken, signal)
1144
- return {
1145
- pushed: true,
1146
- branch: job.branch,
1147
- summary,
1148
- stats,
1149
- ...(usage ? { usage } : {}),
1150
- ...(callMetrics ? { callMetrics } : {}),
1151
- }
1173
+ return mergeEffort(
1174
+ {
1175
+ pushed: true,
1176
+ branch: job.branch,
1177
+ summary,
1178
+ stats,
1179
+ ...(usage ? { usage } : {}),
1180
+ ...(callMetrics ? { callMetrics } : {}),
1181
+ },
1182
+ effortReport,
1183
+ )
1152
1184
  })
1153
1185
  }
1154
1186
 
@@ -1239,22 +1271,23 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
1239
1271
 
1240
1272
  opts.onPhase?.('agent')
1241
1273
  logger.info('agent(bootstrap): running agent')
1242
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace(
1243
- {
1244
- dir,
1245
- systemPrompt: job.systemPrompt,
1246
- userPrompt: job.userPrompt,
1247
- model: job.model,
1248
- harness: job.harness,
1249
- subscriptionToken: job.subscriptionToken,
1250
- subscriptionBaseUrl: job.subscriptionBaseUrl,
1251
- ambientAuth: job.ambientAuth,
1252
- proxyBaseUrl: job.proxyBaseUrl,
1253
- sessionToken: job.sessionToken,
1254
- guardLimits: job.guardLimits,
1255
- },
1256
- opts,
1257
- )
1274
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
1275
+ await runAgentInWorkspace(
1276
+ {
1277
+ dir,
1278
+ systemPrompt: job.systemPrompt,
1279
+ userPrompt: job.userPrompt,
1280
+ model: job.model,
1281
+ harness: job.harness,
1282
+ subscriptionToken: job.subscriptionToken,
1283
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
1284
+ ambientAuth: job.ambientAuth,
1285
+ proxyBaseUrl: job.proxyBaseUrl,
1286
+ sessionToken: job.sessionToken,
1287
+ guardLimits: job.guardLimits,
1288
+ },
1289
+ opts,
1290
+ )
1258
1291
 
1259
1292
  // Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
1260
1293
  // reached the model), and a force-push would then publish an empty tree — leaving the
@@ -1263,14 +1296,17 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
1263
1296
  if (!(await producedRepoContent(dir, !fromScratch, signal))) {
1264
1297
  const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
1265
1298
  logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
1266
- return {
1267
- summary,
1268
- stats,
1269
- error,
1270
- failureCause: 'agent',
1271
- ...(usage ? { usage } : {}),
1272
- ...(callMetrics ? { callMetrics } : {}),
1273
- }
1299
+ return mergeEffort(
1300
+ {
1301
+ summary,
1302
+ stats,
1303
+ error,
1304
+ failureCause: 'agent',
1305
+ ...(usage ? { usage } : {}),
1306
+ ...(callMetrics ? { callMetrics } : {}),
1307
+ },
1308
+ effortReport,
1309
+ )
1274
1310
  }
1275
1311
 
1276
1312
  opts.onPhase?.('push')
@@ -1286,13 +1322,16 @@ async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResul
1286
1322
  : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
1287
1323
  })
1288
1324
  logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
1289
- return {
1290
- defaultBranch: boot.target.defaultBranch,
1291
- summary,
1292
- stats,
1293
- ...(usage ? { usage } : {}),
1294
- ...(callMetrics ? { callMetrics } : {}),
1295
- }
1325
+ return mergeEffort(
1326
+ {
1327
+ defaultBranch: boot.target.defaultBranch,
1328
+ summary,
1329
+ stats,
1330
+ ...(usage ? { usage } : {}),
1331
+ ...(callMetrics ? { callMetrics } : {}),
1332
+ },
1333
+ effortReport,
1334
+ )
1296
1335
  })
1297
1336
  }
1298
1337
 
@@ -31,6 +31,7 @@ import {
31
31
  } from './git.js'
32
32
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
33
33
  import type { HarnessCallMetric, PiRunStats } from './pi.js'
34
+ import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
34
35
  import {
35
36
  acquireRepoCheckout,
36
37
  agentNeverActed,
@@ -123,6 +124,8 @@ export interface CodingAgentOutcome {
123
124
  usage?: { inputTokens: number; outputTokens: number }
124
125
  /** Per-model-call telemetry from a subscription harness's CLI stream (absent for Pi). */
125
126
  callMetrics?: HarnessCallMetric[]
127
+ /** The agent's effort self-assessment, lifted from its sentinel file (absent when it wrote none). */
128
+ effortReport?: EffortReport
126
129
  /**
127
130
  * Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
128
131
  * exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
@@ -242,6 +245,14 @@ export async function runCodingAgent(
242
245
  const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir
243
246
  if (serviceDirectory) await mkdir(workDir, { recursive: true })
244
247
 
248
+ // Every container agent is asked to write its effort self-assessment to `.cat-effort.json`
249
+ // in its cwd (the backend appends EFFORT_REPORT_GUIDANCE to every container prompt). Locally
250
+ // exclude it from git — exactly like the follow-ups sentinel below — so the agent's own
251
+ // `git add` can never stage it into the PR. `readEffortReport` also removes it after the run,
252
+ // but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
253
+ // filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
254
+ await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
255
+
245
256
  // Follow-up companion: tail the Coder's sentinel file and stream new items out on the
246
257
  // job view. Locally exclude it from git first so the agent's own `git add` can never
247
258
  // stage it and it never surfaces as an untracked leftover or in the PR. The sentinel
@@ -462,7 +473,7 @@ async function finalizeCodingRun(args: {
462
473
  agentRun,
463
474
  } = args
464
475
  const { signal } = opts
465
- const { summary, stats, stderrTail, usage, callMetrics } = agentRun
476
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun
466
477
  let outcome: CodingAgentOutcome
467
478
 
468
479
  // Stop tailing the follow-up sentinel and flush any items written after the last
@@ -521,6 +532,7 @@ async function finalizeCodingRun(args: {
521
532
  ...(stderrTail ? { stderrTail } : {}),
522
533
  ...(usage ? { usage } : {}),
523
534
  ...(callMetrics ? { callMetrics } : {}),
535
+ ...(effortReport ? { effortReport } : {}),
524
536
  }
525
537
  } else {
526
538
  opts.onPhase?.('push')
@@ -534,6 +546,7 @@ async function finalizeCodingRun(args: {
534
546
  ...(stderrTail ? { stderrTail } : {}),
535
547
  ...(usage ? { usage } : {}),
536
548
  ...(callMetrics ? { callMetrics } : {}),
549
+ ...(effortReport ? { effortReport } : {}),
537
550
  }
538
551
  }
539
552
 
@@ -765,26 +778,27 @@ export async function runMultiRepoCoding(
765
778
  // note + the backend system-prompt section explain the layout.
766
779
  opts.onPhase?.('agent')
767
780
  logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) })
768
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace(
769
- {
770
- dir: root,
771
- systemPrompt: job.systemPrompt,
772
- userPrompt: job.userPrompt,
773
- model: job.model,
774
- harness: job.harness,
775
- subscriptionToken: job.subscriptionToken,
776
- subscriptionBaseUrl: job.subscriptionBaseUrl,
777
- ambientAuth: job.ambientAuth,
778
- proxyBaseUrl: job.proxyBaseUrl,
779
- sessionToken: job.sessionToken,
780
- webToolsGuidance: job.webToolsGuidance,
781
- webSearchProxy: job.webSearch,
782
- guardLimits: job.guardLimits,
783
- ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
784
- multiRepo: true,
785
- },
786
- opts,
787
- )
781
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
782
+ await runAgentInWorkspace(
783
+ {
784
+ dir: root,
785
+ systemPrompt: job.systemPrompt,
786
+ userPrompt: job.userPrompt,
787
+ model: job.model,
788
+ harness: job.harness,
789
+ subscriptionToken: job.subscriptionToken,
790
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
791
+ ambientAuth: job.ambientAuth,
792
+ proxyBaseUrl: job.proxyBaseUrl,
793
+ sessionToken: job.sessionToken,
794
+ webToolsGuidance: job.webToolsGuidance,
795
+ webSearchProxy: job.webSearch,
796
+ guardLimits: job.guardLimits,
797
+ ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
798
+ multiRepo: true,
799
+ },
800
+ opts,
801
+ )
788
802
 
789
803
  // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
790
804
  const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(
@@ -807,6 +821,7 @@ export async function runMultiRepoCoding(
807
821
  stats,
808
822
  ...(usage ? { usage } : {}),
809
823
  ...(callMetrics ? { callMetrics } : {}),
824
+ ...(effortReport ? { effortReport } : {}),
810
825
  }
811
826
  }
812
827
  return {
@@ -822,6 +837,7 @@ export async function runMultiRepoCoding(
822
837
  failureCause: 'no-changes',
823
838
  ...(usage ? { usage } : {}),
824
839
  ...(callMetrics ? { callMetrics } : {}),
840
+ ...(effortReport ? { effortReport } : {}),
825
841
  }
826
842
  }
827
843
  logger.info('multi-repo: complete', {
@@ -838,6 +854,7 @@ export async function runMultiRepoCoding(
838
854
  stats,
839
855
  ...(usage ? { usage } : {}),
840
856
  ...(callMetrics ? { callMetrics } : {}),
857
+ ...(effortReport ? { effortReport } : {}),
841
858
  }
842
859
  })
843
860
  }
package/src/effort.ts ADDED
@@ -0,0 +1,99 @@
1
+ import { readFile, rm } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // The agent effort self-assessment side channel. Every container agent is asked
6
+ // (via the backend-composed system prompt) to end its run by writing a short JSON
7
+ // self-assessment — how hard the work was, what reduced its effectiveness, the key
8
+ // obstacles — to a sentinel file in its working directory. The harness reads it after
9
+ // the agent finishes, removes it (so it never lands in a commit), and forwards it on
10
+ // the job result; the backend records it on the step and surfaces it in run details.
11
+ //
12
+ // The filename is kept in sync with `EFFORT_REPORT_FILE` in `@cat-factory/agents`
13
+ // (the executor-harness has no dependency on that package), exactly like CONTEXT_DIR
14
+ // and the follow-ups sentinel. The shape mirrors the contracts `AgentEffortReport`.
15
+ // ---------------------------------------------------------------------------
16
+
17
+ /** The sentinel file the agent writes its effort self-assessment to (relative to its cwd). */
18
+ export const EFFORT_REPORT_FILE = '.cat-effort.json'
19
+
20
+ /** A container agent's self-assessment of the work it just did. */
21
+ export interface EffortReport {
22
+ /** How hard the work was: 1 (trivial) .. 10 (extremely hard). */
23
+ difficulty: number
24
+ /** One or two sentences on how hard/easy the work was and why. */
25
+ summary?: string
26
+ /** What reduced the agent's effectiveness. */
27
+ reducedEffectiveness?: string
28
+ /** The key obstacles the agent hit. */
29
+ obstacles?: string[]
30
+ }
31
+
32
+ /**
33
+ * Read + parse + REMOVE the agent's effort sentinel file from `cwd`. Lenient: returns undefined
34
+ * when the file is absent (the agent wrote none), unreadable, not JSON, or carries nothing
35
+ * meaningful. Never throws — a malformed self-report must never fail an otherwise-good run.
36
+ */
37
+ export async function readEffortReport(cwd: string): Promise<EffortReport | undefined> {
38
+ const path = join(cwd, EFFORT_REPORT_FILE)
39
+ let raw: string
40
+ try {
41
+ raw = await readFile(path, 'utf8')
42
+ } catch {
43
+ return undefined // no report written — the common case
44
+ }
45
+ // Remove it so it never lands in a commit (defence in depth; the backend also excludes it).
46
+ await rm(path, { force: true }).catch(() => {})
47
+ let parsed: unknown
48
+ try {
49
+ parsed = JSON.parse(raw)
50
+ } catch {
51
+ return undefined
52
+ }
53
+ return coerceEffort(parsed)
54
+ }
55
+
56
+ /** Coerce arbitrary parsed JSON into a clean {@link EffortReport}, or undefined when it carries nothing. */
57
+ function coerceEffort(value: unknown): EffortReport | undefined {
58
+ if (typeof value !== 'object' || value === null) return undefined
59
+ const o = value as Record<string, unknown>
60
+ const report: EffortReport = { difficulty: clampDifficulty(o.difficulty) }
61
+ if (typeof o.summary === 'string' && o.summary.trim()) {
62
+ report.summary = o.summary.trim().slice(0, 2000)
63
+ }
64
+ if (typeof o.reducedEffectiveness === 'string' && o.reducedEffectiveness.trim()) {
65
+ report.reducedEffectiveness = o.reducedEffectiveness.trim().slice(0, 2000)
66
+ }
67
+ if (Array.isArray(o.obstacles)) {
68
+ const obstacles = o.obstacles
69
+ .filter((x): x is string => typeof x === 'string' && x.trim().length > 0)
70
+ .map((x) => x.trim().slice(0, 500))
71
+ .slice(0, 20)
72
+ if (obstacles.length) report.obstacles = obstacles
73
+ }
74
+ // Nothing beyond a defaulted difficulty ⇒ the agent didn't really report anything; drop it so
75
+ // run details don't show an empty "5/10, no detail" card for a stray/blank file.
76
+ if (
77
+ report.summary === undefined &&
78
+ report.reducedEffectiveness === undefined &&
79
+ report.obstacles === undefined &&
80
+ !isFiniteNumber(o.difficulty)
81
+ ) {
82
+ return undefined
83
+ }
84
+ return report
85
+ }
86
+
87
+ function clampDifficulty(v: unknown): number {
88
+ const n = isFiniteNumber(v)
89
+ ? v
90
+ : typeof v === 'string' && v.trim() !== ''
91
+ ? Number(v)
92
+ : Number.NaN
93
+ if (!Number.isFinite(n)) return 5
94
+ return Math.min(10, Math.max(1, Math.round(n)))
95
+ }
96
+
97
+ function isFiniteNumber(v: unknown): v is number {
98
+ return typeof v === 'number' && Number.isFinite(v)
99
+ }