prizmkit 1.1.107 → 1.1.109

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 (86) hide show
  1. package/bin/create-prizmkit.js +4 -0
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/dev-pipeline/prizmkit_runtime/cli.py +20 -0
  4. package/bundled/dev-pipeline/prizmkit_runtime/commands.py +1 -0
  5. package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
  6. package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +21 -0
  7. package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
  8. package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +1 -1
  9. package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
  10. package/bundled/dev-pipeline/prizmkit_runtime/platform_detection.py +208 -0
  11. package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
  12. package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +3 -1
  13. package/bundled/dev-pipeline/prizmkit_runtime/runners.py +20 -1
  14. package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +508 -0
  15. package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +151 -30
  16. package/bundled/dev-pipeline/scripts/continuation.py +0 -1
  17. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +8 -46
  18. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +9 -34
  19. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +42 -33
  20. package/bundled/dev-pipeline/scripts/prizmkit-runtime-helper.py +27 -0
  21. package/bundled/dev-pipeline/scripts/utils.py +94 -0
  22. package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
  23. package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
  24. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +39 -65
  25. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +58 -96
  26. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +54 -95
  27. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -18
  28. package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -17
  29. package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
  30. package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -13
  31. package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
  32. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +31 -61
  33. package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +21 -37
  34. package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +22 -69
  35. package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
  36. package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
  37. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
  38. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -3
  39. package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
  40. package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +7 -9
  41. package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +4 -8
  42. package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -7
  43. package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +0 -7
  44. package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
  45. package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +3 -10
  46. package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +2 -9
  47. package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
  48. package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +1 -8
  49. package/bundled/dev-pipeline/templates/sections/phase-review-full.md +1 -8
  50. package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +9 -15
  51. package/bundled/dev-pipeline/templates/sections/phase0-init.md +1 -8
  52. package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
  53. package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
  54. package/bundled/dev-pipeline/tests/conftest.py +0 -1
  55. package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +392 -23
  56. package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +37 -17
  57. package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +118 -14
  58. package/bundled/dev-pipeline/tests/test_python_runner_parity.py +79 -2
  59. package/bundled/dev-pipeline/tests/test_runtime_helper.py +211 -0
  60. package/bundled/dev-pipeline/tests/test_unified_cli.py +663 -13
  61. package/bundled/skills/_metadata.json +4 -4
  62. package/bundled/skills/app-planner/SKILL.md +1 -1
  63. package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
  64. package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
  65. package/bundled/skills/bug-planner/SKILL.md +101 -15
  66. package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
  67. package/bundled/skills/feature-planner/SKILL.md +87 -8
  68. package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
  69. package/bundled/skills/feature-workflow/SKILL.md +100 -81
  70. package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
  71. package/bundled/skills/prizmkit-code-review/SKILL.md +1 -1
  72. package/bundled/skills/refactor-planner/SKILL.md +88 -7
  73. package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
  74. package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
  75. package/bundled/skills/refactor-workflow/SKILL.md +116 -57
  76. package/bundled/templates/project-memory-template.md +1 -1
  77. package/package.json +1 -1
  78. package/src/ai-cli-launch.js +194 -0
  79. package/src/config.js +26 -14
  80. package/src/index.js +11 -44
  81. package/src/platforms.js +10 -3
  82. package/src/prompts.js +60 -2
  83. package/src/scaffold.js +26 -14
  84. package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
  85. package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
  86. 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.109",
3
+ "bundledAt": "2026-07-08T05:29:47.417Z",
4
+ "bundledFrom": "814bf2f"
5
5
  }
@@ -9,6 +9,7 @@ from collections.abc import Sequence
9
9
  from .commands import CommandResult, handle_diagnostics, handle_runtime_command
10
10
  from .compat import UnsupportedPythonVersion, ensure_supported_python
11
11
  from .paths import resolve_runtime_paths
12
+ from .runtime_helper import render_result, run_helper_command
12
13
 
