@sebastianandreasson/pi-autonomous-agents 0.2.0 → 0.3.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.
@@ -79,6 +79,14 @@ function printTerminalSummary(config, summary) {
79
79
  lines.push(`[PI supervisor] notes=${summary.notes}`)
80
80
  }
81
81
 
82
+ if (summary.terminalReason) {
83
+ lines.push(`[PI supervisor] terminal_reason=${summary.terminalReason}`)
84
+ }
85
+
86
+ if (summary.commitPlanFound !== undefined) {
87
+ lines.push(`[PI supervisor] commit_plan_found=${summary.commitPlanFound}`)
88
+ }
89
+
82
90
  if (summary.sessionId) {
83
91
  lines.push(`[PI supervisor] session=${summary.sessionId}`)
84
92
  }
@@ -87,6 +95,14 @@ function printTerminalSummary(config, summary) {
87
95
  lines.push(`[PI supervisor] last_output=${summary.outputPath}`)
88
96
  }
89
97
 
98
+ if (config.lastPromptFile) {
99
+ lines.push(`[PI supervisor] last_prompt=${toDisplayPath(config, config.lastPromptFile)}`)
100
+ }
101
+
102
+ if (config.lastIterationSummaryFile) {
103
+ lines.push(`[PI supervisor] iteration_summary=${toDisplayPath(config, config.lastIterationSummaryFile)}`)
104
+ }
105
+
90
106
  process.stderr.write(`${lines.join('\n')}\n`)
91
107
  }
92
108
 
@@ -109,6 +125,98 @@ function parseTesterVerdict(output) {
109
125
  return match?.[1]?.toUpperCase() ?? 'UNKNOWN'
110
126
  }
111
127
 
