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.
- package/bin/create-prizmkit.js +4 -0
- package/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/prizmkit_runtime/cli.py +20 -0
- package/bundled/dev-pipeline/prizmkit_runtime/commands.py +1 -0
- package/bundled/dev-pipeline/prizmkit_runtime/config.py +81 -14
- package/bundled/dev-pipeline/prizmkit_runtime/gitops.py +21 -0
- package/bundled/dev-pipeline/prizmkit_runtime/heartbeat.py +19 -8
- package/bundled/dev-pipeline/prizmkit_runtime/interoperability.py +1 -1
- package/bundled/dev-pipeline/prizmkit_runtime/launch_profiles.py +18 -0
- package/bundled/dev-pipeline/prizmkit_runtime/platform_detection.py +208 -0
- package/bundled/dev-pipeline/prizmkit_runtime/runner_bookkeeping.py +22 -2
- package/bundled/dev-pipeline/prizmkit_runtime/runner_recovery.py +3 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runners.py +20 -1
- package/bundled/dev-pipeline/prizmkit_runtime/runtime_helper.py +508 -0
- package/bundled/dev-pipeline/prizmkit_runtime/sessions.py +151 -30
- package/bundled/dev-pipeline/scripts/continuation.py +0 -1
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +8 -46
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +9 -34
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +42 -33
- package/bundled/dev-pipeline/scripts/prizmkit-runtime-helper.py +27 -0
- package/bundled/dev-pipeline/scripts/utils.py +94 -0
- package/bundled/dev-pipeline/templates/agent-prompts/critic-plan-challenge.md +4 -3
- package/bundled/dev-pipeline/templates/bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +39 -65
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +58 -96
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +54 -95
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +6 -18
- package/bundled/dev-pipeline/templates/refactor-bootstrap-prompt.md +5 -17
- package/bundled/dev-pipeline/templates/sections/checkpoint-system.md +14 -48
- package/bundled/dev-pipeline/templates/sections/context-budget-rules.md +7 -13
- package/bundled/dev-pipeline/templates/sections/feature-context.md +0 -4
- package/bundled/dev-pipeline/templates/sections/phase-browser-verification-auto.md +31 -61
- package/bundled/dev-pipeline/templates/sections/phase-browser-verification-opencli.md +21 -37
- package/bundled/dev-pipeline/templates/sections/phase-browser-verification.md +22 -69
- package/bundled/dev-pipeline/templates/sections/phase-commit-full.md +0 -14
- package/bundled/dev-pipeline/templates/sections/phase-commit.md +0 -14
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-agent-suffix.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-base.md +1 -3
- package/bundled/dev-pipeline/templates/sections/phase-context-snapshot-lite-suffix.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-critic-plan-full.md +7 -9
- package/bundled/dev-pipeline/templates/sections/phase-critic-plan.md +4 -8
- package/bundled/dev-pipeline/templates/sections/phase-implement-agent.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-implement-full.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-implement-lite.md +0 -7
- package/bundled/dev-pipeline/templates/sections/phase-plan-agent.md +3 -10
- package/bundled/dev-pipeline/templates/sections/phase-plan-lite.md +2 -9
- package/bundled/dev-pipeline/templates/sections/phase-prizmkit-test.md +15 -16
- package/bundled/dev-pipeline/templates/sections/phase-review-agent.md +1 -8
- package/bundled/dev-pipeline/templates/sections/phase-review-full.md +1 -8
- package/bundled/dev-pipeline/templates/sections/phase-specify-plan-full.md +9 -15
- package/bundled/dev-pipeline/templates/sections/phase0-init.md +1 -8
- package/bundled/dev-pipeline/templates/sections/phase0-test-baseline.md +2 -15
- package/bundled/dev-pipeline/templates/sections/subagent-timeout-recovery.md +1 -1
- package/bundled/dev-pipeline/tests/conftest.py +0 -1
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +392 -23
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +37 -17
- package/bundled/dev-pipeline/tests/test_generate_refactor_prompt.py +118 -14
- package/bundled/dev-pipeline/tests/test_python_runner_parity.py +79 -2
- package/bundled/dev-pipeline/tests/test_runtime_helper.py +211 -0
- package/bundled/dev-pipeline/tests/test_unified_cli.py +663 -13
- package/bundled/skills/_metadata.json +4 -4
- package/bundled/skills/app-planner/SKILL.md +1 -1
- package/bundled/skills/app-planner/references/architecture-decisions.md +1 -1
- package/bundled/skills/bug-fix-workflow/SKILL.md +174 -128
- package/bundled/skills/bug-planner/SKILL.md +101 -15
- package/bundled/skills/bug-planner/references/severity-rules.md +20 -8
- package/bundled/skills/feature-planner/SKILL.md +87 -8
- package/bundled/skills/feature-planner/assets/planning-guide.md +34 -10
- package/bundled/skills/feature-workflow/SKILL.md +100 -81
- package/bundled/skills/{prizm-kit → prizmkit}/SKILL.md +1 -1
- package/bundled/skills/prizmkit-code-review/SKILL.md +1 -1
- package/bundled/skills/refactor-planner/SKILL.md +88 -7
- package/bundled/skills/refactor-planner/references/fast-path.md +2 -1
- package/bundled/skills/refactor-planner/references/planning-phases.md +29 -11
- package/bundled/skills/refactor-workflow/SKILL.md +116 -57
- package/bundled/templates/project-memory-template.md +1 -1
- package/package.json +1 -1
- package/src/ai-cli-launch.js +194 -0
- package/src/config.js +26 -14
- package/src/index.js +11 -44
- package/src/platforms.js +10 -3
- package/src/prompts.js +60 -2
- package/src/scaffold.js +26 -14
- package/bundled/dev-pipeline/scripts/prizmkit-test-gate.py +0 -446
- package/bundled/dev-pipeline/templates/sections/log-size-awareness.md +0 -20
- 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 =
|
|
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())
|