its-magic 0.1.2-9 → 0.1.3-0

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 (237) hide show
  1. package/README.md +1602 -755
  2. package/bin/its-magic.js +121 -11
  3. package/bin/postinstall.js +93 -0
  4. package/installer.ps1 +759 -0
  5. package/installer.py +1036 -0
  6. package/installer.sh +649 -0
  7. package/package.json +18 -14
  8. package/scripts/check_intake_template_parity.py +312 -0
  9. package/scripts/doc_profile_lib.py +415 -0
  10. package/scripts/guard_installer_publish.py +88 -0
  11. package/scripts/intake_bug_routing_guard.py +67 -0
  12. package/scripts/intake_evidence_lib.py +802 -0
  13. package/scripts/intake_evidence_validate.py +73 -0
  14. package/scripts/materialize_codebase_map.py +184 -0
  15. package/scripts/remote_config_summary.py +243 -0
  16. package/template/.cursor/agents/curator.mdc +35 -0
  17. package/template/.cursor/agents/dev.mdc +29 -0
  18. package/template/.cursor/agents/po.mdc +141 -0
  19. package/template/.cursor/agents/qa.mdc +28 -0
  20. package/template/.cursor/agents/release.mdc +28 -0
  21. package/template/.cursor/agents/security.mdc +98 -0
  22. package/template/.cursor/agents/tech-lead.mdc +57 -0
  23. package/template/.cursor/commands/architecture.md +127 -0
  24. package/template/.cursor/commands/ask.md +55 -0
  25. package/template/.cursor/commands/auto.md +533 -0
  26. package/template/.cursor/commands/discovery.md +47 -0
  27. package/template/.cursor/commands/execute.md +343 -0
  28. package/template/.cursor/commands/intake.md +323 -0
  29. package/template/.cursor/commands/map-codebase.md +46 -0
  30. package/template/.cursor/commands/memory-audit.md +175 -0
  31. package/template/.cursor/commands/milestone-complete.md +51 -0
  32. package/template/.cursor/commands/milestone-start.md +59 -0
  33. package/template/.cursor/commands/pause.md +64 -0
  34. package/{.cursor/commands/gsd-phase-context.md → template/.cursor/commands/phase-context.md} +6 -2
  35. package/template/.cursor/commands/plan-verify.md +34 -0
  36. package/template/.cursor/commands/qa.md +226 -0
  37. package/template/.cursor/commands/quick.md +41 -0
  38. package/template/.cursor/commands/refresh-context.md +82 -0
  39. package/template/.cursor/commands/release.md +523 -0
  40. package/template/.cursor/commands/research.md +49 -0
  41. package/template/.cursor/commands/resume.md +67 -0
  42. package/template/.cursor/commands/security-review.md +81 -0
  43. package/template/.cursor/commands/sprint-plan.md +103 -0
  44. package/template/.cursor/commands/status-reconcile.md +95 -0
  45. package/template/.cursor/commands/verify-work.md +152 -0
  46. package/template/.cursor/dev-environment.json.example +22 -0
  47. package/{.cursor → template/.cursor}/hooks/README.md +3 -2
  48. package/{.cursor/hooks/gsd-hook.py → template/.cursor/hooks/hook.py} +15 -6
  49. package/template/.cursor/hooks.json +26 -0
  50. package/template/.cursor/remote.json +31 -0
  51. package/template/.cursor/rules/caveman.mdc +184 -0
  52. package/template/.cursor/rules/coding-standards.mdc +39 -0
  53. package/template/.cursor/rules/core.mdc +111 -0
  54. package/template/.cursor/rules/handoffs.mdc +27 -0
  55. package/template/.cursor/rules/quality.mdc +49 -0
  56. package/template/.cursor/scratchpad.local.example.md +323 -0
  57. package/template/.cursor/scratchpad.md +324 -0
  58. package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/SKILL.md +2 -2
  59. package/template/.cursorignore +6 -0
  60. package/template/.env.example +28 -0
  61. package/template/.github/workflows/ci.yml +194 -0
  62. package/{.github → template/.github}/workflows/deploy.yml +5 -2
  63. package/template/.its-magic-version +1 -0
  64. package/template/CHANGELOG.md +11 -0
  65. package/template/README.md +1602 -0
  66. package/template/decisions/DEC-0001.md +11 -0
  67. package/template/decisions/DEC-0002.md +11 -0
  68. package/template/docs/developer/README.md +44 -0
  69. package/template/docs/engineering/architecture.md +9 -0
  70. package/template/docs/engineering/artifact-ordering-policy.md +48 -0
  71. package/template/docs/engineering/artifact-ownership-policy.md +57 -0
  72. package/template/docs/engineering/auto-orchestration-reference.md +1211 -0
  73. package/{docs → template/docs}/engineering/codebase-map.md +0 -5
  74. package/template/docs/engineering/compatibility-report.md +20 -0
  75. package/template/docs/engineering/compatibility-signals.md +7 -0
  76. package/template/docs/engineering/component-scope-report.md +16 -0
  77. package/template/docs/engineering/component-scope.md +19 -0
  78. package/template/docs/engineering/context/installer-owned-paths.manifest +96 -0
  79. package/template/docs/engineering/context/readme-section-affinity.json +30 -0
  80. package/template/docs/engineering/decisions.md +16 -0
  81. package/template/docs/engineering/legacy-drift-audit.md +21 -0
  82. package/template/docs/engineering/manifests/components/api-gateway.manifest.yaml +12 -0
  83. package/template/docs/engineering/manifests/registry.manifest.yaml +20 -0
  84. package/template/docs/engineering/manifests/repo.manifest.yaml +11 -0
  85. package/template/docs/engineering/phase-context.md +16 -0
  86. package/template/docs/engineering/release-targets.json +123 -0
  87. package/template/docs/engineering/research.md +29 -0
  88. package/template/docs/engineering/runbook.md +2391 -0
  89. package/template/docs/engineering/runtime-connectivity.md +81 -0
  90. package/template/docs/engineering/security-review.md +33 -0
  91. package/template/docs/engineering/spec-pack/README.md +20 -0
  92. package/template/docs/engineering/state-archive/README.md +15 -0
  93. package/template/docs/engineering/state.md +20 -0
  94. package/template/docs/engineering/status-normalization-report.md +19 -0
  95. package/template/docs/engineering/token-cost-parity-manifest.md +16 -0
  96. package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
  97. package/template/docs/product/acceptance.md +1 -0
  98. package/template/docs/product/backlog.md +5 -0
  99. package/template/docs/product/vision.md +11 -0
  100. package/template/docs/user-guides/README.md +40 -0
  101. package/{handoffs → template/handoffs}/dev_to_qa.md +0 -3
  102. package/{handoffs → template/handoffs}/po_to_tl.md +0 -3
  103. package/{handoffs → template/handoffs}/qa_to_dev.md +1 -2
  104. package/template/handoffs/release_notes.md +51 -0
  105. package/template/handoffs/release_queue.md +47 -0
  106. package/template/handoffs/release_to_dev.md +31 -0
  107. package/template/handoffs/releases/Sxxxx-release-notes.md +62 -0
  108. package/template/handoffs/releases/vX.Y.Z-release-notes.md.example +21 -0
  109. package/template/handoffs/resume_brief.md +14 -0
  110. package/{handoffs → template/handoffs}/tl_to_dev.md +0 -2
  111. package/template/handoffs/token_cost_runs/README.md +26 -0
  112. package/template/its_magic/.its-magic-version +1 -0
  113. package/template/its_magic/README.md +9 -0
  114. package/template/scripts/auto_outer_driver.py +521 -0
  115. package/template/scripts/caveman_compress_input.py +903 -0
  116. package/template/scripts/check_downstream_ci_guard.py +67 -0
  117. package/template/scripts/check_intake_template_parity.py +312 -0
  118. package/template/scripts/check_token_cost_parity.py +69 -0
  119. package/template/scripts/dev_environment_lib.py +601 -0
  120. package/template/scripts/doc_profile_lib.py +415 -0
  121. package/template/scripts/downstream_ci_guard_lib.py +222 -0
  122. package/template/scripts/enforce-triad-hot-surface.py +753 -0
  123. package/template/scripts/guard_installer_publish.py +88 -0
  124. package/template/scripts/intake_bug_resume_brief_refresh.py +303 -0
  125. package/template/scripts/intake_bug_routing_guard.py +67 -0
  126. package/template/scripts/intake_evidence_lib.py +802 -0
  127. package/template/scripts/intake_evidence_validate.py +73 -0
  128. package/template/scripts/materialize_codebase_map.py +184 -0
  129. package/template/scripts/pack_json_validate.py +130 -0
  130. package/template/scripts/project_readme_coverage_lib.py +417 -0
  131. package/template/scripts/readme_feature_coverage_lib.py +608 -0
  132. package/{scripts → template/scripts}/release-all.sh +27 -4
  133. package/template/scripts/release_changelog_backfill.py +153 -0
  134. package/template/scripts/release_changelog_lib.py +544 -0
  135. package/template/scripts/release_changelog_validate.py +134 -0
  136. package/template/scripts/remote_config_summary.py +243 -0
  137. package/template/scripts/sync_push_gates.py +198 -0
  138. package/template/scripts/token_cost_compare.py +40 -0
  139. package/template/scripts/token_cost_lib.py +108 -0
  140. package/template/scripts/uat_probe_lib.py +868 -0
  141. package/template/scripts/validate-and-push.ps1 +280 -0
  142. package/template/scripts/validate-and-push.sh +243 -0
  143. package/template/scripts/validate_doc_profile.py +103 -0
  144. package/template/scripts/validate_project_readme_coverage.py +151 -0
  145. package/template/scripts/validate_readme_feature_coverage.py +140 -0
  146. package/template/sprints/S0001/progress.md +1 -0
  147. package/template/sprints/S0001/qa-findings.md +9 -0
  148. package/template/sprints/S0001/release-findings.md +24 -0
  149. package/template/sprints/S0001/sprint.md +9 -0
  150. package/template/sprints/S0001/summary.md +7 -0
  151. package/template/sprints/S0001/tasks.md +1 -0
  152. package/template/sprints/S0001/uat.json +6 -0
  153. package/template/sprints/S0001/uat.md +5 -0
  154. package/template/sprints/quick/Q0001/summary.md +1 -0
  155. package/{sprints → template/sprints}/quick/Q0001/task.json +0 -1
  156. package/.cursor/agents/curator.mdc +0 -21
  157. package/.cursor/agents/dev.mdc +0 -20
  158. package/.cursor/agents/po.mdc +0 -19
  159. package/.cursor/agents/qa.mdc +0 -19
  160. package/.cursor/agents/release.mdc +0 -19
  161. package/.cursor/agents/tech-lead.mdc +0 -21
  162. package/.cursor/commands/gsd-architecture.md +0 -29
  163. package/.cursor/commands/gsd-auto.md +0 -27
  164. package/.cursor/commands/gsd-discovery.md +0 -27
  165. package/.cursor/commands/gsd-execute.md +0 -32
  166. package/.cursor/commands/gsd-intake.md +0 -28
  167. package/.cursor/commands/gsd-map-codebase.md +0 -25
  168. package/.cursor/commands/gsd-milestone-complete.md +0 -24
  169. package/.cursor/commands/gsd-milestone-start.md +0 -26
  170. package/.cursor/commands/gsd-pause.md +0 -25
  171. package/.cursor/commands/gsd-plan-verify.md +0 -26
  172. package/.cursor/commands/gsd-qa.md +0 -28
  173. package/.cursor/commands/gsd-quick.md +0 -24
  174. package/.cursor/commands/gsd-refresh-context.md +0 -26
  175. package/.cursor/commands/gsd-release.md +0 -29
  176. package/.cursor/commands/gsd-research.md +0 -28
  177. package/.cursor/commands/gsd-resume.md +0 -26
  178. package/.cursor/commands/gsd-sprint-plan.md +0 -30
  179. package/.cursor/commands/gsd-verify-work.md +0 -25
  180. package/.cursor/hooks.json +0 -26
  181. package/.cursor/plans/cursor-gsd-team-kit_8cfee9b8.plan.md +0 -57
  182. package/.cursor/remote.json +0 -18
  183. package/.cursor/rules/gsd-core.mdc +0 -18
  184. package/.cursor/rules/gsd-handoffs.mdc +0 -10
  185. package/.cursor/rules/gsd-quality.mdc +0 -15
  186. package/.cursor/scratchpad.md +0 -34
  187. package/.github/workflows/ci.yml +0 -47
  188. package/decisions/DEC-0001.md +0 -21
  189. package/decisions/DEC-0002.md +0 -21
  190. package/docs/engineering/architecture.md +0 -354
  191. package/docs/engineering/decisions.md +0 -6
  192. package/docs/engineering/research.md +0 -11
  193. package/docs/engineering/runbook.md +0 -32
  194. package/docs/engineering/state.md +0 -33
  195. package/docs/product/acceptance.md +0 -6
  196. package/docs/product/backlog.md +0 -7
  197. package/docs/product/vision.md +0 -46
  198. package/gsd-installer.ps1 +0 -189
  199. package/gsd-installer.py +0 -195
  200. package/gsd-installer.sh +0 -201
  201. package/handoffs/release_notes.md +0 -14
  202. package/handoffs/resume_brief.md +0 -8
  203. package/milestones/M0001/milestone.json +0 -7
  204. package/milestones/M0001/phases.json +0 -9
  205. package/milestones/M0001/progress.md +0 -3
  206. package/milestones/M0001/summary.md +0 -3
  207. package/scripts/generate-release-notes.ps1 +0 -74
  208. package/scripts/generate-release-notes.sh +0 -63
  209. package/scripts/release-all.ps1 +0 -423
  210. package/sprints/S0001/progress.md +0 -4
  211. package/sprints/S0001/qa-findings.md +0 -113
  212. package/sprints/S0001/sprint.md +0 -70
  213. package/sprints/S0001/summary.md +0 -46
  214. package/sprints/S0001/tasks.md +0 -35
  215. package/sprints/S0001/uat.json +0 -8
  216. package/sprints/S0001/uat.md +0 -8
  217. package/sprints/quick/Q0001/summary.md +0 -3
  218. /package/{.cursor/rules/gsd-escalation.mdc → template/.cursor/rules/escalation.mdc} +0 -0
  219. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.json +0 -0
  220. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/acceptance.md +0 -0
  221. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.json +0 -0
  222. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/architecture.md +0 -0
  223. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.json +0 -0
  224. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/decision.md +0 -0
  225. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.json +0 -0
  226. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/handoff.md +0 -0
  227. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/milestone.json +0 -0
  228. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/phase-context.json +0 -0
  229. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/plan-verify.json +0 -0
  230. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.json +0 -0
  231. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/sprint.md +0 -0
  232. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.json +0 -0
  233. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/story.md +0 -0
  234. /package/{.cursor/skills/gsd-team → template/.cursor/skills/its-magic}/templates/uat.json +0 -0
  235. /package/{docs → template/docs}/engineering/context/phase-template.json +0 -0
  236. /package/{docs → template/docs}/engineering/dependencies.json +0 -0
  237. /package/{sprints → template/sprints}/S0001/plan-verify.json +0 -0
