prizmkit 1.1.106 → 1.1.108

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 (85) hide show
  1. package/bin/create-prizmkit.js +4 -0
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
  4. package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
  5. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +0 -1
  6. package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
  8. package/bundled/dev-pipeline/prizmkit_runtime/runner_classification.py +56 -5
  9. package/bundled/dev-pipeline/prizmkit_runtime/runner_models.py +0 -8
  10. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +11 -1
  11. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +298 -63
  12. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +243 -37
  13. package/bundled/dev-pipeline/scripts/continuation.py +0 -5
  14. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +10 -82
  15. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +7 -64
  16. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +40 -63
  17. package/bundled/dev-pipeline/scripts/utils.py +123 -0
  18. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  19. package/bundled/dev-pipeline/templates/agent-prompts/reviewer-review.md +5 -6
  20. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  21. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +36 -35
  22. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +53 -80
  23. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +43 -78
  24. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +5 -17
  25. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +6 -18
  26. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  27. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -14
  28. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  29. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +0 -7
  30. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +0 -7
  31. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +0 -7
  32. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  33. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  34. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  35. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +0 -2
  36. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  37. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +6 -8
  38. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +3 -7
  39. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +4 -16
  40. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +4 -17
  41. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  42. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +2 -9
  43. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +1 -8
  44. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  45. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +3 -10
  46. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +3 -10
  47. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +2 -9
  48. package/bundled/dev-pipeline/templates/sections/phase0-init.md +0 -7
  49. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  50. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  51. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +395 -21
  52. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +50 -14
  53. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +154 -2
  54. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +431 -3
  55. package/bundled/dev-pipeline/tests/test_unified_cli.py +857 -5
  56. package/bundled/skills/_metadata.json +4 -4
  57. package/bundled/skills/app-planner/SKILL.md +1 -1
  58. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  59. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  60. package/bundled/skills/bug-planner/SKILL.md +101 -15
  61. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  62. package/bundled/skills/feature-planner/SKILL.md +87 -8
  63. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  64. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  65. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  66. package/bundled/skills/prizmkit-code-review/SKILL.md +12 -13
  67. package/bundled/skills/prizmkit-code-review/references/reviewer-agent-prompt.md +10 -10
  68. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  69. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  70. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  71. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  72. package/bundled/templates/project-memory-template.md +1 -1
  73. package/package.json +1 -1
  74. package/src/ai-cli-launch.js +194 -0
  75. package/src/config.js +26 -14
  76. package/src/index.js +11 -44
  77. package/src/platforms.js +10 -3
  78. package/src/prompts.js +60 -2
  79. package/src/scaffold.js +26 -14
  80. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  81. package/bundled/dev-pipeline/templates/agent-prompts/dev-fix.md +0 -7
  82. package/bundled/dev-pipeline/templates/agent-prompts/dev-resume.md +0 -5
  83. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  84. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
  85. package/bundled/skills/prizmkit-code-review/references/dev-agent-prompt.md +0 -30
@@ -46,6 +46,8 @@ program
46
46
  .option('--no-open-cli-auto-download', 'Skip auto-download, show manual steps only')
47
47
  .option('--rules <preset>', 'Rules preset: recommended, minimal, or none', 'recommended')
48
48
  .option('--ai-cli <command>', 'AI CLI executable command (e.g. cbc, claude, codex)')
49
+ .option('--headless-prompt-arg <arg>', 'Prompt injection argv token for a custom AI CLI (e.g. -p or -i)')
50
+ .option('--headless-extra-args <args>', 'Additional headless argv tokens for a custom AI CLI')
49
51
  .option('--external-skills <names>', 'Comma-separated external skill names to install (e.g. find-skills,impeccable)')
50
52
  .option('--non-interactive', 'Skip interactive prompts (requires --platform)')
51
53
  .option('--dry-run', 'Show what would be created without making changes')
@@ -95,6 +97,8 @@ program
95
97
  .option('--skills <suite>', 'Skill suite: core, minimal, or recommended:<type>')
96
98
  .option('--rules <preset>', 'Rules preset: recommended, minimal, or none')
