prizmkit 1.1.107 → 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 (78) 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_recovery.py +3 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +20 -1
  10. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +151 -30
  11. package/bundled/dev-pipeline/scripts/continuation.py +0 -1
  12. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +6 -46
  13. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +7 -34
  14. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +40 -33
  15. package/bundled/dev-pipeline/scripts/utils.py +123 -0
  16. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  17. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  18. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +8 -7
  19. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +25 -36
  20. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +16 -31
  21. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +4 -16
  22. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +3 -15
  23. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  24. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -13
  25. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  26. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +0 -7
  27. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +0 -7
  28. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +0 -7
  29. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  30. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  31. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  32. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +0 -2
  33. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  34. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +6 -8
  35. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +3 -7
  36. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -7
  37. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +0 -7
  38. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  39. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +2 -9
  40. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +1 -8
  41. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  42. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +0 -7
  43. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +0 -7
  44. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +2 -9
  45. package/bundled/dev-pipeline/templates/sections/phase0-init.md +0 -7
  46. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  47. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  48. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +350 -23
  49. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +36 -17
  50. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +117 -14
  51. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +79 -2
  52. package/bundled/dev-pipeline/tests/test_unified_cli.py +658 -13
  53. package/bundled/skills/_metadata.json +4 -4
  54. package/bundled/skills/app-planner/SKILL.md +1 -1
  55. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  56. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  57. package/bundled/skills/bug-planner/SKILL.md +101 -15
  58. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  59. package/bundled/skills/feature-planner/SKILL.md +87 -8
  60. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  61. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  62. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  63. package/bundled/skills/prizmkit-code-review/SKILL.md +1 -1
  64. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  65. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  66. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  67. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  68. package/bundled/templates/project-memory-template.md +1 -1
  69. package/package.json +1 -1
  70. package/src/ai-cli-launch.js +194 -0
  71. package/src/config.js +26 -14
  72. package/src/index.js +11 -44
  73. package/src/platforms.js +10 -3
  74. package/src/prompts.js +60 -2
  75. package/src/scaffold.js +26 -14
  76. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  77. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  78. package/bundled/dev-pipeline/tests/test_prizmkit_test_gate.py +0 -107
@@ -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.107",
3
- "bundledAt": "2026-07-07T11:02:06.367Z",
4
- "bundledFrom": "93afdd5"
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)
@@ -123,7 +123,7 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
123
123
  return 1, "", "No supported AI CLI command was found for recovery\n"
124
124
  env = RunnerEnvironment.from_env()
125
125
  stream_json = False
126
- if env.live_output:
126
+ if env.live_output and getattr(config.ai_client, "launch_profile", None) is None:
127
127
  stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
128
128
  launcher = AISessionLauncher(
129
129
  AISessionConfig(
@@ -139,7 +139,9 @@ def run_recovery(paths: RuntimePaths, legacy_args: tuple[str, ...] = ()) -> tupl
139
139
  verbose=env.verbose,
140
140
  live_output=env.live_output,
141
141
  use_stream_json=stream_json,
142
+ launch_profile=getattr(config.ai_client, "launch_profile", None),
142
143
  stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
144
+ heartbeat_stale_threshold_seconds=env.heartbeat_stale_threshold_seconds,
143
145
  live_progress_interval_seconds=env.heartbeat_interval_seconds,
144
146
  live_banner=False,
145
147
  suppress_stream_output=True,
@@ -29,6 +29,7 @@ from .gitops import (
29
29
  from .runner_bookkeeping import (
30
30
  commit_final_bookkeeping,
31
31
  commit_pre_branch_bookkeeping,
32
+ commit_preflight_ready_changes,
32
33
  commit_task_branch_changes,
33
34
  commit_wip_changes,
34
35
  )
@@ -389,6 +390,20 @@ def _process_item(
389
390
  execution_root = paths.project_root
390
391
 
391
392
  try:
393
+ preflight = commit_preflight_ready_changes(paths.project_root, item_id)
394
+ if preflight.committed:
395
+ _emit_info(f"Preflight: preserved existing user-visible changes before {family.kind} task {item_id}")
396
+ elif preflight.attempted:
397
+ detail = ""
398
+ if preflight.result is not None:
399
+ detail = (preflight.result.stderr or preflight.result.stdout).strip()
400
+ _emit_warning(f"Preflight: failed to preserve existing changes before {family.kind} task {item_id}")
401
+ if detail:
402
+ _emit_warning(detail.splitlines()[0][:500])
403
+ raise RuntimeError(f"Preflight dirty workspace preservation failed before {family.kind} task {item_id}")
404
+ else:
405
+ _emit_info(f"Preflight: no user-visible changes to preserve before {family.kind} task {item_id}")
406
+
392
407
  if use_worktree:
393
408
  worktree_runtime = worktree_runtime_context(
394
409
  branch_context,
@@ -499,7 +514,9 @@ def _process_item(
499
514
  if invocation.dry_run:
500
515
  return "dry_run"
501
516
  _emit_prompt_summary(prompt, session_id)
502
- stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
517
+ stream_json = False
518
+ if getattr(config.ai_client, "launch_profile", None) is None:
519
+ stream_json = detect_stream_json_support(config.ai_client.command, config.ai_client.platform).enabled
503
520
  base_head = run_git_command(execution_root, ("rev-parse", "HEAD")).stdout.strip()
504
521
  session_result = AISessionLauncher(
505
522
  AISessionConfig(
@@ -515,7 +532,9 @@ def _process_item(
515
532
  verbose=env.verbose,
516
533
  live_output=env.live_output,
517
534
  use_stream_json=stream_json,
535
+ launch_profile=getattr(config.ai_client, "launch_profile", None),
518
536
  stale_kill_threshold_seconds=env.stale_kill_threshold_seconds,
537
+ heartbeat_stale_threshold_seconds=env.heartbeat_stale_threshold_seconds,
519
538
  live_progress_interval_seconds=env.heartbeat_interval_seconds,
520
539
  live_banner=False,
521
540
  suppress_stream_output=True,