13
14
  DESCRIPTION = (
14
15
  "PrizmKit canonical Python runtime CLI.\n"
@@ -81,6 +82,16 @@ def _add_diagnostics_subparsers(subparsers: argparse._SubParsersAction[argparse.
81
82
  action_parser.set_defaults(handler="diagnostics", action=action)
82
83
 
83
84
 
85
+ def _add_helper_subparser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
86
+ helper_parser = subparsers.add_parser("helper", help="Cross-platform prompt runtime helper commands")
87
+ helper_parser.add_argument(
88
+ "args",
89
+ nargs=argparse.REMAINDER,
90
+ help="Arguments forwarded to prizmkit-runtime-helper.",
91
+ )
92
+ helper_parser.set_defaults(handler="helper", group="helper", action="run", target=None)
93
+
94
+
84
95
  _PIPELINE_GROUPS = {"feature", "bugfix", "refactor", "recovery"}
85
96
  _GLOBAL_OPTIONS_WITH_VALUES = {"--project-root", "--pipeline-root"}
86
97
 
@@ -150,6 +161,7 @@ def build_parser() -> argparse.ArgumentParser:
150
161
  _add_daemon_subparsers(subparsers)
151
162
  _add_status_subparsers(subparsers)
152
163
  _add_diagnostics_subparsers(subparsers)
164
+ _add_helper_subparser(subparsers)
153
165
  return parser
154
166
 
155
167
 
@@ -161,6 +173,14 @@ def _dispatch(args: argparse.Namespace) -> CommandResult:
161
173
  paths = _runtime_paths_from_args(args)
162
174
  if args.handler == "diagnostics":
163
175
  return handle_diagnostics(args.action, paths)
176
+ if args.handler == "helper":
177
+ helper_result = run_helper_command(tuple(getattr(args, "args", ()) or ()))
178
+ return CommandResult(
179
+ helper_result.exit_code,
180
+ "Python runtime helper",
181
+ "Cross-platform prompt runtime helper result.",
182
+ stdout=render_result(helper_result, json_output="--json" in tuple(getattr(args, "args", ()) or ())),
183
+ )
164
184
  return handle_runtime_command(
165
185
  args.group,
166
186
  args.action,
@@ -280,6 +280,7 @@ def diagnostics_entrypoints(paths: RuntimePaths | None = None) -> CommandResult:
280
280
  details: list[str] = [
281
281
  f"runtime:cli: {runtime_paths.pipeline_root / 'cli.py'}",
282
282
  f"runtime:module: {runtime_paths.pipeline_root / 'prizmkit_runtime'}",
283
+ f"runtime:helper-module: {runtime_paths.pipeline_root / 'prizmkit_runtime' / 'runtime_helper.py'}",
283
284
  ]
284
285
  for name, relative_path in sorted(UTILITY_ENTRYPOINTS.items()):
285
286
  invocation = describe_existing_entrypoint("utility", name, paths=runtime_paths)
@@ -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 (
@@ -33,6 +33,8 @@ HIDDEN_TOOL_WORKTREE_EXCLUDES = (
33
33
  ":(top,exclude).prizmkit/manifest.json",
34
34
  ":(top,exclude).prizmkit/dev-pipeline/scripts",
35
35
  ":(top,exclude,glob).prizmkit/dev-pipeline/scripts/**",
36
+ ":(top,exclude).prizmkit/dev-pipeline/prizmkit_runtime",
37
+ ":(top,exclude,glob).prizmkit/dev-pipeline/prizmkit_runtime/**",
36
38
  )
37
39
 
38
40
  WORKTREE_PLATFORM_SUPPORT_DIRS = (".claude", ".codebuddy", ".codex", ".agents")
@@ -242,6 +244,14 @@ def materialize_worktree_support_assets(runtime: WorktreeRuntimeContext) -> Work
242
244
  target = target_root / ".prizmkit" / "dev-pipeline" / "scripts"
243
245
  changed = _copy_support_entry(runtime_scripts, target)
244
246
  (copied if changed else existing).append(target)
247
+
248
+ runtime_package = _runtime_support_package_source(source_root)
249
+ if runtime_package is None:
250
+ missing.append(".prizmkit/dev-pipeline/prizmkit_runtime")
251
+ else:
252
+ target = target_root / ".prizmkit" / "dev-pipeline" / "prizmkit_runtime"
253
+ changed = _copy_support_entry(runtime_package, target)
254
+ (copied if changed else existing).append(target)
245
255
  except OSError as exc:
246
256
  return WorktreeSupportAssetsResult(False, tuple(copied), tuple(existing), tuple(missing), str(exc))
247
257
 
@@ -276,6 +286,17 @@ def _runtime_support_scripts_source(project_root: Path) -> Path | None:
276
286
  return None
277
287
 
278
288
 
289
+ def _runtime_support_package_source(project_root: Path) -> Path | None:
290
+ candidates = (
291
+ project_root / ".prizmkit" / "dev-pipeline" / "prizmkit_runtime",
292
+ project_root / "dev-pipeline" / "prizmkit_runtime",
293
+ )
294
+ for candidate in candidates:
295
+ if candidate.is_dir():
296
+ return candidate
297
+ return None
298
+
299
+
279
300
  def _copy_support_entry(source: Path, target: Path) -> bool:
280
301
  if source.is_symlink():
281
302
  if target.exists() or target.is_symlink():
@@ -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,7 @@ 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",
27
+ "prizmkit-runtime-helper": "scripts/prizmkit-runtime-helper.py",
28
28
  "update-bug-status": "scripts/update-bug-status.py",
29
29
  "update-checkpoint": "scripts/update-checkpoint.py",
30
30
  "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"
@@ -0,0 +1,208 @@
1
+ """Shared AI platform and skill path detection for prompt/runtime helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ SUPPORTED_PLATFORMS = ("claude", "codex", "codebuddy")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class PlatformResolution:
16
+ """Resolved AI platform plus the source that selected it."""
17
+
18
+ platform: str
19
+ source: str
20
+ notes: tuple[str, ...] = ()
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class SkillCandidate:
25
+ """One possible filesystem location for a skill."""
26
+
27
+ platform: str
28
+ scope: str
29
+ path: Path
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SkillResolution:
34
+ """Resolved skill lookup result."""
35
+
36
+ marker: str
37
+ platform: str
38
+ skill: str
39
+ path: Path | None
40
+ candidates: tuple[SkillCandidate, ...]
41
+
42
+
43
+ _PLATFORM_MARKERS = {
44
+ "claude": (".claude/agents", ".claude/commands", ".claude/skills"),
45
+ "codex": (".codex/agents", ".agents/skills"),
46
+ "codebuddy": (".codebuddy/agents", ".codebuddy/skills"),
47
+ }
48
+
49
+
50
+ _PLATFORM_SKILL_PATTERNS = {
51
+ "claude": (
52
+ ("project-command-file", ".claude/commands/{skill}.md"),
53
+ ("project-command-dir", ".claude/commands/{skill}"),
54
+ ("project-skill", ".claude/skills/{skill}"),
55
+ ("home-skill", "~/.claude/skills/{skill}"),
56
+ ),
57
+ "codex": (
58
+ ("project-skill", ".agents/skills/{skill}"),
59
+ ("project-codex-skill", ".codex/skills/{skill}"),
60
+ ),
61
+ "codebuddy": (
62
+ ("project-skill", ".codebuddy/skills/{skill}"),
63
+ ("home-skill", "~/.codebuddy/skills/{skill}"),
64
+ ("legacy-home-skill", "~/.cbc/skills/{skill}"),
65
+ ),
66
+ }
67
+
68
+
69
+ def read_json_object(path: str | Path) -> dict:
70
+ """Read a JSON object, returning an empty dict when unavailable."""
71
+ try:
72
+ with Path(path).expanduser().open("r", encoding="utf-8") as handle:
73
+ data = json.load(handle)
74
+ except (OSError, json.JSONDecodeError):
75
+ return {}
76
+ return data if isinstance(data, dict) else {}
77
+
78
+
79
+ def platform_from_command(command: object) -> str:
80
+ """Map an AI CLI command/config value to a PrizmKit platform name."""
81
+ cmd = str(command or "").strip().lower()
82
+ if not cmd:
83
+ return ""
84
+ base = os.path.basename(cmd.split()[0])
85
+ if base in ("claude", "claude-code") or "claude" in base:
86
+ return "claude"
87
+ if base in ("codex", "openai-codex") or "codex" in base:
88
+ return "codex"
89
+ if base in ("cbc", "codebuddy") or "codebuddy" in base:
90
+ return "codebuddy"
91
+ return ""
92
+
93
+
94
+ def valid_prompt_platform(value: object) -> str:
95
+ """Normalize a platform value for prompt path rendering."""
96
+ platform = str(value or "").strip().lower()
97
+ return platform if platform in SUPPORTED_PLATFORMS else ""
98
+
99
+
100
+ def resolve_platform(project_root: str | Path, env: Mapping[str, str] | None = None) -> PlatformResolution:
101
+ """Resolve the platform whose paths should appear in prompts/helper output.
102
+
103
+ Precedence mirrors prompt generation: explicit environment, active/configured
104
+ AI CLI, PrizmKit config/manifest platform, then filesystem markers.
105
+ """
106
+ environment = os.environ if env is None else env
107
+ root = Path(project_root).expanduser().resolve()
108
+
109
+ explicit = valid_prompt_platform(environment.get("PRIZMKIT_PLATFORM", ""))
110
+ if explicit:
111
+ return PlatformResolution(explicit, "environment:PRIZMKIT_PLATFORM")
112
+
113
+ for env_name in ("AI_CLI", "CLAUDECODE", "CLAUDE_CODE", "CODEBUDDY_CLI"):
114
+ detected = platform_from_command(environment.get(env_name, ""))
115
+ if detected:
116
+ return PlatformResolution(detected, f"environment:{env_name}")
117
+
118
+ config = read_json_object(root / ".prizmkit" / "config.json")
119
+ for key in ("ai_cli", "aiCli"):
120
+ detected = platform_from_command(config.get(key, ""))
121
+ if detected:
122
+ return PlatformResolution(detected, f"config:{key}")
123
+ detected = valid_prompt_platform(config.get("platform", ""))
124
+ if detected:
125
+ return PlatformResolution(detected, "config:platform")
126
+
127
+ manifest = read_json_object(root / ".prizmkit" / "manifest.json")
128
+ options = manifest.get("options", {}) if isinstance(manifest.get("options"), dict) else {}
129
+ for key, value in (("aiCli", manifest.get("aiCli", "")), ("options.aiCli", options.get("aiCli", ""))):
130
+ detected = platform_from_command(value)
131
+ if detected:
132
+ return PlatformResolution(detected, f"manifest:{key}")
133
+ detected = valid_prompt_platform(manifest.get("platform", ""))
134
+ if detected:
135
+ return PlatformResolution(detected, "manifest:platform")
136
+
137
+ for platform in SUPPORTED_PLATFORMS:
138
+ for marker in _PLATFORM_MARKERS[platform]:
139
+ if (root / marker).is_dir():
140
+ return PlatformResolution(platform, f"filesystem:{marker}")
141
+
142
+ return PlatformResolution(
143
+ "",
144
+ "unresolved",
145
+ (
146
+ "No supported platform marker found. Set PRIZMKIT_PLATFORM, configure .prizmkit/config.json, "
147
+ "or install Claude/Codex/CodeBuddy platform files.",
148
+ ),
149
+ )
150
+
151
+
152
+ def _expand_skill_pattern(project_root: Path, skill: str, platform: str, scope: str, pattern: str) -> SkillCandidate:
153
+ rendered = pattern.format(skill=skill)
154
+ if rendered.startswith("~"):
155
+ path = Path(rendered).expanduser()
156
+ else:
157
+ path = project_root / rendered
158
+ return SkillCandidate(platform=platform, scope=scope, path=path.resolve())
159
+
160
+
161
+ def skill_candidates(project_root: str | Path, skill: str, platform: str) -> tuple[SkillCandidate, ...]:
162
+ """Return deterministic candidate locations for a skill on one platform."""
163
+ normalized = valid_prompt_platform(platform)
164
+ if not normalized:
165
+ return ()
166
+ root = Path(project_root).expanduser().resolve()
167
+ return tuple(
168
+ _expand_skill_pattern(root, skill, normalized, scope, pattern)
169
+ for scope, pattern in _PLATFORM_SKILL_PATTERNS[normalized]
170
+ )
171
+
172
+
173
+ def _candidate_matches(candidate: SkillCandidate, skill: str, *, prefix: bool) -> bool:
174
+ path = candidate.path
175
+ if path.exists():
176
+ return True
177
+ if not prefix:
178
+ return False
179
+ parent = path.parent
180
+ if not parent.is_dir():
181
+ return False
182
+ return any(child.name.startswith(skill) for child in parent.iterdir())
183
+
184
+
185
+ def resolve_skill(
186
+ project_root: str | Path,
187
+ skill: str,
188
+ *,
189
+ platform: str = "auto",
190
+ env: Mapping[str, str] | None = None,
191
+ prefix: bool = False,
192
+ ) -> SkillResolution:
193
+ """Resolve a skill path for the selected platform."""
194
+ normalized_skill = str(skill or "").strip()
195
+ selected_platform = valid_prompt_platform(platform)
196
+ if platform == "auto" or not platform:
197
+ selected_platform = resolve_platform(project_root, env=env).platform
198
+ if not selected_platform:
199
+ return SkillResolution("NOT_INSTALLED", "", normalized_skill, None, ())
200
+
201
+ candidates = skill_candidates(project_root, normalized_skill, selected_platform)
202
+ for candidate in candidates:
203
+ if _candidate_matches(candidate, normalized_skill, prefix=prefix):
204
+ if candidate.path.exists():
205
+ return SkillResolution("SKILL_EXISTS", selected_platform, normalized_skill, candidate.path, candidates)
206
+ matches = sorted(child for child in candidate.path.parent.iterdir() if child.name.startswith(normalized_skill))
207
+ return SkillResolution("SKILL_EXISTS", selected_platform, normalized_skill, matches[0].resolve(), candidates)
208
+ return SkillResolution("SKILL_MISSING", selected_platform, normalized_skill, None, candidates)
@@ -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)