97
99
  .option('--ai-cli <command>', 'AI CLI executable command (e.g. cbc, claude, codex)')
100
+ .option('--headless-prompt-arg <arg>', 'Prompt injection argv token for a custom AI CLI (e.g. -p or -i)')
101
+ .option('--headless-extra-args <args>', 'Additional headless argv tokens for a custom AI CLI')
98
102
  .option('--team', 'Enable multi-agent team mode')
99
103
  .option('--no-team', 'Disable multi-agent team mode')
100
104
  .option('--pipeline', 'Enable dev-pipeline')
@@ -1,5 +1,5 @@
1
1
  {
2
- "frameworkVersion": "1.1.106",
3
- "bundledAt": "2026-07-07T02:54:30.507Z",
4
- "bundledFrom": "8e4fe49"
2
+ "frameworkVersion": "1.1.108",
3
+ "bundledAt": "2026-07-08T01:30:44.419Z",
4
+ "bundledFrom": "d04466a"
5
5
  }
@@ -4,11 +4,13 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import shlex
7
8
  import shutil
8
9
  from collections.abc import Mapping
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
 
13
+ from .launch_profiles import HeadlessLaunchProfile
12
14
  from .paths import RuntimePaths
13
15
 
14
16
 
@@ -33,6 +35,7 @@ class AIClientResolution:
33
35
  source: str
34
36
  precedence: tuple[str, ...]
35
37
  notes: tuple[str, ...] = ()
38
+ launch_profile: HeadlessLaunchProfile | None = None
36
39
 
37
40
 
38
41
  @dataclass(frozen=True)
