@sebastianandreasson/pi-autonomous-agents 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ import {
13
13
  import { appendTelemetry, ensureTelemetryFiles } from './pi-telemetry.mjs'
14
14
  import {
15
15
  appendLog,
16
+ collectLargeFileWarnings,
16
17
  commitStagedFiles,
17
18
  didRepoChange,
18
19
  ensureFileExists,
@@ -79,6 +80,18 @@ function printTerminalSummary(config, summary) {
79
80
  lines.push(`[PI supervisor] notes=${summary.notes}`)
80
81
  }
81
82
 
83
+ if (Array.isArray(summary.largeFileWarnings) && summary.largeFileWarnings.length > 0) {
84
+ lines.push(`[PI supervisor] large_file_warnings=${formatLargeFileWarningsInline(summary.largeFileWarnings)}`)
85
+ }
86
+
87
+ if (summary.terminalReason) {
88
+ lines.push(`[PI supervisor] terminal_reason=${summary.terminalReason}`)
89
+ }
90
+
91
+ if (summary.commitPlanFound !== undefined) {
92
+ lines.push(`[PI supervisor] commit_plan_found=${summary.commitPlanFound}`)
93
+ }
94
+
82
95
  if (summary.sessionId) {
83
96
  lines.push(`[PI supervisor] session=${summary.sessionId}`)
84
97
  }
@@ -87,6 +100,14 @@ function printTerminalSummary(config, summary) {
87
100
  lines.push(`[PI supervisor] last_output=${summary.outputPath}`)
88
101
  }
89
102
 
103
+ if (config.lastPromptFile) {
104
+ lines.push(`[PI supervisor] last_prompt=${toDisplayPath(config, config.lastPromptFile)}`)
105
+ }
106
+
107
+ if (config.lastIterationSummaryFile) {
108
+ lines.push(`[PI supervisor] iteration_summary=${toDisplayPath(config, config.lastIterationSummaryFile)}`)
109
+ }
110
+
90
111
  process.stderr.write(`${lines.join('\n')}\n`)
91
112
  }
92
113
 
@@ -109,6 +130,133 @@ function parseTesterVerdict(output) {
109
130
  return match?.[1]?.toUpperCase() ?? 'UNKNOWN'
110
131
  }
111
132
 
133
+ function buildRetryReason(invocation) {
134
+ const loopSignature = String(invocation?.result?.loopSignature ?? '')
135
+ const notes = String(invocation?.result?.notes ?? '')
136
+
137
+ if (loopSignature.startsWith('same_path:')) {
138
+ const target = loopSignature.slice('same_path:'.length)
139
+ return `The previous turn got stuck repeatedly editing ${target}. Reread ${target} exactly once before any new edit. Switch approach. Do not attempt another exact oldText patch on ${target} unless the file changed since the failed attempt.`
140
+ }
141
+
142
+ if (notes.includes('loop_detected=')) {
143
+ return `The previous turn got stuck repeating the same tool call (${notes}). Continue from the current repo state without rereading the same file over and over.`
144
+ }
145
+
146
+ return 'The previous turn stalled or timed out. Continue from the current repo state.'
147
+ }
148
+
149
+ function formatIterationSummary(summary) {
150
+ return `${JSON.stringify(summary, null, 2)}\n`
151
+ }
152
+
153
+ async function writeIterationSummary(config, summary) {
154
+ await writeTextFile(config.lastIterationSummaryFile, formatIterationSummary(summary))
155
+ }
156
+
157
+ function createIterationSummary({
158
+ iteration,
159
+ phase,
160
+ task,
161
+ repoChanged,
162
+ developerStatus,
163
+ testerStatus,
164
+ testerVerdict,
165
+ verificationStatus,
166
+ commitPlanFound,
167
+ gitFinalizeStatus,
168
+ visualStatus,
169
+ terminalReason,
170
+ largeFileWarnings,
171
+ sessionId,
172
+ developerModel,
173
+ testerModel,
174
+ visualModel,
175
+ }) {
176
+ return {
177
+ iteration,
178
+ phase,
179
+ task,
180
+ repoChanged,
181
+ developerStatus,
182
+ testerStatus,
183
+ testerVerdict,
184
+ verificationStatus,
185
+ commitPlanFound,
186
+ gitFinalizeStatus,
187
+ visualStatus,
188
+ terminalReason,
189
+ largeFileWarnings,
190
+ sessionId,
191
+ developerModel,
192
+ testerModel,
193
+ visualModel,
194
+ }
195
+ }
196
+
197
+ function didInvocationCreateCommit(invocation) {
198
+ return invocation?.beforeSnapshot?.head !== invocation?.afterSnapshot?.head
199
+ }
200
+
201
+ function mergeLargeFileWarnings(existing, incoming) {
202
+ const merged = new Map()
203
+ for (const warning of [...(existing || []), ...(incoming || [])]) {
204
+ if (!warning?.file) {
205
+ continue
206
+ }
207
+ const key = `${warning.kind}:${warning.file}`
208
+ const current = merged.get(key)
209
+ if (!current || Number(warning.lineCount) > Number(current.lineCount)) {
210
+ merged.set(key, warning)
211
+ }
212
+ }
213
+ return [...merged.values()].sort((left, right) => right.lineCount - left.lineCount)
214
+ }
215
+
216
+ function findLargeFileWarnings(config, files) {
217
+ return collectLargeFileWarnings(config.cwd, files, {
218
+ largeFileWarningLines: config.largeFileWarningLines,
219
+ largeSpecWarningLines: config.largeSpecWarningLines,
220
+ })
221
+ }
222
+
223
+ function formatLargeFileWarningsInline(warnings) {
224
+ const list = Array.isArray(warnings) ? warnings : []
225
+ if (list.length === 0) {
226
+ return ''
227
+ }
228
+ return list
229
+ .slice(0, 3)
230
+ .map((warning) => `${warning.file}(${warning.lineCount}${warning.kind === 'large_spec' ? ',spec' : ''})`)
231
+ .join(', ')
232
+ }
233
+
234
+ function clampPromptLines(text, maxLines) {
235
+ const normalized = String(text ?? '').trim()
236
+ if (normalized === '') {
237
+ return ''
238
+ }
239
+
240
+ const lines = normalized.split('\n')
241
+ if (!Number.isFinite(maxLines) || maxLines <= 0 || lines.length <= maxLines) {
242
+ return normalized
243
+ }
244
+
245
+ const remaining = lines.length - maxLines
246
+ return `${lines.slice(0, maxLines).join('\n')}\n... (${remaining} more lines omitted)`
247
+ }
248
+
249
+ function compactNotePartsForPrompt(config, noteParts, fallback = '(none provided)') {
250
+ const items = Array.isArray(noteParts) ? noteParts.filter(Boolean) : []
251
+ if (items.length === 0) {
252
+ return fallback
253
+ }
254
+
255
+ const maxItems = Math.min(6, items.length)
256
+ const selected = items.slice(-maxItems)
257
+ return clampPromptLines(selected.join('\n'), Number(config.maxPromptNotesLines) || 16)
258
+ }
259
+
112
260
  function isInfrastructureVerificationFailure(output) {
113
261
  const text = String(output ?? '')
114
262
  return [
@@ -142,6 +290,16 @@ async function runAgentInvocation({
142
290
  }) {
143
291
  const beforeSnapshot = getRepoSnapshot(config.cwd)
144
292
  const resolvedModel = resolveRoleModel(config, role)
293
+ const promptSnapshot = [
294
+ `role=${role}`,
295
+ `kind=${kind}`,
296
+ `phase=${phase}`,
297
+ `reason=${reason}`,
298
+ `model=${resolvedModel.model || '(PI default)'}`,
299
+ '',
300
+ prompt,
301
+ ].join('\n')
302
+ await writeTextFile(config.lastPromptFile, `${promptSnapshot}\n`)
145
303
  const result = await runAgentTurn({
146
304
  config,
147
305
  model: resolvedModel.model,
@@ -178,6 +336,17 @@ async function runAgentInvocation({
178
336
  changedFilesCount: changedFiles.length,
179
337
  verificationStatus,
180
338
  retryCount,
339
+ role,
340
+ model: resolvedModel.model || '(PI default)',
341
+ toolCalls: result.toolCalls ?? 0,
342
+ toolErrors: result.toolErrors ?? 0,
343
+ messageUpdates: result.messageUpdates ?? 0,
344
+ stopReason: result.stopReason ?? '',
345
+ loopDetected: result.loopDetected === true,
346
+ loopSignature: result.loopSignature ?? '',
347
+ testerVerdict: '',
348
+ commitPlanFound: '',
349
+ terminalReason: result.terminalReason ?? '',
181
350
  notes: `${result.notes} role=${role} model=${resolvedModel.model || '(PI default)'}`.trim(),
182
351
  })
183
352
 
@@ -202,7 +371,7 @@ async function readLatestVisualFeedback(config) {
202
371
  if (trimmed === '') {
203
372
  return ''
204
373
  }
205
- return trimmed.split('\n').slice(0, 80).join('\n')
374
+ return clampPromptLines(trimmed, Number(config.maxVisualFeedbackLines) || 20)
206
375
  }
207
376
 
208
377
  async function readLatestTesterFeedback(config) {
@@ -211,7 +380,7 @@ async function readLatestTesterFeedback(config) {
211
380
  if (trimmed === '') {
212
381
  return ''
213
382
  }
214
- return trimmed.split('\n').slice(0, 120).join('\n')
383
+ return clampPromptLines(trimmed, Number(config.maxTesterFeedbackLines) || 32)
215
384
  }
216
385
 
217
386
  async function writeTesterFeedback(config, { iteration, phase, task, source, status, output }) {
@@ -241,17 +410,43 @@ async function writeTesterFeedback(config, { iteration, phase, task, source, sta
241
410
 
242
411
  function parseCommitPlan(output) {
243
412
  const raw = String(output ?? '')
244
- const messageMatch = raw.match(/^COMMIT_MESSAGE:\s*(.+)\s*$/im)
245
- const filesBlockMatch = raw.match(/^COMMIT_FILES:\s*\n((?:\s*-\s+.+\n?)+)/im)
246
- const files = filesBlockMatch
247
- ? filesBlockMatch[1]
248
- .split('\n')
249
- .map((line) => /^\s*-\s+(.+?)\s*$/.exec(line)?.[1] ?? '')
250
- .filter(Boolean)
251
- : []
413
+ const lines = raw.split('\n')
414
+ const messageLine = lines.find((line) => /^\s*(?:[-*]\s+)?COMMIT_MESSAGE:\s*(.+?)\s*$/i.test(line))
415
+ const message = messageLine
416
+ ? messageLine.replace(/^\s*(?:[-*]\s+)?COMMIT_MESSAGE:\s*/i, '').trim()
417
+ : ''
418
+
419
+ const filesStartIndex = lines.findIndex((line) => /^\s*(?:[-*]\s+)?COMMIT_FILES:\s*$/i.test(line))
420
+ const files = []
421
+ if (filesStartIndex >= 0) {
422
+ for (const line of lines.slice(filesStartIndex + 1)) {
423
+ const trimmed = line.trim()
424
+ if (trimmed === '') {
425
+ if (files.length > 0) {
426
+ break
427
+ }
428
+ continue
429
+ }
430
+ if (/^VERDICT:/i.test(trimmed)) {
431
+ break
432
+ }
433
+ if (/^(?:[-*]\s+)?[A-Z_]+:\s*/.test(trimmed)) {
434
+ break
435
+ }
436
+
437
+ const normalized = trimmed
438
+ .replace(/^[-*]\s+/, '')
439
+ .replace(/^\d+\.\s+/, '')
440
+ .trim()
441
+
442
+ if (normalized !== '') {
443
+ files.push(normalized)
444
+ }
445
+ }
446
+ }
252
447
 
253
448
  return {
254
- message: messageMatch?.[1]?.trim() ?? '',
449
+ message,
255
450
  files: [...new Set(files)],
256
451
  }
257
452
  }
@@ -275,6 +470,7 @@ async function runHarnessGitFinalize({
275
470
  const commitMessage = String(commitPlan.message ?? '').trim()
276
471
  let status = 'success'
277
472
  let notes = ''
473
+ let terminalReason = 'commit_created'
278
474
  const cleanupNewlyStagedFiles = (stagedBefore, stagedNow) => {
279
475
  const stagedBeforeSet = new Set(stagedBefore)
280
476
  const newlyStagedFiles = stagedNow.filter((file) => !stagedBeforeSet.has(file))
@@ -288,6 +484,7 @@ async function runHarnessGitFinalize({
288
484
  if (commitMessage === '' || requestedFiles.length === 0) {
289
485
  status = 'stalled'
290
486
  notes = 'commit_plan_missing=true'
487
+ terminalReason = 'awaiting_commit_plan'
291
488
  } else {
292
489
  const stagedBefore = listStagedFiles(config.cwd)
293
490
  const unrelatedStagedBefore = stagedBefore.filter((file) => !requestedFiles.includes(file))
@@ -295,11 +492,13 @@ async function runHarnessGitFinalize({
295
492
  if (unrelatedStagedBefore.length > 0) {
296
493
  status = 'blocked'
297
494
  notes = `commit_blocked_unrelated_staged_files=${unrelatedStagedBefore.join(',')}`
495
+ terminalReason = 'commit_finalize_blocked_unrelated_staged'
298
496
  } else {
299
497
  const filesToStage = requestedFiles.filter((file) => dirtyFiles.has(file))
300
498
  if (filesToStage.length === 0) {
301
499
  status = 'stalled'
302
500
  notes = 'commit_plan_no_dirty_files=true'
501
+ terminalReason = 'commit_plan_no_dirty_files'
303
502
  } else {
304
503
  try {
305
504
  stageFiles(config.cwd, filesToStage)
@@ -310,18 +509,22 @@ async function runHarnessGitFinalize({
310
509
  const cleanedFiles = cleanupNewlyStagedFiles(stagedBefore, stagedAfter)
311
510
  status = 'blocked'
312
511
  notes = `commit_blocked_unexpected_staged_files=${unexpectedStaged.join(',')} unstaged_cleanup=${cleanedFiles.join(',')}`.trim()
512
+ terminalReason = 'commit_finalize_blocked_unexpected_staged'
313
513
  } else if (!stagedAfter.some((file) => requestedFiles.includes(file))) {
314
514
  const cleanedFiles = cleanupNewlyStagedFiles(stagedBefore, stagedAfter)
315
515
  status = 'stalled'
316
516
  notes = `commit_plan_failed_to_stage=true unstaged_cleanup=${cleanedFiles.join(',')}`.trim()
517
+ terminalReason = 'commit_plan_failed_to_stage'
317
518
  } else {
318
519
  commitStagedFiles(config.cwd, commitMessage)
319
520
  notes = `commit_created=true files=${filesToStage.join(',')} message=${commitMessage}`
521
+ terminalReason = 'commit_created'
320
522
  }
321
523
  } catch (error) {
322
524
  const cleanedFiles = cleanupNewlyStagedFiles(stagedBefore, listStagedFiles(config.cwd))
323
525
  status = 'failed'
324
526
  notes = `commit_failed=${formatExecError(error)}${cleanedFiles.length > 0 ? ` unstaged_cleanup=${cleanedFiles.join(',')}` : ''}`
527
+ terminalReason = 'commit_finalize_failed'
325
528
  }
326
529
  }
327
530
  }
@@ -329,12 +532,16 @@ async function runHarnessGitFinalize({
329
532
 
330
533
  const afterSnapshot = getRepoSnapshot(config.cwd)
331
534
  const changedFiles = listChangedFiles(config.cwd)
535
+ const finalStatus = status === 'success' && beforeSnapshot.head === afterSnapshot.head ? 'stalled' : status
536
+ if (status === 'success' && finalStatus === 'stalled') {
537
+ terminalReason = 'commit_not_created'
538
+ }
332
539
 
333
540
  await recordEvent(config, {
334
541
  iteration,
335
542
  phase,
336
543
  kind: 'git_finalize',
337
- status,
544
+ status: finalStatus,
338
545
  transport: 'local',
339
546
  sessionId: '',
340
547
  timedOut: false,
@@ -346,12 +553,24 @@ async function runHarnessGitFinalize({
346
553
  changedFilesCount: changedFiles.length,
347
554
  verificationStatus: 'not_run',
348
555
  retryCount: 0,
556
+ role: '',
557
+ model: '',
558
+ toolCalls: 0,
559
+ toolErrors: 0,
560
+ messageUpdates: 0,
561
+ stopReason: '',
562
+ loopDetected: false,
563
+ loopSignature: '',
564
+ testerVerdict: '',
565
+ commitPlanFound: requestedFiles.length > 0,
566
+ terminalReason,
349
567
  notes,
350
568
  })
351
569
 
352
570
  return {
353
- status: status === 'success' && beforeSnapshot.head === afterSnapshot.head ? 'stalled' : status,
571
+ status: finalStatus,
354
572
  notes,
573
+ terminalReason,
355
574
  }
356
575
  }
357
576
 
@@ -385,6 +604,17 @@ async function runVerificationStep({ config, iteration, phase, kind }) {
385
604
  changedFilesCount: changedFiles.length,
386
605
  verificationStatus: verification.status,
387
606
  retryCount: 0,
607
+ role: '',
608
+ model: '',
609
+ toolCalls: 0,
610
+ toolErrors: 0,
611
+ messageUpdates: 0,
612
+ stopReason: '',
613
+ loopDetected: false,
614
+ loopSignature: '',
615
+ testerVerdict: '',
616
+ commitPlanFound: '',
617
+ terminalReason: `verification_${verification.status}`,
388
618
  notes: verificationNotes,
389
619
  })
390
620
 
@@ -438,6 +668,7 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
438
668
  result: {
439
669
  ...invocation.result,
440
670
  status: 'stalled',
671
+ terminalReason: 'no_repo_change',
441
672
  notes: `${invocation.result.notes} no_repo_change=true`,
442
673
  },
443
674
  }
@@ -448,13 +679,12 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
448
679
  }
449
680
 
450
681
  reason = shouldRetryForTimeout
451
- ? invocation.result.notes.includes('loop_detected=')
452
- ? `The previous turn got stuck repeating the same tool call (${invocation.result.notes}). Continue from the current repo state without rereading the same file over and over.`
453
- : 'The previous turn stalled or timed out. Continue from the current repo state.'
682
+ ? buildRetryReason(invocation)
454
683
  : 'The previous turn ended without changing the repo. Continue and complete one coherent task.'
455
684
  prompt = buildSteeringPrompt(config, reason, {
456
685
  visualFeedback: await readLatestVisualFeedback(config),
457
686
  testerFeedback: await readLatestTesterFeedback(config),
687
+ largeFileWarnings: findLargeFileWarnings(config, listChangedFiles(config.cwd)),
458
688
  })
459
689
 
460
690
  if (shouldRetryForTimeout || shouldRetryForNoChange) {
@@ -467,12 +697,14 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
467
697
  }
468
698
 
469
699
  async function runFixTurn({ config, iteration, phase, sessionId, sessionFile, testerOutput }) {
700
+ const largeFileWarnings = findLargeFileWarnings(config, listChangedFiles(config.cwd))
470
701
  const fixPrompt = buildFixPrompt(
471
702
  config,
472
- testerOutput.trim().split('\n').slice(-120).join('\n'),
703
+ clampPromptLines(testerOutput, Number(config.maxVerificationExcerptLines) || 40),
473
704
  {
474
705
  visualFeedback: await readLatestVisualFeedback(config),
475
706
  testerFeedback: await readLatestTesterFeedback(config),
707
+ largeFileWarnings,
476
708
  }
477
709
  )
478
710
  return await runAgentInvocation({
@@ -573,6 +805,7 @@ async function runTesterTurn({
573
805
  developerNotes,
574
806
  reason,
575
807
  }) {
808
+ const largeFileWarnings = findLargeFileWarnings(config, changedFiles)
576
809
  const prompt = buildTesterPrompt(config, {
577
810
  phase,
578
811
  task,
@@ -581,6 +814,7 @@ async function runTesterTurn({
581
814
  reason,
582
815
  visualFeedback: await readLatestVisualFeedback(config),
583
816
  testerFeedback: await readLatestTesterFeedback(config),
817
+ largeFileWarnings,
584
818
  })
585
819
 
586
820
  const invocation = await runAgentInvocation({
@@ -600,22 +834,38 @@ async function runTesterTurn({
600
834
  const commitPlan = parseCommitPlan(invocation.result.output)
601
835
  const notesWithVerdict = `${invocation.result.notes} tester_verdict=${verdict} commit_plan_files=${commitPlan.files.length}`.trim()
602
836
  let testerStatus = invocation.result.status
837
+ let terminalReason = invocation.result.terminalReason || ''
603
838
 
604
839
  if (testerStatus === 'success' && verdict === 'FAIL') {
605
840
  testerStatus = 'failed'
841
+ terminalReason = 'tester_verdict_fail'
606
842
  } else if (testerStatus === 'success' && verdict === 'BLOCKED') {
607
843
  testerStatus = 'stalled'
844
+ terminalReason = 'tester_verdict_blocked'
608
845
  } else if (testerStatus === 'success' && verdict === 'UNKNOWN') {
609
846
  testerStatus = 'stalled'
847
+ terminalReason = 'tester_verdict_unknown'
848
+ } else if (testerStatus === 'success' && config.commitMode === 'plan') {
849
+ terminalReason = commitPlan.message !== '' && commitPlan.files.length > 0
850
+ ? 'tester_pass_with_commit_plan'
851
+ : 'awaiting_commit_plan'
852
+ } else if (testerStatus === 'success') {
853
+ terminalReason = didInvocationCreateCommit(invocation)
854
+ ? 'tester_pass_with_agent_commit'
855
+ : invocation.repoChanged
856
+ ? 'tester_left_uncommitted_changes'
857
+ : 'awaiting_agent_commit'
610
858
  }
611
859
 
612
860
  return {
613
861
  ...invocation,
614
862
  testerVerdict: verdict,
863
+ commitPlanFound: commitPlan.message !== '' && commitPlan.files.length > 0,
615
864
  commitPlan,
616
865
  result: {
617
866
  ...invocation.result,
618
867
  status: testerStatus,
868
+ terminalReason,
619
869
  notes: notesWithVerdict,
620
870
  },
621
871
  }
@@ -630,6 +880,7 @@ async function runTesterCommitTurn({
630
880
  developerNotes,
631
881
  reason,
632
882
  }) {
883
+ const largeFileWarnings = findLargeFileWarnings(config, changedFiles)
633
884
  const prompt = buildCommitPrompt(config, {
634
885
  phase,
635
886
  task,
@@ -638,6 +889,7 @@ async function runTesterCommitTurn({
638
889
  reason,
639
890
  visualFeedback: await readLatestVisualFeedback(config),
640
891
  testerFeedback: await readLatestTesterFeedback(config),
892
+ largeFileWarnings,
641
893
  })
642
894
 
643
895
  const invocation = await runAgentInvocation({
@@ -657,22 +909,30 @@ async function runTesterCommitTurn({
657
909
  const commitPlan = parseCommitPlan(invocation.result.output)
658
910
  const notesWithVerdict = `${invocation.result.notes} tester_verdict=${verdict} commit_plan_files=${commitPlan.files.length}`.trim()
659
911
  let testerStatus = invocation.result.status
912
+ let terminalReason = invocation.result.terminalReason || ''
660
913
 
661
914
  if (testerStatus === 'success' && verdict === 'BLOCKED') {
662
915
  testerStatus = 'stalled'
916
+ terminalReason = 'tester_commit_blocked'
663
917
  } else if (testerStatus === 'success' && verdict !== 'PASS') {
664
918
  testerStatus = 'stalled'
919
+ terminalReason = 'tester_commit_missing_pass'
665
920
  } else if (testerStatus === 'success' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
666
921
  testerStatus = 'stalled'
922
+ terminalReason = 'awaiting_commit_plan'
923
+ } else if (testerStatus === 'success') {
924
+ terminalReason = 'tester_commit_plan_ready'
667
925
  }
668
926
 
669
927
  return {
670
928
  ...invocation,
671
929
  testerVerdict: verdict,
930
+ commitPlanFound: commitPlan.message !== '' && commitPlan.files.length > 0,
672
931
  commitPlan,
673
932
  result: {
674
933
  ...invocation.result,
675
934
  status: testerStatus,
935
+ terminalReason,
676
936
  notes: notesWithVerdict,
677
937
  },
678
938
  }
@@ -701,6 +961,17 @@ async function runVisualReview({ config, iteration, phase, task, changedFiles })
701
961
  changedFilesCount: changedFiles.length,
702
962
  verificationStatus: 'not_run',
703
963
  retryCount: 0,
964
+ role: '',
965
+ model: '',
966
+ toolCalls: 0,
967
+ toolErrors: 0,
968
+ messageUpdates: 0,
969
+ stopReason: '',
970
+ loopDetected: false,
971
+ loopSignature: '',
972
+ testerVerdict: '',
973
+ commitPlanFound: '',
974
+ terminalReason: `visual_capture_${capture.status}`,
704
975
  notes: capture.status === 'passed'
705
976
  ? `screenshots=${capture.screenshots.length} manifest=${capture.manifestPath}`
706
977
  : capture.output.trim().split('\n').slice(-8).join(' '),
@@ -777,6 +1048,17 @@ async function runVisualReview({ config, iteration, phase, task, changedFiles })
777
1048
  changedFilesCount: changedFiles.length,
778
1049
  verificationStatus: 'not_run',
779
1050
  retryCount: 0,
1051
+ role: 'visualReview',
1052
+ model: visualReviewModel.model || '(unset)',
1053
+ toolCalls: 0,
1054
+ toolErrors: 0,
1055
+ messageUpdates: 0,
1056
+ stopReason: '',
1057
+ loopDetected: false,
1058
+ loopSignature: '',
1059
+ testerVerdict: verdict,
1060
+ commitPlanFound: '',
1061
+ terminalReason: `visual_review_${status}`,
780
1062
  notes: `verdict=${verdict} feedback=${config.visualFeedbackFile} role=visualReview model=${visualReviewModel.model || '(unset)'}`.trim(),
781
1063
  })
782
1064
 
@@ -791,6 +1073,7 @@ async function runIteration({ config, state, iteration }) {
791
1073
  const testerModelName = resolveRoleModelName(config, 'tester')
792
1074
  const visualReviewRoleModel = resolveRoleModel(config, 'visualReview')
793
1075
  const visualModelName = visualReviewRoleModel.model
1076
+ const iterationStartSnapshot = getRepoSnapshot(config.cwd)
794
1077
  const taskInfo = findFirstUncheckedTaskInfo(config.taskFile)
795
1078
  if (!taskInfo.hasUncheckedTasks) {
796
1079
  await appendLog(config.logFile, 'No unchecked tasks remain in TODOS.md')
@@ -808,9 +1091,17 @@ async function runIteration({ config, state, iteration }) {
808
1091
  summary: {
809
1092
  iteration,
810
1093
  phase: taskInfo.phase || 'complete',
1094
+ task: '',
1095
+ repoChanged: false,
811
1096
  developerStatus: 'complete',
812
1097
  testerStatus: 'not_needed',
1098
+ testerVerdict: 'NOT_RUN',
813
1099
  verificationStatus: 'not_needed',
1100
+ commitPlanFound: false,
1101
+ gitFinalizeStatus: 'not_run',
1102
+ visualStatus: 'not_run',
1103
+ terminalReason: 'all_tasks_complete',
1104
+ largeFileWarnings: [],
814
1105
  notes: 'No unchecked tasks remain in TODOS.md.',
815
1106
  sessionId: state.sessionId || '',
816
1107
  outputPath: config.lastAgentOutputFile,
@@ -854,13 +1145,19 @@ async function runIteration({ config, state, iteration }) {
854
1145
  let sessionFile = mainInvocation.result.sessionFile || startingSessionFile
855
1146
  let developerStatus = mainInvocation.result.status
856
1147
  let testerStatus = 'not_run'
1148
+ let testerVerdict = 'NOT_RUN'
857
1149
  let finalVerificationStatus = 'not_run'
858
1150
  let visualStatus = 'not_run'
1151
+ let commitPlanFound = false
1152
+ let gitFinalizeStatus = 'not_run'
1153
+ let terminalReason = mainInvocation.result.terminalReason || ''
1154
+ let largeFileWarnings = findLargeFileWarnings(config, mainInvocation.changedFiles)
859
1155
  const noteParts = [`developer: ${mainInvocation.result.notes}`]
860
1156
 
861
1157
  if (mainInvocation.result.status === 'success' && config.transport === 'mock') {
862
1158
  testerStatus = 'skipped'
863
1159
  finalVerificationStatus = 'skipped'
1160
+ terminalReason = 'mock_completed'
864
1161
  } else if (mainInvocation.result.status === 'success') {
865
1162
  const developerVerification = await runDeveloperVerificationAndFix({
866
1163
  config,
@@ -875,6 +1172,13 @@ async function runIteration({ config, state, iteration }) {
875
1172
  sessionFile = developerVerification.sessionFile
876
1173
  developerStatus = developerVerification.developerStatus
877
1174
  finalVerificationStatus = developerVerification.verificationStatus
1175
+ if (developerStatus !== 'success') {
1176
+ terminalReason = developerStatus === 'blocked'
1177
+ ? 'verification_infrastructure_failure'
1178
+ : 'developer_fix_incomplete'
1179
+ } else if (finalVerificationStatus !== 'passed' && finalVerificationStatus !== 'not_run') {
1180
+ terminalReason = `verification_${finalVerificationStatus}`
1181
+ }
878
1182
 
879
1183
  if (developerVerification.feedbackSource && developerVerification.verificationOutput.trim() !== '') {
880
1184
  await writeTesterFeedback(config, {
@@ -894,11 +1198,15 @@ async function runIteration({ config, state, iteration }) {
894
1198
  phase,
895
1199
  task,
896
1200
  changedFiles: listChangedFiles(config.cwd),
897
- developerNotes: noteParts.join(' | '),
1201
+ developerNotes: compactNotePartsForPrompt(config, noteParts),
898
1202
  reason: 'tester_review_after_basic_smoke_passed',
899
1203
  })
900
1204
 
901
1205
  testerStatus = testerInvocation.result.status
1206
+ testerVerdict = testerInvocation.testerVerdict
1207
+ commitPlanFound = testerInvocation.commitPlanFound === true
1208
+ terminalReason = testerInvocation.result.terminalReason || terminalReason
1209
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
902
1210
  noteParts.push(`tester: ${testerInvocation.result.notes}`)
903
1211
  await writeTesterFeedback(config, {
904
1212
  iteration,
@@ -911,18 +1219,22 @@ async function runIteration({ config, state, iteration }) {
911
1219
 
912
1220
  let commitPlan = testerInvocation.commitPlan
913
1221
 
914
- if (testerStatus === 'success' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
1222
+ if (testerStatus === 'success' && config.commitMode === 'plan' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
915
1223
  const testerCommitInvocation = await runTesterCommitTurn({
916
1224
  config,
917
1225
  iteration,
918
1226
  phase,
919
1227
  task,
920
1228
  changedFiles: listChangedFiles(config.cwd),
921
- developerNotes: noteParts.join(' | '),
1229
+ developerNotes: compactNotePartsForPrompt(config, noteParts),
922
1230
  reason: 'tester_passed_without_commit',
923
1231
  })
924
1232
 
925
1233
  testerStatus = testerCommitInvocation.result.status
1234
+ testerVerdict = testerCommitInvocation.testerVerdict
1235
+ commitPlanFound = testerCommitInvocation.commitPlanFound === true
1236
+ terminalReason = testerCommitInvocation.result.terminalReason || terminalReason
1237
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
926
1238
  noteParts.push(`tester_commit: ${testerCommitInvocation.result.notes}`)
927
1239
  await writeTesterFeedback(config, {
928
1240
  iteration,
@@ -935,7 +1247,7 @@ async function runIteration({ config, state, iteration }) {
935
1247
  commitPlan = testerCommitInvocation.commitPlan
936
1248
  }
937
1249
 
938
- if (testerStatus === 'success') {
1250
+ if (testerStatus === 'success' && config.commitMode === 'plan') {
939
1251
  const gitFinalize = await runHarnessGitFinalize({
940
1252
  config,
941
1253
  iteration,
@@ -943,10 +1255,27 @@ async function runIteration({ config, state, iteration }) {
943
1255
  commitPlan,
944
1256
  })
945
1257
  testerStatus = gitFinalize.status
1258
+ gitFinalizeStatus = gitFinalize.status
1259
+ terminalReason = gitFinalize.terminalReason || terminalReason
946
1260
  noteParts.push(`git_finalize: ${gitFinalize.notes}`)
1261
+ } else if (testerStatus === 'success') {
1262
+ if (didInvocationCreateCommit(testerInvocation)) {
1263
+ gitFinalizeStatus = 'committed_by_agent'
1264
+ terminalReason = 'completed_phase_step'
1265
+ } else {
1266
+ testerStatus = 'stalled'
1267
+ gitFinalizeStatus = 'awaiting_agent_commit'
1268
+ terminalReason = testerInvocation.repoChanged
1269
+ ? 'tester_left_uncommitted_changes'
1270
+ : 'awaiting_agent_commit'
1271
+ noteParts.push('git_finalize: committed_by_agent=false')
1272
+ }
947
1273
  }
948
1274
  } else {
949
1275
  testerStatus = 'skipped'
1276
+ if (terminalReason === '') {
1277
+ terminalReason = 'tester_skipped_after_verification'
1278
+ }
950
1279
  }
951
1280
 
952
1281
  if (testerStatus === 'failed') {
@@ -956,12 +1285,14 @@ async function runIteration({ config, state, iteration }) {
956
1285
  phase,
957
1286
  sessionId,
958
1287
  sessionFile,
959
- testerOutput: noteParts.join('\n'),
1288
+ testerOutput: compactNotePartsForPrompt(config, noteParts),
960
1289
  })
961
1290
 
962
1291
  sessionId = fixInvocation.result.sessionId || sessionId
963
1292
  sessionFile = fixInvocation.result.sessionFile || sessionFile
964
1293
  developerStatus = fixInvocation.result.status
1294
+ terminalReason = fixInvocation.result.terminalReason || 'developer_fix_incomplete'
1295
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
965
1296
  noteParts.push(`developer_fix: ${fixInvocation.result.notes}`)
966
1297
 
967
1298
  if (fixInvocation.result.status === 'success') {
@@ -976,6 +1307,10 @@ async function runIteration({ config, state, iteration }) {
976
1307
  })
977
1308
 
978
1309
  testerStatus = testerRecheck.result.status
1310
+ testerVerdict = testerRecheck.testerVerdict
1311
+ commitPlanFound = testerRecheck.commitPlanFound === true
1312
+ terminalReason = testerRecheck.result.terminalReason || terminalReason
1313
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
979
1314
  noteParts.push(`tester_recheck: ${testerRecheck.result.notes}`)
980
1315
  await writeTesterFeedback(config, {
981
1316
  iteration,
@@ -988,18 +1323,22 @@ async function runIteration({ config, state, iteration }) {
988
1323
 
989
1324
  let commitPlan = testerRecheck.commitPlan
990
1325
 
991
- if (testerStatus === 'success' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
1326
+ if (testerStatus === 'success' && config.commitMode === 'plan' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
992
1327
  const testerCommitInvocation = await runTesterCommitTurn({
993
1328
  config,
994
1329
  iteration,
995
1330
  phase,
996
1331
  task,
997
1332
  changedFiles: listChangedFiles(config.cwd),
998
- developerNotes: noteParts.join(' | '),
1333
+ developerNotes: compactNotePartsForPrompt(config, noteParts),
999
1334
  reason: 'tester_recheck_passed_without_commit',
1000
1335
  })
1001
1336
 
1002
1337
  testerStatus = testerCommitInvocation.result.status
1338
+ testerVerdict = testerCommitInvocation.testerVerdict
1339
+ commitPlanFound = testerCommitInvocation.commitPlanFound === true
1340
+ terminalReason = testerCommitInvocation.result.terminalReason || terminalReason
1341
+ largeFileWarnings = mergeLargeFileWarnings(largeFileWarnings, findLargeFileWarnings(config, listChangedFiles(config.cwd)))
1003
1342
  noteParts.push(`tester_commit: ${testerCommitInvocation.result.notes}`)
1004
1343
  await writeTesterFeedback(config, {
1005
1344
  iteration,
@@ -1012,7 +1351,7 @@ async function runIteration({ config, state, iteration }) {
1012
1351
  commitPlan = testerCommitInvocation.commitPlan
1013
1352
  }
1014
1353
 
1015
- if (testerStatus === 'success') {
1354
+ if (testerStatus === 'success' && config.commitMode === 'plan') {
1016
1355
  const gitFinalize = await runHarnessGitFinalize({
1017
1356
  config,
1018
1357
  iteration,
@@ -1020,7 +1359,21 @@ async function runIteration({ config, state, iteration }) {
1020
1359
  commitPlan,
1021
1360
  })
1022
1361
  testerStatus = gitFinalize.status
1362
+ gitFinalizeStatus = gitFinalize.status
1363
+ terminalReason = gitFinalize.terminalReason || terminalReason
1023
1364
  noteParts.push(`git_finalize: ${gitFinalize.notes}`)
1365
+ } else if (testerStatus === 'success') {
1366
+ if (didInvocationCreateCommit(testerRecheck)) {
1367
+ gitFinalizeStatus = 'committed_by_agent'
1368
+ terminalReason = 'completed_phase_step'
1369
+ } else {
1370
+ testerStatus = 'stalled'
1371
+ gitFinalizeStatus = 'awaiting_agent_commit'
1372
+ terminalReason = testerRecheck.repoChanged
1373
+ ? 'tester_left_uncommitted_changes'
1374
+ : 'awaiting_agent_commit'
1375
+ noteParts.push('git_finalize: committed_by_agent=false')
1376
+ }
1024
1377
  }
1025
1378
 
1026
1379
  if (testerStatus === 'success') {
@@ -1032,12 +1385,18 @@ async function runIteration({ config, state, iteration }) {
1032
1385
  })
1033
1386
 
1034
1387
  finalVerificationStatus = reverify.status
1388
+ if (finalVerificationStatus !== 'passed') {
1389
+ terminalReason = `verification_${finalVerificationStatus}`
1390
+ }
1035
1391
  }
1036
1392
  }
1037
1393
  }
1038
1394
  } else {
1039
1395
  testerStatus = 'not_run'
1040
1396
  finalVerificationStatus = 'not_run'
1397
+ if (terminalReason === '') {
1398
+ terminalReason = 'developer_turn_incomplete'
1399
+ }
1041
1400
  }
1042
1401
 
1043
1402
  const workflowStatus = deriveWorkflowStatus({
@@ -1070,6 +1429,9 @@ async function runIteration({ config, state, iteration }) {
1070
1429
  changedFiles: listChangedFiles(config.cwd),
1071
1430
  })
1072
1431
  visualStatus = visualReview.status
1432
+ terminalReason = visualReview.status === 'passed'
1433
+ ? terminalReason
1434
+ : `visual_review_${visualReview.status}`
1073
1435
  noteParts.push(`visual: ${visualReview.notes}`)
1074
1436
  } else if (config.visualReviewEnabled) {
1075
1437
  visualStatus = 'skipped'
@@ -1080,6 +1442,22 @@ async function runIteration({ config, state, iteration }) {
1080
1442
  visualStatus,
1081
1443
  })
1082
1444
 
1445
+ if (finalStatus === 'success') {
1446
+ terminalReason = 'completed_phase_step'
1447
+ } else if (terminalReason === '') {
1448
+ terminalReason = testerStatus === 'failed'
1449
+ ? 'tester_verdict_fail'
1450
+ : testerStatus === 'stalled'
1451
+ ? 'iteration_stalled'
1452
+ : developerStatus === 'blocked'
1453
+ ? 'developer_blocked'
1454
+ : developerStatus === 'failed'
1455
+ ? 'developer_failed'
1456
+ : finalVerificationStatus !== 'not_run'
1457
+ ? `verification_${finalVerificationStatus}`
1458
+ : 'workflow_incomplete'
1459
+ }
1460
+
1083
1461
  const successfulIterations = (
1084
1462
  finalStatus === 'success'
1085
1463
  ? candidateSuccessfulIterations
@@ -1112,18 +1490,77 @@ async function runIteration({ config, state, iteration }) {
1112
1490
 
1113
1491
  await appendLog(
1114
1492
  config.logFile,
1115
- `Finished iteration ${iteration} with status=${finalStatus} verification=${finalVerificationStatus}`
1493
+ `Finished iteration ${iteration} with status=${finalStatus} verification=${finalVerificationStatus} tester_verdict=${testerVerdict} commit_plan_found=${commitPlanFound} terminal_reason=${terminalReason}${largeFileWarnings.length > 0 ? ` large_file_warnings=${formatLargeFileWarningsInline(largeFileWarnings)}` : ''}`
1116
1494
  )
1117
1495
 
1496
+ const iterationEndSnapshot = getRepoSnapshot(config.cwd)
1497
+ const iterationSummary = createIterationSummary({
1498
+ iteration,
1499
+ phase,
1500
+ task,
1501
+ repoChanged: didRepoChange(iterationStartSnapshot, iterationEndSnapshot),
1502
+ developerStatus,
1503
+ testerStatus,
1504
+ testerVerdict,
1505
+ verificationStatus: finalVerificationStatus,
1506
+ commitPlanFound,
1507
+ gitFinalizeStatus,
1508
+ visualStatus,
1509
+ terminalReason,
1510
+ largeFileWarnings,
1511
+ sessionId,
1512
+ developerModel: developerModelName,
1513
+ testerModel: testerModelName,
1514
+ visualModel: visualModelName,
1515
+ })
1516
+
1517
+ await recordEvent(config, {
1518
+ iteration,
1519
+ phase,
1520
+ kind: 'iteration_summary',
1521
+ status: finalStatus,
1522
+ transport: config.transport,
1523
+ sessionId,
1524
+ timedOut: false,
1525
+ exitCode: finalStatus === 'success' ? 0 : 1,
1526
+ durationSeconds: 0,
1527
+ commitBefore: iterationStartSnapshot.head,
1528
+ commitAfter: iterationEndSnapshot.head,
1529
+ repoChanged: iterationSummary.repoChanged,
1530
+ changedFilesCount: listChangedFiles(config.cwd).length,
1531
+ verificationStatus: finalVerificationStatus,
1532
+ retryCount: 0,
1533
+ role: '',
1534
+ model: '',
1535
+ toolCalls: 0,
1536
+ toolErrors: 0,
1537
+ messageUpdates: 0,
1538
+ stopReason: '',
1539
+ loopDetected: false,
1540
+ loopSignature: '',
1541
+ testerVerdict,
1542
+ commitPlanFound,
1543
+ terminalReason,
1544
+ riskWarnings: formatLargeFileWarningsInline(largeFileWarnings),
1545
+ notes: noteParts.join(' | '),
1546
+ })
1547
+
1118
1548
  return {
1119
1549
  stateUpdate: nextState,
1120
1550
  summary: {
1121
1551
  iteration,
1122
1552
  phase,
1553
+ task,
1554
+ repoChanged: iterationSummary.repoChanged,
1123
1555
  developerStatus,
1124
1556
  testerStatus,
1557
+ testerVerdict,
1125
1558
  verificationStatus: finalVerificationStatus,
1559
+ commitPlanFound,
1560
+ gitFinalizeStatus,
1126
1561
  visualStatus,
1562
+ terminalReason,
1563
+ largeFileWarnings,
1127
1564
  notes: noteParts.join(' | '),
1128
1565
  sessionId,
1129
1566
  outputPath: config.lastAgentOutputFile,
@@ -1134,6 +1571,7 @@ async function runIteration({ config, state, iteration }) {
1134
1571
  testerModel: testerModelName,
1135
1572
  visualModel: visualModelName,
1136
1573
  },
1574
+ iterationSummary,
1137
1575
  shouldStop: false,
1138
1576
  }
1139
1577
  }
@@ -1153,6 +1591,7 @@ async function main() {
1153
1591
  while (!stopRequested) {
1154
1592
  const iteration = state.iteration + 1
1155
1593
  const result = await runIteration({ config, state, iteration })
1594
+ await writeIterationSummary(config, result.iterationSummary ?? result.summary)
1156
1595
  state = result.stateUpdate
1157
1596
  await writeState(config.stateFile, state)
1158
1597
  printTerminalSummary(config, result.summary)