its-magic 0.1.2-55 → 0.1.2
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/package.json +1 -1
- package/scripts/check_intake_template_parity.py +96 -0
- package/template/.cursor/commands/architecture.md +4 -0
- package/template/.cursor/commands/auto.md +110 -15
- package/template/.cursor/commands/discovery.md +4 -0
- package/template/.cursor/commands/execute.md +343 -291
- package/template/.cursor/commands/intake.md +4 -0
- package/template/.cursor/commands/map-codebase.md +4 -0
- package/template/.cursor/commands/memory-audit.md +4 -0
- package/template/.cursor/commands/milestone-complete.md +4 -0
- package/template/.cursor/commands/milestone-start.md +4 -0
- package/template/.cursor/commands/pause.md +4 -0
- package/template/.cursor/commands/phase-context.md +4 -0
- package/template/.cursor/commands/plan-verify.md +4 -0
- package/template/.cursor/commands/qa.md +226 -222
- package/template/.cursor/commands/quick.md +13 -0
- package/template/.cursor/commands/refresh-context.md +4 -0
- package/template/.cursor/commands/release.md +505 -487
- package/template/.cursor/commands/research.md +4 -0
- package/template/.cursor/commands/resume.md +4 -0
- package/template/.cursor/commands/security-review.md +4 -0
- package/template/.cursor/commands/sprint-plan.md +4 -0
- package/template/.cursor/commands/status-reconcile.md +4 -0
- package/template/.cursor/commands/verify-work.md +4 -0
- package/template/.cursor/dev-environment.json.example +22 -0
- package/template/.cursor/scratchpad.local.example.md +40 -3
- package/template/.cursor/scratchpad.md +324 -262
- package/template/.cursorignore +1 -0
- package/template/docs/engineering/auto-orchestration-reference.md +121 -14
- package/template/docs/engineering/context/installer-owned-paths.manifest +4 -1
- package/template/docs/engineering/runbook.md +207 -4
- package/template/scripts/check_intake_template_parity.py +96 -0
- package/template/scripts/dev_environment_lib.py +463 -0
- package/template/scripts/pack_json_validate.py +130 -0
- package/template/scripts/project_readme_coverage_lib.py +417 -0
- package/template/scripts/readme_feature_coverage_lib.py +2 -2
- package/template/scripts/validate_project_readme_coverage.py +151 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dev environment auto-launch profile: load, detect, classify, relaunch, connect (US-0098 / DEC-0084).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import fnmatch
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
SCHEMA_VERSION = 1
|
|
18
|
+
DEFAULT_PROFILE_PATH = ".cursor/dev-environment.json"
|
|
19
|
+
MAX_RETRY_COUNT = 2
|
|
20
|
+
RETRY_DELAYS_SECONDS = (5, 15)
|
|
21
|
+
|
|
22
|
+
VALID_DETECTED_MODES = frozenset({"local", "docker-host-local", "docker", "ssh"})
|
|
23
|
+
VALID_TIERS = frozenset({"A", "B", "C"})
|
|
24
|
+
CONNECT_STANDARD_KEYS = frozenset({"endpoint", "health_path"})
|
|
25
|
+
SECRET_LIKE_PATTERN = re.compile(
|
|
26
|
+
r"(?i)(password|secret|token|api[_-]?key|private[_-]?key)\s*[:=]\s*['\"]?[^'\"]{8,}"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# DEV_ENV_PROFILE_* reason codes (DEC-0084 §10)
|
|
30
|
+
DEV_ENV_PROFILE_DISABLED = "DEV_ENV_PROFILE_DISABLED"
|
|
31
|
+
DEV_ENV_PROFILE_INVALID = "DEV_ENV_PROFILE_INVALID"
|
|
32
|
+
DEV_ENV_PROFILE_MISSING = "DEV_ENV_PROFILE_MISSING"
|
|
33
|
+
DEV_ENV_DETECT_AMBIGUOUS = "DEV_ENV_DETECT_AMBIGUOUS"
|
|
34
|
+
DEV_ENV_COMPOSE_UNRESOLVED = "DEV_ENV_COMPOSE_UNRESOLVED"
|
|
35
|
+
DEV_ENV_TARGET_DISABLED = "DEV_ENV_TARGET_DISABLED"
|
|
36
|
+
DEV_ENV_SECRET_SURFACE_VIOLATION = "DEV_ENV_SECRET_SURFACE_VIOLATION"
|
|
37
|
+
|
|
38
|
+
# DEV_ENV_RELAUNCH_* reason codes (DEC-0084 §10)
|
|
39
|
+
DEV_ENV_RELAUNCH_SKIPPED_NO_SURFACE = "DEV_ENV_RELAUNCH_SKIPPED_NO_SURFACE"
|
|
40
|
+
DEV_ENV_RELAUNCH_SKIPPED_PROFILE_OFF = "DEV_ENV_RELAUNCH_SKIPPED_PROFILE_OFF"
|
|
41
|
+
DEV_ENV_RELAUNCH_FAILED = "DEV_ENV_RELAUNCH_FAILED"
|
|
42
|
+
DEV_ENV_RELAUNCH_RETRY_EXHAUSTED = "DEV_ENV_RELAUNCH_RETRY_EXHAUSTED"
|
|
43
|
+
DEV_ENV_RELAUNCH_TIMEOUT = "DEV_ENV_RELAUNCH_TIMEOUT"
|
|
44
|
+
DEV_ENV_CONNECT_UNAVAILABLE = "DEV_ENV_CONNECT_UNAVAILABLE"
|
|
45
|
+
|
|
46
|
+
PROFILE_REASON_CODES = (
|
|
47
|
+
DEV_ENV_PROFILE_DISABLED,
|
|
48
|
+
DEV_ENV_PROFILE_INVALID,
|
|
49
|
+
DEV_ENV_PROFILE_MISSING,
|
|
50
|
+
DEV_ENV_DETECT_AMBIGUOUS,
|
|
51
|
+
DEV_ENV_COMPOSE_UNRESOLVED,
|
|
52
|
+
DEV_ENV_TARGET_DISABLED,
|
|
53
|
+
DEV_ENV_SECRET_SURFACE_VIOLATION,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
RELAUNCH_REASON_CODES = (
|
|
57
|
+
DEV_ENV_RELAUNCH_SKIPPED_NO_SURFACE,
|
|
58
|
+
DEV_ENV_RELAUNCH_SKIPPED_PROFILE_OFF,
|
|
59
|
+
DEV_ENV_RELAUNCH_FAILED,
|
|
60
|
+
DEV_ENV_RELAUNCH_RETRY_EXHAUSTED,
|
|
61
|
+
DEV_ENV_RELAUNCH_TIMEOUT,
|
|
62
|
+
DEV_ENV_CONNECT_UNAVAILABLE,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
TIER_A_PATTERNS = (
|
|
66
|
+
"Dockerfile",
|
|
67
|
+
"Dockerfile.*",
|
|
68
|
+
"*Dockerfile",
|
|
69
|
+
"docker-compose*.yml",
|
|
70
|
+
"docker-compose*.yaml",
|
|
71
|
+
"compose.y*ml",
|
|
72
|
+
"package-lock.json",
|
|
73
|
+
"yarn.lock",
|
|
74
|
+
"pnpm-lock.yaml",
|
|
75
|
+
"Pipfile.lock",
|
|
76
|
+
"poetry.lock",
|
|
77
|
+
"go.sum",
|
|
78
|
+
"Cargo.lock",
|
|
79
|
+
"requirements.txt",
|
|
80
|
+
"pyproject.toml",
|
|
81
|
+
"package.json",
|
|
82
|
+
"Gemfile.lock",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
TIER_B_PATTERNS = (
|
|
86
|
+
"*.env.example",
|
|
87
|
+
"nginx*.conf",
|
|
88
|
+
"traefik*.yml",
|
|
89
|
+
"traefik*.yaml",
|
|
90
|
+
"traefik*.toml",
|
|
91
|
+
"application.y*ml",
|
|
92
|
+
"application.y*aml",
|
|
93
|
+
"scripts/docker/**",
|
|
94
|
+
"docker/**/entrypoint*",
|
|
95
|
+
"docker/**/Dockerfile*",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
TIER_C_SKIP_PREFIXES = (
|
|
99
|
+
"docs/",
|
|
100
|
+
"handoffs/",
|
|
101
|
+
"sprints/",
|
|
102
|
+
"decisions/",
|
|
103
|
+
"tests/",
|
|
104
|
+
".cursor/commands/",
|
|
105
|
+
"template/docs/",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _normalize_path(path: str) -> str:
|
|
110
|
+
return path.replace("\\", "/")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _check_secret_literals(obj: Any, path: str = "") -> Optional[str]:
|
|
114
|
+
if isinstance(obj, dict):
|
|
115
|
+
for key, value in obj.items():
|
|
116
|
+
full = f"{path}.{key}" if path else key
|
|
117
|
+
if key == "connect" and isinstance(value, dict):
|
|
118
|
+
for ck, cv in value.items():
|
|
119
|
+
if ck not in CONNECT_STANDARD_KEYS and not ck.endswith("Env"):
|
|
120
|
+
return f"connect key must be *Env or standard: {ck}"
|
|
121
|
+
if isinstance(cv, str) and SECRET_LIKE_PATTERN.search(cv):
|
|
122
|
+
return DEV_ENV_SECRET_SURFACE_VIOLATION
|
|
123
|
+
child = _check_secret_literals(value, full)
|
|
124
|
+
if child:
|
|
125
|
+
return child
|
|
126
|
+
elif isinstance(obj, list):
|
|
127
|
+
for i, item in enumerate(obj):
|
|
128
|
+
child = _check_secret_literals(item, f"{path}[{i}]")
|
|
129
|
+
if child:
|
|
130
|
+
return child
|
|
131
|
+
elif isinstance(obj, str):
|
|
132
|
+
if SECRET_LIKE_PATTERN.search(obj):
|
|
133
|
+
return DEV_ENV_SECRET_SURFACE_VIOLATION
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_profile_schema(data: Any) -> List[str]:
|
|
138
|
+
codes: List[str] = []
|
|
139
|
+
if not isinstance(data, dict):
|
|
140
|
+
return [DEV_ENV_PROFILE_INVALID]
|
|
141
|
+
|
|
142
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
143
|
+
codes.append(DEV_ENV_PROFILE_INVALID)
|
|
144
|
+
|
|
145
|
+
mode = data.get("detected_mode")
|
|
146
|
+
if mode is not None and mode not in VALID_DETECTED_MODES:
|
|
147
|
+
codes.append(DEV_ENV_PROFILE_INVALID)
|
|
148
|
+
|
|
149
|
+
connect = data.get("connect")
|
|
150
|
+
if connect is not None:
|
|
151
|
+
if not isinstance(connect, dict):
|
|
152
|
+
codes.append(DEV_ENV_PROFILE_INVALID)
|
|
153
|
+
else:
|
|
154
|
+
for ck in connect:
|
|
155
|
+
if ck not in CONNECT_STANDARD_KEYS and not ck.endswith("Env"):
|
|
156
|
+
codes.append(DEV_ENV_SECRET_SURFACE_VIOLATION)
|
|
157
|
+
|
|
158
|
+
env_refs = data.get("env_refs")
|
|
159
|
+
if env_refs is not None and not isinstance(env_refs, list):
|
|
160
|
+
codes.append(DEV_ENV_PROFILE_INVALID)
|
|
161
|
+
|
|
162
|
+
secret_violation = _check_secret_literals(data)
|
|
163
|
+
if secret_violation == DEV_ENV_SECRET_SURFACE_VIOLATION:
|
|
164
|
+
codes.append(DEV_ENV_SECRET_SURFACE_VIOLATION)
|
|
165
|
+
|
|
166
|
+
return codes
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def load_profile(path: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
170
|
+
"""Parse JSON profile; reject inline secrets; names-only schema validation."""
|
|
171
|
+
if not os.path.isfile(path):
|
|
172
|
+
return None, DEV_ENV_PROFILE_MISSING
|
|
173
|
+
try:
|
|
174
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
175
|
+
data = json.load(f)
|
|
176
|
+
except (OSError, json.JSONDecodeError):
|
|
177
|
+
return None, DEV_ENV_PROFILE_INVALID
|
|
178
|
+
|
|
179
|
+
codes = validate_profile_schema(data)
|
|
180
|
+
if codes:
|
|
181
|
+
return None, codes[0]
|
|
182
|
+
return data, None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _match_tier_patterns(rel_path: str, patterns: Tuple[str, ...]) -> bool:
|
|
186
|
+
norm = _normalize_path(rel_path)
|
|
187
|
+
base = os.path.basename(norm)
|
|
188
|
+
for pat in patterns:
|
|
189
|
+
if fnmatch.fnmatch(norm, pat) or fnmatch.fnmatch(base, pat):
|
|
190
|
+
return True
|
|
191
|
+
if "**" in pat:
|
|
192
|
+
prefix = pat.split("**")[0]
|
|
193
|
+
if norm.startswith(prefix) and fnmatch.fnmatch(norm, pat):
|
|
194
|
+
return True
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def classify_touched_files(paths: List[str]) -> Optional[str]:
|
|
199
|
+
"""Return highest matching tier A/B/C or None when no runtime surface matched."""
|
|
200
|
+
highest: Optional[str] = None
|
|
201
|
+
tier_rank = {"A": 3, "B": 2, "C": 1}
|
|
202
|
+
for raw in paths:
|
|
203
|
+
rel = _normalize_path(raw)
|
|
204
|
+
if any(rel.startswith(p) for p in TIER_C_SKIP_PREFIXES):
|
|
205
|
+
if not (_match_tier_patterns(rel, TIER_A_PATTERNS) or _match_tier_patterns(rel, TIER_B_PATTERNS)):
|
|
206
|
+
continue
|
|
207
|
+
if _match_tier_patterns(rel, TIER_A_PATTERNS):
|
|
208
|
+
if highest is None or tier_rank["A"] > tier_rank.get(highest, 0):
|
|
209
|
+
highest = "A"
|
|
210
|
+
elif _match_tier_patterns(rel, TIER_B_PATTERNS):
|
|
211
|
+
if highest is None or tier_rank["B"] > tier_rank.get(highest, 0):
|
|
212
|
+
highest = "B"
|
|
213
|
+
elif not any(rel.startswith(p) for p in TIER_C_SKIP_PREFIXES):
|
|
214
|
+
if highest is None:
|
|
215
|
+
highest = "C"
|
|
216
|
+
return highest
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _read_remote_config(repo: Path) -> Optional[Dict[str, Any]]:
|
|
220
|
+
remote_path = repo / ".cursor" / "remote.json"
|
|
221
|
+
if not remote_path.is_file():
|
|
222
|
+
return None
|
|
223
|
+
try:
|
|
224
|
+
with open(remote_path, "r", encoding="utf-8") as f:
|
|
225
|
+
return json.load(f)
|
|
226
|
+
except (OSError, json.JSONDecodeError):
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _resolve_us0086_remote_target(
|
|
231
|
+
repo: Path, scratchpad: Dict[str, str]
|
|
232
|
+
) -> Optional[Tuple[str, Optional[str]]]:
|
|
233
|
+
"""US-0086 precedence: remote docker/ssh wins over docker-host-local."""
|
|
234
|
+
if scratchpad.get("AUTO_REMOTE_AUTOMATION_PROFILE", "off") != "deterministic_v1":
|
|
235
|
+
return None
|
|
236
|
+
remote = _read_remote_config(repo)
|
|
237
|
+
if not remote:
|
|
238
|
+
return None
|
|
239
|
+
default_id = remote.get("defaultTarget")
|
|
240
|
+
targets = remote.get("targets") or []
|
|
241
|
+
for target in targets:
|
|
242
|
+
if not isinstance(target, dict):
|
|
243
|
+
continue
|
|
244
|
+
tid = target.get("id")
|
|
245
|
+
if tid != default_id:
|
|
246
|
+
continue
|
|
247
|
+
if not target.get("enabled", False):
|
|
248
|
+
return None, DEV_ENV_TARGET_DISABLED
|
|
249
|
+
ttype = target.get("type")
|
|
250
|
+
if ttype == "docker":
|
|
251
|
+
return "docker", None
|
|
252
|
+
if ttype == "ssh":
|
|
253
|
+
return "ssh", None
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _compose_resolvable(repo: Path, profile: Optional[Dict[str, Any]]) -> bool:
|
|
258
|
+
candidates: List[Path] = []
|
|
259
|
+
if profile and profile.get("compose_file"):
|
|
260
|
+
candidates.append(repo / profile["compose_file"])
|
|
261
|
+
candidates.extend(
|
|
262
|
+
[
|
|
263
|
+
repo / "docker-compose.yml",
|
|
264
|
+
repo / "docker-compose.yaml",
|
|
265
|
+
repo / "compose.yml",
|
|
266
|
+
repo / "compose.yaml",
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
return any(p.is_file() for p in candidates)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _local_docker_cli_succeeds() -> bool:
|
|
273
|
+
try:
|
|
274
|
+
proc = subprocess.run(
|
|
275
|
+
["docker", "info"],
|
|
276
|
+
capture_output=True,
|
|
277
|
+
text=True,
|
|
278
|
+
timeout=5,
|
|
279
|
+
check=False,
|
|
280
|
+
)
|
|
281
|
+
return proc.returncode == 0
|
|
282
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
283
|
+
return False
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _dev_server_inferable(scratchpad: Dict[str, str]) -> bool:
|
|
287
|
+
cmd = (scratchpad.get("DEV_SERVER_COMMAND") or "").strip()
|
|
288
|
+
port = (scratchpad.get("DEV_SERVER_PORT") or "").strip()
|
|
289
|
+
return bool(cmd or port)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def detect_mode(
|
|
293
|
+
repo: Path,
|
|
294
|
+
profile: Optional[Dict[str, Any]],
|
|
295
|
+
scratchpad: Dict[str, str],
|
|
296
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
297
|
+
"""
|
|
298
|
+
Detection precedence (DEC-0084 §3):
|
|
299
|
+
profile off -> skip; US-0086 remote wins; compose+local docker -> docker-host-local;
|
|
300
|
+
DEV_SERVER_* -> local; else DEV_ENV_DETECT_AMBIGUOUS.
|
|
301
|
+
"""
|
|
302
|
+
if scratchpad.get("DEV_AUTO_LAUNCH_PROFILE", "off") == "off":
|
|
303
|
+
return None, DEV_ENV_PROFILE_DISABLED
|
|
304
|
+
|
|
305
|
+
remote_result = _resolve_us0086_remote_target(repo, scratchpad)
|
|
306
|
+
if remote_result is not None:
|
|
307
|
+
if isinstance(remote_result, tuple) and len(remote_result) == 2:
|
|
308
|
+
mode, reason = remote_result
|
|
309
|
+
if mode:
|
|
310
|
+
return mode, reason
|
|
311
|
+
if reason:
|
|
312
|
+
return None, reason
|
|
313
|
+
|
|
314
|
+
if _compose_resolvable(repo, profile) and _local_docker_cli_succeeds():
|
|
315
|
+
return "docker-host-local", None
|
|
316
|
+
|
|
317
|
+
if _dev_server_inferable(scratchpad):
|
|
318
|
+
return "local", None
|
|
319
|
+
|
|
320
|
+
if profile and profile.get("detected_mode") in VALID_DETECTED_MODES:
|
|
321
|
+
return profile["detected_mode"], None
|
|
322
|
+
|
|
323
|
+
return None, DEV_ENV_DETECT_AMBIGUOUS
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def build_relaunch_plan(
|
|
327
|
+
mode: str,
|
|
328
|
+
tier: Optional[str],
|
|
329
|
+
profile: Dict[str, Any],
|
|
330
|
+
) -> List[str]:
|
|
331
|
+
"""Command list for tier recipe; no .env reads; retry_count max 2 enforced by caller."""
|
|
332
|
+
if tier is None:
|
|
333
|
+
return []
|
|
334
|
+
service = profile.get("service") or "app"
|
|
335
|
+
compose_file = profile.get("compose_file") or "docker-compose.yml"
|
|
336
|
+
commands: List[str] = []
|
|
337
|
+
if tier == "A":
|
|
338
|
+
if mode in ("docker-host-local", "docker"):
|
|
339
|
+
commands = [
|
|
340
|
+
f"docker compose -f {compose_file} build {service}",
|
|
341
|
+
f"docker compose -f {compose_file} up -d {service}",
|
|
342
|
+
]
|
|
343
|
+
elif mode == "local":
|
|
344
|
+
cmd = profile.get("rebuild_recipe", {}).get("local_build_command")
|
|
345
|
+
if cmd:
|
|
346
|
+
commands = [cmd]
|
|
347
|
+
elif tier == "B":
|
|
348
|
+
if mode in ("docker-host-local", "docker"):
|
|
349
|
+
commands = [f"docker compose -f {compose_file} restart {service}"]
|
|
350
|
+
elif mode == "local":
|
|
351
|
+
cmd = profile.get("rebuild_recipe", {}).get("restart_command")
|
|
352
|
+
if cmd:
|
|
353
|
+
commands = [cmd]
|
|
354
|
+
elif tier == "C":
|
|
355
|
+
if mode == "local":
|
|
356
|
+
cmd = profile.get("rebuild_recipe", {}).get("dev_server_command")
|
|
357
|
+
if not cmd:
|
|
358
|
+
cmd = os.environ.get("DEV_SERVER_COMMAND", "").strip()
|
|
359
|
+
if cmd:
|
|
360
|
+
commands = [cmd]
|
|
361
|
+
elif mode == "docker-host-local":
|
|
362
|
+
restart_on_change = profile.get("rebuild_recipe", {}).get(
|
|
363
|
+
"restart_on_source_change", False
|
|
364
|
+
)
|
|
365
|
+
if restart_on_change:
|
|
366
|
+
commands = [f"docker compose -f {compose_file} restart {service}"]
|
|
367
|
+
return commands
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def format_connect_block(profile: Dict[str, Any], outcome: str) -> str:
|
|
371
|
+
"""Markdown Connect block with mandatory names-only fields."""
|
|
372
|
+
connect = profile.get("connect") or {}
|
|
373
|
+
env_refs = sorted(set(profile.get("env_refs") or []))
|
|
374
|
+
lines = [
|
|
375
|
+
"## Connect",
|
|
376
|
+
"",
|
|
377
|
+
f"- **runtime_mode**: {profile.get('detected_mode', 'unknown')}",
|
|
378
|
+
f"- **connect_endpoint**: {connect.get('endpoint', '(unset)')}",
|
|
379
|
+
f"- **health_path**: {connect.get('health_path', '/')}",
|
|
380
|
+
f"- **service_id**: {profile.get('service', '')}",
|
|
381
|
+
f"- **container_id**: (resolve via operator shell)",
|
|
382
|
+
f"- **target_id**: {profile.get('target_id', '')}",
|
|
383
|
+
f"- **env_refs**: {', '.join(env_refs) if env_refs else '(none)'}",
|
|
384
|
+
f"- **relaunch_outcome**: {outcome}",
|
|
385
|
+
"",
|
|
386
|
+
]
|
|
387
|
+
return "\n".join(lines)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def run_self_test() -> int:
|
|
391
|
+
fixture_path = Path(__file__).resolve().parent.parent / "template" / ".cursor" / "dev-environment.json.example"
|
|
392
|
+
data, err = load_profile(str(fixture_path))
|
|
393
|
+
if err or not data:
|
|
394
|
+
print(f"DEV_ENV_SELF_TEST_FAIL: load_profile {err}", file=sys.stderr)
|
|
395
|
+
return 1
|
|
396
|
+
|
|
397
|
+
tier_a = classify_touched_files(["docker-compose.yml", "docs/foo.md"])
|
|
398
|
+
if tier_a != "A":
|
|
399
|
+
print(f"DEV_ENV_SELF_TEST_FAIL: tier A expected, got {tier_a}", file=sys.stderr)
|
|
400
|
+
return 1
|
|
401
|
+
|
|
402
|
+
tier_none = classify_touched_files(["docs/engineering/runbook.md"])
|
|
403
|
+
if tier_none is not None:
|
|
404
|
+
print(f"DEV_ENV_SELF_TEST_FAIL: docs should skip, got {tier_none}", file=sys.stderr)
|
|
405
|
+
return 1
|
|
406
|
+
|
|
407
|
+
scratchpad = {"DEV_AUTO_LAUNCH_PROFILE": "off"}
|
|
408
|
+
mode, reason = detect_mode(Path("."), data, scratchpad)
|
|
409
|
+
if mode is not None or reason != DEV_ENV_PROFILE_DISABLED:
|
|
410
|
+
print(f"DEV_ENV_SELF_TEST_FAIL: off gate {mode}/{reason}", file=sys.stderr)
|
|
411
|
+
return 1
|
|
412
|
+
|
|
413
|
+
block = format_connect_block(data, "success")
|
|
414
|
+
for field in (
|
|
415
|
+
"runtime_mode",
|
|
416
|
+
"connect_endpoint",
|
|
417
|
+
"health_path",
|
|
418
|
+
"service_id",
|
|
419
|
+
"container_id",
|
|
420
|
+
"target_id",
|
|
421
|
+
"env_refs",
|
|
422
|
+
"relaunch_outcome",
|
|
423
|
+
):
|
|
424
|
+
if field not in block:
|
|
425
|
+
print(f"DEV_ENV_SELF_TEST_FAIL: missing {field}", file=sys.stderr)
|
|
426
|
+
return 1
|
|
427
|
+
|
|
428
|
+
plan = build_relaunch_plan("docker-host-local", "A", data)
|
|
429
|
+
if not plan or "docker compose" not in plan[0]:
|
|
430
|
+
print("DEV_ENV_SELF_TEST_FAIL: relaunch plan", file=sys.stderr)
|
|
431
|
+
return 1
|
|
432
|
+
|
|
433
|
+
if MAX_RETRY_COUNT != 2:
|
|
434
|
+
print("DEV_ENV_SELF_TEST_FAIL: retry_count", file=sys.stderr)
|
|
435
|
+
return 1
|
|
436
|
+
|
|
437
|
+
print("[DEV_ENVIRONMENT_SELF_TEST_OK]")
|
|
438
|
+
return 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def main() -> int:
|
|
442
|
+
p = argparse.ArgumentParser(description="Dev environment profile helper (US-0098).")
|
|
443
|
+
p.add_argument("--self-test", action="store_true", help="Run built-in checks.")
|
|
444
|
+
p.add_argument("--load", metavar="PATH", help="Load and validate profile path.")
|
|
445
|
+
args = p.parse_args()
|
|
446
|
+
|
|
447
|
+
if args.self_test:
|
|
448
|
+
return run_self_test()
|
|
449
|
+
|
|
450
|
+
if args.load:
|
|
451
|
+
_, err = load_profile(args.load)
|
|
452
|
+
if err:
|
|
453
|
+
print(err, file=sys.stderr)
|
|
454
|
+
return 1
|
|
455
|
+
print("[DEV_ENVIRONMENT_LOAD_OK]")
|
|
456
|
+
return 0
|
|
457
|
+
|
|
458
|
+
p.print_help()
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
if __name__ == "__main__":
|
|
463
|
+
sys.exit(main())
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate work/<story_id>/pack.json schema v1 (US-0096 / DEC-0082)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
REQUIRED_FIELDS = (
|
|
13
|
+
"schema_version",
|
|
14
|
+
"story_id",
|
|
15
|
+
"delivery_mode",
|
|
16
|
+
"status",
|
|
17
|
+
"ac",
|
|
18
|
+
"tasks",
|
|
19
|
+
"refs",
|
|
20
|
+
"deltas",
|
|
21
|
+
"memory_layer",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
VALID_DELIVERY_MODES = frozenset({"standard", "ultra_lean", "mega_quick"})
|
|
25
|
+
VALID_MEMORY_LAYER = "pack"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def validate_pack(data: Any, *, expected_story_id: str | None = None) -> list[str]:
|
|
29
|
+
"""Return PACK_* reason codes; empty list means valid."""
|
|
30
|
+
codes: list[str] = []
|
|
31
|
+
if not isinstance(data, dict):
|
|
32
|
+
return ["PACK_INVALID_ROOT"]
|
|
33
|
+
|
|
34
|
+
for field in REQUIRED_FIELDS:
|
|
35
|
+
if field not in data:
|
|
36
|
+
codes.append(f"PACK_MISSING_REQUIRED_FIELD:{field}")
|
|
37
|
+
|
|
38
|
+
if codes:
|
|
39
|
+
return codes
|
|
40
|
+
|
|
41
|
+
if data.get("schema_version") != "1":
|
|
42
|
+
codes.append("PACK_SCHEMA_VERSION_INVALID")
|
|
43
|
+
|
|
44
|
+
story_id = data.get("story_id")
|
|
45
|
+
if not isinstance(story_id, str) or not story_id.strip():
|
|
46
|
+
codes.append("PACK_STORY_ID_INVALID")
|
|
47
|
+
elif expected_story_id and story_id != expected_story_id:
|
|
48
|
+
codes.append("PACK_STORY_ID_MISMATCH")
|
|
49
|
+
|
|
50
|
+
delivery_mode = data.get("delivery_mode")
|
|
51
|
+
if delivery_mode not in VALID_DELIVERY_MODES:
|
|
52
|
+
codes.append("PACK_DELIVERY_MODE_INVALID")
|
|
53
|
+
|
|
54
|
+
if not isinstance(data.get("status"), str) or not str(data["status"]).strip():
|
|
55
|
+
codes.append("PACK_STATUS_INVALID")
|
|
56
|
+
|
|
57
|
+
for list_field in ("ac", "tasks", "refs", "deltas"):
|
|
58
|
+
if not isinstance(data.get(list_field), list):
|
|
59
|
+
codes.append(f"PACK_INVALID_FIELD_TYPE:{list_field}")
|
|
60
|
+
|
|
61
|
+
if data.get("memory_layer") != VALID_MEMORY_LAYER:
|
|
62
|
+
codes.append("PACK_MEMORY_LAYER_INVALID")
|
|
63
|
+
|
|
64
|
+
return codes
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main() -> int:
|
|
68
|
+
p = argparse.ArgumentParser(description="Validate pack.json schema v1 (US-0096).")
|
|
69
|
+
p.add_argument("--file", type=Path, help="Path to pack.json")
|
|
70
|
+
p.add_argument(
|
|
71
|
+
"--story-id",
|
|
72
|
+
help="Expected story_id (e.g. US-0096); mismatch → PACK_STORY_ID_MISMATCH",
|
|
73
|
+
)
|
|
74
|
+
p.add_argument(
|
|
75
|
+
"--self-test",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Run built-in fixture checks and exit.",
|
|
78
|
+
)
|
|
79
|
+
args = p.parse_args()
|
|
80
|
+
|
|
81
|
+
if args.self_test:
|
|
82
|
+
valid = {
|
|
83
|
+
"schema_version": "1",
|
|
84
|
+
"story_id": "US-0096",
|
|
85
|
+
"delivery_mode": "ultra_lean",
|
|
86
|
+
"status": "OPEN",
|
|
87
|
+
"ac": ["AC-1"],
|
|
88
|
+
"tasks": [{"id": "T-001", "status": "pending"}],
|
|
89
|
+
"refs": ["decisions/DEC-0082.md"],
|
|
90
|
+
"deltas": [],
|
|
91
|
+
"memory_layer": "pack",
|
|
92
|
+
}
|
|
93
|
+
if validate_pack(valid):
|
|
94
|
+
print("PACK_SELF_TEST_FAIL: valid fixture rejected", file=sys.stderr)
|
|
95
|
+
return 1
|
|
96
|
+
invalid = dict(valid)
|
|
97
|
+
del invalid["story_id"]
|
|
98
|
+
if "PACK_MISSING_REQUIRED_FIELD:story_id" not in validate_pack(invalid):
|
|
99
|
+
print("PACK_SELF_TEST_FAIL: missing field not detected", file=sys.stderr)
|
|
100
|
+
return 1
|
|
101
|
+
print("[PACK_JSON_SELF_TEST_OK]")
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
if not args.file:
|
|
105
|
+
print("error: specify --file or --self-test", file=sys.stderr)
|
|
106
|
+
return 2
|
|
107
|
+
|
|
108
|
+
path = args.file.resolve()
|
|
109
|
+
if not path.is_file():
|
|
110
|
+
print(f"PACK_FILE_NOT_FOUND: {path}", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
115
|
+
except json.JSONDecodeError as exc:
|
|
116
|
+
print(f"PACK_INVALID_JSON: {exc}", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
|
|
119
|
+
codes = validate_pack(data, expected_story_id=args.story_id)
|
|
120
|
+
if codes:
|
|
121
|
+
for code in codes:
|
|
122
|
+
print(code, file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
print("[PACK_JSON_VALIDATION_OK]")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
raise SystemExit(main())
|