128
+ function buildRetryReason(invocation) {
129
+ const loopSignature = String(invocation?.result?.loopSignature ?? '')
130
+ const notes = String(invocation?.result?.notes ?? '')
131
+
132
+ if (loopSignature.startsWith('same_path:')) {
133
+ const target = loopSignature.slice('same_path:'.length)
134
+ 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.`
135
+ }
136
+
137
+ if (notes.includes('loop_detected=')) {
138
+ 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.`
139
+ }
140
+
141
+ return 'The previous turn stalled or timed out. Continue from the current repo state.'
142
+ }
143
+
144
+ function formatIterationSummary(summary) {
145
+ return `${JSON.stringify(summary, null, 2)}\n`
146
+ }
147
+
148
+ async function writeIterationSummary(config, summary) {
149
+ await writeTextFile(config.lastIterationSummaryFile, formatIterationSummary(summary))
150
+ }
151
+
152
+ function createIterationSummary({
153
+ iteration,
154
+ phase,
155
+ task,
156
+ repoChanged,
157
+ developerStatus,
158
+ testerStatus,
159
+ testerVerdict,
160
+ verificationStatus,
161
+ commitPlanFound,
162
+ gitFinalizeStatus,
163
+ visualStatus,
164
+ terminalReason,
165
+ sessionId,
166
+ developerModel,
167
+ testerModel,
168
+ visualModel,
169
+ }) {
170
+ return {
171
+ iteration,
172
+ phase,
173
+ task,
174
+ repoChanged,
175
+ developerStatus,
176
+ testerStatus,
177
+ testerVerdict,
178
+ verificationStatus,
179
+ commitPlanFound,
180
+ gitFinalizeStatus,
181
+ visualStatus,
182
+ terminalReason,
183
+ sessionId,
184
+ developerModel,
185
+ testerModel,
186
+ visualModel,
187
+ }
188
+ }
189
+
190
+ function didInvocationCreateCommit(invocation) {
191
+ return invocation?.beforeSnapshot?.head !== invocation?.afterSnapshot?.head
192
+ }
193
+
194
+ function clampPromptLines(text, maxLines) {
195
+ const normalized = String(text ?? '').trim()
196
+ if (normalized === '') {
197
+ return ''
198
+ }
199
+
200
+ const lines = normalized.split('\n')
201
+ if (!Number.isFinite(maxLines) || maxLines <= 0 || lines.length <= maxLines) {
202
+ return normalized
203
+ }
204
+
205
+ const remaining = lines.length - maxLines
206
+ return `${lines.slice(0, maxLines).join('\n')}\n... (${remaining} more lines omitted)`
207
+ }
208
+
209
+ function compactNotePartsForPrompt(config, noteParts, fallback = '(none provided)') {
210
+ const items = Array.isArray(noteParts) ? noteParts.filter(Boolean) : []
211
+ if (items.length === 0) {
212
+ return fallback
213
+ }
214
+
215
+ const maxItems = Math.min(6, items.length)
216
+ const selected = items.slice(-maxItems)
217
+ return clampPromptLines(selected.join('\n'), Number(config.maxPromptNotesLines) || 16)
218
+ }
219
+
112
220
  function isInfrastructureVerificationFailure(output) {
113
221
  const text = String(output ?? '')
114
222
  return [
@@ -142,6 +250,16 @@ async function runAgentInvocation({
142
250
  }) {
143
251
  const beforeSnapshot = getRepoSnapshot(config.cwd)
144
252
  const resolvedModel = resolveRoleModel(config, role)
253
+ const promptSnapshot = [
254
+ `role=${role}`,
255
+ `kind=${kind}`,
256
+ `phase=${phase}`,
257
+ `reason=${reason}`,
258
+ `model=${resolvedModel.model || '(PI default)'}`,
259
+ '',
260
+ prompt,
261
+ ].join('\n')
262
+ await writeTextFile(config.lastPromptFile, `${promptSnapshot}\n`)
145
263
  const result = await runAgentTurn({
146
264
  config,
147
265
  model: resolvedModel.model,
@@ -178,6 +296,17 @@ async function runAgentInvocation({
178
296
  changedFilesCount: changedFiles.length,
179
297
  verificationStatus,
180
298
  retryCount,
299
+ role,
300
+ model: resolvedModel.model || '(PI default)',
301
+ toolCalls: result.toolCalls ?? 0,
302
+ toolErrors: result.toolErrors ?? 0,
303
+ messageUpdates: result.messageUpdates ?? 0,
304
+ stopReason: result.stopReason ?? '',
305
+ loopDetected: result.loopDetected === true,
306
+ loopSignature: result.loopSignature ?? '',
307
+ testerVerdict: '',
308
+ commitPlanFound: '',
309
+ terminalReason: result.terminalReason ?? '',
181
310
  notes: `${result.notes} role=${role} model=${resolvedModel.model || '(PI default)'}`.trim(),
182
311
  })
183
312
 
@@ -202,7 +331,7 @@ async function readLatestVisualFeedback(config) {
202
331
  if (trimmed === '') {
203
332
  return ''
204
333
  }
205
- return trimmed.split('\n').slice(0, 80).join('\n')
334
+ return clampPromptLines(trimmed, Number(config.maxVisualFeedbackLines) || 20)
206
335
  }
207
336
 
208
337
  async function readLatestTesterFeedback(config) {
@@ -211,7 +340,7 @@ async function readLatestTesterFeedback(config) {
211
340
  if (trimmed === '') {
212
341
  return ''
213
342
  }
214
- return trimmed.split('\n').slice(0, 120).join('\n')
343
+ return clampPromptLines(trimmed, Number(config.maxTesterFeedbackLines) || 32)
215
344
  }
216
345
 
217
346
  async function writeTesterFeedback(config, { iteration, phase, task, source, status, output }) {
@@ -241,17 +370,43 @@ async function writeTesterFeedback(config, { iteration, phase, task, source, sta
241
370
 
242
371
  function parseCommitPlan(output) {
243
372
  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
- : []
373
+ const lines = raw.split('\n')
374
+ const messageLine = lines.find((line) => /^\s*(?:[-*]\s+)?COMMIT_MESSAGE:\s*(.+?)\s*$/i.test(line))
375
+ const message = messageLine
376
+ ? messageLine.replace(/^\s*(?:[-*]\s+)?COMMIT_MESSAGE:\s*/i, '').trim()
377
+ : ''
378
+
379
+ const filesStartIndex = lines.findIndex((line) => /^\s*(?:[-*]\s+)?COMMIT_FILES:\s*$/i.test(line))
380
+ const files = []
381
+ if (filesStartIndex >= 0) {
382
+ for (const line of lines.slice(filesStartIndex + 1)) {
383
+ const trimmed = line.trim()
384
+ if (trimmed === '') {
385
+ if (files.length > 0) {
386
+ break
387
+ }
388
+ continue
389
+ }
390
+ if (/^VERDICT:/i.test(trimmed)) {
391
+ break
392
+ }
393
+ if (/^(?:[-*]\s+)?[A-Z_]+:\s*/.test(trimmed)) {
394
+ break
395
+ }
396
+
397
+ const normalized = trimmed
398
+ .replace(/^[-*]\s+/, '')
399
+ .replace(/^\d+\.\s+/, '')
400
+ .trim()
401
+
402
+ if (normalized !== '') {
403
+ files.push(normalized)
404
+ }
405
+ }
406
+ }
252
407
 
253
408
  return {
254
- message: messageMatch?.[1]?.trim() ?? '',
409
+ message,
255
410
  files: [...new Set(files)],
256
411
  }
257
412
  }
@@ -275,6 +430,7 @@ async function runHarnessGitFinalize({
275
430
  const commitMessage = String(commitPlan.message ?? '').trim()
276
431
  let status = 'success'
277
432
  let notes = ''
433
+ let terminalReason = 'commit_created'
278
434
  const cleanupNewlyStagedFiles = (stagedBefore, stagedNow) => {
279
435
  const stagedBeforeSet = new Set(stagedBefore)
280
436
  const newlyStagedFiles = stagedNow.filter((file) => !stagedBeforeSet.has(file))
@@ -288,6 +444,7 @@ async function runHarnessGitFinalize({
288
444
  if (commitMessage === '' || requestedFiles.length === 0) {
289
445
  status = 'stalled'
290
446
  notes = 'commit_plan_missing=true'
447
+ terminalReason = 'awaiting_commit_plan'
291
448
  } else {
292
449
  const stagedBefore = listStagedFiles(config.cwd)
293
450
  const unrelatedStagedBefore = stagedBefore.filter((file) => !requestedFiles.includes(file))
@@ -295,11 +452,13 @@ async function runHarnessGitFinalize({
295
452
  if (unrelatedStagedBefore.length > 0) {
296
453
  status = 'blocked'
297
454
  notes = `commit_blocked_unrelated_staged_files=${unrelatedStagedBefore.join(',')}`
455
+ terminalReason = 'commit_finalize_blocked_unrelated_staged'
298
456
  } else {
299
457
  const filesToStage = requestedFiles.filter((file) => dirtyFiles.has(file))
300
458
  if (filesToStage.length === 0) {
301
459
  status = 'stalled'
302
460
  notes = 'commit_plan_no_dirty_files=true'
461
+ terminalReason = 'commit_plan_no_dirty_files'
303
462
  } else {
304
463
  try {
305
464
  stageFiles(config.cwd, filesToStage)
@@ -310,18 +469,22 @@ async function runHarnessGitFinalize({
310
469
  const cleanedFiles = cleanupNewlyStagedFiles(stagedBefore, stagedAfter)
311
470
  status = 'blocked'
312
471
  notes = `commit_blocked_unexpected_staged_files=${unexpectedStaged.join(',')} unstaged_cleanup=${cleanedFiles.join(',')}`.trim()
472
+ terminalReason = 'commit_finalize_blocked_unexpected_staged'
313
473
  } else if (!stagedAfter.some((file) => requestedFiles.includes(file))) {
314
474
  const cleanedFiles = cleanupNewlyStagedFiles(stagedBefore, stagedAfter)
315
475
  status = 'stalled'
316
476
  notes = `commit_plan_failed_to_stage=true unstaged_cleanup=${cleanedFiles.join(',')}`.trim()
477
+ terminalReason = 'commit_plan_failed_to_stage'
317
478
  } else {
318
479
  commitStagedFiles(config.cwd, commitMessage)
319
480
  notes = `commit_created=true files=${filesToStage.join(',')} message=${commitMessage}`
481
+ terminalReason = 'commit_created'
320
482
  }
321
483
  } catch (error) {
322
484
  const cleanedFiles = cleanupNewlyStagedFiles(stagedBefore, listStagedFiles(config.cwd))
323
485
  status = 'failed'
324
486
  notes = `commit_failed=${formatExecError(error)}${cleanedFiles.length > 0 ? ` unstaged_cleanup=${cleanedFiles.join(',')}` : ''}`
487
+ terminalReason = 'commit_finalize_failed'
325
488
  }
326
489
  }
327
490
  }
@@ -329,12 +492,16 @@ async function runHarnessGitFinalize({
329
492
 
330
493
  const afterSnapshot = getRepoSnapshot(config.cwd)
331
494
  const changedFiles = listChangedFiles(config.cwd)
495
+ const finalStatus = status === 'success' && beforeSnapshot.head === afterSnapshot.head ? 'stalled' : status
496
+ if (status === 'success' && finalStatus === 'stalled') {
497
+ terminalReason = 'commit_not_created'
498
+ }
332
499
 
333
500
  await recordEvent(config, {
334
501
  iteration,
335
502
  phase,
336
503
  kind: 'git_finalize',
337
- status,
504
+ status: finalStatus,
338
505
  transport: 'local',
339
506
  sessionId: '',
340
507
  timedOut: false,
@@ -346,12 +513,24 @@ async function runHarnessGitFinalize({
346
513
  changedFilesCount: changedFiles.length,
347
514
  verificationStatus: 'not_run',
348
515
  retryCount: 0,
516
+ role: '',
517
+ model: '',
518
+ toolCalls: 0,
519
+ toolErrors: 0,
520
+ messageUpdates: 0,
521
+ stopReason: '',
522
+ loopDetected: false,
523
+ loopSignature: '',
524
+ testerVerdict: '',
525
+ commitPlanFound: requestedFiles.length > 0,
526
+ terminalReason,
349
527
  notes,
350
528
  })
351
529
 
352
530
  return {
353
- status: status === 'success' && beforeSnapshot.head === afterSnapshot.head ? 'stalled' : status,
531
+ status: finalStatus,
354
532
  notes,
533
+ terminalReason,
355
534
  }
356
535
  }
357
536
 
@@ -385,6 +564,17 @@ async function runVerificationStep({ config, iteration, phase, kind }) {
385
564
  changedFilesCount: changedFiles.length,
386
565
  verificationStatus: verification.status,
387
566
  retryCount: 0,
567
+ role: '',
568
+ model: '',
569
+ toolCalls: 0,
570
+ toolErrors: 0,
571
+ messageUpdates: 0,
572
+ stopReason: '',
573
+ loopDetected: false,
574
+ loopSignature: '',
575
+ testerVerdict: '',
576
+ commitPlanFound: '',
577
+ terminalReason: `verification_${verification.status}`,
388
578
  notes: verificationNotes,
389
579
  })
390
580
 
@@ -438,6 +628,7 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
438
628
  result: {
439
629
  ...invocation.result,
440
630
  status: 'stalled',
631
+ terminalReason: 'no_repo_change',
441
632
  notes: `${invocation.result.notes} no_repo_change=true`,
442
633
  },
443
634
  }
@@ -448,9 +639,7 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
448
639
  }
449
640
 
450
641
  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.'
642
+ ? buildRetryReason(invocation)
454
643
  : 'The previous turn ended without changing the repo. Continue and complete one coherent task.'
455
644
  prompt = buildSteeringPrompt(config, reason, {
456
645
  visualFeedback: await readLatestVisualFeedback(config),
@@ -469,7 +658,7 @@ async function runMainTurnWithRetries({ config, iteration, phase, sessionId, ses
469
658
  async function runFixTurn({ config, iteration, phase, sessionId, sessionFile, testerOutput }) {
470
659
  const fixPrompt = buildFixPrompt(
471
660
  config,
472
- testerOutput.trim().split('\n').slice(-120).join('\n'),
661
+ clampPromptLines(testerOutput, Number(config.maxVerificationExcerptLines) || 40),
473
662
  {
474
663
  visualFeedback: await readLatestVisualFeedback(config),
475
664
  testerFeedback: await readLatestTesterFeedback(config),
@@ -600,22 +789,38 @@ async function runTesterTurn({
600
789
  const commitPlan = parseCommitPlan(invocation.result.output)
601
790
  const notesWithVerdict = `${invocation.result.notes} tester_verdict=${verdict} commit_plan_files=${commitPlan.files.length}`.trim()
602
791
  let testerStatus = invocation.result.status
792
+ let terminalReason = invocation.result.terminalReason || ''
603
793
 
604
794
  if (testerStatus === 'success' && verdict === 'FAIL') {
605
795
  testerStatus = 'failed'
796
+ terminalReason = 'tester_verdict_fail'
606
797
  } else if (testerStatus === 'success' && verdict === 'BLOCKED') {
607
798
  testerStatus = 'stalled'
799
+ terminalReason = 'tester_verdict_blocked'
608
800
  } else if (testerStatus === 'success' && verdict === 'UNKNOWN') {
609
801
  testerStatus = 'stalled'
802
+ terminalReason = 'tester_verdict_unknown'
803
+ } else if (testerStatus === 'success' && config.commitMode === 'plan') {
804
+ terminalReason = commitPlan.message !== '' && commitPlan.files.length > 0
805
+ ? 'tester_pass_with_commit_plan'
806
+ : 'awaiting_commit_plan'
807
+ } else if (testerStatus === 'success') {
808
+ terminalReason = didInvocationCreateCommit(invocation)
809
+ ? 'tester_pass_with_agent_commit'
810
+ : invocation.repoChanged
811
+ ? 'tester_left_uncommitted_changes'
812
+ : 'awaiting_agent_commit'
610
813
  }
611
814
 
612
815
  return {
613
816
  ...invocation,
614
817
  testerVerdict: verdict,
818
+ commitPlanFound: commitPlan.message !== '' && commitPlan.files.length > 0,
615
819
  commitPlan,
616
820
  result: {
617
821
  ...invocation.result,
618
822
  status: testerStatus,
823
+ terminalReason,
619
824
  notes: notesWithVerdict,
620
825
  },
621
826
  }
@@ -657,22 +862,30 @@ async function runTesterCommitTurn({
657
862
  const commitPlan = parseCommitPlan(invocation.result.output)
658
863
  const notesWithVerdict = `${invocation.result.notes} tester_verdict=${verdict} commit_plan_files=${commitPlan.files.length}`.trim()
659
864
  let testerStatus = invocation.result.status
865
+ let terminalReason = invocation.result.terminalReason || ''
660
866
 
661
867
  if (testerStatus === 'success' && verdict === 'BLOCKED') {
662
868
  testerStatus = 'stalled'
869
+ terminalReason = 'tester_commit_blocked'
663
870
  } else if (testerStatus === 'success' && verdict !== 'PASS') {
664
871
  testerStatus = 'stalled'
872
+ terminalReason = 'tester_commit_missing_pass'
665
873
  } else if (testerStatus === 'success' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
666
874
  testerStatus = 'stalled'
875
+ terminalReason = 'awaiting_commit_plan'
876
+ } else if (testerStatus === 'success') {
877
+ terminalReason = 'tester_commit_plan_ready'
667
878
  }
668
879
 
669
880
  return {
670
881
  ...invocation,
671
882
  testerVerdict: verdict,
883
+ commitPlanFound: commitPlan.message !== '' && commitPlan.files.length > 0,
672
884
  commitPlan,
673
885
  result: {
674
886
  ...invocation.result,
675
887
  status: testerStatus,
888
+ terminalReason,
676
889
  notes: notesWithVerdict,
677
890
  },
678
891
  }
@@ -701,6 +914,17 @@ async function runVisualReview({ config, iteration, phase, task, changedFiles })
701
914
  changedFilesCount: changedFiles.length,
702
915
  verificationStatus: 'not_run',
703
916
  retryCount: 0,
917
+ role: '',
918
+ model: '',
919
+ toolCalls: 0,
920
+ toolErrors: 0,
921
+ messageUpdates: 0,
922
+ stopReason: '',
923
+ loopDetected: false,
924
+ loopSignature: '',
925
+ testerVerdict: '',
926
+ commitPlanFound: '',
927
+ terminalReason: `visual_capture_${capture.status}`,
704
928
  notes: capture.status === 'passed'
705
929
  ? `screenshots=${capture.screenshots.length} manifest=${capture.manifestPath}`
706
930
  : capture.output.trim().split('\n').slice(-8).join(' '),
@@ -777,6 +1001,17 @@ async function runVisualReview({ config, iteration, phase, task, changedFiles })
777
1001
  changedFilesCount: changedFiles.length,
778
1002
  verificationStatus: 'not_run',
779
1003
  retryCount: 0,
1004
+ role: 'visualReview',
1005
+ model: visualReviewModel.model || '(unset)',
1006
+ toolCalls: 0,
1007
+ toolErrors: 0,
1008
+ messageUpdates: 0,
1009
+ stopReason: '',
1010
+ loopDetected: false,
1011
+ loopSignature: '',
1012
+ testerVerdict: verdict,
1013
+ commitPlanFound: '',
1014
+ terminalReason: `visual_review_${status}`,
780
1015
  notes: `verdict=${verdict} feedback=${config.visualFeedbackFile} role=visualReview model=${visualReviewModel.model || '(unset)'}`.trim(),
781
1016
  })
782
1017
 
@@ -791,6 +1026,7 @@ async function runIteration({ config, state, iteration }) {
791
1026
  const testerModelName = resolveRoleModelName(config, 'tester')
792
1027
  const visualReviewRoleModel = resolveRoleModel(config, 'visualReview')
793
1028
  const visualModelName = visualReviewRoleModel.model
1029
+ const iterationStartSnapshot = getRepoSnapshot(config.cwd)
794
1030
  const taskInfo = findFirstUncheckedTaskInfo(config.taskFile)
795
1031
  if (!taskInfo.hasUncheckedTasks) {
796
1032
  await appendLog(config.logFile, 'No unchecked tasks remain in TODOS.md')
@@ -808,9 +1044,16 @@ async function runIteration({ config, state, iteration }) {
808
1044
  summary: {
809
1045
  iteration,
810
1046
  phase: taskInfo.phase || 'complete',
1047
+ task: '',
1048
+ repoChanged: false,
811
1049
  developerStatus: 'complete',
812
1050
  testerStatus: 'not_needed',
1051
+ testerVerdict: 'NOT_RUN',
813
1052
  verificationStatus: 'not_needed',
1053
+ commitPlanFound: false,
1054
+ gitFinalizeStatus: 'not_run',
1055
+ visualStatus: 'not_run',
1056
+ terminalReason: 'all_tasks_complete',
814
1057
  notes: 'No unchecked tasks remain in TODOS.md.',
815
1058
  sessionId: state.sessionId || '',
816
1059
  outputPath: config.lastAgentOutputFile,
@@ -854,13 +1097,18 @@ async function runIteration({ config, state, iteration }) {
854
1097
  let sessionFile = mainInvocation.result.sessionFile || startingSessionFile
855
1098
  let developerStatus = mainInvocation.result.status
856
1099
  let testerStatus = 'not_run'
1100
+ let testerVerdict = 'NOT_RUN'
857
1101
  let finalVerificationStatus = 'not_run'
858
1102
  let visualStatus = 'not_run'
1103
+ let commitPlanFound = false
1104
+ let gitFinalizeStatus = 'not_run'
1105
+ let terminalReason = mainInvocation.result.terminalReason || ''
859
1106
  const noteParts = [`developer: ${mainInvocation.result.notes}`]
860
1107
 
861
1108
  if (mainInvocation.result.status === 'success' && config.transport === 'mock') {
862
1109
  testerStatus = 'skipped'
863
1110
  finalVerificationStatus = 'skipped'
1111
+ terminalReason = 'mock_completed'
864
1112
  } else if (mainInvocation.result.status === 'success') {
865
1113
  const developerVerification = await runDeveloperVerificationAndFix({
866
1114
  config,
@@ -875,6 +1123,13 @@ async function runIteration({ config, state, iteration }) {
875
1123
  sessionFile = developerVerification.sessionFile
876
1124
  developerStatus = developerVerification.developerStatus
877
1125
  finalVerificationStatus = developerVerification.verificationStatus
1126
+ if (developerStatus !== 'success') {
1127
+ terminalReason = developerStatus === 'blocked'
1128
+ ? 'verification_infrastructure_failure'
1129
+ : 'developer_fix_incomplete'
1130
+ } else if (finalVerificationStatus !== 'passed' && finalVerificationStatus !== 'not_run') {
1131
+ terminalReason = `verification_${finalVerificationStatus}`
1132
+ }
878
1133
 
879
1134
  if (developerVerification.feedbackSource && developerVerification.verificationOutput.trim() !== '') {
880
1135
  await writeTesterFeedback(config, {
@@ -894,11 +1149,14 @@ async function runIteration({ config, state, iteration }) {
894
1149
  phase,
895
1150
  task,
896
1151
  changedFiles: listChangedFiles(config.cwd),
897
- developerNotes: noteParts.join(' | '),
1152
+ developerNotes: compactNotePartsForPrompt(config, noteParts),
898
1153
  reason: 'tester_review_after_basic_smoke_passed',
899
1154
  })
900
1155
 
901
1156
  testerStatus = testerInvocation.result.status
1157
+ testerVerdict = testerInvocation.testerVerdict
1158
+ commitPlanFound = testerInvocation.commitPlanFound === true
1159
+ terminalReason = testerInvocation.result.terminalReason || terminalReason
902
1160
  noteParts.push(`tester: ${testerInvocation.result.notes}`)
903
1161
  await writeTesterFeedback(config, {
904
1162
  iteration,
@@ -911,18 +1169,21 @@ async function runIteration({ config, state, iteration }) {
911
1169
 
912
1170
  let commitPlan = testerInvocation.commitPlan
913
1171
 
914
- if (testerStatus === 'success' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
1172
+ if (testerStatus === 'success' && config.commitMode === 'plan' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
915
1173
  const testerCommitInvocation = await runTesterCommitTurn({
916
1174
  config,
917
1175
  iteration,
918
1176
  phase,
919
1177
  task,
920
1178
  changedFiles: listChangedFiles(config.cwd),
921
- developerNotes: noteParts.join(' | '),
1179
+ developerNotes: compactNotePartsForPrompt(config, noteParts),
922
1180
  reason: 'tester_passed_without_commit',
923
1181
  })
924
1182
 
925
1183
  testerStatus = testerCommitInvocation.result.status
1184
+ testerVerdict = testerCommitInvocation.testerVerdict
1185
+ commitPlanFound = testerCommitInvocation.commitPlanFound === true
1186
+ terminalReason = testerCommitInvocation.result.terminalReason || terminalReason
926
1187
  noteParts.push(`tester_commit: ${testerCommitInvocation.result.notes}`)
927
1188
  await writeTesterFeedback(config, {
928
1189
  iteration,
@@ -935,7 +1196,7 @@ async function runIteration({ config, state, iteration }) {
935
1196
  commitPlan = testerCommitInvocation.commitPlan
936
1197
  }
937
1198
 
938
- if (testerStatus === 'success') {
1199
+ if (testerStatus === 'success' && config.commitMode === 'plan') {
939
1200
  const gitFinalize = await runHarnessGitFinalize({
940
1201
  config,
941
1202
  iteration,
@@ -943,10 +1204,27 @@ async function runIteration({ config, state, iteration }) {
943
1204
  commitPlan,
944
1205
  })
945
1206
  testerStatus = gitFinalize.status
1207
+ gitFinalizeStatus = gitFinalize.status
1208
+ terminalReason = gitFinalize.terminalReason || terminalReason
946
1209
  noteParts.push(`git_finalize: ${gitFinalize.notes}`)
1210
+ } else if (testerStatus === 'success') {
1211
+ if (didInvocationCreateCommit(testerInvocation)) {
1212
+ gitFinalizeStatus = 'committed_by_agent'
1213
+ terminalReason = 'completed_phase_step'
1214
+ } else {
1215
+ testerStatus = 'stalled'
1216
+ gitFinalizeStatus = 'awaiting_agent_commit'
1217
+ terminalReason = testerInvocation.repoChanged
1218
+ ? 'tester_left_uncommitted_changes'
1219
+ : 'awaiting_agent_commit'
1220
+ noteParts.push('git_finalize: committed_by_agent=false')
1221
+ }
947
1222
  }
948
1223
  } else {
949
1224
  testerStatus = 'skipped'
1225
+ if (terminalReason === '') {
1226
+ terminalReason = 'tester_skipped_after_verification'
1227
+ }
950
1228
  }
951
1229
 
952
1230
  if (testerStatus === 'failed') {
@@ -956,12 +1234,13 @@ async function runIteration({ config, state, iteration }) {
956
1234
  phase,
957
1235
  sessionId,
958
1236
  sessionFile,
959
- testerOutput: noteParts.join('\n'),
1237
+ testerOutput: compactNotePartsForPrompt(config, noteParts),
960
1238
  })
961
1239
 
962
1240
  sessionId = fixInvocation.result.sessionId || sessionId
963
1241
  sessionFile = fixInvocation.result.sessionFile || sessionFile
964
1242
  developerStatus = fixInvocation.result.status
1243
+ terminalReason = fixInvocation.result.terminalReason || 'developer_fix_incomplete'
965
1244
  noteParts.push(`developer_fix: ${fixInvocation.result.notes}`)
966
1245
 
967
1246
  if (fixInvocation.result.status === 'success') {
@@ -976,6 +1255,9 @@ async function runIteration({ config, state, iteration }) {
976
1255
  })
977
1256
 
978
1257
  testerStatus = testerRecheck.result.status
1258
+ testerVerdict = testerRecheck.testerVerdict
1259
+ commitPlanFound = testerRecheck.commitPlanFound === true
1260
+ terminalReason = testerRecheck.result.terminalReason || terminalReason
979
1261
  noteParts.push(`tester_recheck: ${testerRecheck.result.notes}`)
980
1262
  await writeTesterFeedback(config, {
981
1263
  iteration,
@@ -988,18 +1270,21 @@ async function runIteration({ config, state, iteration }) {
988
1270
 
989
1271
  let commitPlan = testerRecheck.commitPlan
990
1272
 
991
- if (testerStatus === 'success' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
1273
+ if (testerStatus === 'success' && config.commitMode === 'plan' && (commitPlan.message === '' || commitPlan.files.length === 0)) {
992
1274
  const testerCommitInvocation = await runTesterCommitTurn({
993
1275
  config,
994
1276
  iteration,
995
1277
  phase,
996
1278
  task,
997
1279
  changedFiles: listChangedFiles(config.cwd),
998
- developerNotes: noteParts.join(' | '),
1280
+ developerNotes: compactNotePartsForPrompt(config, noteParts),
999
1281
  reason: 'tester_recheck_passed_without_commit',
1000
1282
  })
1001
1283
 
1002
1284
  testerStatus = testerCommitInvocation.result.status
1285
+ testerVerdict = testerCommitInvocation.testerVerdict
1286
+ commitPlanFound = testerCommitInvocation.commitPlanFound === true
1287
+ terminalReason = testerCommitInvocation.result.terminalReason || terminalReason
1003
1288
  noteParts.push(`tester_commit: ${testerCommitInvocation.result.notes}`)
1004
1289
  await writeTesterFeedback(config, {
1005
1290
  iteration,
@@ -1012,7 +1297,7 @@ async function runIteration({ config, state, iteration }) {
1012
1297
  commitPlan = testerCommitInvocation.commitPlan
1013
1298
  }
1014
1299
 
1015
- if (testerStatus === 'success') {
1300
+ if (testerStatus === 'success' && config.commitMode === 'plan') {
1016
1301
  const gitFinalize = await runHarnessGitFinalize({
1017
1302
  config,
1018
1303
  iteration,
@@ -1020,7 +1305,21 @@ async function runIteration({ config, state, iteration }) {
1020
1305
  commitPlan,
1021
1306
  })
1022
1307
  testerStatus = gitFinalize.status
1308
+ gitFinalizeStatus = gitFinalize.status
1309
+ terminalReason = gitFinalize.terminalReason || terminalReason
1023
1310
  noteParts.push(`git_finalize: ${gitFinalize.notes}`)
1311
+ } else if (testerStatus === 'success') {
1312
+ if (didInvocationCreateCommit(testerRecheck)) {
1313
+ gitFinalizeStatus = 'committed_by_agent'
1314
+ terminalReason = 'completed_phase_step'
1315
+ } else {
1316
+ testerStatus = 'stalled'
1317
+ gitFinalizeStatus = 'awaiting_agent_commit'
1318
+ terminalReason = testerRecheck.repoChanged
1319
+ ? 'tester_left_uncommitted_changes'
1320
+ : 'awaiting_agent_commit'
1321
+ noteParts.push('git_finalize: committed_by_agent=false')
1322
+ }
1024
1323
  }
1025
1324
 
1026
1325
  if (testerStatus === 'success') {
@@ -1032,12 +1331,18 @@ async function runIteration({ config, state, iteration }) {
1032
1331
  })
1033
1332
 
1034
1333
  finalVerificationStatus = reverify.status
1334
+ if (finalVerificationStatus !== 'passed') {
1335
+ terminalReason = `verification_${finalVerificationStatus}`
1336
+ }
1035
1337
  }
1036
1338
  }
1037
1339
  }
1038
1340
  } else {
1039
1341
  testerStatus = 'not_run'
1040
1342
  finalVerificationStatus = 'not_run'
1343
+ if (terminalReason === '') {
1344
+ terminalReason = 'developer_turn_incomplete'
1345
+ }
1041
1346
  }
1042
1347
 
1043
1348
  const workflowStatus = deriveWorkflowStatus({
@@ -1070,6 +1375,9 @@ async function runIteration({ config, state, iteration }) {
1070
1375
  changedFiles: listChangedFiles(config.cwd),
1071
1376
  })
1072
1377
  visualStatus = visualReview.status
1378
+ terminalReason = visualReview.status === 'passed'
1379
+ ? terminalReason
1380
+ : `visual_review_${visualReview.status}`
1073
1381
  noteParts.push(`visual: ${visualReview.notes}`)
1074
1382
  } else if (config.visualReviewEnabled) {
1075
1383
  visualStatus = 'skipped'
@@ -1080,6 +1388,22 @@ async function runIteration({ config, state, iteration }) {
1080
1388
  visualStatus,
1081
1389
  })
1082
1390
 
1391
+ if (finalStatus === 'success') {
1392
+ terminalReason = 'completed_phase_step'
1393
+ } else if (terminalReason === '') {
1394
+ terminalReason = testerStatus === 'failed'
1395
+ ? 'tester_verdict_fail'
1396
+ : testerStatus === 'stalled'
1397
+ ? 'iteration_stalled'
1398
+ : developerStatus === 'blocked'
1399
+ ? 'developer_blocked'
1400
+ : developerStatus === 'failed'
1401
+ ? 'developer_failed'
1402
+ : finalVerificationStatus !== 'not_run'
1403
+ ? `verification_${finalVerificationStatus}`
1404
+ : 'workflow_incomplete'
1405
+ }
1406
+
1083
1407
  const successfulIterations = (
1084
1408
  finalStatus === 'success'
1085
1409
  ? candidateSuccessfulIterations
@@ -1112,18 +1436,74 @@ async function runIteration({ config, state, iteration }) {
1112
1436
 
1113
1437
  await appendLog(
1114
1438
  config.logFile,
1115
- `Finished iteration ${iteration} with status=${finalStatus} verification=${finalVerificationStatus}`
1439
+ `Finished iteration ${iteration} with status=${finalStatus} verification=${finalVerificationStatus} tester_verdict=${testerVerdict} commit_plan_found=${commitPlanFound} terminal_reason=${terminalReason}`
1116
1440
  )
1117
1441
 
1442
+ const iterationEndSnapshot = getRepoSnapshot(config.cwd)
1443
+ const iterationSummary = createIterationSummary({
1444
+ iteration,
1445
+ phase,
1446
+ task,
1447
+ repoChanged: didRepoChange(iterationStartSnapshot, iterationEndSnapshot),
1448
+ developerStatus,
1449
+ testerStatus,
1450
+ testerVerdict,
1451
+ verificationStatus: finalVerificationStatus,
1452
+ commitPlanFound,
1453
+ gitFinalizeStatus,
1454
+ visualStatus,
1455
+ terminalReason,
1456
+ sessionId,
1457
+ developerModel: developerModelName,
1458
+ testerModel: testerModelName,
1459
+ visualModel: visualModelName,
1460
+ })
1461
+
1462
+ await recordEvent(config, {
1463
+ iteration,
1464
+ phase,
1465
+ kind: 'iteration_summary',
1466
+ status: finalStatus,
1467
+ transport: config.transport,
1468
+ sessionId,
1469
+ timedOut: false,
1470
+ exitCode: finalStatus === 'success' ? 0 : 1,
1471
+ durationSeconds: 0,
1472
+ commitBefore: iterationStartSnapshot.head,
1473
+ commitAfter: iterationEndSnapshot.head,
1474
+ repoChanged: iterationSummary.repoChanged,
1475
+ changedFilesCount: listChangedFiles(config.cwd).length,
1476
+ verificationStatus: finalVerificationStatus,
1477
+ retryCount: 0,
1478
+ role: '',
1479
+ model: '',
1480
+ toolCalls: 0,
1481
+ toolErrors: 0,
1482
+ messageUpdates: 0,
1483
+ stopReason: '',
1484
+ loopDetected: false,
1485
+ loopSignature: '',
1486
+ testerVerdict,
1487
+ commitPlanFound,
1488
+ terminalReason,
1489
+ notes: noteParts.join(' | '),
1490
+ })
1491
+
1118
1492
  return {
1119
1493
  stateUpdate: nextState,
1120
1494
  summary: {
1121
1495
  iteration,
1122
1496
  phase,
1497
+ task,
1498
+ repoChanged: iterationSummary.repoChanged,
1123
1499
  developerStatus,
1124
1500
  testerStatus,
1501
+ testerVerdict,
1125
1502
  verificationStatus: finalVerificationStatus,
1503
+ commitPlanFound,
1504
+ gitFinalizeStatus,
1126
1505
  visualStatus,
1506
+ terminalReason,
1127
1507
  notes: noteParts.join(' | '),
1128
1508
  sessionId,
1129
1509
  outputPath: config.lastAgentOutputFile,
@@ -1134,6 +1514,7 @@ async function runIteration({ config, state, iteration }) {
1134
1514
  testerModel: testerModelName,
1135
1515
  visualModel: visualModelName,
1136
1516
  },
1517
+ iterationSummary,
1137
1518
  shouldStop: false,
1138
1519
  }
1139
1520
  }
@@ -1153,6 +1534,7 @@ async function main() {
1153
1534
  while (!stopRequested) {
1154
1535
  const iteration = state.iteration + 1
1155
1536
  const result = await runIteration({ config, state, iteration })
1537
+ await writeIterationSummary(config, result.iterationSummary ?? result.summary)
1156
1538
  state = result.stateUpdate
1157
1539
  await writeState(config.stateFile, state)
1158
1540
  printTerminalSummary(config, result.summary)