prizmkit 1.1.104 → 1.1.106

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.
Files changed (24) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/dev-pipeline/README.md +6 -7
  3. package/bundled/dev-pipeline/assets/prizm-dev-team-integration.md +3 -3
  4. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +0 -1
  5. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +83 -12
  6. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +38 -2
  7. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +497 -154
  8. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +98 -9
  9. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +37 -28
  10. package/bundled/dev-pipeline/scripts/generate-recovery-prompt.py +4 -3
  11. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +62 -38
  12. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +57 -49
  13. package/bundled/dev-pipeline/templates/sections/critical-paths-agent.md +0 -1
  14. package/bundled/dev-pipeline/templates/sections/critical-paths-full.md +0 -1
  15. package/bundled/dev-pipeline/templates/sections/directory-convention-agent.md +2 -2
  16. package/bundled/dev-pipeline/templates/sections/directory-convention-full.md +2 -2
  17. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +1 -0
  18. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +1 -0
  19. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +46 -0
  20. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +308 -0
  21. package/bundled/dev-pipeline/tests/test_unified_cli.py +1 -0
  22. package/bundled/skills/_metadata.json +1 -1
  23. package/package.json +1 -1
  24. package/bundled/dev-pipeline/templates/agent-prompts/dev-implement.md +0 -71
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import sys
6
7
  import time
7
8
  from pathlib import Path
8
9
 