@@ -0,0 +1,601 @@
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 shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional, Tuple
17
+
18
+ SCHEMA_VERSION = 1
19
+ DEFAULT_PROFILE_PATH = ".cursor/dev-environment.json"
20
+ MAX_RETRY_COUNT = 2
21
+ RETRY_DELAYS_SECONDS = (5, 15)
22
+
23
+ VALID_DETECTED_MODES = frozenset({"local", "docker-host-local", "docker", "ssh"})
24
+ VALID_TIERS = frozenset({"A", "B", "C"})
25
+ CONNECT_STANDARD_KEYS = frozenset({"endpoint", "health_path"})
26
+ SECRET_LIKE_PATTERN = re.compile(
27
+ r"(?i)(password|secret|token|api[_-]?key|private[_-]?key)\s*[:=]\s*['\"]?[^'\"]{8,}"
28
+ )
29
+
30
+ # DEV_ENV_PROFILE_* reason codes (DEC-0084 §10)
31
+ DEV_ENV_PROFILE_DISABLED = "DEV_ENV_PROFILE_DISABLED"
32
+ DEV_ENV_PROFILE_INVALID = "DEV_ENV_PROFILE_INVALID"
33
+ DEV_ENV_PROFILE_MISSING = "DEV_ENV_PROFILE_MISSING"
34
+ DEV_ENV_DETECT_AMBIGUOUS = "DEV_ENV_DETECT_AMBIGUOUS"
35
+ DEV_ENV_COMPOSE_UNRESOLVED = "DEV_ENV_COMPOSE_UNRESOLVED"
36
+ DEV_ENV_TARGET_DISABLED = "DEV_ENV_TARGET_DISABLED"
37
+ DEV_ENV_SECRET_SURFACE_VIOLATION = "DEV_ENV_SECRET_SURFACE_VIOLATION"
38
+
39
+ # DEV_ENV_RELAUNCH_* reason codes (DEC-0084 §10)
40
+ DEV_ENV_RELAUNCH_SKIPPED_NO_SURFACE = "DEV_ENV_RELAUNCH_SKIPPED_NO_SURFACE"
41
+ DEV_ENV_RELAUNCH_SKIPPED_PROFILE_OFF = "DEV_ENV_RELAUNCH_SKIPPED_PROFILE_OFF"
42
+ DEV_ENV_RELAUNCH_FAILED = "DEV_ENV_RELAUNCH_FAILED"
43
+ DEV_ENV_RELAUNCH_RETRY_EXHAUSTED = "DEV_ENV_RELAUNCH_RETRY_EXHAUSTED"
44
+ DEV_ENV_RELAUNCH_TIMEOUT = "DEV_ENV_RELAUNCH_TIMEOUT"
45
+ DEV_ENV_CONNECT_UNAVAILABLE = "DEV_ENV_CONNECT_UNAVAILABLE"
46
+
47
+ PROFILE_REASON_CODES = (
48
+ DEV_ENV_PROFILE_DISABLED,
49
+ DEV_ENV_PROFILE_INVALID,
50
+ DEV_ENV_PROFILE_MISSING,
51
+ DEV_ENV_DETECT_AMBIGUOUS,
52
+ DEV_ENV_COMPOSE_UNRESOLVED,
53
+ DEV_ENV_TARGET_DISABLED,
54
+ DEV_ENV_SECRET_SURFACE_VIOLATION,
55
+ )
56
+
57
+ RELAUNCH_REASON_CODES = (
58
+ DEV_ENV_RELAUNCH_SKIPPED_NO_SURFACE,
59
+ DEV_ENV_RELAUNCH_SKIPPED_PROFILE_OFF,
60
+ DEV_ENV_RELAUNCH_FAILED,
61
+ DEV_ENV_RELAUNCH_RETRY_EXHAUSTED,
62
+ DEV_ENV_RELAUNCH_TIMEOUT,
63
+ DEV_ENV_CONNECT_UNAVAILABLE,
64
+ )
65
+
66
+ # DEV_ENV_BOOTSTRAP_* reason codes (DEC-0084 § bootstrap posture / US-0099)
67
+ DEV_ENV_BOOTSTRAP_COPIED = "DEV_ENV_BOOTSTRAP_COPIED"
68
+ DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS = "DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS"
69
+ DEV_ENV_BOOTSTRAP_PATH_INVALID = "DEV_ENV_BOOTSTRAP_PATH_INVALID"
70
+ DEV_ENV_BOOTSTRAP_SOURCE_MISSING = "DEV_ENV_BOOTSTRAP_SOURCE_MISSING"
71
+
72
+ BOOTSTRAP_REASON_CODES = (
73
+ DEV_ENV_BOOTSTRAP_COPIED,
74
+ DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS,
75
+ DEV_ENV_BOOTSTRAP_PATH_INVALID,
76
+ DEV_ENV_BOOTSTRAP_SOURCE_MISSING,
77
+ )
78
+
79
+ SCRATCHPAD_EXAMPLE_REL = ".cursor/scratchpad.local.example.md"
80
+ SCRATCHPAD_BASELINE_REL = ".cursor/scratchpad.md"
81
+ SCRATCHPAD_LOCAL_REL = ".cursor/scratchpad.local.md"
82
+ DEV_ENV_EXAMPLE_REL = ".cursor/dev-environment.json.example"
83
+
84
+ TIER_A_PATTERNS = (
85
+ "Dockerfile",
86
+ "Dockerfile.*",
87
+ "*Dockerfile",
88
+ "docker-compose*.yml",
89
+ "docker-compose*.yaml",
90
+ "compose.y*ml",
91
+ "package-lock.json",
92
+ "yarn.lock",
93
+ "pnpm-lock.yaml",
94
+ "Pipfile.lock",
95
+ "poetry.lock",
96
+ "go.sum",
97
+ "Cargo.lock",
98
+ "requirements.txt",
99
+ "pyproject.toml",
100
+ "package.json",
101
+ "Gemfile.lock",
102
+ )
103
+
104
+ TIER_B_PATTERNS = (
105
+ "*.env.example",
106
+ "nginx*.conf",
107
+ "traefik*.yml",
108
+ "traefik*.yaml",
109
+ "traefik*.toml",
110
+ "application.y*ml",
111
+ "application.y*aml",
112
+ "scripts/docker/**",
113
+ "docker/**/entrypoint*",
114
+ "docker/**/Dockerfile*",
115
+ )
116
+
117
+ TIER_C_SKIP_PREFIXES = (
118
+ "docs/",
119
+ "handoffs/",
120
+ "sprints/",
121
+ "decisions/",
122
+ "tests/",
123
+ ".cursor/commands/",
124
+ "template/docs/",
125
+ )
126
+
127
+
128
+ def _normalize_path(path: str) -> str:
129
+ return path.replace("\\", "/")
130
+
131
+
132
+ def _parse_scratchpad_file(path: Path) -> Dict[str, str]:
133
+ if not path.is_file():
134
+ return {}
135
+ out: Dict[str, str] = {}
136
+ with open(path, "r", encoding="utf-8") as f:
137
+ for raw in f:
138
+ line = raw.strip()
139
+ if not line or line.startswith("#") or line.startswith("- "):
140
+ continue
141
+ if "=" not in line:
142
+ continue
143
+ key, _, val = line.partition("=")
144
+ key = key.strip()
145
+ if key:
146
+ out[key] = val.strip()
147
+ return out
148
+
149
+
150
+ def read_merged_scratchpad(target_root: Path) -> Dict[str, str]:
151
+ """Model B merge precedence: local > materialized baseline > example."""
152
+ example = _parse_scratchpad_file(target_root / SCRATCHPAD_EXAMPLE_REL)
153
+ baseline = _parse_scratchpad_file(target_root / SCRATCHPAD_BASELINE_REL)
154
+ local = _parse_scratchpad_file(target_root / SCRATCHPAD_LOCAL_REL)
155
+ merged: Dict[str, str] = {}
156
+ for key in set(example) | set(baseline) | set(local):
157
+ if key in local:
158
+ merged[key] = local[key]
159
+ elif key in baseline:
160
+ merged[key] = baseline[key]
161
+ elif key in example:
162
+ merged[key] = example[key]
163
+ return merged
164
+
165
+
166
+ def resolve_profile_path(
167
+ target_root: Path | str,
168
+ scratchpad: Optional[Dict[str, str]] = None,
169
+ ) -> Tuple[Optional[Path], Optional[str]]:
170
+ """Resolve profile path from scratchpad override or default; fail closed on invalid override."""
171
+ root = Path(target_root).resolve()
172
+ pad = scratchpad or {}
173
+ override = (pad.get("DEV_ENVIRONMENT_CONFIG") or "").strip()
174
+ rel = override if override else DEFAULT_PROFILE_PATH
175
+ norm = _normalize_path(rel)
176
+
177
+ if os.path.isabs(rel) or (len(rel) > 1 and rel[1] == ":"):
178
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
179
+ if ".." in norm.split("/"):
180
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
181
+ if not norm.endswith(".json"):
182
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
183
+
184
+ resolved = (root / norm).resolve()
185
+ try:
186
+ resolved.relative_to(root)
187
+ except ValueError:
188
+ return None, DEV_ENV_BOOTSTRAP_PATH_INVALID
189
+ return resolved, None
190
+
191
+
192
+ def bootstrap_dev_environment_profile(
193
+ target_root: Path | str,
194
+ source_root: Optional[Path | str] = None,
195
+ scratchpad: Optional[Dict[str, str]] = None,
196
+ ) -> Tuple[str, str]:
197
+ """
198
+ Copy example profile when target absent (non-destructive).
199
+ Returns (reason_code, log_channel) where log_channel is stdout or stderr.
200
+ """
201
+ root = Path(target_root).resolve()
202
+ if source_root is None:
203
+ src_root = Path(__file__).resolve().parent.parent / "template"
204
+ else:
205
+ src_root = Path(source_root).resolve()
206
+
207
+ profile_path, path_err = resolve_profile_path(root, scratchpad)
208
+ if path_err:
209
+ print(f"[DEV_ENV_BOOTSTRAP_ERROR] {path_err}", file=sys.stderr)
210
+ return path_err, "stderr"
211
+
212
+ source_path = src_root / DEV_ENV_EXAMPLE_REL
213
+ if not source_path.is_file():
214
+ print(f"[DEV_ENV_BOOTSTRAP_ERROR] {DEV_ENV_BOOTSTRAP_SOURCE_MISSING}", file=sys.stderr)
215
+ return DEV_ENV_BOOTSTRAP_SOURCE_MISSING, "stderr"
216
+
217
+ rel_target = profile_path.relative_to(root).as_posix()
218
+ if profile_path.is_file():
219
+ print(f"[DEV_ENV_BOOTSTRAP_OK] skipped: profile exists at {rel_target}")
220
+ return DEV_ENV_BOOTSTRAP_SKIPPED_EXISTS, "stdout"
221
+
222
+ profile_path.parent.mkdir(parents=True, exist_ok=True)
223
+ shutil.copy2(source_path, profile_path)
224
+ print(f"[DEV_ENV_BOOTSTRAP_OK] copied: {rel_target}")
225
+ return DEV_ENV_BOOTSTRAP_COPIED, "stdout"
226
+
227
+
228
+ def run_bootstrap_cli(target: Path, source_root: Path) -> int:
229
+ scratchpad = read_merged_scratchpad(target)
230
+ reason, _channel = bootstrap_dev_environment_profile(target, source_root, scratchpad)
231
+ if reason in (DEV_ENV_BOOTSTRAP_PATH_INVALID, DEV_ENV_BOOTSTRAP_SOURCE_MISSING):
232
+ return 1
233
+ return 0
234
+
235
+
236
+ def _check_secret_literals(obj: Any, path: str = "") -> Optional[str]:
237
+ if isinstance(obj, dict):
238
+ for key, value in obj.items():
239
+ full = f"{path}.{key}" if path else key
240
+ if key == "connect" and isinstance(value, dict):
241
+ for ck, cv in value.items():
242
+ if ck not in CONNECT_STANDARD_KEYS and not ck.endswith("Env"):
243
+ return f"connect key must be *Env or standard: {ck}"
244
+ if isinstance(cv, str) and SECRET_LIKE_PATTERN.search(cv):
245
+ return DEV_ENV_SECRET_SURFACE_VIOLATION
246
+ child = _check_secret_literals(value, full)
247
+ if child:
248
+ return child
249
+ elif isinstance(obj, list):
250
+ for i, item in enumerate(obj):
251
+ child = _check_secret_literals(item, f"{path}[{i}]")
252
+ if child:
253
+ return child
254
+ elif isinstance(obj, str):
255
+ if SECRET_LIKE_PATTERN.search(obj):
256
+ return DEV_ENV_SECRET_SURFACE_VIOLATION
257
+ return None
258
+
259
+
260
+ def validate_profile_schema(data: Any) -> List[str]:
261
+ codes: List[str] = []
262
+ if not isinstance(data, dict):
263
+ return [DEV_ENV_PROFILE_INVALID]
264
+
265
+ if data.get("schema_version") != SCHEMA_VERSION:
266
+ codes.append(DEV_ENV_PROFILE_INVALID)
267
+
268
+ mode = data.get("detected_mode")
269
+ if mode is not None and mode not in VALID_DETECTED_MODES:
270
+ codes.append(DEV_ENV_PROFILE_INVALID)
271
+
272
+ connect = data.get("connect")
273
+ if connect is not None:
274
+ if not isinstance(connect, dict):
275
+ codes.append(DEV_ENV_PROFILE_INVALID)
276
+ else:
277
+ for ck in connect:
278
+ if ck not in CONNECT_STANDARD_KEYS and not ck.endswith("Env"):
279
+ codes.append(DEV_ENV_SECRET_SURFACE_VIOLATION)
280
+
281
+ env_refs = data.get("env_refs")
282
+ if env_refs is not None and not isinstance(env_refs, list):
283
+ codes.append(DEV_ENV_PROFILE_INVALID)
284
+
285
+ secret_violation = _check_secret_literals(data)
286
+ if secret_violation == DEV_ENV_SECRET_SURFACE_VIOLATION:
287
+ codes.append(DEV_ENV_SECRET_SURFACE_VIOLATION)
288
+
289
+ return codes
290
+
291
+
292
+ def load_profile(path: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
293
+ """Parse JSON profile; reject inline secrets; names-only schema validation."""
294
+ if not os.path.isfile(path):
295
+ return None, DEV_ENV_PROFILE_MISSING
296
+ try:
297
+ with open(path, "r", encoding="utf-8") as f:
298
+ data = json.load(f)
299
+ except (OSError, json.JSONDecodeError):
300
+ return None, DEV_ENV_PROFILE_INVALID
301
+
302
+ codes = validate_profile_schema(data)
303
+ if codes:
304
+ return None, codes[0]
305
+ return data, None
306
+
307
+
308
+ def _match_tier_patterns(rel_path: str, patterns: Tuple[str, ...]) -> bool:
309
+ norm = _normalize_path(rel_path)
310
+ base = os.path.basename(norm)
311
+ for pat in patterns:
312
+ if fnmatch.fnmatch(norm, pat) or fnmatch.fnmatch(base, pat):
313
+ return True
314
+ if "**" in pat:
315
+ prefix = pat.split("**")[0]
316
+ if norm.startswith(prefix) and fnmatch.fnmatch(norm, pat):
317
+ return True
318
+ return False
319
+
320
+
321
+ def classify_touched_files(paths: List[str]) -> Optional[str]:
322
+ """Return highest matching tier A/B/C or None when no runtime surface matched."""
323
+ highest: Optional[str] = None
324
+ tier_rank = {"A": 3, "B": 2, "C": 1}
325
+ for raw in paths:
326
+ rel = _normalize_path(raw)
327
+ if any(rel.startswith(p) for p in TIER_C_SKIP_PREFIXES):
328
+ if not (_match_tier_patterns(rel, TIER_A_PATTERNS) or _match_tier_patterns(rel, TIER_B_PATTERNS)):
329
+ continue
330
+ if _match_tier_patterns(rel, TIER_A_PATTERNS):
331
+ if highest is None or tier_rank["A"] > tier_rank.get(highest, 0):
332
+ highest = "A"
333
+ elif _match_tier_patterns(rel, TIER_B_PATTERNS):
334
+ if highest is None or tier_rank["B"] > tier_rank.get(highest, 0):
335
+ highest = "B"
336
+ elif not any(rel.startswith(p) for p in TIER_C_SKIP_PREFIXES):
337
+ if highest is None:
338
+ highest = "C"
339
+ return highest
340
+
341
+
342
+ def _read_remote_config(repo: Path) -> Optional[Dict[str, Any]]:
343
+ remote_path = repo / ".cursor" / "remote.json"
344
+ if not remote_path.is_file():
345
+ return None
346
+ try:
347
+ with open(remote_path, "r", encoding="utf-8") as f:
348
+ return json.load(f)
349
+ except (OSError, json.JSONDecodeError):
350
+ return None
351
+
352
+
353
+ def _resolve_us0086_remote_target(
354
+ repo: Path, scratchpad: Dict[str, str]
355
+ ) -> Optional[Tuple[str, Optional[str]]]:
356
+ """US-0086 precedence: remote docker/ssh wins over docker-host-local."""
357
+ if scratchpad.get("AUTO_REMOTE_AUTOMATION_PROFILE", "off") != "deterministic_v1":
358
+ return None
359
+ remote = _read_remote_config(repo)
360
+ if not remote:
361
+ return None
362
+ default_id = remote.get("defaultTarget")
363
+ targets = remote.get("targets") or []
364
+ for target in targets:
365
+ if not isinstance(target, dict):
366
+ continue
367
+ tid = target.get("id")
368
+ if tid != default_id:
369
+ continue
370
+ if not target.get("enabled", False):
371
+ return None, DEV_ENV_TARGET_DISABLED
372
+ ttype = target.get("type")
373
+ if ttype == "docker":
374
+ return "docker", None
375
+ if ttype == "ssh":
376
+ return "ssh", None
377
+ return None
378
+
379
+
380
+ def _compose_resolvable(repo: Path, profile: Optional[Dict[str, Any]]) -> bool:
381
+ candidates: List[Path] = []
382
+ if profile and profile.get("compose_file"):
383
+ candidates.append(repo / profile["compose_file"])
384
+ candidates.extend(
385
+ [
386
+ repo / "docker-compose.yml",
387
+ repo / "docker-compose.yaml",
388
+ repo / "compose.yml",
389
+ repo / "compose.yaml",
390
+ ]
391
+ )
392
+ return any(p.is_file() for p in candidates)
393
+
394
+
395
+ def _local_docker_cli_succeeds() -> bool:
396
+ try:
397
+ proc = subprocess.run(
398
+ ["docker", "info"],
399
+ capture_output=True,
400
+ text=True,
401
+ timeout=5,
402
+ check=False,
403
+ )
404
+ return proc.returncode == 0
405
+ except (OSError, subprocess.TimeoutExpired):
406
+ return False
407
+
408
+
409
+ def _dev_server_inferable(scratchpad: Dict[str, str]) -> bool:
410
+ cmd = (scratchpad.get("DEV_SERVER_COMMAND") or "").strip()
411
+ port = (scratchpad.get("DEV_SERVER_PORT") or "").strip()
412
+ return bool(cmd or port)
413
+
414
+
415
+ def detect_mode(
416
+ repo: Path,
417
+ profile: Optional[Dict[str, Any]],
418
+ scratchpad: Dict[str, str],
419
+ ) -> Tuple[Optional[str], Optional[str]]:
420
+ """
421
+ Detection precedence (DEC-0084 §3):
422
+ profile off -> skip; US-0086 remote wins; compose+local docker -> docker-host-local;
423
+ DEV_SERVER_* -> local; else DEV_ENV_DETECT_AMBIGUOUS.
424
+ """
425
+ if scratchpad.get("DEV_AUTO_LAUNCH_PROFILE", "off") == "off":
426
+ return None, DEV_ENV_PROFILE_DISABLED
427
+
428
+ remote_result = _resolve_us0086_remote_target(repo, scratchpad)
429
+ if remote_result is not None:
430
+ if isinstance(remote_result, tuple) and len(remote_result) == 2:
431
+ mode, reason = remote_result
432
+ if mode:
433
+ return mode, reason
434
+ if reason:
435
+ return None, reason
436
+
437
+ if _compose_resolvable(repo, profile) and _local_docker_cli_succeeds():
438
+ return "docker-host-local", None
439
+
440
+ if _dev_server_inferable(scratchpad):
441
+ return "local", None
442
+
443
+ if profile and profile.get("detected_mode") in VALID_DETECTED_MODES:
444
+ return profile["detected_mode"], None
445
+
446
+ return None, DEV_ENV_DETECT_AMBIGUOUS
447
+
448
+
449
+ def build_relaunch_plan(
450
+ mode: str,
451
+ tier: Optional[str],
452
+ profile: Dict[str, Any],
453
+ ) -> List[str]:
454
+ """Command list for tier recipe; no .env reads; retry_count max 2 enforced by caller."""
455
+ if tier is None:
456
+ return []
457
+ service = profile.get("service") or "app"
458
+ compose_file = profile.get("compose_file") or "docker-compose.yml"
459
+ commands: List[str] = []
460
+ if tier == "A":
461
+ if mode in ("docker-host-local", "docker"):
462
+ commands = [
463
+ f"docker compose -f {compose_file} build {service}",
464
+ f"docker compose -f {compose_file} up -d {service}",
465
+ ]
466
+ elif mode == "local":
467
+ cmd = profile.get("rebuild_recipe", {}).get("local_build_command")
468
+ if cmd:
469
+ commands = [cmd]
470
+ elif tier == "B":
471
+ if mode in ("docker-host-local", "docker"):
472
+ commands = [f"docker compose -f {compose_file} restart {service}"]
473
+ elif mode == "local":
474
+ cmd = profile.get("rebuild_recipe", {}).get("restart_command")
475
+ if cmd:
476
+ commands = [cmd]
477
+ elif tier == "C":
478
+ if mode == "local":
479
+ cmd = profile.get("rebuild_recipe", {}).get("dev_server_command")
480
+ if not cmd:
481
+ cmd = os.environ.get("DEV_SERVER_COMMAND", "").strip()
482
+ if cmd:
483
+ commands = [cmd]
484
+ elif mode == "docker-host-local":
485
+ restart_on_change = profile.get("rebuild_recipe", {}).get(
486
+ "restart_on_source_change", False
487
+ )
488
+ if restart_on_change:
489
+ commands = [f"docker compose -f {compose_file} restart {service}"]
490
+ return commands
491
+
492
+
493
+ def format_connect_block(profile: Dict[str, Any], outcome: str) -> str:
494
+ """Markdown Connect block with mandatory names-only fields."""
495
+ connect = profile.get("connect") or {}
496
+ env_refs = sorted(set(profile.get("env_refs") or []))
497
+ lines = [
498
+ "## Connect",
499
+ "",
500
+ f"- **runtime_mode**: {profile.get('detected_mode', 'unknown')}",
501
+ f"- **connect_endpoint**: {connect.get('endpoint', '(unset)')}",
502
+ f"- **health_path**: {connect.get('health_path', '/')}",
503
+ f"- **service_id**: {profile.get('service', '')}",
504
+ f"- **container_id**: (resolve via operator shell)",
505
+ f"- **target_id**: {profile.get('target_id', '')}",
506
+ f"- **env_refs**: {', '.join(env_refs) if env_refs else '(none)'}",
507
+ f"- **relaunch_outcome**: {outcome}",
508
+ "",
509
+ ]
510
+ return "\n".join(lines)
511
+
512
+
513
+ def run_self_test() -> int:
514
+ fixture_path = Path(__file__).resolve().parent.parent / "template" / ".cursor" / "dev-environment.json.example"
515
+ data, err = load_profile(str(fixture_path))
516
+ if err or not data:
517
+ print(f"DEV_ENV_SELF_TEST_FAIL: load_profile {err}", file=sys.stderr)
518
+ return 1
519
+
520
+ tier_a = classify_touched_files(["docker-compose.yml", "docs/foo.md"])
521
+ if tier_a != "A":
522
+ print(f"DEV_ENV_SELF_TEST_FAIL: tier A expected, got {tier_a}", file=sys.stderr)
523
+ return 1
524
+
525
+ tier_none = classify_touched_files(["docs/engineering/runbook.md"])
526
+ if tier_none is not None:
527
+ print(f"DEV_ENV_SELF_TEST_FAIL: docs should skip, got {tier_none}", file=sys.stderr)
528
+ return 1
529
+
530
+ scratchpad = {"DEV_AUTO_LAUNCH_PROFILE": "off"}
531
+ mode, reason = detect_mode(Path("."), data, scratchpad)
532
+ if mode is not None or reason != DEV_ENV_PROFILE_DISABLED:
533
+ print(f"DEV_ENV_SELF_TEST_FAIL: off gate {mode}/{reason}", file=sys.stderr)
534
+ return 1
535
+
536
+ block = format_connect_block(data, "success")
537
+ for field in (
538
+ "runtime_mode",
539
+ "connect_endpoint",
540
+ "health_path",
541
+ "service_id",
542
+ "container_id",
543
+ "target_id",
544
+ "env_refs",
545
+ "relaunch_outcome",
546
+ ):
547
+ if field not in block:
548
+ print(f"DEV_ENV_SELF_TEST_FAIL: missing {field}", file=sys.stderr)
549
+ return 1
550
+
551
+ plan = build_relaunch_plan("docker-host-local", "A", data)
552
+ if not plan or "docker compose" not in plan[0]:
553
+ print("DEV_ENV_SELF_TEST_FAIL: relaunch plan", file=sys.stderr)
554
+ return 1
555
+
556
+ if MAX_RETRY_COUNT != 2:
557
+ print("DEV_ENV_SELF_TEST_FAIL: retry_count", file=sys.stderr)
558
+ return 1
559
+
560
+ print("[DEV_ENVIRONMENT_SELF_TEST_OK]")
561
+ return 0
562
+
563
+
564
+ def main() -> int:
565
+ p = argparse.ArgumentParser(description="Dev environment profile helper (US-0098).")
566
+ p.add_argument("--self-test", action="store_true", help="Run built-in checks.")
567
+ p.add_argument("--load", metavar="PATH", help="Load and validate profile path.")
568
+ p.add_argument("--bootstrap", action="store_true", help="Bootstrap dev-environment profile (US-0099).")
569
+ p.add_argument("--target", metavar="PATH", help="Consumer repository root (default: cwd).")
570
+ p.add_argument(
571
+ "--source-root",
572
+ metavar="PATH",
573
+ help="Packaged template root (default: <pkg>/template).",
574
+ )
575
+ args = p.parse_args()
576
+
577
+ if args.self_test:
578
+ return run_self_test()
579
+
580
+ if args.bootstrap:
581
+ target = Path(args.target or ".").resolve()
582
+ if args.source_root:
583
+ source_root = Path(args.source_root).resolve()
584
+ else:
585
+ source_root = Path(__file__).resolve().parent.parent / "template"
586
+ return run_bootstrap_cli(target, source_root)
587
+
588
+ if args.load:
589
+ _, err = load_profile(args.load)
590
+ if err:
591
+ print(err, file=sys.stderr)
592
+ return 1
593
+ print("[DEV_ENVIRONMENT_LOAD_OK]")
594
+ return 0
595
+
596
+ p.print_help()
597
+ return 0
598
+
599
+
600
+ if __name__ == "__main__":
601
+ sys.exit(main())