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
@@ -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,
@@ -0,0 +1,508 @@
1
+ """Cross-platform helper CLI for prompt runtime operations.
2
+
3
+ The helpers in this module use Python standard-library APIs for file checks,
4
+ source discovery, text search, platform detection, executable checks, port
5
+ readiness, process cleanup, and lightweight browser-readiness polling. Stdout
6
+ starts with deterministic markers so generated AI prompts can branch on text
7
+ instead of shell exit-code chains.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import os
15
+ import shutil
16
+ import socket
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.request
22
+ from dataclasses import asdict, dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .paths import resolve_runtime_paths
27
+ from .platform_detection import resolve_platform, resolve_skill
28
+ from .processes import is_pid_running, terminate_pid_tree
29
+
30
+ SOURCE_EXTENSIONS = (
31
+ ".js",
32
+ ".jsx",
33
+ ".ts",
34
+ ".tsx",
35
+ ".mjs",
36
+ ".cjs",
37
+ ".py",
38
+ ".go",
39
+ ".rs",
40
+ ".java",
41
+ ".kt",
42
+ ".rb",
43
+ ".php",
44
+ ".cs",
45
+ ".swift",
46
+ ".sh",
47
+ ".ps1",
48
+ ".md",
49
+ ".json",
50
+ ".yaml",
51
+ ".yml",
52
+ ".toml",
53
+ )
54
+
55
+ DEFAULT_EXCLUDES = (
56
+ ".git",
57
+ "node_modules",
58
+ "__pycache__",
59
+ ".pytest_cache",
60
+ ".mypy_cache",
61
+ "dist",
62
+ "build",
63
+ "vendor",
64
+ )
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class HelperResult:
69
+ """Structured helper result rendered as marker text or JSON."""
70
+
71
+ marker: str
72
+ ok: bool
73
+ fields: dict[str, Any]
74
+ exit_code: int = 0
75
+
76
+
77
+ def _path(value: str | Path) -> Path:
78
+ return Path(value).expanduser().resolve()
79
+
80
+
81
+ def _relative(path: Path, root: Path) -> str:
82
+ try:
83
+ return path.relative_to(root).as_posix()
84
+ except ValueError:
85
+ return path.as_posix()
86
+
87
+
88
+ def render_result(result: HelperResult, *, json_output: bool = False) -> str:
89
+ """Render a helper result without writing to stdout."""
90
+ if json_output:
91
+ payload = {"marker": result.marker, "ok": result.ok, **result.fields}
92
+ return json.dumps(payload, ensure_ascii=False, sort_keys=True)
93
+
94
+ lines = [result.marker]
95
+ for key, value in result.fields.items():
96
+ if value is None:
97
+ continue
98
+ if isinstance(value, (list, tuple)):
99
+ for item in value:
100
+ lines.append(f"{key}: {item}")
101
+ elif isinstance(value, dict):
102
+ lines.append(f"{key}: {json.dumps(value, ensure_ascii=False, sort_keys=True)}")
103
+ else:
104
+ lines.append(f"{key}: {value}")
105
+ return "\n".join(lines)
106
+
107
+
108
+ def _emit(result: HelperResult, *, json_output: bool = False) -> int:
109
+ print(render_result(result, json_output=json_output))
110
+ return result.exit_code
111
+
112
+
113
+ def _add_json(parser: argparse.ArgumentParser) -> None:
114
+ parser.add_argument("--json", action="store_true", help="Emit a single JSON object instead of marker text.")
115
+
116
+
117
+ def _iter_files(root: Path, *, excludes: tuple[str, ...] = DEFAULT_EXCLUDES):
118
+ if root.is_file():
119
+ yield root
120
+ return
121
+ if not root.is_dir():
122
+ return
123
+ for current_root, dir_names, file_names in os.walk(root):
124
+ dir_names[:] = sorted(name for name in dir_names if name not in excludes)
125
+ for file_name in sorted(file_names):
126
+ yield Path(current_root) / file_name
127
+
128
+
129
+ def _artifact_exists(args: argparse.Namespace) -> HelperResult:
130
+ target = _path(args.path)
131
+ exists = target.exists()
132
+ exists_marker = getattr(args, "exists_marker", "EXISTS")
133
+ missing_marker = getattr(args, "missing_marker", "MISSING")
134
+ return HelperResult(exists_marker if exists else missing_marker, exists, {"path": str(target), "type": "dir" if target.is_dir() else "file" if target.is_file() else "missing"})
135
+
136
+
137
+ def _artifact_ensure_dir(args: argparse.Namespace) -> HelperResult:
138
+ target = _path(args.path)
139
+ target.mkdir(parents=True, exist_ok=True)
140
+ return HelperResult("DIR_READY", True, {"path": str(target)})
141
+
142
+
143
+ def _artifact_touch(args: argparse.Namespace) -> HelperResult:
144
+ target = _path(args.path)
145
+ target.parent.mkdir(parents=True, exist_ok=True)
146
+ target.touch(exist_ok=True)
147
+ return HelperResult("TOUCHED", True, {"path": str(target)})
148
+
149
+
150
+ def _artifact_list(args: argparse.Namespace) -> HelperResult:
151
+ target = _path(args.path)
152
+ if not target.is_dir():
153
+ return HelperResult("MISSING", False, {"path": str(target), "entries": []}, 1)
154
+ entries = []
155
+ for child in sorted(target.iterdir(), key=lambda item: item.name):
156
+ entries.append({"name": child.name, "type": "dir" if child.is_dir() else "file" if child.is_file() else "other"})
157
+ if args.json:
158
+ return HelperResult("LIST", True, {"path": str(target), "entries": entries})
159
+ lines = [f"{entry['type']}:{entry['name']}" for entry in entries]
160
+ return HelperResult("LIST", True, {"path": str(target), "entry": lines})
161
+
162
+
163
+ def _artifact_tree(args: argparse.Namespace) -> HelperResult:
164
+ root = _path(args.path)
165
+ if not root.exists():
166
+ return HelperResult("MISSING", False, {"path": str(root), "entries": []}, 1)
167
+ max_depth = max(0, int(args.max_depth))
168
+ entries: list[str] = []
169
+ if root.is_file():
170
+ entries.append(root.name)
171
+ else:
172
+ for current_root, dir_names, file_names in os.walk(root):
173
+ current = Path(current_root)
174
+ depth = len(current.relative_to(root).parts) if current != root else 0
175
+ if depth >= max_depth:
176
+ dir_names[:] = []
177
+ dir_names[:] = sorted(name for name in dir_names if name not in DEFAULT_EXCLUDES)
178
+ for directory in dir_names:
179
+ entries.append(f"{_relative(current / directory, root)}/")
180
+ for file_name in sorted(file_names):
181
+ entries.append(_relative(current / file_name, root))
182
+ return HelperResult("TREE", True, {"path": str(root), "entries": entries} if args.json else {"path": str(root), "entry": entries})
183
+
184
+
185
+ def _artifact_source_files(args: argparse.Namespace) -> HelperResult:
186
+ root = _path(args.path)
187
+ extensions = tuple(args.ext or SOURCE_EXTENSIONS)
188
+ files = [
189
+ _relative(file_path, root if root.is_dir() else root.parent)
190
+ for file_path in _iter_files(root)
191
+ if file_path.suffix in extensions
192
+ ]
193
+ return HelperResult("SOURCE_FILES", True, {"path": str(root), "files": files} if args.json else {"path": str(root), "file": files})
194
+
195
+
196
+ def _text_search(args: argparse.Namespace) -> HelperResult:
197
+ target = _path(args.path)
198
+ pattern = args.pattern
199
+ count = 0
200
+ matches: list[str] = []
201
+ for file_path in _iter_files(target):
202
+ try:
203
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
204
+ except OSError:
205
+ continue
206
+ file_count = text.count(pattern)
207
+ if file_count:
208
+ count += file_count
209
+ matches.append(_relative(file_path, target if target.is_dir() else target.parent))
210
+ found = count > 0
211
+ return HelperResult("FOUND" if found else "NOT_FOUND", found, {"count": count, "match": matches})
212
+
213
+
214
+ def _text_count(args: argparse.Namespace) -> HelperResult:
215
+ target = _path(args.path)
216
+ count = 0
217
+ for file_path in _iter_files(target):
218
+ try:
219
+ count += file_path.read_text(encoding="utf-8", errors="ignore").count(args.pattern)
220
+ except OSError:
221
+ continue
222
+ return HelperResult("COUNT", True, {"count": count})
223
+
224
+
225
+ def _text_contains(args: argparse.Namespace) -> HelperResult:
226
+ target = _path(args.path)
227
+ if not target.is_file():
228
+ return HelperResult("GATE:MISSING", False, {"path": str(target), "reason": "file_missing"}, 1)
229
+ try:
230
+ found = args.pattern in target.read_text(encoding="utf-8", errors="ignore")
231
+ except OSError as exc:
232
+ return HelperResult("GATE:MISSING", False, {"path": str(target), "reason": type(exc).__name__}, 1)
233
+ return HelperResult(args.found_marker if found else args.missing_marker, found, {"path": str(target), "pattern": args.pattern})
234
+
235
+
236
+ def _platform_detect(args: argparse.Namespace) -> HelperResult:
237
+ paths = resolve_runtime_paths(project_root=args.project_root, pipeline_root=args.pipeline_root)
238
+ resolution = resolve_platform(paths.project_root)
239
+ marker = resolution.platform.upper() if resolution.platform else "NOT_INSTALLED"
240
+ return HelperResult(marker, bool(resolution.platform), {"platform": resolution.platform, "source": resolution.source, "notes": list(resolution.notes)})
241
+
242
+
243
+ def _platform_skill(args: argparse.Namespace) -> HelperResult:
244
+ paths = resolve_runtime_paths(project_root=args.project_root, pipeline_root=args.pipeline_root)
245
+ resolution = resolve_skill(paths.project_root, args.skill, platform=args.platform, prefix=args.prefix)
246
+ fields = {
247
+ "platform": resolution.platform,
248
+ "skill": resolution.skill,
249
+ "path": str(resolution.path) if resolution.path else None,
250
+ "candidates": [asdict(candidate) | {"path": str(candidate.path)} for candidate in resolution.candidates],
251
+ }
252
+ return HelperResult(resolution.marker, resolution.marker == "SKILL_EXISTS", fields)
253
+
254
+
255
+ def _executable_check(args: argparse.Namespace) -> HelperResult:
256
+ executable = shutil.which(args.name)
257
+ return HelperResult("INSTALLED" if executable else "NOT_INSTALLED", bool(executable), {"name": args.name, "path": executable})
258
+
259
+
260
+ def _executable_version(args: argparse.Namespace) -> HelperResult:
261
+ executable = shutil.which(args.name)
262
+ if not executable:
263
+ return HelperResult("NOT_INSTALLED", False, {"name": args.name}, 1)
264
+ command = [executable, *(args.arg or ["--version"])]
265
+ try:
266
+ completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=args.timeout, check=False)
267
+ except (OSError, subprocess.TimeoutExpired) as exc:
268
+ return HelperResult("VERSION_ERROR", False, {"name": args.name, "error": type(exc).__name__}, 1)
269
+ output = (completed.stdout or completed.stderr or "").strip().splitlines()
270
+ return HelperResult("VERSION", completed.returncode == 0, {"name": args.name, "path": executable, "version": output[0] if output else "", "return_code": completed.returncode}, completed.returncode)
271
+
272
+
273
+ def _port_check(args: argparse.Namespace) -> HelperResult:
274
+ port = int(args.port)
275
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
276
+ try:
277
+ sock.settimeout(float(args.timeout))
278
+ in_use = sock.connect_ex((args.host, port)) == 0
279
+ finally:
280
+ sock.close()
281
+ return HelperResult("PORT_IN_USE" if in_use else "PORT_FREE", not in_use, {"host": args.host, "port": port})
282
+
283
+
284
+ def _process_status_pid(args: argparse.Namespace) -> HelperResult:
285
+ pid = int(args.pid)
286
+ running = is_pid_running(pid)
287
+ return HelperResult("PROCESS_RUNNING" if running else "PROCESS_NOT_RUNNING", running, {"pid": pid})
288
+
289
+
290
+ def _process_cleanup_pid(args: argparse.Namespace) -> HelperResult:
291
+ pid = int(args.pid)
292
+ result = terminate_pid_tree(pid, "runtime_helper_cleanup", grace_seconds=float(args.grace_seconds))
293
+ marker = "PROCESS_TERMINATED" if result.terminated else "PROCESS_NOT_RUNNING" if not is_pid_running(pid) else "PROCESS_CLEANUP_FAILED"
294
+ return HelperResult(marker, marker != "PROCESS_CLEANUP_FAILED", {"pid": pid, "forced": result.forced, "details": list(result.details)})
295
+
296
+
297
+ def _process_cleanup_port(args: argparse.Namespace) -> HelperResult:
298
+ # The Python standard library cannot portably map listening ports to owning
299
+ # PIDs on all supported platforms without shelling out to lsof/netstat/taskkill.
300
+ # Keep this deterministic and safe instead of pretending cleanup happened.
301
+ port_result = _port_check(argparse.Namespace(port=args.port, host=args.host, timeout=args.timeout))
302
+ marker = "PORT_FREE" if port_result.marker == "PORT_FREE" else "UNSUPPORTED"
303
+ ok = marker == "PORT_FREE"
304
+ return HelperResult(marker, ok, {"host": args.host, "port": int(args.port), "reason": "pid_lookup_by_port_not_available_in_python_stdlib"}, 0 if ok else 2)
305
+
306
+
307
+ def _process_start(args: argparse.Namespace) -> HelperResult:
308
+ command = list(args.command)
309
+ if command and command[0] == "--":
310
+ command = command[1:]
311
+ if not command:
312
+ return HelperResult("PROCESS_START_FAILED", False, {"reason": "missing_command"}, 2)
313
+ cwd = _path(args.cwd or ".")
314
+ stdout_target = subprocess.DEVNULL if not args.stdout else open(_path(args.stdout), "a", encoding="utf-8")
315
+ stderr_target = subprocess.DEVNULL if not args.stderr else open(_path(args.stderr), "a", encoding="utf-8")
316
+ try:
317
+ process = subprocess.Popen(
318
+ [str(part) for part in command],
319
+ cwd=cwd,
320
+ stdout=stdout_target,
321
+ stderr=stderr_target,
322
+ stdin=subprocess.DEVNULL,
323
+ start_new_session=(os.name != "nt"),
324
+ creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0,
325
+ )
326
+ except OSError as exc:
327
+ return HelperResult("PROCESS_START_FAILED", False, {"reason": type(exc).__name__, "error": str(exc)}, 1)
328
+ finally:
329
+ for target in (stdout_target, stderr_target):
330
+ if hasattr(target, "close"):
331
+ target.close()
332
+ if args.pid_file:
333
+ pid_file = _path(args.pid_file)
334
+ pid_file.parent.mkdir(parents=True, exist_ok=True)
335
+ pid_file.write_text(f"{process.pid}\n", encoding="utf-8")
336
+ return HelperResult("PROCESS_STARTED", True, {"pid": process.pid, "pid_file": str(_path(args.pid_file)) if args.pid_file else None})
337
+
338
+
339
+ def _web_wait_ready(args: argparse.Namespace) -> HelperResult:
340
+ deadline = time.monotonic() + float(args.timeout)
341
+ statuses: list[str] = []
342
+ while time.monotonic() <= deadline:
343
+ try:
344
+ request = urllib.request.Request(args.url, method="GET")
345
+ with urllib.request.urlopen(request, timeout=float(args.request_timeout)) as response:
346
+ status = int(response.status)
347
+ statuses.append(str(status))
348
+ if status in args.status:
349
+ return HelperResult("READY", True, {"url": args.url, "status": status})
350
+ except urllib.error.HTTPError as exc:
351
+ statuses.append(str(exc.code))
352
+ if int(exc.code) in args.status:
353
+ return HelperResult("READY", True, {"url": args.url, "status": int(exc.code)})
354
+ except (OSError, urllib.error.URLError, TimeoutError) as exc:
355
+ statuses.append(type(exc).__name__)
356
+ time.sleep(float(args.interval))
357
+ return HelperResult("NOT_READY", False, {"url": args.url, "observed": statuses[-5:]}, 1)
358
+
359
+
360
+ def build_parser() -> argparse.ArgumentParser:
361
+ parser = argparse.ArgumentParser(prog="prizmkit-runtime-helper", description="Cross-platform PrizmKit prompt runtime helper CLI.")
362
+ subparsers = parser.add_subparsers(dest="group", required=True)
363
+
364
+ artifact = subparsers.add_parser("artifact", help="Artifact and source discovery helpers")
365
+ artifact_sub = artifact.add_subparsers(dest="action", required=True)
366
+ exists = artifact_sub.add_parser("exists", help="Check whether a path exists")
367
+ exists.add_argument("path")
368
+ exists.add_argument("--exists-marker", default="EXISTS")
369
+ exists.add_argument("--missing-marker", default="MISSING")
370
+ _add_json(exists)
371
+ exists.set_defaults(func=_artifact_exists)
372
+ ensure_dir = artifact_sub.add_parser("ensure-dir", help="Create a directory if missing")
373
+ ensure_dir.add_argument("path")
374
+ _add_json(ensure_dir)
375
+ ensure_dir.set_defaults(func=_artifact_ensure_dir)
376
+ touch = artifact_sub.add_parser("touch", help="Create a file and parent directories if missing")
377
+ touch.add_argument("path")
378
+ _add_json(touch)
379
+ touch.set_defaults(func=_artifact_touch)
380
+ list_parser = artifact_sub.add_parser("list", help="List a directory deterministically")
381
+ list_parser.add_argument("path")
382
+ _add_json(list_parser)
383
+ list_parser.set_defaults(func=_artifact_list)
384
+ tree = artifact_sub.add_parser("tree", help="Render a deterministic tree")
385
+ tree.add_argument("path")
386
+ tree.add_argument("--max-depth", type=int, default=3)
387
+ _add_json(tree)
388
+ tree.set_defaults(func=_artifact_tree)
389
+ source = artifact_sub.add_parser("source-files", help="Discover source files")
390
+ source.add_argument("path")
391
+ source.add_argument("--ext", action="append", help="Extension to include, e.g. .py")
392
+ _add_json(source)
393
+ source.set_defaults(func=_artifact_source_files)
394
+
395
+ text = subparsers.add_parser("text", help="Text search helpers")
396
+ text_sub = text.add_subparsers(dest="action", required=True)
397
+ search = text_sub.add_parser("search", help="Search text under a path")
398
+ search.add_argument("path")
399
+ search.add_argument("--pattern", required=True)
400
+ _add_json(search)
401
+ search.set_defaults(func=_text_search)
402
+ count = text_sub.add_parser("count", help="Count text occurrences under a path")
403
+ count.add_argument("path")
404
+ count.add_argument("--pattern", required=True)
405
+ _add_json(count)
406
+ count.set_defaults(func=_text_count)
407
+ contains = text_sub.add_parser("contains", help="Check one file contains text and emit gate markers")
408
+ contains.add_argument("path")
409
+ contains.add_argument("--pattern", required=True)
410
+ contains.add_argument("--found-marker", default="GATE:PASS")
411
+ contains.add_argument("--missing-marker", default="GATE:MISSING")
412
+ _add_json(contains)
413
+ contains.set_defaults(func=_text_contains)
414
+
415
+ platform = subparsers.add_parser("platform", help="AI platform and skill detection")
416
+ platform_sub = platform.add_subparsers(dest="action", required=True)
417
+ detect = platform_sub.add_parser("detect", help="Detect current AI platform")
418
+ detect.add_argument("--project-root")
419
+ detect.add_argument("--pipeline-root")
420
+ _add_json(detect)
421
+ detect.set_defaults(func=_platform_detect)
422
+ skill = platform_sub.add_parser("skill", help="Detect a platform skill")
423
+ skill.add_argument("skill")
424
+ skill.add_argument("--platform", default="auto")
425
+ skill.add_argument("--project-root")
426
+ skill.add_argument("--pipeline-root")
427
+ skill.add_argument("--prefix", action="store_true")
428
+ _add_json(skill)
429
+ skill.set_defaults(func=_platform_skill)
430
+
431
+ executable = subparsers.add_parser("executable", help="Executable checks")
432
+ executable_sub = executable.add_subparsers(dest="action", required=True)
433
+ check = executable_sub.add_parser("check", help="Check executable availability")
434
+ check.add_argument("name")
435
+ _add_json(check)
436
+ check.set_defaults(func=_executable_check)
437
+ version = executable_sub.add_parser("version", help="Run executable version command")
438
+ version.add_argument("name")
439
+ version.add_argument("--arg", action="append", default=None)
440
+ version.add_argument("--timeout", type=float, default=10.0)
441
+ _add_json(version)
442
+ version.set_defaults(func=_executable_version)
443
+
444
+ port = subparsers.add_parser("port", help="Port readiness helpers")
445
+ port_sub = port.add_subparsers(dest="action", required=True)
446
+ port_check = port_sub.add_parser("check", help="Check whether a TCP port is in use")
447
+ port_check.add_argument("port", type=int)
448
+ port_check.add_argument("--host", default="127.0.0.1")
449
+ port_check.add_argument("--timeout", type=float, default=0.5)
450
+ _add_json(port_check)
451
+ port_check.set_defaults(func=_port_check)
452
+
453
+ process = subparsers.add_parser("process", help="Process lifecycle helpers")
454
+ process_sub = process.add_subparsers(dest="action", required=True)
455
+ start = process_sub.add_parser("start", help="Start a process and optionally write a PID file")
456
+ start.add_argument("--pid-file")
457
+ start.add_argument("--cwd")
458
+ start.add_argument("--stdout")
459
+ start.add_argument("--stderr")
460
+ start.add_argument("command", nargs=argparse.REMAINDER)
461
+ _add_json(start)
462
+ start.set_defaults(func=_process_start)
463
+ status_pid = process_sub.add_parser("status-pid", help="Check whether a PID is running")
464
+ status_pid.add_argument("pid", type=int)
465
+ _add_json(status_pid)
466
+ status_pid.set_defaults(func=_process_status_pid)
467
+ cleanup_pid = process_sub.add_parser("cleanup-pid", help="Best-effort cleanup by PID")
468
+ cleanup_pid.add_argument("pid", type=int)
469
+ cleanup_pid.add_argument("--grace-seconds", type=float, default=2.0)
470
+ _add_json(cleanup_pid)
471
+ cleanup_pid.set_defaults(func=_process_cleanup_pid)
472
+ cleanup_port = process_sub.add_parser("cleanup-port", help="Best-effort cleanup by port when supported")
473
+ cleanup_port.add_argument("port", type=int)
474
+ cleanup_port.add_argument("--host", default="127.0.0.1")
475
+ cleanup_port.add_argument("--timeout", type=float, default=0.5)
476
+ _add_json(cleanup_port)
477
+ cleanup_port.set_defaults(func=_process_cleanup_port)
478
+
479
+ web = subparsers.add_parser("web", help="Browser/server readiness helpers")
480
+ web_sub = web.add_subparsers(dest="action", required=True)
481
+ wait_ready = web_sub.add_parser("wait-ready", help="Wait for an HTTP endpoint to be ready")
482
+ wait_ready.add_argument("--url", required=True)
483
+ wait_ready.add_argument("--timeout", type=float, default=30.0)
484
+ wait_ready.add_argument("--interval", type=float, default=2.0)
485
+ wait_ready.add_argument("--request-timeout", type=float, default=2.0)
486
+ wait_ready.add_argument("--status", action="append", type=int, default=[200, 302])
487
+ _add_json(wait_ready)
488
+ wait_ready.set_defaults(func=_web_wait_ready)
489
+
490
+ return parser
491
+
492
+
493
+ def run_helper_command(argv: list[str] | tuple[str, ...]) -> HelperResult:
494
+ """Run a helper command argv and return the structured result."""
495
+ parser = build_parser()
496
+ args = parser.parse_args(list(argv))
497
+ return args.func(args)
498
+
499
+
500
+ def main(argv: list[str] | None = None) -> int:
501
+ parser = build_parser()
502
+ args = parser.parse_args(argv)
503
+ result = args.func(args)
504
+ return _emit(result, json_output=bool(getattr(args, "json", False)))
505
+
506
+
507
+ if __name__ == "__main__":
508
+ raise SystemExit(main())