@@ -15,6 +16,7 @@ from .gitops import (
15
16
  branch_ensure_return,
16
17
  branch_merge_plan,
17
18
  current_branch,
19
+ default_branch,
18
20
  ensure_linked_worktree,
19
21
  guarded_worktree_remove,
20
22
  materialize_worktree_support_assets,
@@ -24,7 +26,12 @@ from .gitops import (
24
26
  worktree_runtime_context,
25
27
  worktree_save_wip,
26
28
  )
27
- from .runner_bookkeeping import commit_final_bookkeeping, commit_pre_branch_bookkeeping
29
+ from .runner_bookkeeping import (
30
+ commit_final_bookkeeping,
31
+ commit_pre_branch_bookkeeping,
32
+ commit_task_branch_changes,
33
+ commit_wip_changes,
34
+ )
28
35
  from .runner_classification import classify_session
29
36
  from .runner_models import RunnerEnvironment, RunnerFamily, RunnerInvocation, SessionPaths, family_for, parse_invocation
30
37
  from .runner_prompts import PromptGenerationResult, generate_prompt
@@ -92,7 +99,10 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
92
99
  processed = 0
93
100
  details: list[str] = []
94
101
  if invocation.item_id:
95
- status = _process_item(family, invocation, invocation.item_id, paths, initial_metadata={})
102
+ try:
103
+ status = _process_item(family, invocation, invocation.item_id, paths, initial_metadata={})
104
+ except KeyboardInterrupt:
105
+ return PipelineRunResult(False, 1, "interrupted", "interrupted", tuple(details))
96
106
  return PipelineRunResult(status == "completed", 1, "single_item_done", status, tuple(details))
97
107
 
98
108
  while True:
@@ -109,10 +119,16 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
109
119
  item_id = str(next_result.data.get(f"{family.kind}_id") or next_result.data.get("feature_id") or next_result.data.get("bug_id") or next_result.data.get("refactor_id") or "")
110
120
  if not item_id:
111
121
  return PipelineRunResult(False, processed, "missing_item_id", details=(marker[:500],))
112
- final_status = _process_item(family, invocation, item_id, paths, initial_metadata=next_result.data)
122
+ try:
123
+ final_status = _process_item(family, invocation, item_id, paths, initial_metadata=next_result.data)
124
+ except KeyboardInterrupt:
125
+ processed += 1
126
+ details.append(f"{item_id}:interrupted")
127
+ return PipelineRunResult(False, processed, "interrupted", "interrupted", tuple(details))
113
128
  processed += 1
114
129
  details.append(f"{item_id}:{final_status}")
115
130
  if final_status == "completed":
131
+ _emit_info(f"Pausing 5s before next {family.kind}...")
116
132
  continue
117
133
  if final_status == "pending":
118
134
  continue
@@ -122,6 +138,167 @@ def _run_pipeline(family: RunnerFamily, invocation: RunnerInvocation, paths) ->
122
138
  return PipelineRunResult(False, processed, "item_failed", final_status, tuple(details))
123
139
 
124
140
 
141
+ _SEPARATOR = "─" * 52
142
+
143
+
144
+ def _emit_separator() -> None:
145
+ print(_SEPARATOR, file=sys.stderr, flush=True)
146
+
147
+
148
+ def _emit_info(message: str, *, level: str = "INFO") -> None:
149
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
150
+ print(f"{f'[{level}]'.ljust(10)}{timestamp} {message}", file=sys.stderr, flush=True)
151
+
152
+
153
+ def _emit_success(message: str) -> None:
154
+ _emit_info(message, level="SUCCESS")
155
+
156
+
157
+ def _emit_warning(message: str) -> None:
158
+ _emit_info(message, level="WARN")
159
+
160
+
161
+ def _family_label(family: RunnerFamily) -> str:
162
+ return {"feature": "Feature", "bugfix": "Bug", "refactor": "Refactor"}.get(family.kind, family.kind.title())
163
+
164
+
165
+ def _max_retries(invocation: RunnerInvocation) -> int:
166
+ if invocation.max_retries is not None:
167
+ return max(0, invocation.max_retries)
168
+ return RunnerEnvironment.from_env().max_retries
169
+
170
+
171
+ def _emit_item_header(family: RunnerFamily, invocation: RunnerInvocation, item_id: str, metadata: dict[str, object]) -> None:
172
+ title = str(metadata.get("title") or metadata.get("description") or "").strip()
173
+ title_text = f" — {title}" if title else ""
174
+ retry_count = int(metadata.get("retry_count") or 0)
175
+ infra_count = int(metadata.get("infra_error_count") or 0)
176
+ _emit_separator()
177
+ _emit_info(f"{_family_label(family)}: {item_id}{title_text}")
178
+ _emit_info(f"Code retry: {retry_count} / {_max_retries(invocation)}")
179
+ _emit_info(f"Infrastructure retry: {infra_count} / {invocation.max_infra_retries}")
180
+ _emit_separator()
181
+
182
+
183
+ def _is_recovery_side_effect_branch(branch: str, family: RunnerFamily, item_id: str) -> bool:
184
+ return bool(branch) and branch.startswith(f"{family.branch_prefix}/{item_id}-") and "-recovery-" in branch
185
+
186
+
187
+ def _is_current_task_branch(branch: str, family: RunnerFamily, item_id: str) -> bool:
188
+ if not branch or _is_recovery_side_effect_branch(branch, family, item_id):
189
+ return False
190
+ return branch == f"{family.branch_prefix}/{item_id}" or branch.startswith(f"{family.branch_prefix}/{item_id}-")
191
+
192
+
193
+ def _resolve_base_branch(project_root: Path, metadata: dict[str, object], active_branch: str, family: RunnerFamily, item_id: str) -> str:
194
+ recorded = str(metadata.get("base_branch") or "").strip()
195
+ if recorded:
196
+ return recorded
197
+ detected_default = default_branch(project_root)
198
+ if _is_current_task_branch(active_branch, family, item_id) or _is_recovery_side_effect_branch(active_branch, family, item_id):
199
+ return detected_default
200
+ return active_branch or detected_default
201
+
202
+
203
+ def _resolve_working_branch(family: RunnerFamily, item_id: str, metadata: dict[str, object], env: RunnerEnvironment, active_branch: str) -> str:
204
+ recorded = str(metadata.get("active_dev_branch") or "").strip()
205
+ if recorded and not _is_recovery_side_effect_branch(recorded, family, item_id):
206
+ return recorded
207
+ if env.dev_branch and not _is_recovery_side_effect_branch(env.dev_branch, family, item_id):
208
+ return env.dev_branch
209
+ if _is_current_task_branch(active_branch, family, item_id):
210
+ return active_branch
211
+ return _default_branch_name(family, item_id)
212
+
213
+
214
+ def _emit_branch_setup_result(branch_context: BranchContext, result) -> None:
215
+ results = getattr(result, "results", ()) or ()
216
+ created = len(results) > 1 and results[1].return_code == 0
217
+ reused = len(results) > 2 and results[2].return_code == 0 and not created
218
+ if created:
219
+ _emit_info(f"Created and checked out branch: {branch_context.working_branch} (from {branch_context.base_branch})")
220
+ elif reused:
221
+ _emit_info(f"Reusing branch: {branch_context.working_branch} (from {branch_context.base_branch})")
222
+ else:
223
+ _emit_info(f"Dev branch: {branch_context.working_branch}")
224
+ _emit_info(f"Dev branch: {branch_context.working_branch}")
225
+
226
+
227
+ def _emit_git_plan_output(result) -> None:
228
+ for command_result in getattr(result, "results", ()) or ():
229
+ for text in (getattr(command_result, "stdout", ""), getattr(command_result, "stderr", "")):
230
+ stripped = str(text or "").strip()
231
+ if stripped:
232
+ print(stripped, file=sys.stderr, flush=True)
233
+
234
+
235
+ def _bytes_text(size: int) -> str:
236
+ if size >= 1024 * 1024:
237
+ return f"{round(size / (1024 * 1024))}MB"
238
+ if size >= 1024:
239
+ return f"{round(size / 1024)}KB"
240
+ return f"{size}B"
241
+
242
+
243
+ def _session_log_summary(session_log: Path) -> tuple[int, str]:
244
+ try:
245
+ size = session_log.stat().st_size
246
+ with session_log.open("r", encoding="utf-8", errors="ignore") as handle:
247
+ line_count = sum(1 for _ in handle)
248
+ return line_count, _bytes_text(size)
249
+ except OSError:
250
+ return 0, "0B"
251
+
252
+
253
+ def _progress_data(path: Path) -> dict[str, object]:
254
+ if not path.is_file():
255
+ return {}
256
+ try:
257
+ data = json.loads(path.read_text(encoding="utf-8"))
258
+ except (OSError, json.JSONDecodeError):
259
+ return {}
260
+ return data if isinstance(data, dict) else {}
261
+
262
+
263
+ def _emit_prompt_summary(prompt: PromptGenerationResult, session_id: str) -> None:
264
+ if prompt.pipeline_mode:
265
+ _emit_info(f"Pipeline mode: {_pipeline_mode_label(prompt.pipeline_mode)}")
266
+ if prompt.agent_count:
267
+ critic = "enabled" if prompt.critic_enabled else "disabled"
268
+ _emit_info(f"Agents: {prompt.agent_count} (critic: {critic})")
269
+ _emit_info(f"Spawning AI CLI session: {session_id}")
270
+
271
+
272
+ def _pipeline_mode_label(mode: str) -> str:
273
+ labels = {
274
+ "lite": "lite (Tier 1 — Single Agent)",
275
+ "standard": "standard (Tier 2 — Orchestrator + Critic/Reviewer)",
276
+ "full": "full (Tier 3 — Orchestrator + Critic/Reviewer)",
277
+ }
278
+ return labels.get(mode, mode)
279
+
280
+
281
+ def _emit_session_summary(session_result, session_paths: SessionPaths, classification) -> None:
282
+ lines, size = _session_log_summary(session_paths.session_log)
283
+ _emit_info(f"Session log: {lines} lines, {size}")
284
+ _emit_info(f"exit_code={getattr(session_result, 'exit_code', None)}")
285
+ _emit_info(f"Session result: {classification.session_status}")
286
+ progress = _progress_data(session_paths.progress_json)
287
+ subagent_calls = int(progress.get("subagent_spawn_count") or 0)
288
+ if subagent_calls:
289
+ _emit_info(f"Subagent calls detected in session: {subagent_calls}")
290
+ if classification.session_status == "success":
291
+ _emit_info("CHECKPOINT: All workflow steps completed")
292
+
293
+
294
+ def _emit_update_summary(family: RunnerFamily, item_id: str, update) -> None:
295
+ if not isinstance(getattr(update, "data", None), dict):
296
+ return
297
+ new_status = str(update.data.get("new_status") or "")
298
+ if family.kind == "feature" and new_status == "completed":
299
+ _emit_info(f"Propagated completion notes for {item_id} to feature-list.json")
300
+
301
+
125
302
  def _process_item(
126
303
  family: RunnerFamily,
127
304
  invocation: RunnerInvocation,
@@ -134,185 +311,306 @@ def _process_item(
134
311
  config = load_runtime_config(paths)
135
312
  if not config.ai_client.command:
136
313
  raise RuntimeError("No supported AI CLI command was found")
137
- start = start_item(family, invocation, item_id, paths.project_root)
138
- if not start.ok:
139
- raise RuntimeError(start.text)
140
- commit_pre_branch_bookkeeping(paths.project_root, family, item_id)
141
314
 
142
- base_branch = str(initial_metadata.get("base_branch") or current_branch(paths.project_root) or "main")
143
- branch_name = str(initial_metadata.get("active_dev_branch") or env.dev_branch or _default_branch_name(family, item_id))
315
+ active_branch = current_branch(paths.project_root)
316
+ base_branch = _resolve_base_branch(paths.project_root, initial_metadata, active_branch, family, item_id)
317
+ branch_name = _resolve_working_branch(family, item_id, initial_metadata, env, active_branch)
144
318
  branch_context = BranchContext(base_branch, branch_name, paths.project_root)
145
319
  worktree_runtime = None
146
320
  use_worktree = _use_worktree_for_family(env, family)
147
321
  execution_root = paths.project_root
148
- if use_worktree:
149
- worktree_runtime = worktree_runtime_context(
150
- branch_context,
151
- WorktreePolicy(
152
- use_worktree=True,
153
- cleanup_on_success=True,
154
- preserve_on_failure=True,
155
- worktree_root=paths.state_dir / "worktrees",
156
- ),
157
- )
158
- setup = ensure_linked_worktree(worktree_runtime)
159
- if not setup.ok:
160
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
161
- return _status_from_update(status)
162
- worktree_runtime = setup.runtime or worktree_runtime
163
- support_assets = materialize_worktree_support_assets(worktree_runtime)
164
- if not support_assets.ok:
165
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
166
- return _status_from_update(status)
167
- execution_root = worktree_runtime.worktree_path
168
- else:
169
- branch_result = run_git_plan(paths.project_root, branch_create_plan(branch_context))
170
- if not branch_result.ok:
171
- status = update_item(family, invocation, item_id, "infra_error", "", paths.project_root)
172
- return _status_from_update(status)
173
-
174
- previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
175
- previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
176
- continuation_pending = bool(initial_metadata.get("continuation_pending"))
177
- previous_session_id = str(initial_metadata.get("previous_session_id") or initial_metadata.get("last_context_overflow_session_id") or "")
178
- continuation_count = int(initial_metadata.get("continuation_count") or 0)
179
- context_overflow_count = int(initial_metadata.get("context_overflow_count") or 0)
180
322
 
181
- while True:
182
- session_id = _session_id(item_id)
183
- session_paths = SessionPaths.for_item(family.state_dir, item_id, session_id)
184
- continuation = None
185
- if continuation_pending:
186
- continuation = {
187
- "previous_session_id": previous_session_id,
188
- "continuation_count": continuation_count,
189
- "context_overflow_count": context_overflow_count,
190
- "task_type": family.task_type,
191
- "active_dev_branch": branch_name,
192
- "base_branch": base_branch,
193
- "continuation_summary_path": _continuation_summary_path(paths.project_root, family, item_id, None),
194
- }
195
- prompt = generate_prompt(
196
- family,
197
- invocation,
198
- item_id=item_id,
199
- session_id=session_id,
200
- run_id="run-python",
201
- retry_count=0,
202
- session_paths=session_paths,
203
- project_root=paths.project_root,
204
- execution_root=execution_root,
205
- continuation=continuation,
206
- )
207
- if invocation.dry_run:
208
- return "dry_run"
209
- stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
210
- base_head = run_git_command(execution_root, ("rev-parse", "HEAD")).stdout.strip()
211
- session_result = AISessionLauncher(
212
- AISessionConfig(
213
- cli_command=config.ai_client.command,
214
- platform=config.ai_client.platform or "codebuddy",
215
- model=prompt.model or config.model,
216
- prompt_path=prompt.output_path,
217
- cwd=execution_root,
218
- log_path=session_paths.session_log,
219
- progress_path=session_paths.progress_json,
220
- heartbeat_path=session_paths.heartbeat_json,
221
- effort=config.effort,
222
- verbose=env.verbose,
223
- live_output=env.live_output,
224
- use_stream_json=stream_json,
225
- stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
323
+ try:
324
+ if use_worktree:
325
+ worktree_runtime = worktree_runtime_context(
326
+ branch_context,
327
+ WorktreePolicy(
328
+ use_worktree=True,
329
+ cleanup_on_success=True,
330
+ preserve_on_failure=True,
331
+ worktree_root=paths.state_dir / "worktrees",
332
+ ),
226
333
  )
227
- ).run()
228
- classification = classify_session(
229
- session_result,
230
- project_root=execution_root,
231
- base_head=base_head,
232
- prompt=prompt,
233
- previous_fingerprint=previous_fingerprint,
234
- previous_no_progress_count=previous_no_progress_count,
235
- )
236
- if classification.session_status == "context_overflow":
237
- summary_path = _continuation_summary_path(paths.project_root, family, item_id, prompt)
238
- update = update_item(
334
+ setup = ensure_linked_worktree(worktree_runtime)
335
+ if not setup.ok:
336
+ return "infra_error"
337
+ worktree_runtime = setup.runtime or worktree_runtime
338
+ _emit_info(f"Dev branch: {branch_name}")
339
+ _emit_info(f"Worktree: {worktree_runtime.worktree_path}")
340
+ support_assets = materialize_worktree_support_assets(worktree_runtime)
341
+ if not support_assets.ok:
342
+ return _record_setup_failure(
343
+ family,
344
+ invocation,
345
+ item_id,
346
+ paths.project_root,
347
+ worktree_runtime.worktree_path,
348
+ base_branch,
349
+ branch_name,
350
+ worktree_runtime=worktree_runtime,
351
+ )
352
+ execution_root = worktree_runtime.worktree_path
353
+ else:
354
+ branch_result = run_git_plan(paths.project_root, branch_create_plan(branch_context))
355
+ _emit_branch_setup_result(branch_context, branch_result)
356
+ if not branch_result.ok:
357
+ return "infra_error"
358
+
359
+ _emit_item_header(family, invocation, item_id, initial_metadata)
360
+ status_root = execution_root if use_worktree else paths.project_root
361
+ status_family = _family_for_execution_root(family, paths.project_root, status_root)
362
+ status_invocation = _invocation_for_execution_family(invocation, status_family)
363
+ start = start_item(status_family, status_invocation, item_id, status_root)
364
+ if not start.ok:
365
+ raise RuntimeError(start.text)
366
+ commit_pre_branch_bookkeeping(status_root, status_family, item_id)
367
+
368
+ previous_fingerprint = initial_metadata.get("last_progress_fingerprint")
369
+ previous_no_progress_count = int(initial_metadata.get("no_progress_count") or 0)
370
+ continuation_pending = bool(initial_metadata.get("continuation_pending"))
371
+ previous_session_id = str(initial_metadata.get("previous_session_id") or initial_metadata.get("last_context_overflow_session_id") or "")
372
+ continuation_count = int(initial_metadata.get("continuation_count") or 0)
373
+ context_overflow_count = int(initial_metadata.get("context_overflow_count") or 0)
374
+
375
+ while True:
376
+ session_id = _session_id(item_id)
377
+ session_paths = SessionPaths.for_item(family.state_dir, item_id, session_id)
378
+ continuation = None
379
+ if continuation_pending:
380
+ continuation = {
381
+ "previous_session_id": previous_session_id,
382
+ "continuation_count": continuation_count,
383
+ "context_overflow_count": context_overflow_count,
384
+ "task_type": family.task_type,
385
+ "active_dev_branch": branch_name,
386
+ "base_branch": base_branch,
387
+ "continuation_summary_path": _continuation_summary_path(execution_root, family, item_id, None),
388
+ }
389
+ prompt = generate_prompt(
239
390
  family,
240
391
  invocation,
392
+ item_id=item_id,
393
+ session_id=session_id,
394
+ run_id="run-python",
395
+ retry_count=0,
396
+ session_paths=session_paths,
397
+ project_root=paths.project_root,
398
+ execution_root=execution_root,
399
+ continuation=continuation,
400
+ )
401
+ if invocation.dry_run:
402
+ return "dry_run"
403
+ _emit_prompt_summary(prompt, session_id)
404
+ stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
405
+ base_head = run_git_command(execution_root, ("rev-parse", "HEAD")).stdout.strip()
406
+ session_result = AISessionLauncher(
407
+ AISessionConfig(
408
+ cli_command=config.ai_client.command,
409
+ platform=config.ai_client.platform or "codebuddy",
410
+ model=prompt.model or config.model,
411
+ prompt_path=prompt.output_path,
412
+ cwd=execution_root,
413
+ log_path=session_paths.session_log,
414
+ progress_path=session_paths.progress_json,
415
+ heartbeat_path=session_paths.heartbeat_json,
416
+ effort=config.effort,
417
+ verbose=env.verbose,
418
+ live_output=env.live_output,
419
+ use_stream_json=stream_json,
420
+ stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
421
+ live_progress_interval_seconds=env.heartbeat_interval_seconds,
422
+ live_banner=False,
423
+ suppress_stream_output=True,
424
+ )
425
+ ).run()
426
+ classification = classify_session(
427
+ session_result,
428
+ project_root=execution_root,
429
+ base_head=base_head,
430
+ prompt=prompt,
431
+ previous_fingerprint=previous_fingerprint,
432
+ previous_no_progress_count=previous_no_progress_count,
433
+ )
434
+ _emit_session_summary(session_result, session_paths, classification)
435
+ if classification.session_status == "context_overflow":
436
+ summary_path = _continuation_summary_path(execution_root, family, item_id, prompt)
437
+ update = update_item(
438
+ status_family,
439
+ status_invocation,
440
+ item_id,
441
+ "context_overflow",
442
+ session_id,
443
+ status_root,
444
+ active_dev_branch=branch_name,
445
+ base_branch=base_branch,
446
+ last_fatal_error_code=classification.fatal_error_code or "context_overflow",
447
+ continuation_summary_path=summary_path,
448
+ no_progress_count=classification.no_progress_count,
449
+ progress_fingerprint=classification.progress_fingerprint,
450
+ )
451
+ if not update.ok:
452
+ raise RuntimeError(update.text)
453
+ _emit_update_summary(family, item_id, update)
454
+ previous_fingerprint = classification.progress_fingerprint
455
+ previous_no_progress_count = classification.no_progress_count
456
+ continuation_pending = True
457
+ previous_session_id = session_id
458
+ continuation_count += 1
459
+ context_overflow_count += 1
460
+ continue
461
+
462
+ final_session_status = classification.session_status
463
+
464
+ _write_session_status(
465
+ session_paths,
466
+ status=final_session_status,
467
+ session_id=session_id,
468
+ task_id=item_id,
469
+ task_type=family.task_type,
470
+ base_branch=base_branch,
471
+ active_dev_branch=branch_name,
472
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
473
+ reason=classification.reason,
474
+ fatal_error_code=classification.fatal_error_code,
475
+ )
476
+
477
+ update = update_item(
478
+ status_family,
479
+ status_invocation,
241
480
  item_id,
242
- "context_overflow",
481
+ final_session_status,
243
482
  session_id,
244
- paths.project_root,
483
+ status_root,
245
484
  active_dev_branch=branch_name,
246
485
  base_branch=base_branch,
247
- last_fatal_error_code=classification.fatal_error_code or "context_overflow",
248
- continuation_summary_path=summary_path,
486
+ last_fatal_error_code=classification.fatal_error_code,
249
487
  no_progress_count=classification.no_progress_count,
250
488
  progress_fingerprint=classification.progress_fingerprint,
251
489
  )
252
490
  if not update.ok:
253
491
  raise RuntimeError(update.text)
254
- previous_fingerprint = classification.progress_fingerprint
255
- previous_no_progress_count = classification.no_progress_count
256
- continuation_pending = True
257
- previous_session_id = session_id
258
- continuation_count += 1
259
- context_overflow_count += 1
260
- continue
492
+ _emit_update_summary(family, item_id, update)
493
+ new_status = _status_from_update(update)
494
+ if final_session_status != "success":
495
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
496
+ _save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
497
+ if use_worktree and worktree_runtime is not None:
498
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
499
+ else:
500
+ branch_ensure_return(paths.project_root, base_branch, branch_name)
501
+ return new_status
261
502
 
262
- final_session_status = classification.session_status
263
- if use_worktree and worktree_runtime is not None:
264
- if final_session_status == "success":
503
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
504
+ commit_task_branch_changes(execution_root, family, item_id, new_status)
505
+ if use_worktree and worktree_runtime is not None:
506
+ _emit_info(f"Merging {branch_name} into {base_branch}...")
265
507
  merge = merge_linked_worktree(worktree_runtime, auto_push=env.auto_push)
508
+ _emit_git_plan_output(merge)
266
509
  if merge.ok:
267
510
  guarded_worktree_remove(worktree_runtime, delete_branch=True)
268
- else:
269
- final_session_status = "merge_conflict"
270
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
511
+ _emit_success(f"Merged {branch_name} into {base_branch}")
512
+ return new_status
513
+ final_session_status = "merge_conflict"
271
514
  else:
272
- worktree_save_wip(worktree_runtime)
273
- guarded_worktree_remove(worktree_runtime, delete_branch=False)
274
- else:
275
- if final_session_status == "success":
515
+ _emit_info(f"Merging {branch_name} into {base_branch}...")
276
516
  merge = run_git_plan(paths.project_root, branch_merge_plan(branch_context, auto_push=env.auto_push))
277
- if not merge.ok:
278
- branch_ensure_return(paths.project_root, base_branch, branch_name)
279
- final_session_status = "merge_conflict"
517
+ _emit_git_plan_output(merge)
518
+ if merge.ok:
519
+ _emit_success(f"Merged {branch_name} into {base_branch}")
520
+ return new_status
521
+ final_session_status = "merge_conflict"
522
+
523
+ _write_session_status(
524
+ session_paths,
525
+ status=final_session_status,
526
+ session_id=session_id,
527
+ task_id=item_id,
528
+ task_type=family.task_type,
529
+ base_branch=base_branch,
530
+ active_dev_branch=branch_name,
531
+ worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
532
+ reason="merge_conflict",
533
+ fatal_error_code=classification.fatal_error_code,
534
+ )
535
+ update = update_item(
536
+ status_family,
537
+ status_invocation,
538
+ item_id,
539
+ final_session_status,
540
+ session_id,
541
+ status_root,
542
+ active_dev_branch=branch_name,
543
+ base_branch=base_branch,
544
+ last_fatal_error_code=classification.fatal_error_code,
545
+ no_progress_count=classification.no_progress_count,
546
+ progress_fingerprint=classification.progress_fingerprint,
547
+ )
548
+ if not update.ok:
549
+ raise RuntimeError(update.text)
550
+ _emit_update_summary(family, item_id, update)
551
+ new_status = _status_from_update(update)
552
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
553
+ _save_failure_wip(execution_root, family, item_id, new_status, worktree_runtime)
554
+ if use_worktree and worktree_runtime is not None:
555
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
280
556
  else:
281
557
  branch_ensure_return(paths.project_root, base_branch, branch_name)
558
+ return new_status
559
+ except KeyboardInterrupt:
560
+ _emit_warning(f"Interrupted while processing {item_id}; preserving work and returning to {base_branch}")
561
+ if use_worktree and worktree_runtime is not None:
562
+ worktree_save_wip(worktree_runtime)
563
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
564
+ else:
565
+ branch_ensure_return(paths.project_root, base_branch, branch_name)
566
+ raise
282
567
 
283
- _write_session_status(
284
- session_paths,
285
- status=final_session_status,
286
- session_id=session_id,
287
- task_id=item_id,
288
- task_type=family.task_type,
289
- base_branch=base_branch,
290
- active_dev_branch=branch_name,
291
- worktree_path=worktree_runtime.worktree_path if worktree_runtime is not None else None,
292
- reason=classification.reason,
293
- fatal_error_code=classification.fatal_error_code,
294
- )
295
568
 
296
- update = update_item(
297
- family,
298
- invocation,
299
- item_id,
300
- final_session_status,
301
- session_id,
302
- paths.project_root,
303
- active_dev_branch=branch_name,
304
- base_branch=base_branch,
305
- last_fatal_error_code=classification.fatal_error_code,
306
- no_progress_count=classification.no_progress_count,
307
- progress_fingerprint=classification.progress_fingerprint,
308
- )
309
- if not update.ok:
310
- raise RuntimeError(update.text)
311
- new_status = _status_from_update(update)
312
- commit_final_bookkeeping(paths.project_root, family, item_id, new_status)
313
- return new_status
569
+ def _save_failure_wip(execution_root: Path, family: RunnerFamily, item_id: str, status: str, worktree_runtime) -> None:
570
+ if worktree_runtime is not None:
571
+ worktree_save_wip(worktree_runtime)
572
+ else:
573
+ commit_wip_changes(execution_root, family, item_id, status)
314
574
 
315
575
 
576
+ def _record_setup_failure(
577
+ family: RunnerFamily,
578
+ invocation: RunnerInvocation,
579
+ item_id: str,
580
+ project_root: Path,
581
+ status_root: Path,
582
+ base_branch: str,
583
+ branch_name: str,
584
+ *,
585
+ worktree_runtime=None,
586
+ ) -> str:
587
+ """Record setup-time infra failures on a task branch when one exists."""
588
+ if status_root.resolve() == project_root.resolve() and current_branch(project_root) != branch_name:
589
+ return "infra_error"
590
+ status_family = _family_for_execution_root(family, project_root, status_root)
591
+ status_invocation = _invocation_for_execution_family(invocation, status_family)
592
+ start = start_item(status_family, status_invocation, item_id, status_root)
593
+ if start.ok:
594
+ commit_pre_branch_bookkeeping(status_root, status_family, item_id)
595
+ update = update_item(
596
+ status_family,
597
+ status_invocation,
598
+ item_id,
599
+ "infra_error",
600
+ "",
601
+ status_root,
602
+ active_dev_branch=branch_name,
603
+ base_branch=base_branch,
604
+ )
605
+ new_status = _status_from_update(update) if update.ok else "infra_error"
606
+ if update.ok:
607
+ commit_final_bookkeeping(status_root, status_family, item_id, new_status)
608
+ _save_failure_wip(status_root, family, item_id, new_status, worktree_runtime)
609
+ if worktree_runtime is not None:
610
+ guarded_worktree_remove(worktree_runtime, delete_branch=False)
611
+ elif current_branch(project_root) == branch_name:
612
+ branch_ensure_return(project_root, base_branch, branch_name)
613
+ return new_status
316
614
 
317
615
  def _test_cli(kind: str, paths) -> CommandResult:
318
616
  """Report the AI CLI resolution used by Python runner commands."""
@@ -340,6 +638,52 @@ def _use_worktree_for_family(env: RunnerEnvironment, family: RunnerFamily) -> bo
340
638
  return env.use_worktree and family.kind in {"feature", "bugfix"}
341
639
 
342
640
 
641
+ def _family_for_execution_root(family: RunnerFamily, project_root: Path, execution_root: Path) -> RunnerFamily:
642
+ """Return family paths rooted in the task execution checkout."""
643
+ if execution_root.resolve() == project_root.resolve():
644
+ return family
645
+ return RunnerFamily(
646
+ kind=family.kind,
647
+ id_prefix=family.id_prefix,
648
+ list_key=family.list_key,
649
+ list_arg=family.list_arg,
650
+ item_id_arg=family.item_id_arg,
651
+ state_dir=_path_in_execution_root(family.state_dir, project_root, execution_root),
652
+ plan_path=_path_in_execution_root(family.plan_path, project_root, execution_root),
653
+ updater_script=family.updater_script,
654
+ prompt_script=family.prompt_script,
655
+ branch_prefix=family.branch_prefix,
656
+ artifact_root_name=family.artifact_root_name,
657
+ )
658
+
659
+
660
+ def _invocation_for_execution_family(invocation: RunnerInvocation, family: RunnerFamily) -> RunnerInvocation:
661
+ return RunnerInvocation(
662
+ action=invocation.action,
663
+ list_path=family.plan_path,
664
+ item_id=invocation.item_id,
665
+ item_filter=invocation.item_filter,
666
+ max_retries=invocation.max_retries,
667
+ max_infra_retries=invocation.max_infra_retries,
668
+ mode=invocation.mode,
669
+ critic=invocation.critic,
670
+ dry_run=invocation.dry_run,
671
+ clean=invocation.clean,
672
+ no_reset=invocation.no_reset,
673
+ resume_phase=invocation.resume_phase,
674
+ help_requested=invocation.help_requested,
675
+ unknown_args=invocation.unknown_args,
676
+ raw_args=invocation.raw_args,
677
+ )
678
+
679
+
680
+ def _path_in_execution_root(path: Path, project_root: Path, execution_root: Path) -> Path:
681
+ try:
682
+ return execution_root / path.resolve().relative_to(project_root.resolve())
683
+ except ValueError:
684
+ return path
685
+
686
+
343
687
  def _write_session_status(
344
688
  session_paths: SessionPaths,
345
689
  *,
@@ -380,8 +724,7 @@ def _status_from_update(update_result) -> str:
380
724
 
381
725
 
382
726
  def _default_branch_name(family: RunnerFamily, item_id: str) -> str:
383
- stamp = time.strftime("%Y%m%d%H%M")
384
- return f"{family.branch_prefix}/{item_id}-{stamp}"
727
+ return f"{family.branch_prefix}/{item_id}-{time.strftime('%Y%m%d%H%M%S')}"
385
728
 
386
729
 
387
730
  def _session_id(item_id: str) -> str: