prizmkit 1.1.57 → 1.1.60

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 (188) hide show
  1. package/bin/create-prizmkit.js +8 -6
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/adapters/codex/agent-adapter.js +38 -0
  4. package/bundled/adapters/codex/paths.js +27 -0
  5. package/bundled/adapters/codex/rules-adapter.js +30 -0
  6. package/bundled/adapters/codex/settings-adapter.js +27 -0
  7. package/bundled/adapters/codex/skill-adapter.js +65 -0
  8. package/bundled/adapters/codex/team-adapter.js +37 -0
  9. package/bundled/dev-pipeline/.env.example +2 -1
  10. package/bundled/dev-pipeline/README.md +10 -7
  11. package/bundled/dev-pipeline/lib/common.sh +278 -37
  12. package/bundled/dev-pipeline/run-bugfix.sh +10 -61
  13. package/bundled/dev-pipeline/run-feature.sh +10 -78
  14. package/bundled/dev-pipeline/run-recovery.sh +10 -46
  15. package/bundled/dev-pipeline/run-refactor.sh +10 -61
  16. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +17 -7
  17. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +9 -3
  18. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +9 -3
  19. package/bundled/dev-pipeline/scripts/utils.py +6 -4
  20. package/bundled/dev-pipeline-windows/.env.example +28 -0
  21. package/bundled/dev-pipeline-windows/README.md +30 -0
  22. package/bundled/dev-pipeline-windows/SCHEMA_ANALYSIS.md +525 -0
  23. package/bundled/dev-pipeline-windows/assets/feature-list-example.json +146 -0
  24. package/bundled/dev-pipeline-windows/assets/prizm-dev-team-integration.md +138 -0
  25. package/bundled/dev-pipeline-windows/launch-bugfix-daemon.ps1 +9 -0
  26. package/bundled/dev-pipeline-windows/launch-feature-daemon.ps1 +9 -0
  27. package/bundled/dev-pipeline-windows/launch-refactor-daemon.ps1 +9 -0
  28. package/bundled/dev-pipeline-windows/lib/common.ps1 +432 -0
  29. package/bundled/dev-pipeline-windows/lib/daemon.ps1 +140 -0
  30. package/bundled/dev-pipeline-windows/lib/pipeline.ps1 +446 -0
  31. package/bundled/dev-pipeline-windows/lib/reset.ps1 +87 -0
  32. package/bundled/dev-pipeline-windows/reset-bug.ps1 +9 -0
  33. package/bundled/dev-pipeline-windows/reset-feature.ps1 +9 -0
  34. package/bundled/dev-pipeline-windows/reset-refactor.ps1 +9 -0
  35. package/bundled/dev-pipeline-windows/run-bugfix.ps1 +9 -0
  36. package/bundled/dev-pipeline-windows/run-feature.ps1 +9 -0
  37. package/bundled/dev-pipeline-windows/run-recovery.ps1 +76 -0
  38. package/bundled/dev-pipeline-windows/run-refactor.ps1 +9 -0
  39. package/bundled/dev-pipeline-windows/scripts/check-session-status.py +228 -0
  40. package/bundled/dev-pipeline-windows/scripts/cleanup-logs.py +192 -0
  41. package/bundled/dev-pipeline-windows/scripts/detect-stuck.py +530 -0
  42. package/bundled/dev-pipeline-windows/scripts/generate-bootstrap-prompt.py +1737 -0
  43. package/bundled/dev-pipeline-windows/scripts/generate-bugfix-prompt.py +685 -0
  44. package/bundled/dev-pipeline-windows/scripts/generate-recovery-prompt.py +805 -0
  45. package/bundled/dev-pipeline-windows/scripts/generate-refactor-prompt.py +763 -0
  46. package/bundled/dev-pipeline-windows/scripts/init-bugfix-pipeline.py +316 -0
  47. package/bundled/dev-pipeline-windows/scripts/init-dev-team.py +134 -0
  48. package/bundled/dev-pipeline-windows/scripts/init-pipeline.py +380 -0
  49. package/bundled/dev-pipeline-windows/scripts/init-refactor-pipeline.py +399 -0
  50. package/bundled/dev-pipeline-windows/scripts/parse-stream-progress.py +388 -0
  51. package/bundled/dev-pipeline-windows/scripts/patch-completion-notes.py +191 -0
  52. package/bundled/dev-pipeline-windows/scripts/update-bug-status.py +864 -0
  53. package/bundled/dev-pipeline-windows/scripts/update-checkpoint.py +173 -0
  54. package/bundled/dev-pipeline-windows/scripts/update-feature-status.py +1501 -0
  55. package/bundled/dev-pipeline-windows/scripts/update-refactor-status.py +1073 -0
  56. package/bundled/dev-pipeline-windows/scripts/utils.py +542 -0
  57. package/bundled/dev-pipeline-windows/templates/agent-prompts/critic-plan-challenge.md +7 -0
  58. package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-fix.md +7 -0
  59. package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-implement.md +30 -0
  60. package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-resume.md +5 -0
  61. package/bundled/dev-pipeline-windows/templates/agent-prompts/reviewer-review.md +7 -0
  62. package/bundled/dev-pipeline-windows/templates/bootstrap-prompt.md +46 -0
  63. package/bundled/dev-pipeline-windows/templates/bootstrap-tier1.md +43 -0
  64. package/bundled/dev-pipeline-windows/templates/bootstrap-tier2.md +43 -0
  65. package/bundled/dev-pipeline-windows/templates/bootstrap-tier3.md +43 -0
  66. package/bundled/dev-pipeline-windows/templates/bug-fix-list-schema.json +263 -0
  67. package/bundled/dev-pipeline-windows/templates/bugfix-bootstrap-prompt.md +320 -0
  68. package/bundled/dev-pipeline-windows/templates/feature-list-schema.json +237 -0
  69. package/bundled/dev-pipeline-windows/templates/refactor-bootstrap-prompt.md +331 -0
  70. package/bundled/dev-pipeline-windows/templates/refactor-list-schema.json +270 -0
  71. package/bundled/dev-pipeline-windows/templates/sections/ac-verification-checklist.md +13 -0
  72. package/bundled/dev-pipeline-windows/templates/sections/checkpoint-system.md +91 -0
  73. package/bundled/dev-pipeline-windows/templates/sections/context-budget-rules.md +33 -0
  74. package/bundled/dev-pipeline-windows/templates/sections/critical-paths-agent.md +10 -0
  75. package/bundled/dev-pipeline-windows/templates/sections/critical-paths-full.md +12 -0
  76. package/bundled/dev-pipeline-windows/templates/sections/critical-paths-lite.md +7 -0
  77. package/bundled/dev-pipeline-windows/templates/sections/directory-convention-agent.md +8 -0
  78. package/bundled/dev-pipeline-windows/templates/sections/directory-convention-full.md +9 -0
  79. package/bundled/dev-pipeline-windows/templates/sections/directory-convention-lite.md +6 -0
  80. package/bundled/dev-pipeline-windows/templates/sections/failure-capture.md +21 -0
  81. package/bundled/dev-pipeline-windows/templates/sections/feature-context.md +31 -0
  82. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-auto.md +72 -0
  83. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-opencli.md +63 -0
  84. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification.md +62 -0
  85. package/bundled/dev-pipeline-windows/templates/sections/phase-commit-full.md +71 -0
  86. package/bundled/dev-pipeline-windows/templates/sections/phase-commit.md +64 -0
  87. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-agent-suffix.md +23 -0
  88. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-base.md +24 -0
  89. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-lite-suffix.md +12 -0
  90. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan-full.md +53 -0
  91. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan.md +32 -0
  92. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-agent.md +37 -0
  93. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-full.md +50 -0
  94. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-lite.md +52 -0
  95. package/bundled/dev-pipeline-windows/templates/sections/phase-plan-agent.md +27 -0
  96. package/bundled/dev-pipeline-windows/templates/sections/phase-plan-lite.md +27 -0
  97. package/bundled/dev-pipeline-windows/templates/sections/phase-review-agent.md +27 -0
  98. package/bundled/dev-pipeline-windows/templates/sections/phase-review-full.md +29 -0
  99. package/bundled/dev-pipeline-windows/templates/sections/phase-specify-plan-full.md +77 -0
  100. package/bundled/dev-pipeline-windows/templates/sections/phase0-init.md +13 -0
  101. package/bundled/dev-pipeline-windows/templates/sections/phase0-test-baseline.md +23 -0
  102. package/bundled/dev-pipeline-windows/templates/sections/session-context.md +5 -0
  103. package/bundled/dev-pipeline-windows/templates/sections/subagent-timeout-recovery.md +6 -0
  104. package/bundled/dev-pipeline-windows/templates/sections/test-failure-recovery-agent.md +67 -0
  105. package/bundled/dev-pipeline-windows/templates/sections/test-failure-recovery-lite.md +58 -0
  106. package/bundled/dev-pipeline-windows/templates/session-status-schema.json +83 -0
  107. package/bundled/skills/_metadata.json +1 -1
  108. package/bundled/skills/app-planner/SKILL.md +26 -18
  109. package/bundled/skills/app-planner/references/architecture-decisions.md +9 -5
  110. package/bundled/skills/app-planner/references/frontend-design-guide.md +1 -1
  111. package/bundled/skills/feature-planner/SKILL.md +9 -2
  112. package/bundled/skills/prizmkit-init/SKILL.md +7 -6
  113. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +2 -0
  114. package/bundled/skills-windows/app-planner/SKILL.md +639 -0
  115. package/bundled/skills-windows/app-planner/assets/app-design-guide.md +101 -0
  116. package/bundled/skills-windows/app-planner/references/architecture-decisions.md +52 -0
  117. package/bundled/skills-windows/app-planner/references/brainstorm-guide.md +101 -0
  118. package/bundled/skills-windows/app-planner/references/frontend-design-guide.md +71 -0
  119. package/bundled/skills-windows/app-planner/references/project-brief-guide.md +82 -0
  120. package/bundled/skills-windows/app-planner/references/red-team-checklist.md +40 -0
  121. package/bundled/skills-windows/app-planner/references/rules/backend/derivation-rules.md +609 -0
  122. package/bundled/skills-windows/app-planner/references/rules/backend/fixed-rules.md +285 -0
  123. package/bundled/skills-windows/app-planner/references/rules/backend/question-bank.md +249 -0
  124. package/bundled/skills-windows/app-planner/references/rules/backend/template.md +173 -0
  125. package/bundled/skills-windows/app-planner/references/rules/database/derivation-rules.md +373 -0
  126. package/bundled/skills-windows/app-planner/references/rules/database/fixed-rules.md +211 -0
  127. package/bundled/skills-windows/app-planner/references/rules/database/question-bank.md +184 -0
  128. package/bundled/skills-windows/app-planner/references/rules/database/template.md +158 -0
  129. package/bundled/skills-windows/app-planner/references/rules/frontend/derivation-rules.md +810 -0
  130. package/bundled/skills-windows/app-planner/references/rules/frontend/fixed-rules.md +188 -0
  131. package/bundled/skills-windows/app-planner/references/rules/frontend/question-bank.md +302 -0
  132. package/bundled/skills-windows/app-planner/references/rules/frontend/template.md +320 -0
  133. package/bundled/skills-windows/app-planner/references/rules/mobile/derivation-rules.md +639 -0
  134. package/bundled/skills-windows/app-planner/references/rules/mobile/fixed-rules.md +290 -0
  135. package/bundled/skills-windows/app-planner/references/rules/mobile/question-bank.md +232 -0
  136. package/bundled/skills-windows/app-planner/references/rules/mobile/template.md +175 -0
  137. package/bundled/skills-windows/bug-fix-workflow/SKILL.md +415 -0
  138. package/bundled/skills-windows/bug-planner/SKILL.md +395 -0
  139. package/bundled/skills-windows/bug-planner/assets/bug-confirmation-template.md +43 -0
  140. package/bundled/skills-windows/bug-planner/references/critic-and-verification.md +44 -0
  141. package/bundled/skills-windows/bug-planner/references/error-recovery.md +73 -0
  142. package/bundled/skills-windows/bug-planner/references/input-formats.md +53 -0
  143. package/bundled/skills-windows/bug-planner/references/schema-validation.md +25 -0
  144. package/bundled/skills-windows/bug-planner/references/severity-rules.md +16 -0
  145. package/bundled/skills-windows/bug-planner/scripts/validate-bug-list.py +322 -0
  146. package/bundled/skills-windows/bugfix-pipeline-launcher/SKILL.md +380 -0
  147. package/bundled/skills-windows/feature-pipeline-launcher/SKILL.md +441 -0
  148. package/bundled/skills-windows/feature-pipeline-launcher/scripts/preflight-check.py +462 -0
  149. package/bundled/skills-windows/feature-planner/SKILL.md +401 -0
  150. package/bundled/skills-windows/feature-planner/assets/evaluation-guide.md +64 -0
  151. package/bundled/skills-windows/feature-planner/assets/planning-guide.md +214 -0
  152. package/bundled/skills-windows/feature-planner/references/browser-interaction.md +59 -0
  153. package/bundled/skills-windows/feature-planner/references/completeness-review.md +57 -0
  154. package/bundled/skills-windows/feature-planner/references/decomposition-patterns.md +75 -0
  155. package/bundled/skills-windows/feature-planner/references/error-recovery.md +90 -0
  156. package/bundled/skills-windows/feature-planner/references/incremental-feature-planning.md +112 -0
  157. package/bundled/skills-windows/feature-planner/references/new-project-planning.md +85 -0
  158. package/bundled/skills-windows/feature-planner/scripts/validate-and-generate.py +1029 -0
  159. package/bundled/skills-windows/feature-workflow/SKILL.md +531 -0
  160. package/bundled/skills-windows/prizmkit-init/SKILL.md +356 -0
  161. package/bundled/skills-windows/prizmkit-init/assets/project-brief-template.md +82 -0
  162. package/bundled/skills-windows/prizmkit-init/references/config-schema.md +68 -0
  163. package/bundled/skills-windows/prizmkit-init/references/rules/layer-detection.md +41 -0
  164. package/bundled/skills-windows/prizmkit-init/references/tech-stack-catalog.md +13 -0
  165. package/bundled/skills-windows/prizmkit-init/references/update-supplement.md +9 -0
  166. package/bundled/skills-windows/recovery-workflow/SKILL.md +456 -0
  167. package/bundled/skills-windows/recovery-workflow/evals/evals.json +46 -0
  168. package/bundled/skills-windows/recovery-workflow/scripts/detect-recovery-state.py +544 -0
  169. package/bundled/skills-windows/refactor-pipeline-launcher/SKILL.md +406 -0
  170. package/bundled/skills-windows/refactor-planner/SKILL.md +540 -0
  171. package/bundled/skills-windows/refactor-planner/assets/planning-guide.md +292 -0
  172. package/bundled/skills-windows/refactor-planner/references/behavior-preservation.md +301 -0
  173. package/bundled/skills-windows/refactor-planner/references/refactor-scoping-guide.md +221 -0
  174. package/bundled/skills-windows/refactor-planner/scripts/validate-and-generate-refactor.py +858 -0
  175. package/bundled/skills-windows/refactor-workflow/SKILL.md +503 -0
  176. package/package.json +3 -2
  177. package/src/clean.js +73 -2
  178. package/src/config.js +159 -50
  179. package/src/detect-platform.js +16 -8
  180. package/src/external-skills.js +26 -19
  181. package/src/index.js +31 -9
  182. package/src/manifest.js +6 -2
  183. package/src/metadata.js +43 -5
  184. package/src/platforms.js +36 -0
  185. package/src/prompts.js +31 -6
  186. package/src/runtimes.js +20 -0
  187. package/src/scaffold.js +314 -110
  188. package/src/upgrade.js +81 -41
@@ -0,0 +1,1737 @@
1
+ #!/usr/bin/env python3
2
+ """Generate a session-specific bootstrap prompt from template and feature list.
3
+
4
+ Supports two modes:
5
+ 1. **Section assembly** (preferred): Loads modular section files from
6
+ templates/sections/ and assembles them based on tier, conditions, and
7
+ feature configuration. Conditional logic is handled in Python code,
8
+ not regex-based template blocks.
9
+ 2. **Legacy template** (fallback): Reads a monolithic bootstrap-tier{1,2,3}.md
10
+ template and resolves {{PLACEHOLDER}} variables and {{IF_xxx}} blocks.
11
+
12
+ The section assembly mode is used when templates/sections/ directory exists.
13
+ Otherwise, falls back to legacy templates for backward compatibility.
14
+
15
+ Usage:
16
+ python3 generate-bootstrap-prompt.py \
17
+ --feature-list <path> --feature-id <id> \
18
+ --session-id <id> --run-id <id> \
19
+ --retry-count <n> --resume-phase <n|null> \
20
+ --output <path>
21
+ """
22
+
23
+ import argparse
24
+ import json
25
+ import os
26
+ import re
27
+ import sys
28
+
29
+ from utils import enrich_global_context, load_json_file, read_platform_conventions, setup_logging
30
+
31
+
32
+ DEFAULT_MAX_RETRIES = 3
33
+
34
+ LOGGER = setup_logging("generate-bootstrap-prompt")
35
+
36
+
37
+ def parse_args():
38
+ parser = argparse.ArgumentParser(
39
+ description=(
40
+ "Generate a session-specific bootstrap prompt from a template "
41
+ "and .prizmkit/plans/feature-list.json."
42
+ )
43
+ )
44
+ parser.add_argument(
45
+ "--feature-list",
46
+ required=True,
47
+ help="Path to .prizmkit/plans/feature-list.json",
48
+ )
49
+ parser.add_argument(
50
+ "--feature-id",
51
+ required=True,
52
+ help="Feature ID to generate prompt for (e.g. F-001)",
53
+ )
54
+ parser.add_argument(
55
+ "--session-id",
56
+ required=True,
57
+ help="Session ID for this pipeline session",
58
+ )
59
+ parser.add_argument(
60
+ "--run-id",
61
+ required=True,
62
+ help="Pipeline run ID",
63
+ )
64
+ parser.add_argument(
65
+ "--retry-count",
66
+ required=True,
67
+ help="Current retry count",
68
+ )
69
+ parser.add_argument(
70
+ "--resume-phase",
71
+ required=True,
72
+ help='Phase to resume from, or "null" for fresh start',
73
+ )
74
+ parser.add_argument(
75
+ "--state-dir",
76
+ default=None,
77
+ help="State directory path for reading previous session info",
78
+ )
79
+ parser.add_argument(
80
+ "--output",
81
+ required=True,
82
+ help="Output path for the rendered prompt",
83
+ )
84
+ parser.add_argument(
85
+ "--template",
86
+ default=None,
87
+ help=(
88
+ "Custom template path. Defaults to "
89
+ "{script_dir}/../templates/bootstrap-prompt.md"
90
+ ),
91
+ )
92
+ parser.add_argument(
93
+ "--mode",
94
+ choices=["lite", "standard", "full"],
95
+ default=None,
96
+ help="Override pipeline mode (default: auto-detect from complexity)",
97
+ )
98
+ parser.add_argument(
99
+ "--critic",
100
+ choices=["true", "false"],
101
+ default=None,
102
+ help="Override critic enablement (default: read from feature field)",
103
+ )
104
+ parser.add_argument(
105
+ "--extract-baselines",
106
+ action="store_true",
107
+ help="Run tests and extract baseline failures (slower, optional)",
108
+ )
109
+ parser.add_argument(
110
+ "--no-checkpoint",
111
+ action="store_true",
112
+ help="Do not write workflow-checkpoint.json (used by pipeline dry-run)",
113
+ )
114
+
115
+ return parser.parse_args()
116
+
117
+
118
+ def read_text_file(path):
119
+ """Read and return the text content of a file."""
120
+ abs_path = os.path.abspath(path)
121
+ if not os.path.isfile(abs_path):
122
+ return None, "File not found: {}".format(abs_path)
123
+ try:
124
+ with open(abs_path, "r", encoding="utf-8") as f:
125
+ return f.read(), None
126
+ except IOError as e:
127
+ return None, "Cannot read file: {}".format(str(e))
128
+
129
+
130
+ def find_feature(features, feature_id):
131
+ """Find and return the feature dict matching the given ID."""
132
+ for feature in features:
133
+ if isinstance(feature, dict) and feature.get("id") == feature_id:
134
+ return feature
135
+ return None
136
+
137
+
138
+ def compute_feature_slug(feature_id, title):
139
+ """Compute the prizmkit feature slug: ###-kebab-case-name.
140
+
141
+ e.g. F-001 + "Project Infrastructure Setup" -> "001-project-infrastructure-setup"
142
+ The prizmkit skills use this slug to create per-feature directories.
143
+ """
144
+ # Extract numeric part from feature_id (e.g., "F-001" -> "001")
145
+ numeric = feature_id.replace("F-", "").replace("f-", "")
146
+ # Pad to 3 digits
147
+ numeric = numeric.zfill(3)
148
+
149
+ # Convert title to kebab-case
150
+ slug = title.lower()
151
+ slug = re.sub(r"[^a-z0-9\s-]", "", slug) # remove non-alphanumeric
152
+ slug = re.sub(r"[\s]+", "-", slug.strip()) # spaces to hyphens
153
+ slug = re.sub(r"-+", "-", slug) # collapse multiple hyphens
154
+ slug = slug.strip("-")
155
+
156
+ return "{}-{}".format(numeric, slug)
157
+
158
+
159
+ def format_acceptance_criteria(criteria):
160
+ """Format acceptance criteria as a markdown bullet list."""
161
+ if not criteria:
162
+ return "- (none specified)"
163
+ lines = []
164
+ for item in criteria:
165
+ lines.append("- {}".format(item))
166
+ return "\n".join(lines)
167
+
168
+ def dedupe_test_commands(test_commands):
169
+ """Return test commands with original order preserved."""
170
+ return list(dict.fromkeys(test_commands))
171
+
172
+
173
+ def format_powershell_test_commands(test_commands):
174
+ """Format test commands as PowerShell statements with fail-fast guards."""
175
+ commands = dedupe_test_commands(test_commands)
176
+ if not commands:
177
+ return ""
178
+ guarded_commands = [
179
+ "{}; if ($LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}".format(command)
180
+ for command in commands
181
+ ]
182
+ return "; ".join(guarded_commands)
183
+
184
+
185
+ def format_baseline_test_commands(test_commands):
186
+ """Format test commands for Python's sequential baseline extraction."""
187
+ return dedupe_test_commands(test_commands)
188
+
189
+
190
+ def detect_test_command_list(project_root):
191
+ """
192
+ Auto-detect test commands based on project structure.
193
+
194
+ Returns: ordered list of test commands.
195
+ """
196
+ test_commands = []
197
+
198
+ # Check for npm/package.json
199
+ if os.path.exists(os.path.join(project_root, "package.json")):
200
+ test_commands.append("npm test")
201
+
202
+ # Check for Go
203
+ if os.path.exists(os.path.join(project_root, "go.mod")):
204
+ test_commands.append("go test ./...")
205
+
206
+ # Check for Rust/Cargo
207
+ if os.path.exists(os.path.join(project_root, "Cargo.toml")):
208
+ test_commands.append("cargo test")
209
+
210
+ # Check for Python pytest
211
+ if os.path.exists(os.path.join(project_root, "pytest.ini")) or \
212
+ os.path.exists(os.path.join(project_root, "setup.py")):
213
+ test_commands.append("pytest")
214
+
215
+ # Check for Make test target
216
+ makefile_path = os.path.join(project_root, "Makefile")
217
+ if os.path.exists(makefile_path):
218
+ try:
219
+ with open(makefile_path, 'r') as f:
220
+ if 'test:' in f.read():
221
+ test_commands.append("make test")
222
+ except Exception:
223
+ pass
224
+
225
+ return dedupe_test_commands(test_commands)
226
+
227
+
228
+ def detect_test_commands(project_root):
229
+ """Auto-detect and format test commands for Windows PowerShell prompts."""
230
+ return format_powershell_test_commands(detect_test_command_list(project_root))
231
+
232
+
233
+ def extract_baseline_failures(test_commands, project_root):
234
+ """
235
+ Run test command and extract failing tests.
236
+
237
+ Returns: semicolon-separated list of failing test names
238
+ """
239
+ if isinstance(test_commands, str):
240
+ test_commands = [test_commands]
241
+ test_commands = [command for command in test_commands if command]
242
+
243
+ if not test_commands or test_commands[0].startswith("(auto-detection"):
244
+ return ""
245
+
246
+ original_cwd = os.getcwd()
247
+ try:
248
+ import subprocess
249
+ os.chdir(project_root)
250
+
251
+ output_parts = []
252
+ for command in test_commands:
253
+ result = subprocess.run(
254
+ command,
255
+ shell=True,
256
+ capture_output=True,
257
+ text=True,
258
+ timeout=120
259
+ )
260
+ output_parts.append(result.stdout + result.stderr)
261
+
262
+ os.chdir(original_cwd)
263
+
264
+ output = "\n".join(output_parts)
265
+ failures = []
266
+
267
+ for line in output.split('\n'):
268
+ if 'FAILED' in line and '::' in line:
269
+ parts = line.split('FAILED')
270
+ if len(parts) > 1:
271
+ test_name = parts[1].strip().split(' ')[0]
272
+ if test_name and test_name not in failures:
273
+ failures.append(test_name)
274
+
275
+ return ";".join(failures) if failures else ""
276
+
277
+ except Exception as e:
278
+ return f"(error: {str(e)})"
279
+ finally:
280
+ try:
281
+ os.chdir(original_cwd)
282
+ except Exception:
283
+ pass
284
+
285
+
286
+ def format_ac_checklist(acceptance_criteria):
287
+ """Format acceptance criteria as a markdown checkbox list."""
288
+ if not acceptance_criteria:
289
+ return "- [ ] (no acceptance criteria specified)"
290
+ lines = []
291
+ for item in acceptance_criteria:
292
+ lines.append("- [ ] {}".format(item))
293
+ return "\n".join(lines)
294
+
295
+
296
+
297
+ def format_global_context(global_context, project_root=None):
298
+ """Format global_context dict as a key-value list.
299
+
300
+ If global_context is empty/sparse and project_root is provided,
301
+ auto-detect tech stack from project files to fill gaps.
302
+ """
303
+ if project_root:
304
+ enrich_global_context(global_context, project_root)
305
+
306
+ if not global_context:
307
+ return "- (none specified)"
308
+ lines = []
309
+ for key, value in sorted(global_context.items()):
310
+ lines.append("- **{}**: {}".format(key, value))
311
+ return "\n".join(lines)
312
+
313
+
314
+ def format_user_context(user_context):
315
+ """Format user_context array as a markdown section.
316
+
317
+ Returns empty string if user_context is empty or absent,
318
+ so the template placeholder resolves to nothing.
319
+ """
320
+ if not user_context or not isinstance(user_context, list):
321
+ return ""
322
+ items = [item for item in user_context if isinstance(item, str) and item.strip()]
323
+ if not items:
324
+ return ""
325
+ lines = [
326
+ "### User-Provided Context (HIGHEST PRIORITY)",
327
+ "",
328
+ "> The following materials were provided by the user. "
329
+ "They take precedence over AI inference.",
330
+ "",
331
+ ]
332
+ for item in items:
333
+ lines.append("- {}".format(item))
334
+ return "\n".join(lines)
335
+
336
+
337
+ def get_completed_dependencies(features, feature):
338
+ """Look up dependency features and list those with status=completed.
339
+
340
+ When a completed dependency has completion_notes (written by the AI
341
+ session and propagated by the pipeline runner), include them as rich
342
+ context so the downstream session knows what was built.
343
+ """
344
+ deps = feature.get("dependencies", [])
345
+ if not deps:
346
+ return "- (no dependencies)"
347
+
348
+ # Build a lookup map
349
+ feature_map = {}
350
+ for f in features:
351
+ if isinstance(f, dict) and "id" in f:
352
+ feature_map[f["id"]] = f
353
+
354
+ sections = []
355
+ for dep_id in deps:
356
+ dep = feature_map.get(dep_id)
357
+ if dep and dep.get("status") == "completed":
358
+ header = "- **{}** — {} (completed)".format(
359
+ dep_id, dep.get("title", "Untitled")
360
+ )
361
+ notes = dep.get("completion_notes", [])
362
+ if notes and isinstance(notes, list):
363
+ note_lines = "\n".join(
364
+ " - {}".format(n) for n in notes
365
+ if isinstance(n, str) and n.strip()
366
+ )
367
+ if note_lines:
368
+ header += "\n" + note_lines
369
+ sections.append(header)
370
+
371
+ if not sections:
372
+ return "- (no completed dependencies yet)"
373
+ return "\n".join(sections)
374
+
375
+
376
+ def get_prev_session_status(state_dir, feature_id):
377
+ """Read previous session status from state dir if available."""
378
+ if not state_dir:
379
+ return "N/A (first run)"
380
+
381
+ # Try to read the feature status file to find the last session
382
+ feature_status_path = os.path.join(
383
+ state_dir, "features", feature_id, "status.json"
384
+ )
385
+ if not os.path.isfile(feature_status_path):
386
+ return "N/A (first run)"
387
+
388
+ try:
389
+ with open(feature_status_path, "r", encoding="utf-8") as f:
390
+ feature_status = json.load(f)
391
+ except (json.JSONDecodeError, IOError):
392
+ return "N/A (could not read feature status)"
393
+
394
+ last_session_id = feature_status.get("last_session_id")
395
+ if not last_session_id:
396
+ return "N/A (first run)"
397
+
398
+ # Try to read the last session's session-status.json
399
+ session_status_path = os.path.join(
400
+ state_dir, "features", feature_id, "sessions",
401
+ last_session_id, "session-status.json"
402
+ )
403
+ if not os.path.isfile(session_status_path):
404
+ return "N/A (previous session status file not found)"
405
+
406
+ try:
407
+ with open(session_status_path, "r", encoding="utf-8") as f:
408
+ session_data = json.load(f)
409
+ except (json.JSONDecodeError, IOError):
410
+ return "N/A (could not read previous session status)"
411
+
412
+ status = session_data.get("status", "unknown")
413
+ checkpoint = session_data.get("checkpoint_reached", "none")
414
+ current_phase = session_data.get("current_phase", "unknown")
415
+ errors = session_data.get("errors", [])
416
+
417
+ result = "{} (checkpoint: {}, last phase: {})".format(
418
+ status, checkpoint, current_phase
419
+ )
420
+ if errors:
421
+ result += " — errors: {}".format("; ".join(str(e) for e in errors))
422
+ return result
423
+
424
+
425
+ def _read_project_brief(project_root):
426
+ """Read project-brief.md from new or old location with fallback.
427
+
428
+ Returns the file content as a string, or a fallback message if absent.
429
+ This brief is generated by app-planner during interactive planning and
430
+ captures the user's product ideas as a checklist. Each line is one idea,
431
+ marked [ ] for pending or [x] for completed. Feature sessions should mark
432
+ items [x] and append key file paths when implementing relevant ideas.
433
+ """
434
+ # Check both new and old paths for backward compatibility
435
+ new_path = os.path.join(project_root, ".prizmkit", "plans", "project-brief.md")
436
+ old_path = os.path.join(project_root, "project-brief.md")
437
+
438
+ for brief_path in [new_path, old_path]:
439
+ if os.path.isfile(brief_path):
440
+ try:
441
+ with open(brief_path, "r", encoding="utf-8") as f:
442
+ content = f.read().strip()
443
+ if brief_path == old_path:
444
+ # Warn user about old path
445
+ import sys
446
+ print("⚠️ Migration notice: project-brief.md found in root. "
447
+ "Please move to .prizmkit/plans/project-brief.md",
448
+ file=sys.stderr)
449
+ return content
450
+ except IOError:
451
+ return "(project-brief.md exists but could not be read)"
452
+
453
+ return "(No project brief available)"
454
+
455
+
456
+ def resolve_project_root(script_dir):
457
+ """Resolve project root from script_dir of the prompt-generator script.
458
+
459
+ Layout-aware:
460
+ <project>/.prizmkit/dev-pipeline/scripts/ → project root = <project>
461
+ <repo>/dev-pipeline/scripts/ → project root = <repo>
462
+ """
463
+ pipeline_dir = os.path.dirname(script_dir)
464
+ pipeline_parent = os.path.dirname(pipeline_dir)
465
+ if os.path.basename(pipeline_parent) == ".prizmkit":
466
+ return os.path.abspath(os.path.dirname(pipeline_parent))
467
+ return os.path.abspath(pipeline_parent)
468
+
469
+
470
+ def process_conditional_blocks(content, resume_phase):
471
+ """Handle conditional blocks based on resume_phase.
472
+
473
+ Supports:
474
+ - {{IF_FRESH_START}} / {{END_IF_FRESH_START}}
475
+ - {{IF_RESUME}} / {{END_IF_RESUME}}
476
+ - {{IF_RETRY}} / {{END_IF_RETRY}}
477
+ """
478
+ is_resume = resume_phase != "null"
479
+
480
+ if is_resume:
481
+ # Remove fresh-start blocks, keep resume blocks
482
+ content = re.sub(
483
+ r"\{\{IF_FRESH_START\}\}.*?\{\{END_IF_FRESH_START\}\}\n?",
484
+ "", content, flags=re.DOTALL,
485
+ )
486
+ content = re.sub(r"\{\{IF_RESUME\}\}\n?", "", content)
487
+ content = re.sub(r"\{\{END_IF_RESUME\}\}\n?", "", content)
488
+ else:
489
+ # Keep fresh-start blocks, remove resume blocks
490
+ content = re.sub(r"\{\{IF_FRESH_START\}\}\n?", "", content)
491
+ content = re.sub(r"\{\{END_IF_FRESH_START\}\}\n?", "", content)
492
+ content = re.sub(
493
+ r"\{\{IF_RESUME\}\}.*?\{\{END_IF_RESUME\}\}\n?",
494
+ "", content, flags=re.DOTALL,
495
+ )
496
+
497
+ return content
498
+
499
+
500
+ def process_mode_blocks(content, pipeline_mode, init_done, critic_enabled=False,
501
+ browser_interaction=False, browser_tool="auto"):
502
+ """Process pipeline mode, init, critic, and browser conditional blocks.
503
+
504
+ Keeps the block matching the current mode, removes the others.
505
+ Handles {{IF_CRITIC_ENABLED}} / {{END_IF_CRITIC_ENABLED}} blocks.
506
+ Handles {{IF_BROWSER_INTERACTION}} / {{END_IF_BROWSER_INTERACTION}} blocks.
507
+ Handles {{IF_BROWSER_TOOL_PLAYWRIGHT}} / {{IF_BROWSER_TOOL_OPENCLI}} /
508
+ {{IF_BROWSER_TOOL_AUTO}} blocks (nested inside browser interaction block).
509
+ """
510
+ # Handle lite/standard/full blocks
511
+ modes = ["lite", "standard", "full"]
512
+
513
+ for mode in modes:
514
+ tag_open = "{{{{IF_MODE_{}}}}}".format(mode.upper())
515
+ tag_close = "{{{{END_IF_MODE_{}}}}}".format(mode.upper())
516
+
517
+ if mode == pipeline_mode:
518
+ # Keep content, remove tags
519
+ content = content.replace(tag_open + "\n", "")
520
+ content = content.replace(tag_open, "")
521
+ content = content.replace(tag_close + "\n", "")
522
+ content = content.replace(tag_close, "")
523
+ else:
524
+ # Remove entire block
525
+ pattern = re.escape(tag_open) + r".*?" + re.escape(tag_close) + r"\n?"
526
+ content = re.sub(pattern, "", content, flags=re.DOTALL)
527
+
528
+ # Init blocks
529
+ if init_done:
530
+ content = re.sub(r"\{\{IF_INIT_DONE\}\}\n?", "", content)
531
+ content = re.sub(r"\{\{END_IF_INIT_DONE\}\}\n?", "", content)
532
+ content = re.sub(
533
+ r"\{\{IF_INIT_NEEDED\}\}.*?\{\{END_IF_INIT_NEEDED\}\}\n?",
534
+ "", content, flags=re.DOTALL,
535
+ )
536
+ else:
537
+ content = re.sub(r"\{\{IF_INIT_NEEDED\}\}\n?", "", content)
538
+ content = re.sub(r"\{\{END_IF_INIT_NEEDED\}\}\n?", "", content)
539
+ content = re.sub(
540
+ r"\{\{IF_INIT_DONE\}\}.*?\{\{END_IF_INIT_DONE\}\}\n?",
541
+ "", content, flags=re.DOTALL,
542
+ )
543
+
544
+ # Critic blocks
545
+ critic_open = "{{IF_CRITIC_ENABLED}}"
546
+ critic_close = "{{END_IF_CRITIC_ENABLED}}"
547
+ if critic_enabled:
548
+ # Keep content, remove tags
549
+ content = content.replace(critic_open + "\n", "")
550
+ content = content.replace(critic_open, "")
551
+ content = content.replace(critic_close + "\n", "")
552
+ content = content.replace(critic_close, "")
553
+ else:
554
+ # Remove entire CRITIC blocks
555
+ pattern = re.escape(critic_open) + r".*?" + re.escape(critic_close) + r"\n?"
556
+ content = re.sub(pattern, "", content, flags=re.DOTALL)
557
+
558
+ # Browser interaction blocks
559
+ browser_open = "{{IF_BROWSER_INTERACTION}}"
560
+ browser_close = "{{END_IF_BROWSER_INTERACTION}}"
561
+ if browser_interaction:
562
+ content = content.replace(browser_open + "\n", "")
563
+ content = content.replace(browser_open, "")
564
+ content = content.replace(browser_close + "\n", "")
565
+ content = content.replace(browser_close, "")
566
+ else:
567
+ pattern = re.escape(browser_open) + r".*?" + re.escape(browser_close) + r"\n?"
568
+ content = re.sub(pattern, "", content, flags=re.DOTALL)
569
+
570
+ # Browser tool selection blocks (nested inside browser interaction)
571
+ tool_variants = ["PLAYWRIGHT", "OPENCLI", "AUTO"]
572
+ # Map browser_tool value to the variant tag name
573
+ active_variant = {
574
+ "playwright-cli": "PLAYWRIGHT",
575
+ "opencli": "OPENCLI",
576
+ "auto": "AUTO",
577
+ }.get(browser_tool, "AUTO")
578
+
579
+ for variant in tool_variants:
580
+ tool_open = "{{{{IF_BROWSER_TOOL_{}}}}}".format(variant)
581
+ tool_close = "{{{{END_IF_BROWSER_TOOL_{}}}}}".format(variant)
582
+ if variant == active_variant and browser_interaction:
583
+ # Keep content, remove tags
584
+ content = content.replace(tool_open + "\n", "")
585
+ content = content.replace(tool_open, "")
586
+ content = content.replace(tool_close + "\n", "")
587
+ content = content.replace(tool_close, "")
588
+ else:
589
+ # Remove entire block
590
+ pat = re.escape(tool_open) + r".*?" + re.escape(tool_close) + r"\n?"
591
+ content = re.sub(pat, "", content, flags=re.DOTALL)
592
+
593
+ return content
594
+
595
+
596
+ def detect_init_status(project_root):
597
+ """Check if PrizmKit init has already been done."""
598
+ prizm_docs = os.path.join(project_root, ".prizmkit/prizm-docs", "root.prizm")
599
+ prizmkit_config = os.path.join(project_root, ".prizmkit", "config.json")
600
+ return os.path.isfile(prizm_docs) and os.path.isfile(prizmkit_config)
601
+
602
+
603
+ def detect_existing_artifacts(project_root, feature_slug):
604
+ """Check which planning artifacts already exist for this feature.
605
+
606
+ Returns a dict with keys: has_spec, has_plan, all_complete.
607
+ Tasks are now part of plan.md (Tasks section), not a separate file.
608
+ """
609
+ specs_dir = os.path.join(project_root, ".prizmkit", "specs", feature_slug)
610
+ result = {
611
+ "has_spec": os.path.isfile(os.path.join(specs_dir, "spec.md")),
612
+ "has_plan": os.path.isfile(os.path.join(specs_dir, "plan.md")),
613
+ }
614
+ result["all_complete"] = all([
615
+ result["has_spec"], result["has_plan"]
616
+ ])
617
+ return result
618
+
619
+
620
+ def determine_pipeline_mode(complexity):
621
+ """Map estimated_complexity to pipeline mode.
622
+
623
+ Returns: 'lite', 'standard', or 'full'
624
+
625
+ Tier assignment rationale:
626
+ - low + medium → lite (single agent): most features don't benefit from
627
+ the orchestrator→dev→reviewer overhead. A single agent reading
628
+ .prizmkit/prizm-docs + implementing directly is faster and cheaper.
629
+ - high → standard (orchestrator + dev + reviewer): complex features
630
+ need the spec→plan→analyze→implement→review pipeline.
631
+ - critical → full (full team + framework guardrails): architectural
632
+ changes that touch many files and need extra safety checks.
633
+ """
634
+ mapping = {
635
+ "low": "lite",
636
+ "medium": "lite",
637
+ "high": "standard",
638
+ "critical": "full",
639
+ }
640
+ return mapping.get(complexity, "lite")
641
+
642
+
643
+ # ============================================================
644
+ # Checkpoint generation
645
+ # ============================================================
646
+
647
+ # Mapping: section name -> (skill_key, display_name, required_artifacts)
648
+ # skill_key is a unique identifier in the checkpoint, not necessarily the
649
+ # prizmkit skill name. This ensures each section has a distinct key so
650
+ # merge_checkpoint_state() never collides.
651
+ SECTION_TO_SKILL = {
652
+ "phase0-init": ("prizmkit-init", "Project Bootstrap",
653
+ [".prizmkit/prizm-docs/root.prizm", ".prizmkit/config.json"]),
654
+ "phase0-test-baseline": ("test-baseline", "Test Baseline", []),
655
+ "phase-context-snapshot": ("context-snapshot", "Build Context Snapshot",
656
+ [".prizmkit/specs/{slug}/context-snapshot.md"]),
657
+ "phase-specify-plan": ("context-snapshot-and-plan", "Specify & Plan",
658
+ [".prizmkit/specs/{slug}/context-snapshot.md",
659
+ ".prizmkit/specs/{slug}/plan.md"]),
660
+ "phase-plan": ("prizmkit-plan", "Plan & Tasks",
661
+ [".prizmkit/specs/{slug}/plan.md"]),
662
+ "phase-critic-plan": ("critic-plan-review", "Critic: Plan Review", []),
663
+ "phase-implement": ("prizmkit-implement", "Implement + Test", []),
664
+ "phase-review": ("prizmkit-code-review", "Code Review", []),
665
+ "phase-browser": ("browser-verification", "Browser Verification", []),
666
+ "phase-commit": None, # special: split into retrospective + committer
667
+ }
668
+
669
+ # phase-commit is split into two steps
670
+ _COMMIT_STEPS = [
671
+ ("prizmkit-retrospective", "Retrospective", []),
672
+ ("prizmkit-committer", "Commit", ["--headless"]),
673
+ ]
674
+
675
+
676
+ def _resolve_artifacts(artifact_templates, slug):
677
+ """Replace {slug} placeholder with the actual feature slug."""
678
+ return [a.replace("{slug}", slug) for a in artifact_templates]
679
+
680
+
681
+ def generate_checkpoint_definition(sections, pipeline_mode, workflow_type,
682
+ item_id, item_slug, session_id,
683
+ init_done=False):
684
+ """Derive checkpoint step definitions from the assembled sections list.
685
+
686
+ Args:
687
+ sections: list of (name, content) tuples from assemble_sections()
688
+ pipeline_mode: "lite" | "standard" | "full"
689
+ workflow_type: "feature-pipeline"
690
+ item_id: feature ID (e.g. "F-001")
691
+ item_slug: feature slug (e.g. "001-user-auth")
692
+ session_id: current session ID
693
+ init_done: whether project is already initialized (Phase 0 skip)
694
+
695
+ Returns:
696
+ dict suitable for writing as workflow-checkpoint.json
697
+ """
698
+ steps = []
699
+ step_counter = 1
700
+ prev_step_id = None
701
+
702
+ for section_name, _content in sections:
703
+ if section_name not in SECTION_TO_SKILL:
704
+ continue
705
+
706
+ mapping = SECTION_TO_SKILL[section_name]
707
+
708
+ if mapping is None:
709
+ # phase-commit -> split into retrospective + committer
710
+ for skill, name, artifacts in _COMMIT_STEPS:
711
+ step_id = "S{:02d}".format(step_counter)
712
+ steps.append({
713
+ "id": step_id,
714
+ "skill": skill,
715
+ "name": name,
716
+ "status": "pending",
717
+ "required_artifacts": _resolve_artifacts(artifacts, item_slug),
718
+ "depends_on": prev_step_id,
719
+ })
720
+ prev_step_id = step_id
721
+ step_counter += 1
722
+ continue
723
+
724
+ skill, name, artifacts = mapping
725
+ step_id = "S{:02d}".format(step_counter)
726
+
727
+ status = "pending"
728
+ if init_done and section_name in ("phase0-init", "phase0-test-baseline"):
729
+ status = "skipped"
730
+
731
+ steps.append({
732
+ "id": step_id,
733
+ "skill": skill,
734
+ "name": name,
735
+ "status": status,
736
+ "required_artifacts": _resolve_artifacts(artifacts, item_slug),
737
+ "depends_on": prev_step_id,
738
+ })
739
+
740
+ prev_step_id = step_id
741
+ step_counter += 1
742
+
743
+ return {
744
+ "version": 1,
745
+ "workflow_type": workflow_type,
746
+ "pipeline_mode": pipeline_mode,
747
+ "item_id": item_id,
748
+ "item_slug": item_slug,
749
+ "session_id": session_id,
750
+ "steps": steps,
751
+ }
752
+
753
+
754
+ def merge_checkpoint_state(existing, fresh, project_root):
755
+ """Merge existing checkpoint state into a freshly generated definition.
756
+
757
+ Matching is by skill_key (not step ID), since tier changes across retries
758
+ may shift step IDs.
759
+
760
+ Merge rules:
761
+ 1. Only keep completed steps whose required_artifacts all exist on disk
762
+ 2. Keep skipped steps unconditionally
763
+ 3. Once a step is NOT completed/skipped, break the dependency chain:
764
+ all subsequent steps reset to pending
765
+ """
766
+ existing_status = {}
767
+ existing_artifacts = {}
768
+ for step in existing.get("steps", []):
769
+ existing_status[step["skill"]] = step["status"]
770
+ existing_artifacts[step["skill"]] = step.get("required_artifacts", [])
771
+
772
+ # Determine which completed steps have valid artifacts
773
+ valid_completed = set()
774
+ for skill_key, status in existing_status.items():
775
+ if status == "completed":
776
+ artifacts = existing_artifacts.get(skill_key, [])
777
+ if all(os.path.exists(os.path.join(project_root, a))
778
+ for a in artifacts):
779
+ valid_completed.add(skill_key)
780
+ else:
781
+ LOGGER.warning(
782
+ "Step '%s' was completed but artifacts missing — "
783
+ "resetting to pending", skill_key
784
+ )
785
+ elif status == "skipped":
786
+ valid_completed.add(skill_key)
787
+
788
+ # Apply to fresh steps; break chain on first non-valid step
789
+ chain_broken = False
790
+ for step in fresh["steps"]:
791
+ if chain_broken:
792
+ step["status"] = "pending"
793
+ continue
794
+
795
+ prev = existing_status.get(step["skill"])
796
+ if step["skill"] in valid_completed:
797
+ step["status"] = prev # completed or skipped
798
+ else:
799
+ chain_broken = True
800
+ step["status"] = "pending"
801
+
802
+ return fresh
803
+
804
+
805
+ # ============================================================
806
+ # Section Assembly (new modular approach)
807
+ # ============================================================
808
+
809
+
810
+ def load_section(sections_dir, name):
811
+ """Load a section file from the sections directory.
812
+
813
+ Returns the file content as a string, or raises FileNotFoundError.
814
+ """
815
+ path = os.path.join(sections_dir, name)
816
+ if not os.path.isfile(path):
817
+ raise FileNotFoundError("Section file not found: {}".format(path))
818
+ with open(path, "r", encoding="utf-8") as f:
819
+ return f.read()
820
+
821
+
822
+ def load_agent_prompts(templates_dir):
823
+ """Load agent prompt templates from agent-prompts/ directory.
824
+
825
+ Returns a dict of {{AGENT_PROMPT_XXX}} -> prompt content replacements.
826
+ If the directory does not exist, returns an empty dict (backward compat).
827
+ """
828
+ agent_prompts_dir = os.path.join(templates_dir, "agent-prompts")
829
+ if not os.path.isdir(agent_prompts_dir):
830
+ LOGGER.debug("No agent-prompts/ directory found, skipping")
831
+ return {}
832
+
833
+ # Map filename -> placeholder name
834
+ # e.g. dev-implement.md -> {{AGENT_PROMPT_DEV_IMPLEMENT}}
835
+ prompt_map = {}
836
+ for filename in sorted(os.listdir(agent_prompts_dir)):
837
+ if not filename.endswith(".md"):
838
+ continue
839
+ stem = filename[:-3] # remove .md
840
+ placeholder = "{{{{AGENT_PROMPT_{}}}}}".format(
841
+ stem.upper().replace("-", "_")
842
+ )
843
+ filepath = os.path.join(agent_prompts_dir, filename)
844
+ try:
845
+ with open(filepath, "r", encoding="utf-8") as f:
846
+ prompt_map[placeholder] = f.read().strip()
847
+ LOGGER.debug("Loaded agent prompt: %s -> %s", filename, placeholder)
848
+ except IOError as exc:
849
+ LOGGER.warning("Failed to load agent prompt %s: %s", filename, exc)
850
+
851
+ return prompt_map
852
+
853
+
854
+ def _tier_header(pipeline_mode):
855
+ """Return the tier-specific header and mission description."""
856
+ headers = {
857
+ "lite": (
858
+ "# Dev-Pipeline Session Bootstrap — Tier 1 (Single Agent)\n",
859
+ "**Tier 1 — Single Agent**: You handle everything directly. "
860
+ "No subagents, no TeamCreate.\n",
861
+ ),
862
+ "standard": (
863
+ "# Dev-Pipeline Session Bootstrap — Tier 2 (Dual Agent)\n",
864
+ "**Tier 2 — Dual Agent**: You handle context + planning "
865
+ "directly. Then spawn Dev and Reviewer subagents. Spawn Dev "
866
+ "and Reviewer agents via the Agent tool.\n",
867
+ ),
868
+ "full": (
869
+ "# Dev-Pipeline Session Bootstrap — Tier 3 (Full Team)\n",
870
+ "**Tier 3 — Full Team**: For complex features, use the full "
871
+ "pipeline with Dev + Reviewer agents spawned via the Agent "
872
+ "tool.\n",
873
+ ),
874
+ }
875
+ return headers.get(pipeline_mode, headers["lite"])
876
+
877
+
878
+ def _tier_reminders(pipeline_mode, critic_enabled=False):
879
+ """Return tier-specific reminder text."""
880
+ common = [
881
+ "- MANDATORY skills: `/prizmkit-retrospective`, `/prizmkit-committer` "
882
+ "— never skip these",
883
+ "- Build context-snapshot.md FIRST; use it throughout instead of "
884
+ "re-reading files",
885
+ "- `/prizmkit-committer` is mandatory — do NOT skip the commit phase, "
886
+ "and do NOT replace it with manual git commit commands",
887
+ "- Do NOT run `git add`/`git commit` during implementation phases — "
888
+ "all changes are committed once in the commit phase",
889
+ "- If any files remain after the commit, amend the existing commit — "
890
+ "do NOT create a follow-up commit",
891
+ "- When staging files, always use explicit file names — NEVER use "
892
+ "`git add -A` or `git add .`",
893
+ ]
894
+
895
+ if pipeline_mode == "lite":
896
+ specific = [
897
+ "- Tier 1: you handle everything directly — no subagents needed",
898
+ ]
899
+ elif pipeline_mode == "standard":
900
+ specific = [
901
+ "- Tier 2: orchestrator builds context+plan, Analyzer checks "
902
+ "consistency, Dev implements, Reviewer reviews+tests — use "
903
+ "direct Agent spawn for agents",
904
+ "- context-snapshot.md is append-only: orchestrator writes "
905
+ "Sections 1-4, Dev appends Implementation Log, Reviewer "
906
+ "appends Review Notes",
907
+ "- Gate checks enforce Implementation Log and Review Notes are "
908
+ "written before proceeding",
909
+ "- Do NOT use `run_in_background=true` when spawning subagents",
910
+ "- On timeout: check snapshot + git diff HEAD → model:lite → "
911
+ "remaining steps only → max 2 retries per phase → "
912
+ "orchestrator fallback",
913
+ ]
914
+ else: # full
915
+ specific = [
916
+ "- Tier 3: full team — Dev (implementation) → Reviewer "
917
+ "(review + test) — spawn agents directly via Agent tool",
918
+ "- context-snapshot.md is append-only: orchestrator writes "
919
+ "Sections 1-4, Dev appends Implementation Log, Reviewer "
920
+ "appends Review Notes",
921
+ "- Gate checks enforce Implementation Log and Review Notes are "
922
+ "written before proceeding",
923
+ "- Do NOT use `run_in_background=true` when spawning agents",
924
+ "- On timeout: check snapshot → model:lite → remaining steps "
925
+ "only → max 2 retries → orchestrator fallback",
926
+ ]
927
+
928
+ lines = ["## Reminders\n"] + specific + common
929
+ return "\n".join(lines) + "\n"
930
+
931
+
932
+ def assemble_sections(pipeline_mode, sections_dir, init_done, is_resume,
933
+ critic_enabled, browser_enabled, retry_count=0,
934
+ browser_tool="auto"):
935
+ """Assemble prompt sections based on tier and conditions.
936
+
937
+ Uses Python code for conditional logic instead of regex-based
938
+ template blocks. Each section is loaded from a separate .md file.
939
+
940
+ Returns a list of (section_name, content) tuples in order.
941
+ """
942
+ sections = []
943
+
944
+ # --- Header ---
945
+ title, tier_desc = _tier_header(pipeline_mode)
946
+ sections.append(("header", title))
947
+
948
+ # --- Session Context ---
949
+ sections.append(("session-context",
950
+ load_section(sections_dir, "session-context.md")))
951
+
952
+ # --- Mission ---
953
+ mission = (
954
+ "## Your Mission\n\n"
955
+ "You are the **session orchestrator**. Implement Feature "
956
+ "{{FEATURE_ID}}: \"{{FEATURE_TITLE}}\".\n\n"
957
+ "**CRITICAL**: You MUST NOT exit until ALL work is complete "
958
+ "and committed."
959
+ )
960
+ if pipeline_mode != "lite":
961
+ mission += (
962
+ " When you spawn subagents, wait for each to finish "
963
+ "(run_in_background=false)."
964
+ )
965
+ if pipeline_mode == "full":
966
+ mission += (
967
+ " Do NOT spawn agents in background and exit — "
968
+ "that kills the session."
969
+ )
970
+ mission += "\n\n" + tier_desc
971
+ sections.append(("mission", mission))
972
+
973
+ # --- Feature Context (XML-wrapped, optimization 3) ---
974
+ sections.append(("feature-context",
975
+ load_section(sections_dir, "feature-context.md")))
976
+
977
+ # --- Context Budget Rules ---
978
+ sections.append(("context-budget-rules",
979
+ load_section(sections_dir, "context-budget-rules.md")))
980
+
981
+ # --- Directory Convention (tier-specific) ---
982
+ if pipeline_mode == "lite":
983
+ dc_file = "directory-convention-lite.md"
984
+ elif pipeline_mode == "standard":
985
+ dc_file = "directory-convention-agent.md"
986
+ else:
987
+ dc_file = "directory-convention-full.md"
988
+ sections.append(("directory-convention",
989
+ load_section(sections_dir, dc_file)))
990
+
991
+ # --- Subagent Timeout Recovery (only for agent tiers) ---
992
+ if pipeline_mode in ("standard", "full"):
993
+ sections.append(("timeout-recovery",
994
+ load_section(sections_dir,
995
+ "subagent-timeout-recovery.md")))
996
+
997
+ # --- Checkpoint System ---
998
+ checkpoint_section_path = os.path.join(sections_dir, "checkpoint-system.md")
999
+ if os.path.isfile(checkpoint_section_path):
1000
+ sections.append(("checkpoint-system",
1001
+ load_section(sections_dir, "checkpoint-system.md")))
1002
+
1003
+ # --- Execution header ---
1004
+ sections.append(("execution-header", "---\n\n## Execution\n"))
1005
+
1006
+ # --- Phase 0: Init or Test Baseline ---
1007
+ if not init_done:
1008
+ sections.append(("phase0-init",
1009
+ load_section(sections_dir, "phase0-init.md")))
1010
+ else:
1011
+ if pipeline_mode in ("standard", "full"):
1012
+ sections.append(("phase0-test-baseline",
1013
+ load_section(sections_dir,
1014
+ "phase0-test-baseline.md")))
1015
+ else:
1016
+ sections.append(("phase0-skip",
1017
+ "### Phase 0: SKIP (already initialized)\n"))
1018
+
1019
+ # --- Context Snapshot + Plan (tier-dependent) ---
1020
+ if pipeline_mode == "full":
1021
+ # Tier 3: full specify + plan workflow
1022
+ sections.append(("phase-specify-plan",
1023
+ load_section(sections_dir,
1024
+ "phase-specify-plan-full.md")))
1025
+ else:
1026
+ # Tier 1 & 2: separate context snapshot + plan
1027
+ snapshot_base = load_section(sections_dir,
1028
+ "phase-context-snapshot-base.md")
1029
+ if pipeline_mode == "lite":
1030
+ snapshot_suffix = load_section(
1031
+ sections_dir, "phase-context-snapshot-lite-suffix.md")
1032
+ else:
1033
+ snapshot_suffix = load_section(
1034
+ sections_dir, "phase-context-snapshot-agent-suffix.md")
1035
+ sections.append(("phase-context-snapshot",
1036
+ snapshot_base + "\n" + snapshot_suffix))
1037
+
1038
+ if pipeline_mode == "lite":
1039
+ sections.append(("phase-plan",
1040
+ load_section(sections_dir,
1041
+ "phase-plan-lite.md")))
1042
+ else:
1043
+ sections.append(("phase-plan",
1044
+ load_section(sections_dir,
1045
+ "phase-plan-agent.md")))
1046
+
1047
+ # --- Critic: Plan Challenge (only if critic enabled) ---
1048
+ if critic_enabled:
1049
+ if pipeline_mode == "full":
1050
+ sections.append(("phase-critic-plan",
1051
+ load_section(sections_dir,
1052
+ "phase-critic-plan-full.md")))
1053
+ else:
1054
+ sections.append(("phase-critic-plan",
1055
+ load_section(sections_dir,
1056
+ "phase-critic-plan.md")))
1057
+
1058
+ # --- Implement (tier-dependent) ---
1059
+ if pipeline_mode == "lite":
1060
+ sections.append(("phase-implement",
1061
+ load_section(sections_dir,
1062
+ "phase-implement-lite.md")))
1063
+ elif pipeline_mode == "full":
1064
+ sections.append(("phase-implement",
1065
+ load_section(sections_dir,
1066
+ "phase-implement-full.md")))
1067
+ else:
1068
+ sections.append(("phase-implement",
1069
+ load_section(sections_dir,
1070
+ "phase-implement-agent.md")))
1071
+
1072
+ # --- Test Failure Recovery Protocol (tier-specific) ---
1073
+ if pipeline_mode == "lite":
1074
+ sections.append(("test-failure-recovery",
1075
+ load_section(sections_dir,
1076
+ "test-failure-recovery-lite.md")))
1077
+ else:
1078
+ sections.append(("test-failure-recovery",
1079
+ load_section(sections_dir,
1080
+ "test-failure-recovery-agent.md")))
1081
+
1082
+ # --- AC Verification Checklist (all tiers) ---
1083
+ ac_checklist_path = os.path.join(sections_dir, "ac-verification-checklist.md")
1084
+ if os.path.isfile(ac_checklist_path):
1085
+ sections.append(("ac-verification-checklist",
1086
+ load_section(sections_dir,
1087
+ "ac-verification-checklist.md")))
1088
+
1089
+ # --- Review (only for agent tiers) ---
1090
+ if pipeline_mode == "full":
1091
+ sections.append(("phase-review",
1092
+ load_section(sections_dir,
1093
+ "phase-review-full.md")))
1094
+ elif pipeline_mode == "standard":
1095
+ sections.append(("phase-review",
1096
+ load_section(sections_dir,
1097
+ "phase-review-agent.md")))
1098
+
1099
+ # --- Browser Verification (conditional, tool-aware) ---
1100
+ if browser_enabled:
1101
+ if browser_tool == "opencli":
1102
+ browser_section_file = "phase-browser-verification-opencli.md"
1103
+ elif browser_tool == "playwright-cli":
1104
+ browser_section_file = "phase-browser-verification.md"
1105
+ else:
1106
+ # "auto" or unknown → let AI choose at runtime
1107
+ browser_section_file = "phase-browser-verification-auto.md"
1108
+ sections.append(("phase-browser",
1109
+ load_section(sections_dir,
1110
+ browser_section_file)))
1111
+
1112
+ # --- Commit (tier-dependent) ---
1113
+ if pipeline_mode == "full":
1114
+ sections.append(("phase-commit",
1115
+ load_section(sections_dir,
1116
+ "phase-commit-full.md")))
1117
+ else:
1118
+ sections.append(("phase-commit",
1119
+ load_section(sections_dir,
1120
+ "phase-commit.md")))
1121
+
1122
+ # --- Critical Paths ---
1123
+ if pipeline_mode == "lite":
1124
+ cp_file = "critical-paths-lite.md"
1125
+ elif pipeline_mode == "full":
1126
+ cp_file = "critical-paths-full.md"
1127
+ else:
1128
+ cp_file = "critical-paths-agent.md"
1129
+ sections.append(("critical-paths",
1130
+ load_section(sections_dir, cp_file)))
1131
+
1132
+ # --- Failure Capture ---
1133
+ sections.append(("failure-capture",
1134
+ load_section(sections_dir, "failure-capture.md")))
1135
+
1136
+ # --- Reminders ---
1137
+ sections.append(("reminders",
1138
+ _tier_reminders(pipeline_mode, critic_enabled)))
1139
+
1140
+ return sections
1141
+
1142
+
1143
+ def render_from_sections(sections, replacements):
1144
+ """Join assembled sections and replace all {{PLACEHOLDER}} variables.
1145
+
1146
+ No regex-based conditional block processing needed — all conditions
1147
+ were resolved during assembly.
1148
+ """
1149
+ content = "\n".join(text for _, text in sections)
1150
+
1151
+ # Replace all placeholders — run twice to handle agent prompt templates
1152
+ # that contain their own {{PLACEHOLDER}} variables. First pass injects
1153
+ # agent prompt content (e.g. {{AGENT_PROMPT_DEV_IMPLEMENT}} expands to a
1154
+ # block containing {{FEATURE_ID}}). Second pass replaces the inner vars.
1155
+ for _pass in range(2):
1156
+ for placeholder, value in replacements.items():
1157
+ content = content.replace(placeholder, value)
1158
+
1159
+ return content
1160
+
1161
+
1162
+ # ============================================================
1163
+ # Rendered output validation (optimization 7)
1164
+ # ============================================================
1165
+
1166
+
1167
+ def validate_rendered(content):
1168
+ """Validate rendered prompt content for completeness.
1169
+
1170
+ Checks:
1171
+ 1. No unreplaced {{PLACEHOLDER}} variables remain
1172
+ 2. No unclosed conditional blocks (legacy {{IF_xxx}} tags)
1173
+ 3. Required sections present
1174
+
1175
+ Returns (is_valid, warnings, errors) tuple.
1176
+ """
1177
+ warnings = []
1178
+ errors = []
1179
+
1180
+ # Check for unreplaced placeholders (excluding code blocks that may
1181
+ # contain literal double braces like Jinja or Go templates)
1182
+ unreplaced = re.findall(r"\{\{[A-Z][A-Z_0-9]+\}\}", content)
1183
+ if unreplaced:
1184
+ # Deduplicate
1185
+ unique = sorted(set(unreplaced))
1186
+ warnings.append(
1187
+ "Unreplaced placeholders: {}".format(", ".join(unique))
1188
+ )
1189
+
1190
+ # Check for unclosed conditional blocks (legacy)
1191
+ unclosed_if = re.findall(
1192
+ r"\{\{(?:IF|END_IF)_[A-Z_]+\}\}", content
1193
+ )
1194
+ if unclosed_if:
1195
+ unique = sorted(set(unclosed_if))
1196
+ errors.append(
1197
+ "Unclosed conditional blocks: {}".format(", ".join(unique))
1198
+ )
1199
+
1200
+ # Check required sections exist
1201
+ required_markers = [
1202
+ ("## Your Mission", "Mission section"),
1203
+ ("## Execution", "Execution section"),
1204
+ ("## Failure Capture", "Failure Capture Protocol"),
1205
+ ]
1206
+ for marker, label in required_markers:
1207
+ if marker not in content:
1208
+ errors.append("Missing required section: {}".format(label))
1209
+
1210
+ is_valid = len(errors) == 0
1211
+
1212
+ # Log results
1213
+ for w in warnings:
1214
+ LOGGER.warning("VALIDATE: %s", w)
1215
+ for e in errors:
1216
+ LOGGER.error("VALIDATE: %s", e)
1217
+
1218
+ return is_valid, warnings, errors
1219
+
1220
+
1221
+ def build_replacements(args, feature, features, global_context, script_dir):
1222
+ """Build the full dict of placeholder -> replacement value."""
1223
+ project_root = resolve_project_root(script_dir)
1224
+
1225
+ # Resolve paths - platform-aware agent/team resolution
1226
+ platform = os.environ.get("PRIZMKIT_PLATFORM", "")
1227
+ home_dir = os.path.expanduser("~")
1228
+
1229
+ # Auto-detect platform if not set
1230
+ if not platform:
1231
+ has_codex = os.path.isdir(os.path.join(project_root, ".codex", "agents"))
1232
+ has_claude = os.path.isdir(os.path.join(project_root, ".claude", "agents"))
1233
+ has_codebuddy = os.path.isdir(os.path.join(project_root, ".codebuddy", "agents"))
1234
+ if has_codex:
1235
+ platform = "codex"
1236
+ elif has_claude:
1237
+ platform = "claude"
1238
+ elif has_codebuddy:
1239
+ platform = "codebuddy"
1240
+ else:
1241
+ raise RuntimeError(
1242
+ "PrizmKit agents not found. None of .codex/agents/, .claude/agents/, or .codebuddy/agents/ exists. "
1243
+ "Run `npx prizmkit install` first, or set PRIZMKIT_PLATFORM=codex|claude|codebuddy explicitly."
1244
+ )
1245
+
1246
+ if platform == "claude":
1247
+ # Claude Code: agents in .claude/agents/, no native team config
1248
+ agents_dir = os.path.join(project_root, ".claude", "agents")
1249
+ team_config_path = os.path.join(
1250
+ project_root, ".claude", "team-info.json",
1251
+ )
1252
+ elif platform == "codex":
1253
+ # Codex: agents and team metadata are project-local references.
1254
+ agents_dir = os.path.join(project_root, ".codex", "agents")
1255
+ team_config_path = os.path.join(
1256
+ project_root, ".codex", "team-info.json",
1257
+ )
1258
+ else:
1259
+ # CodeBuddy: agents in .codebuddy/agents/, team in ~/.codebuddy/teams/
1260
+ agents_dir = os.path.join(project_root, ".codebuddy", "agents")
1261
+ team_config_path = os.path.join(
1262
+ home_dir, ".codebuddy", "teams", "prizm-dev-team", "config.json",
1263
+ )
1264
+
1265
+ # Agent definitions are native .toml for Codex and .md for Claude/CodeBuddy.
1266
+ agent_ext = ".toml" if platform == "codex" else ".md"
1267
+ dev_subagent = os.path.join(
1268
+ agents_dir, f"prizm-dev-team-dev{agent_ext}",
1269
+ )
1270
+ reviewer_subagent = os.path.join(
1271
+ agents_dir, f"prizm-dev-team-reviewer{agent_ext}",
1272
+ )
1273
+ critic_subagent = os.path.join(
1274
+ agents_dir, f"prizm-dev-team-critic{agent_ext}",
1275
+ )
1276
+
1277
+ # Verify agent files actually exist — missing files cause confusing
1278
+ # errors when the AI session tries to read them later.
1279
+ for agent_path, agent_name in [
1280
+ (dev_subagent, "dev agent"),
1281
+ (reviewer_subagent, "reviewer agent"),
1282
+ ]:
1283
+ if not os.path.isfile(agent_path):
1284
+ LOGGER.warning(
1285
+ "Agent file not found: %s (%s). "
1286
+ "Subagent spawning may fail. "
1287
+ "Run `npx prizmkit install` to reinstall agent definitions.",
1288
+ agent_path, agent_name,
1289
+ )
1290
+ # Validator scripts - check if they exist in .codebuddy/scripts/, otherwise use .prizmkit/dev-pipeline/scripts/
1291
+ validator_scripts_dir = os.path.join(project_root, ".prizmkit", "dev-pipeline", "scripts")
1292
+ init_script_path = os.path.join(validator_scripts_dir, "init-dev-team.py")
1293
+
1294
+ # Session status path (relative to project root)
1295
+ session_status_path = os.path.join(
1296
+ ".prizmkit", "state", "features", args.feature_id,
1297
+ "sessions", args.session_id, "session-status.json",
1298
+ )
1299
+ # Make it absolute from project root
1300
+ session_status_abs = os.path.join(project_root, session_status_path)
1301
+
1302
+ # Compute feature slug for per-feature directory naming
1303
+ feature_slug = compute_feature_slug(
1304
+ args.feature_id, feature.get("title", "")
1305
+ )
1306
+
1307
+ # Detect project state
1308
+ init_done = detect_init_status(project_root)
1309
+ artifacts = detect_existing_artifacts(project_root, feature_slug)
1310
+ complexity = feature.get(
1311
+ "estimated_complexity",
1312
+ feature.get("complexity", "medium"),
1313
+ )
1314
+ if args.mode:
1315
+ pipeline_mode = args.mode
1316
+ else:
1317
+ pipeline_mode = determine_pipeline_mode(complexity)
1318
+
1319
+ # Auto-detect resume: if all planning artifacts exist and resume_phase
1320
+ # is "null" (fresh start), skip to Phase 6
1321
+ effective_resume = args.resume_phase
1322
+ if effective_resume == "null" and artifacts["all_complete"]:
1323
+ effective_resume = "6"
1324
+
1325
+ # Determine critic enablement (priority: CLI > env > feature field > default)
1326
+ critic_env = os.environ.get("ENABLE_CRITIC", "").lower()
1327
+ if args.critic is not None:
1328
+ critic_enabled = args.critic == "true"
1329
+ elif critic_env in ("true", "1"):
1330
+ critic_enabled = True
1331
+ elif critic_env in ("false", "0"):
1332
+ critic_enabled = False
1333
+ else:
1334
+ critic_enabled = bool(feature.get("critic", False))
1335
+
1336
+ # Determine critic count (from feature field, default 1)
1337
+ # Multi-critic voting (3) must be explicitly set by the user in .prizmkit/plans/feature-list.json
1338
+ critic_count = feature.get("critic_count", 1)
1339
+
1340
+ # Guard: if critic enabled but agent file missing, force disable and warn
1341
+ if critic_enabled and not os.path.isfile(critic_subagent):
1342
+ LOGGER.warning(
1343
+ "Critic enabled but agent file not found: %s. "
1344
+ "Critic phases will be SKIPPED. "
1345
+ "Run `npx prizmkit install` to install agent definitions.",
1346
+ critic_subagent,
1347
+ )
1348
+ critic_enabled = False
1349
+
1350
+ # Guard: if critic enabled but tier doesn't support it (lite), warn and disable
1351
+ if critic_enabled and pipeline_mode == "lite":
1352
+ LOGGER.warning(
1353
+ "Critic enabled for feature %s but pipeline_mode='lite' (tier1) "
1354
+ "does not support critic phases. Critic will be SKIPPED. "
1355
+ "Use estimated_complexity='high' or pass --mode standard/full.",
1356
+ args.feature_id,
1357
+ )
1358
+ critic_enabled = False
1359
+
1360
+ # Browser interaction - extract from feature if present
1361
+ browser_interaction = feature.get("browser_interaction")
1362
+ browser_enabled = False
1363
+ browser_verify_steps = ""
1364
+ browser_tool = "auto" # default: AI chooses at runtime
1365
+
1366
+ browser_verify_env = os.environ.get("BROWSER_VERIFY", "").lower()
1367
+ if browser_verify_env == "false":
1368
+ browser_interaction = None
1369
+
1370
+ if browser_interaction and isinstance(browser_interaction, bool):
1371
+ # Simple boolean: browser verification enabled, no specific goals
1372
+ browser_enabled = True
1373
+ browser_tool = "auto"
1374
+ browser_verify_steps = (
1375
+ " # (no specific verify goals — explore the app and "
1376
+ "verify the feature works as expected)")
1377
+ elif browser_interaction and isinstance(browser_interaction, dict):
1378
+ # Extract tool preference (playwright-cli / opencli / auto)
1379
+ browser_tool = browser_interaction.get("tool", "auto")
1380
+ if browser_tool not in ("playwright-cli", "opencli", "auto"):
1381
+ LOGGER.warning(
1382
+ "Unknown browser_interaction.tool '%s', defaulting to 'auto'",
1383
+ browser_tool,
1384
+ )
1385
+ browser_tool = "auto"
1386
+
1387
+ # browser_interaction only needs verify_steps — AI auto-detects
1388
+ # dev server command, URL, and port from project config
1389
+ steps = browser_interaction.get("verify_steps", [])
1390
+ if steps:
1391
+ browser_enabled = True
1392
+ browser_verify_steps = "\n".join(
1393
+ " # Goal {}: {}".format(i + 1, step)
1394
+ for i, step in enumerate(steps)
1395
+ )
1396
+ elif browser_interaction.get("url") or browser_interaction.get("enabled", True):
1397
+ # Backward compat: old format had url/setup_command fields
1398
+ browser_enabled = True
1399
+ browser_verify_steps = (
1400
+ " # (no specific verify goals — explore the app and "
1401
+ "verify the feature works as expected)")
1402
+
1403
+ # Auto-detect test commands from project structure
1404
+ test_commands = detect_test_command_list(project_root)
1405
+ test_cmd = format_powershell_test_commands(test_commands)
1406
+ if not test_cmd:
1407
+ test_cmd = "(auto-detection found no standard test commands; manually specify TEST_CMD)"
1408
+
1409
+ # Optionally extract baseline failures from test execution
1410
+ baseline_failures = ""
1411
+ if args.extract_baselines:
1412
+ baseline_test_commands = format_baseline_test_commands(test_commands)
1413
+ if baseline_test_commands:
1414
+ baseline_failures = extract_baseline_failures(baseline_test_commands, project_root)
1415
+
1416
+ # Extract coverage target from feature.testing field (new in v2)
1417
+ coverage_target = "80" # Default coverage target
1418
+ testing_config = feature.get("testing", {})
1419
+ if isinstance(testing_config, dict):
1420
+ coverage_target = str(testing_config.get("coverage_target", 80))
1421
+
1422
+ # Detect dev server port from package.json
1423
+ dev_port = "3000" # Default fallback
1424
+ try:
1425
+ pkg_path = os.path.join(project_root, "package.json")
1426
+ if os.path.isfile(pkg_path):
1427
+ with open(pkg_path, "r", encoding="utf-8") as f:
1428
+ pkg = json.load(f)
1429
+ dev_script = pkg.get("scripts", {}).get("dev", "")
1430
+ # Extract -p <port> from dev script
1431
+ port_match = re.search(r"-p\s+(\d+)", dev_script)
1432
+ if port_match:
1433
+ dev_port = port_match.group(1)
1434
+ else:
1435
+ # Fallback: try NEXT_PUBLIC_SITE_URL from .env files
1436
+ for env_file in [".env.local", ".env"]:
1437
+ env_path = os.path.join(project_root, env_file)
1438
+ if os.path.isfile(env_path):
1439
+ with open(env_path, "r", encoding="utf-8") as ef:
1440
+ for line in ef:
1441
+ m = re.match(
1442
+ r"NEXT_PUBLIC_SITE_URL\s*=\s*.*?:([0-9]+)", line.strip()
1443
+ )
1444
+ if m:
1445
+ dev_port = m.group(1)
1446
+ break
1447
+ if dev_port != "3000":
1448
+ break
1449
+ except Exception:
1450
+ pass # Keep default 3000 on any error
1451
+ dev_url = f"http://localhost:{dev_port}"
1452
+
1453
+ replacements = {
1454
+ "{{RUN_ID}}": args.run_id,
1455
+ "{{SESSION_ID}}": args.session_id,
1456
+ "{{FEATURE_ID}}": args.feature_id,
1457
+ "{{FEATURE_LIST_PATH}}": os.path.abspath(args.feature_list),
1458
+ "{{FEATURE_TITLE}}": feature.get("title", ""),
1459
+ "{{FEATURE_DESCRIPTION}}": feature.get("description", ""),
1460
+ "{{USER_CONTEXT}}": format_user_context(feature.get("user_context", [])),
1461
+ "{{ACCEPTANCE_CRITERIA}}": format_acceptance_criteria(
1462
+ feature.get("acceptance_criteria", [])
1463
+ ),
1464
+ "{{COMPLETED_DEPENDENCIES}}": get_completed_dependencies(
1465
+ features, feature
1466
+ ),
1467
+ "{{GLOBAL_CONTEXT}}": format_global_context(global_context, project_root),
1468
+ "{{PROJECT_BRIEF}}": _read_project_brief(project_root),
1469
+ "{{PLATFORM_CONVENTIONS}}": read_platform_conventions(project_root),
1470
+ "{{TEAM_CONFIG_PATH}}": team_config_path,
1471
+ "{{DEV_SUBAGENT_PATH}}": dev_subagent,
1472
+ "{{REVIEWER_SUBAGENT_PATH}}": reviewer_subagent,
1473
+ "{{CRITIC_SUBAGENT_PATH}}": critic_subagent,
1474
+ "{{INIT_SCRIPT_PATH}}": init_script_path,
1475
+ "{{SESSION_STATUS_PATH}}": session_status_abs,
1476
+ "{{PROJECT_ROOT}}": project_root,
1477
+ "{{PIPELINE_DIR}}": ".prizmkit\\dev-pipeline",
1478
+ "{{FEATURE_SLUG}}": feature_slug,
1479
+ "{{CHECKPOINT_PATH}}": os.path.join(
1480
+ ".prizmkit", "specs", feature_slug, "workflow-checkpoint.json",
1481
+ ),
1482
+ "{{PIPELINE_MODE}}": pipeline_mode,
1483
+ "{{COMPLEXITY}}": complexity,
1484
+ "{{CRITIC_ENABLED}}": "true" if critic_enabled else "false",
1485
+ "{{CRITIC_COUNT}}": str(critic_count),
1486
+ "{{INIT_DONE}}": "true" if init_done else "false",
1487
+ "{{HAS_SPEC}}": "true" if artifacts["has_spec"] else "false",
1488
+ "{{HAS_PLAN}}": "true" if artifacts["has_plan"] else "false",
1489
+ "{{BROWSER_VERIFY_STEPS}}": browser_verify_steps,
1490
+ "{{BROWSER_TOOL}}": browser_tool,
1491
+ "{{AC_CHECKLIST}}": format_ac_checklist(
1492
+ feature.get("acceptance_criteria", [])
1493
+ ),
1494
+ "{{TEST_CMD}}": test_cmd,
1495
+ "{{BASELINE_FAILURES}}": baseline_failures,
1496
+ "{{COVERAGE_TARGET}}": coverage_target,
1497
+ "{{DEV_PORT}}": dev_port,
1498
+ "{{DEV_URL}}": dev_url,
1499
+ }
1500
+
1501
+ return replacements, effective_resume, browser_enabled, browser_tool
1502
+
1503
+
1504
+ def render_template(template_content, replacements, resume_phase,
1505
+ browser_enabled=False, browser_tool="auto"):
1506
+ """Render the template by processing conditionals and replacing placeholders."""
1507
+ # Step 1: Process fresh_start/resume conditional blocks
1508
+ content = process_conditional_blocks(template_content, resume_phase)
1509
+
1510
+ # Step 2: Process mode, init, critic, and browser conditional blocks
1511
+ pipeline_mode = replacements.get("{{PIPELINE_MODE}}", "standard")
1512
+ init_done = replacements.get("{{INIT_DONE}}", "false") == "true"
1513
+ critic_enabled = replacements.get("{{CRITIC_ENABLED}}", "false") == "true"
1514
+ content = process_mode_blocks(content, pipeline_mode, init_done, critic_enabled,
1515
+ browser_enabled, browser_tool)
1516
+
1517
+ # Step 3: Replace all {{PLACEHOLDER}} variables (two passes for nested
1518
+ # agent prompt templates that may contain their own placeholders)
1519
+ for _pass in range(2):
1520
+ for placeholder, value in replacements.items():
1521
+ content = content.replace(placeholder, value)
1522
+
1523
+ return content
1524
+
1525
+
1526
+ def write_output(output_path, content):
1527
+ """Write the rendered content to the output file."""
1528
+ abs_path = os.path.abspath(output_path)
1529
+ output_dir = os.path.dirname(abs_path)
1530
+ if output_dir and not os.path.isdir(output_dir):
1531
+ try:
1532
+ os.makedirs(output_dir, exist_ok=True)
1533
+ except OSError as e:
1534
+ return "Cannot create output directory: {}".format(str(e))
1535
+ try:
1536
+ with open(abs_path, "w", encoding="utf-8") as f:
1537
+ f.write(content)
1538
+ except IOError as e:
1539
+ return "Cannot write output file: {}".format(str(e))
1540
+ return None
1541
+
1542
+
1543
+ def emit_failure(message):
1544
+ """Emit standardized failure JSON and exit."""
1545
+ print(json.dumps({"success": False, "error": message}, indent=2, ensure_ascii=False))
1546
+ sys.exit(1)
1547
+
1548
+
1549
+ def main():
1550
+ args = parse_args()
1551
+
1552
+ # Resolve script directory
1553
+ script_dir = os.path.dirname(os.path.abspath(__file__))
1554
+ templates_dir = os.path.join(script_dir, "..", "templates")
1555
+ sections_dir = os.path.join(templates_dir, "sections")
1556
+
1557
+ # Load feature list early (needed for both code paths)
1558
+ feature_list_data, err = load_json_file(args.feature_list)
1559
+ if err:
1560
+ emit_failure("Feature list error: {}".format(err))
1561
+
1562
+ features = feature_list_data.get("features")
1563
+ if not isinstance(features, list):
1564
+ emit_failure("Feature list does not contain a 'features' array")
1565
+
1566
+ feature = find_feature(features, args.feature_id)
1567
+ if feature is None:
1568
+ emit_failure(
1569
+ "Feature '{}' not found in feature list".format(args.feature_id)
1570
+ )
1571
+
1572
+ global_context = feature_list_data.get("global_context", {})
1573
+ if not isinstance(global_context, dict):
1574
+ global_context = {}
1575
+
1576
+ # Build replacements (shared by both code paths)
1577
+ replacements, effective_resume, browser_enabled, browser_tool = build_replacements(
1578
+ args, feature, features, global_context, script_dir
1579
+ )
1580
+
1581
+ # Load agent prompt templates and merge into replacements
1582
+ agent_prompt_replacements = load_agent_prompts(templates_dir)
1583
+ replacements.update(agent_prompt_replacements)
1584
+
1585
+ # Extract state needed for assembly
1586
+ pipeline_mode = replacements.get("{{PIPELINE_MODE}}", "lite")
1587
+ init_done = replacements.get("{{INIT_DONE}}", "false") == "true"
1588
+ is_resume = effective_resume != "null"
1589
+ critic_enabled = replacements.get("{{CRITIC_ENABLED}}", "false") == "true"
1590
+
1591
+ # ── Choose rendering path ──────────────────────────────────────────
1592
+ use_sections = os.path.isdir(sections_dir) and not args.template
1593
+
1594
+ if use_sections:
1595
+ # New modular section assembly (code-level conditional logic)
1596
+ LOGGER.info("Using section assembly from %s", sections_dir)
1597
+ try:
1598
+ sections = assemble_sections(
1599
+ pipeline_mode, sections_dir, init_done, is_resume,
1600
+ critic_enabled, browser_enabled,
1601
+ retry_count=int(args.retry_count),
1602
+ browser_tool=browser_tool,
1603
+ )
1604
+ rendered = render_from_sections(sections, replacements)
1605
+ except FileNotFoundError as exc:
1606
+ LOGGER.warning(
1607
+ "Section assembly failed (%s), falling back to legacy "
1608
+ "template", exc,
1609
+ )
1610
+ use_sections = False
1611
+
1612
+ if not use_sections:
1613
+ # Legacy monolithic template path (backward compatible)
1614
+ if args.template:
1615
+ template_path = args.template
1616
+ else:
1617
+ complexity = feature.get(
1618
+ "estimated_complexity",
1619
+ feature.get("complexity", "medium"),
1620
+ )
1621
+ _mode = args.mode or determine_pipeline_mode(complexity)
1622
+ _tier_file_map = {
1623
+ "lite": "bootstrap-tier1.md",
1624
+ "standard": "bootstrap-tier2.md",
1625
+ "full": "bootstrap-tier3.md",
1626
+ }
1627
+ _tier_file = _tier_file_map.get(_mode, "bootstrap-tier2.md")
1628
+ _tier_path = os.path.join(templates_dir, _tier_file)
1629
+ if os.path.isfile(_tier_path):
1630
+ template_path = _tier_path
1631
+ else:
1632
+ template_path = os.path.join(
1633
+ templates_dir, "bootstrap-prompt.md"
1634
+ )
1635
+
1636
+ template_content, err = read_text_file(template_path)
1637
+ if err:
1638
+ emit_failure("Template error: {}".format(err))
1639
+
1640
+ rendered = render_template(
1641
+ template_content, replacements, effective_resume, browser_enabled,
1642
+ browser_tool
1643
+ )
1644
+
1645
+ # ── Validate rendered output ───────────────────────────────────────
1646
+ is_valid, warnings, errors = validate_rendered(rendered)
1647
+ if not is_valid:
1648
+ LOGGER.error(
1649
+ "Rendered prompt failed validation: %s",
1650
+ "; ".join(errors),
1651
+ )
1652
+ # Continue anyway — a partially valid prompt is better than none
1653
+
1654
+ # ── Write output ───────────────────────────────────────────────────
1655
+ err = write_output(args.output, rendered)
1656
+ if err:
1657
+ emit_failure(err)
1658
+
1659
+ # ── Generate checkpoint file ──────────────────────────────────────
1660
+ project_root = resolve_project_root(
1661
+ os.path.dirname(os.path.abspath(__file__))
1662
+ )
1663
+ feature_slug = replacements.get("{{FEATURE_SLUG}}", "")
1664
+ checkpoint_path = ""
1665
+
1666
+ if not args.no_checkpoint and use_sections and feature_slug:
1667
+ checkpoint = generate_checkpoint_definition(
1668
+ sections=sections,
1669
+ pipeline_mode=pipeline_mode,
1670
+ workflow_type="feature-pipeline",
1671
+ item_id=args.feature_id,
1672
+ item_slug=feature_slug,
1673
+ session_id=args.session_id,
1674
+ init_done=init_done,
1675
+ )
1676
+
1677
+ checkpoint_dir = os.path.join(
1678
+ project_root, ".prizmkit", "specs", feature_slug,
1679
+ )
1680
+ os.makedirs(checkpoint_dir, exist_ok=True)
1681
+ checkpoint_path = os.path.join(
1682
+ checkpoint_dir, "workflow-checkpoint.json",
1683
+ )
1684
+
1685
+ # On resume, merge existing completed state (with artifact validation)
1686
+ if is_resume and os.path.exists(checkpoint_path):
1687
+ try:
1688
+ with open(checkpoint_path, "r", encoding="utf-8") as f:
1689
+ existing = json.load(f)
1690
+ checkpoint = merge_checkpoint_state(
1691
+ existing, checkpoint, project_root,
1692
+ )
1693
+ LOGGER.info("Merged existing checkpoint state from %s",
1694
+ checkpoint_path)
1695
+ except (json.JSONDecodeError, KeyError) as exc:
1696
+ LOGGER.warning(
1697
+ "Existing checkpoint corrupted (%s) — generating fresh",
1698
+ exc,
1699
+ )
1700
+
1701
+ with open(checkpoint_path, "w", encoding="utf-8") as f:
1702
+ json.dump(checkpoint, f, indent=2, ensure_ascii=False)
1703
+ LOGGER.info("Wrote checkpoint to %s", checkpoint_path)
1704
+
1705
+ # ── Success JSON ───────────────────────────────────────────────────
1706
+ feature_model = feature.get("model", "")
1707
+ mode_agent_counts = {"lite": 1, "standard": 3, "full": 3}
1708
+ agent_count = mode_agent_counts.get(pipeline_mode, 1)
1709
+ critic_count_val = int(replacements.get("{{CRITIC_COUNT}}", "1"))
1710
+ if critic_enabled:
1711
+ agent_count += critic_count_val
1712
+ output = {
1713
+ "success": True,
1714
+ "output_path": os.path.abspath(args.output),
1715
+ "model": feature_model,
1716
+ "pipeline_mode": pipeline_mode,
1717
+ "agent_count": agent_count,
1718
+ "critic_enabled": "true" if critic_enabled else "false",
1719
+ "render_mode": "sections" if use_sections else "legacy",
1720
+ "validation_warnings": len(warnings),
1721
+ "validation_errors": len(errors),
1722
+ "checkpoint_path": checkpoint_path,
1723
+ }
1724
+ print(json.dumps(output, indent=2, ensure_ascii=False))
1725
+ sys.exit(0)
1726
+
1727
+
1728
+ if __name__ == "__main__":
1729
+ try:
1730
+ main()
1731
+ except KeyboardInterrupt:
1732
+ emit_failure("generate-bootstrap-prompt interrupted")
1733
+ except SystemExit:
1734
+ raise
1735
+ except Exception as exc:
1736
+ LOGGER.exception("Unhandled exception in generate-bootstrap-prompt")
1737
+ emit_failure("Unexpected error: {}".format(str(exc)))