@@ -65,6 +68,7 @@ class RuntimeConfig:
65
68
  AI_CLIENT_PRECEDENCE = (
66
69
  "AI_CLI",
67
70
  ".prizmkit/config.json:ai_cli",
71
+ ".prizmkit/config.json:ai_cli_launch",
68
72
  ".prizmkit/manifest.json:platform",
69
73
  ".prizmkit/config.json:platform",
70
74
  "CODEBUDDY_CLI",
@@ -137,13 +141,18 @@ def _int_from_env(env: Mapping[str, str], name: str, default: int) -> int:
137
141
  return default
138
142
 
139
143
 
140
- def _read_json_value(path: Path, key: str) -> str | None:
144
+ def _read_json_object(path: Path) -> dict[str, object]:
141
145
  if not path.is_file():
142
- return None
146
+ return {}
143
147
  try:
144
148
  data = json.loads(path.read_text(encoding="utf-8"))
145
149
  except (OSError, json.JSONDecodeError):
146
- return None
150
+ return {}
151
+ return data if isinstance(data, dict) else {}
152
+
153
+
154
+ def _read_json_value(path: Path, key: str) -> str | None:
155
+ data = _read_json_object(path)
147
156
  value = data.get(key)
148
157
  return value if isinstance(value, str) and value.strip() else None
149
158
 
@@ -170,13 +179,16 @@ def _cli_names_for_platform(platform: str | None) -> tuple[str, ...]:
170
179
  def _known_platform_from_cli(cli_command: str | None) -> str | None:
171
180
  if not cli_command:
172
181
  return None
173
- token = cli_command.split()[0].strip("'\"")
182
+ try:
183
+ token = shlex.split(cli_command)[0]
184
+ except ValueError:
185
+ token = cli_command.split()[0].strip("'\"")
174
186
  name = Path(token).name.lower()
175
- if "claude" in name:
187
+ if name == "claude":
176
188
  return "claude"
177
- if "codex" in name:
189
+ if name == "codex":
178
190
  return "codex"
179
- if "cbc" in name or "codebuddy" in name:
191
+ if name in {"cbc", "codebuddy"}:
180
192
  return "codebuddy"
181
193
  return None
182
194
 
@@ -204,24 +216,73 @@ def _expected_cli_text(platform: str | None) -> str:
204
216
  return ", ".join(_cli_names_for_platform(platform))
205
217
 
206
218
 
219
+ def _normalize_extra_args(value: object) -> tuple[str, ...]:
220
+ if value is None:
221
+ return ()
222
+ if isinstance(value, list):
223
+ if not all(isinstance(item, str) for item in value):
224
+ raise RuntimeConfigError("Custom AI CLI extra arguments must be strings.")
225
+ return tuple(value)
226
+ if isinstance(value, str):
227
+ if not value.strip():
228
+ return ()
229
+ try:
230
+ return tuple(shlex.split(value))
231
+ except ValueError as exc:
232
+ raise RuntimeConfigError(f"Invalid custom AI CLI extra arguments: {exc}") from exc
233
+ raise RuntimeConfigError("Custom AI CLI extra arguments must be an array of argv tokens.")
234
+
235
+
236
+ def _one_token(value: object, label: str) -> str:
237
+ text = value.strip() if isinstance(value, str) else ""
238
+ if not text:
239
+ raise RuntimeConfigError("Custom AI CLI requires both executable and prompt injection argument.")
240
+ try:
241
+ tokens = shlex.split(text)
242
+ except ValueError as exc:
243
+ raise RuntimeConfigError(f"Invalid custom AI CLI {label}: {exc}") from exc
244
+ if len(tokens) != 1 or not tokens[0]:
245
+ raise RuntimeConfigError(f"Custom AI CLI {label} must be exactly one argv token.")
246
+ return tokens[0]
247
+
248
+
249
+ def _custom_launch_profile(config_data: Mapping[str, object], config_ai_cli: str | None) -> HeadlessLaunchProfile | None:
250
+ launch = config_data.get("ai_cli_launch")
251
+ custom_cli = bool(config_ai_cli) and _known_platform_from_cli(config_ai_cli) is None
252
+ if not custom_cli:
253
+ return None
254
+ if launch is None:
255
+ raise RuntimeConfigError("Custom AI CLI requires both executable and prompt injection argument in .prizmkit/config.json:ai_cli_launch.prompt_arg.")
256
+ if not isinstance(launch, Mapping):
257
+ raise RuntimeConfigError(".prizmkit/config.json:ai_cli_launch must be an object.")
258
+ if launch.get("profile") != "custom":
259
+ raise RuntimeConfigError("Custom AI CLI requires .prizmkit/config.json:ai_cli_launch.profile to be 'custom'.")
260
+ prompt_arg = _one_token(launch.get("prompt_arg"), "prompt injection argument")
261
+ return HeadlessLaunchProfile(
262
+ profile="custom",
263
+ prompt_arg=prompt_arg,
264
+ extra_args=_normalize_extra_args(launch.get("extra_args")),
265
+ )
266
+
267
+
207
268
  def _installed_platform_cli(
208
269
  paths: RuntimePaths,
209
270
  env: Mapping[str, str],
210
271
  ) -> tuple[str | None, str | None]:
211
272
  if (
212
273
  (paths.project_root / ".codex" / "agents" / "prizm-dev-team-dev.toml").is_file()
213
- or (paths.project_root / ".agents" / "skills" / "prizm-kit" / "SKILL.md").is_file()
274
+ or (paths.project_root / ".agents" / "skills" / "prizmkit" / "SKILL.md").is_file()
214
275
  ):
215
276
  resolved = _resolve_cli_name("codex", env)
216
277
  if resolved:
217
278
  return resolved, "installed-platform:codex"
218
- if (paths.project_root / ".claude" / "commands" / "prizm-kit.md").is_file():
279
+ if (paths.project_root / ".claude" / "commands" / "prizmkit.md").is_file():
219
280
  resolved = _resolve_cli_name("claude", env)
220
281
  if resolved:
221
282
  return resolved, "installed-platform:claude"
222
283
  if (
223
284
  (paths.project_root / ".codebuddy" / "agents" / "prizm-dev-team-dev.md").is_file()
224
- or (paths.project_root / ".codebuddy" / "skills" / "prizm-kit" / "SKILL.md").is_file()
285
+ or (paths.project_root / ".codebuddy" / "skills" / "prizmkit" / "SKILL.md").is_file()
225
286
  ):
226
287
  resolved = _resolve_cli_name("cbc", env)
227
288
  if resolved:
@@ -255,8 +316,12 @@ def resolve_ai_client(
255
316
 
256
317
 
257
318
  def _resolve_ai_client(paths: RuntimePaths, env: Mapping[str, str]) -> AIClientResolution:
258
- config_ai_cli = _read_json_value(paths.prizmkit_dir / "config.json", "ai_cli")
259
- config_platform = _read_json_value(paths.prizmkit_dir / "config.json", "platform")
319
+ config_path = paths.prizmkit_dir / "config.json"
320
+ config_data = _read_json_object(config_path)
321
+ config_ai_cli_value = config_data.get("ai_cli")
322
+ config_ai_cli = config_ai_cli_value if isinstance(config_ai_cli_value, str) and config_ai_cli_value.strip() else None
323
+ config_platform_value = config_data.get("platform")
324
+ config_platform = config_platform_value if isinstance(config_platform_value, str) and config_platform_value.strip() else None
260
325
  manifest_platform = _read_json_value(paths.prizmkit_dir / "manifest.json", "platform")
261
326
 
262
327
  explicit_command = env.get("AI_CLI")
@@ -269,11 +334,13 @@ def _resolve_ai_client(paths: RuntimePaths, env: Mapping[str, str]) -> AIClientR
269
334
  )
270
335
 
271
336
  if config_ai_cli:
337
+ custom_launch = _custom_launch_profile(config_data, config_ai_cli)
272
338
  return AIClientResolution(
273
339
  command=config_ai_cli,
274
- platform=_project_platform_for_cli(config_ai_cli, env, manifest_platform, config_platform),
275
- source="prizmkit-config:ai_cli",
340
+ platform="custom" if custom_launch is not None else _project_platform_for_cli(config_ai_cli, env, manifest_platform, config_platform),
341
+ source="prizmkit-config:ai_cli_launch" if custom_launch is not None else "prizmkit-config:ai_cli",
276
342
  precedence=AI_CLIENT_PRECEDENCE,
343
+ launch_profile=custom_launch,
277
344
  )
278
345
 
279
346
  for platform, source, configured_label in (
@@ -100,11 +100,16 @@ def observe_heartbeat_state(
100
100
  or (progress_signature and progress_signature != previous.progress_signature)
101
101
  or (child_signature and child_signature != previous.child_activity_signature)
102
102
  )
103
- stale_seconds = 0 if progressed else (previous.stale_seconds + config.interval_seconds if previous else 0)
103
+ if progressed:
104
+ last_seen_epoch = now
105
+ stale_seconds = 0.0
106
+ else:
107
+ last_seen_epoch = previous.last_seen_epoch if previous and previous.last_seen_epoch is not None else now
108
+ stale_seconds = max(0.0, now - last_seen_epoch)
104
109
  stale = bool(config.stale_after_seconds > 0 and stale_seconds >= config.stale_after_seconds)
105
110
  details = "progress" if progressed else "no progress"
106
111
  return HeartbeatState(
107
- last_seen_epoch=now if progressed else (previous.last_seen_epoch if previous else now),
112
+ last_seen_epoch=last_seen_epoch,
108
113
  stale=stale,
109
114
  details=details,
110
115
  stale_seconds=stale_seconds,
@@ -118,6 +123,11 @@ def _utc_marker_time() -> str:
118
123
  return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
119
124
 
120
125
 
126
+ def _marker_number(value: float) -> int | float:
127
+ normalized = max(0.0, float(value))
128
+ return int(normalized) if normalized.is_integer() else normalized
129
+
130
+
121
131
  def _progress_fatal_error_code(path: Path | None) -> str:
122
132
  if path is None or not path.is_file():
123
133
  return ""
@@ -142,8 +152,8 @@ def write_stale_marker(
142
152
  payload = {
143
153
  "killed_at": _utc_marker_time(),
144
154
  "reason": reason,
145
- "stale_seconds": int(stale_seconds),
146
- "threshold": int(threshold),
155
+ "stale_seconds": _marker_number(stale_seconds),
156
+ "threshold": _marker_number(threshold),
147
157
  }
148
158
  if fatal_error_code:
149
159
  payload["fatal_error_code"] = fatal_error_code
@@ -164,8 +174,8 @@ def write_fatal_marker(
164
174
  "killed_at": _utc_marker_time(),
165
175
  "reason": fatal_error_code,
166
176
  "fatal_error_code": fatal_error_code,
167
- "stale_seconds": int(stale_seconds),
168
- "threshold": int(threshold),
177
+ "stale_seconds": _marker_number(stale_seconds),
178
+ "threshold": _marker_number(threshold),
169
179
  }
170
180
  fatal_path = marker_dir / "fatal-error.json"
171
181
  stale_path = marker_dir / "stale-kill.json"
@@ -231,13 +241,14 @@ class HeartbeatMonitor:
231
241
  self._killed = True
232
242
  self._reason = fatal_error_code
233
243
  break
234
- if self._state.stale and self.config.kill_after_seconds > 0:
244
+ should_kill = bool(self.config.kill_after_seconds > 0 and self._state.stale_seconds >= self.config.kill_after_seconds)
245
+ if should_kill:
235
246
  marker_dir = self.config.marker_dir or (self.config.session_log.parent if self.config.session_log else self.config.heartbeat_file.parent)
236
247
  self._marker_path = write_stale_marker(
237
248
  marker_dir,
238
249
  reason="stale_session",
239
250
  stale_seconds=self._state.stale_seconds,
240
- threshold=self.config.stale_after_seconds,
251
+ threshold=self.config.kill_after_seconds,
241
252
  )
242
253
  terminate_process_tree(self.handle, "stale_session", grace_seconds=self.config.grace_seconds)
243
254
  self._killed = True
@@ -24,7 +24,6 @@ UTILITY_ENTRYPOINTS = {
24
24
  "init-refactor-pipeline": "scripts/init-refactor-pipeline.py",
25
25
  "parse-stream-progress": "scripts/parse-stream-progress.py",
26
26
  "patch-completion-notes": "scripts/patch-completion-notes.py",
27
- "prizmkit-test-gate": "scripts/prizmkit-test-gate.py",
28
27
  "update-bug-status": "scripts/update-bug-status.py",
29
28
  "update-checkpoint": "scripts/update-checkpoint.py",
30
29
  "update-feature-status": "scripts/update-feature-status.py",
@@ -0,0 +1,18 @@
1
+ """Provider-neutral headless launch profile models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class HeadlessLaunchProfile:
10
+ """Custom AI CLI headless prompt injection contract."""
11
+
12
+ profile: str
13
+ prompt_arg: str
14
+ extra_args: tuple[str, ...] = ()
15
+
16
+ @property
17
+ def is_custom(self) -> bool:
18
+ return self.profile == "custom"
@@ -67,7 +67,21 @@ def commit_bookkeeping_changes(
67
67
  if not paths:
68
68
  return BookkeepingResult(attempted=False, committed=False)
69
69
  run_git_command(project_root, ("add", "-A", "--", *paths))
70
- result = run_git_command(project_root, ("commit", "--no-verify", "-m", message))
70
+ result = run_git_command(project_root, ("commit", "--no-verify", "-m", message, "--", *paths))
71
+ return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
72
+
73
+
74
+ def commit_preflight_ready_changes(project_root: Path, item_id: str) -> BookkeepingResult:
75
+ """Preserve visible pre-existing changes before task branch setup."""
76
+ if not git_status_safe(project_root).strip():
77
+ return BookkeepingResult(attempted=False, committed=False)
78
+ add_result = run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
79
+ if add_result.return_code != 0:
80
+ return BookkeepingResult(attempted=True, committed=False, result=add_result)
81
+ result = run_git_command(
82
+ project_root,
83
+ ("commit", "--no-verify", "-m", f"chore: ready for {item_id}", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES),
84
+ )
71
85
  return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
72
86
 
73
87
 
@@ -84,7 +98,10 @@ def commit_task_branch_changes(
84
98
  if not git_status_safe(project_root).strip():
85
99
  return BookkeepingResult(attempted=False, committed=False)
86
100
  run_git_command(project_root, ("add", "-A", "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES))
87
- result = run_git_command(project_root, ("commit", "--no-verify", "-m", message))
101
+ result = run_git_command(
102
+ project_root,
103
+ ("commit", "--no-verify", "-m", message, "--", ".", *HIDDEN_TOOL_WORKTREE_EXCLUDES),
104
+ )
88
105
  return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
89
106
 
90
107
 
@@ -107,6 +124,9 @@ def commit_wip_changes(
107
124
  f"wip({item_id}): preserve {family.kind} {status} state",
108
125
  "-m",
109
126
  "Pipeline preserved task branch work without merging it to the base branch.",
127
+ "--",
128
+ ".",
129
+ *HIDDEN_TOOL_WORKTREE_EXCLUDES,
110
130
  ),
111
131
  )
112
132
  return BookkeepingResult(attempted=True, committed=result.return_code == 0, result=result)
@@ -29,6 +29,10 @@ INFRA_MARKERS = (
29
29
  "authentication",
30
30
  "unauthorized",
31
31
  "api error",
32
+ "quota",
33
+ "usage limit",
34
+ "credit balance",
35
+ "billing",
32
36
  )
33
37
 
34
38
 
@@ -52,6 +56,13 @@ def classify_session(
52
56
  progress_fingerprint=fingerprint,
53
57
  )
54
58
 
59
+ if _has_late_runtime_error_after_completion(result, prompt, has_commit_since_base):
60
+ return SessionClassification(
61
+ "success",
62
+ reason="completed_checkpoint_with_late_runtime_error",
63
+ progress_fingerprint=fingerprint,
64
+ )
65
+
55
66
  if _is_context_overflow(result):
56
67
  if has_commit_since_base:
57
68
  return SessionClassification("success", reason="commit_since_base", progress_fingerprint=fingerprint)
@@ -121,6 +132,16 @@ def _is_terminal_success(result: AISessionResult) -> bool:
121
132
  )
122
133
 
123
134
 
135
+ def _has_late_runtime_error_after_completion(
136
+ result: AISessionResult,
137
+ prompt: PromptGenerationResult | None,
138
+ has_commit_since_base: bool,
139
+ ) -> bool:
140
+ if not has_commit_since_base or not _has_completed_workflow_artifact(prompt):
141
+ return False
142
+ return _is_context_overflow(result) or _is_infra_error(result)
143
+
144
+
124
145
  def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> bool:
125
146
  if prompt is None:
126
147
  return False
@@ -131,11 +152,32 @@ def _has_completed_workflow_artifact(prompt: PromptGenerationResult | None) -> b
131
152
  data = {}
132
153
  if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
133
154
  return True
134
- if prompt.artifact_path and (prompt.artifact_path / "completion-summary.json").is_file():
155
+ if prompt.artifact_path and has_completed_artifact_path(prompt.artifact_path):
135
156
  return True
136
157
  return False
137
158
 
138
159
 
160
+ def has_branch_completion_evidence(project_root: Path, base_branch: str, working_branch: str, artifact_path: Path | None) -> bool:
161
+ """Return true when a recorded task branch already has completed work to merge."""
162
+ if not base_branch or not working_branch or not has_completed_artifact_path(artifact_path):
163
+ return False
164
+ return _has_commit_range(project_root, base_branch, working_branch)
165
+
166
+
167
+ def has_completed_artifact_path(artifact_path: Path | None) -> bool:
168
+ if artifact_path is None:
169
+ return False
170
+ checkpoint = artifact_path / "workflow-checkpoint.json"
171
+ if checkpoint.is_file():
172
+ try:
173
+ data = json.loads(checkpoint.read_text(encoding="utf-8"))
174
+ except (OSError, json.JSONDecodeError):
175
+ data = {}
176
+ if isinstance(data, dict) and _checkpoint_cursor(data) == "complete":
177
+ return True
178
+ return (artifact_path / "completion-summary.json").is_file()
179
+
180
+
139
181
  def _next_no_progress_count(previous: object | None, current: object, count: int) -> int:
140
182
  if previous and previous == current:
141
183
  return max(0, count) + 1
@@ -161,11 +203,20 @@ def _is_infra_error(result: AISessionResult) -> bool:
161
203
  return bool(result.fatal_error_code and result.fatal_error_code != CONTEXT_OVERFLOW) or any(marker in text for marker in INFRA_MARKERS)
162
204
 
163
205
 
164
- def _has_commit_since(project_root: Path, base_head: str) -> bool:
165
- if not base_head:
206
+ def _has_commit_range(project_root: Path, start_ref: str, end_ref: str = "HEAD") -> bool:
207
+ if not start_ref or not end_ref:
166
208
  return False
167
- current = run_git_command(project_root, ("rev-parse", "HEAD")).stdout.strip()
168
- return bool(current and current != base_head)
209
+ result = run_git_command(project_root, ("rev-list", "--count", f"{start_ref}..{end_ref}"))
210
+ if result.return_code != 0:
211
+ return False
212
+ try:
213
+ return int(result.stdout.strip() or "0") > 0
214
+ except ValueError:
215
+ return False
216
+
217
+
218
+ def _has_commit_since(project_root: Path, base_head: str) -> bool:
219
+ return _has_commit_range(project_root, base_head, "HEAD")
169
220
 
170
221
 
171
222
  def _has_workspace_changes(project_root: Path) -> bool:
@@ -45,7 +45,6 @@ class RunnerEnvironment:
45
45
  heartbeat_interval_seconds: int = 30
46
46
  heartbeat_stale_threshold_seconds: int = 600
47
47
  stale_kill_threshold_seconds: int = 600
48
- max_log_size: int | None = None
49
48
  max_retries: int = 3
50
49
  strict_behavior_check: bool = True
51
50
 
@@ -67,7 +66,6 @@ class RunnerEnvironment:
67
66
  _int_value(values, "HEARTBEAT_TIMEOUT_SECONDS", 600),
68
67
  ),
69
68
  stale_kill_threshold_seconds=_int_value(values, "STALE_KILL_THRESHOLD", 600),
70
- max_log_size=_optional_int(values.get("MAX_LOG_SIZE")),
71
69
  max_retries=max(0, _int_value(values, "MAX_RETRIES", 3)),
72
70
  strict_behavior_check=not _falsey(values.get("STRICT_BEHAVIOR_CHECK")),
73
71
  )
@@ -349,9 +347,3 @@ def _safe_int(value: str | None, default: int) -> int:
349
347
 
350
348
  def _int_value(values: Mapping[str, str], name: str, default: int) -> int:
351
349
  return _safe_int(values.get(name), default)
352
-
353
-
354
- def _optional_int(value: str | None) -> int | None:
355
- if value is None or str(value).strip() == "":
356
- return None
357
- return _safe_int(value, 0)
@@ -12,7 +12,7 @@ from pathlib import Path
12
12
  from .config import load_runtime_config
13
13
  from .paths import RuntimePaths
14
14
  from .runner_models import RunnerEnvironment, SessionPaths
15
- from .sessions import AISessionConfig, AISessionLauncher
15
+ from .sessions import AISessionConfig, AISessionLauncher, detect_stream_json_support
16
16
 
17
17
 
18
18
  @dataclass(frozen=True)
@@ -122,6 +122,9 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
122
122
  if not config.ai_client.command:
123
123
  return 1, "", "No supported AI CLI command was found for recovery\n"
124
124
  env = RunnerEnvironment.from_env()
125
+ stream_json = False
126
+ if env.live_output and getattr(config.ai_client, "launch_profile", None) is None:
127
+ stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
125
128
  launcher = AISessionLauncher(
126
129
  AISessionConfig(
127
130
  cli_command=config.ai_client.command,
@@ -134,7 +137,14 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
134
137
  heartbeat_path=session_paths.heartbeat_json,
135
138
  effort=config.effort,
136
139
  verbose=env.verbose,
140
+ live_output=env.live_output,
141
+ use_stream_json=stream_json,
142
+ launch_profile=getattr(config.ai_client, "launch_profile", None),
137
143
  stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
144
+ heartbeat_stale_threshold_seconds=env.heartbeat_stale_threshold_seconds,
145
+ live_progress_interval_seconds=env.heartbeat_interval_seconds,
146
+ live_banner=False,
147
+ suppress_stream_output=True,
138
148
  )
139
149
  )
140
150
  result = launcher.run()