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.
- package/bin/create-prizmkit.js +8 -6
- package/bundled/VERSION.json +3 -3
- package/bundled/adapters/codex/agent-adapter.js +38 -0
- package/bundled/adapters/codex/paths.js +27 -0
- package/bundled/adapters/codex/rules-adapter.js +30 -0
- package/bundled/adapters/codex/settings-adapter.js +27 -0
- package/bundled/adapters/codex/skill-adapter.js +65 -0
- package/bundled/adapters/codex/team-adapter.js +37 -0
- package/bundled/dev-pipeline/.env.example +2 -1
- package/bundled/dev-pipeline/README.md +10 -7
- package/bundled/dev-pipeline/lib/common.sh +278 -37
- package/bundled/dev-pipeline/run-bugfix.sh +10 -61
- package/bundled/dev-pipeline/run-feature.sh +10 -78
- package/bundled/dev-pipeline/run-recovery.sh +10 -46
- package/bundled/dev-pipeline/run-refactor.sh +10 -61
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +17 -7
- package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +9 -3
- package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +9 -3
- package/bundled/dev-pipeline/scripts/utils.py +6 -4
- package/bundled/dev-pipeline-windows/.env.example +28 -0
- package/bundled/dev-pipeline-windows/README.md +30 -0
- package/bundled/dev-pipeline-windows/SCHEMA_ANALYSIS.md +525 -0
- package/bundled/dev-pipeline-windows/assets/feature-list-example.json +146 -0
- package/bundled/dev-pipeline-windows/assets/prizm-dev-team-integration.md +138 -0
- package/bundled/dev-pipeline-windows/launch-bugfix-daemon.ps1 +9 -0
- package/bundled/dev-pipeline-windows/launch-feature-daemon.ps1 +9 -0
- package/bundled/dev-pipeline-windows/launch-refactor-daemon.ps1 +9 -0
- package/bundled/dev-pipeline-windows/lib/common.ps1 +432 -0
- package/bundled/dev-pipeline-windows/lib/daemon.ps1 +140 -0
- package/bundled/dev-pipeline-windows/lib/pipeline.ps1 +446 -0
- package/bundled/dev-pipeline-windows/lib/reset.ps1 +87 -0
- package/bundled/dev-pipeline-windows/reset-bug.ps1 +9 -0
- package/bundled/dev-pipeline-windows/reset-feature.ps1 +9 -0
- package/bundled/dev-pipeline-windows/reset-refactor.ps1 +9 -0
- package/bundled/dev-pipeline-windows/run-bugfix.ps1 +9 -0
- package/bundled/dev-pipeline-windows/run-feature.ps1 +9 -0
- package/bundled/dev-pipeline-windows/run-recovery.ps1 +76 -0
- package/bundled/dev-pipeline-windows/run-refactor.ps1 +9 -0
- package/bundled/dev-pipeline-windows/scripts/check-session-status.py +228 -0
- package/bundled/dev-pipeline-windows/scripts/cleanup-logs.py +192 -0
- package/bundled/dev-pipeline-windows/scripts/detect-stuck.py +530 -0
- package/bundled/dev-pipeline-windows/scripts/generate-bootstrap-prompt.py +1737 -0
- package/bundled/dev-pipeline-windows/scripts/generate-bugfix-prompt.py +685 -0
- package/bundled/dev-pipeline-windows/scripts/generate-recovery-prompt.py +805 -0
- package/bundled/dev-pipeline-windows/scripts/generate-refactor-prompt.py +763 -0
- package/bundled/dev-pipeline-windows/scripts/init-bugfix-pipeline.py +316 -0
- package/bundled/dev-pipeline-windows/scripts/init-dev-team.py +134 -0
- package/bundled/dev-pipeline-windows/scripts/init-pipeline.py +380 -0
- package/bundled/dev-pipeline-windows/scripts/init-refactor-pipeline.py +399 -0
- package/bundled/dev-pipeline-windows/scripts/parse-stream-progress.py +388 -0
- package/bundled/dev-pipeline-windows/scripts/patch-completion-notes.py +191 -0
- package/bundled/dev-pipeline-windows/scripts/update-bug-status.py +864 -0
- package/bundled/dev-pipeline-windows/scripts/update-checkpoint.py +173 -0
- package/bundled/dev-pipeline-windows/scripts/update-feature-status.py +1501 -0
- package/bundled/dev-pipeline-windows/scripts/update-refactor-status.py +1073 -0
- package/bundled/dev-pipeline-windows/scripts/utils.py +542 -0
- package/bundled/dev-pipeline-windows/templates/agent-prompts/critic-plan-challenge.md +7 -0
- package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-fix.md +7 -0
- package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-implement.md +30 -0
- package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-resume.md +5 -0
- package/bundled/dev-pipeline-windows/templates/agent-prompts/reviewer-review.md +7 -0
- package/bundled/dev-pipeline-windows/templates/bootstrap-prompt.md +46 -0
- package/bundled/dev-pipeline-windows/templates/bootstrap-tier1.md +43 -0
- package/bundled/dev-pipeline-windows/templates/bootstrap-tier2.md +43 -0
- package/bundled/dev-pipeline-windows/templates/bootstrap-tier3.md +43 -0
- package/bundled/dev-pipeline-windows/templates/bug-fix-list-schema.json +263 -0
- package/bundled/dev-pipeline-windows/templates/bugfix-bootstrap-prompt.md +320 -0
- package/bundled/dev-pipeline-windows/templates/feature-list-schema.json +237 -0
- package/bundled/dev-pipeline-windows/templates/refactor-bootstrap-prompt.md +331 -0
- package/bundled/dev-pipeline-windows/templates/refactor-list-schema.json +270 -0
- package/bundled/dev-pipeline-windows/templates/sections/ac-verification-checklist.md +13 -0
- package/bundled/dev-pipeline-windows/templates/sections/checkpoint-system.md +91 -0
- package/bundled/dev-pipeline-windows/templates/sections/context-budget-rules.md +33 -0
- package/bundled/dev-pipeline-windows/templates/sections/critical-paths-agent.md +10 -0
- package/bundled/dev-pipeline-windows/templates/sections/critical-paths-full.md +12 -0
- package/bundled/dev-pipeline-windows/templates/sections/critical-paths-lite.md +7 -0
- package/bundled/dev-pipeline-windows/templates/sections/directory-convention-agent.md +8 -0
- package/bundled/dev-pipeline-windows/templates/sections/directory-convention-full.md +9 -0
- package/bundled/dev-pipeline-windows/templates/sections/directory-convention-lite.md +6 -0
- package/bundled/dev-pipeline-windows/templates/sections/failure-capture.md +21 -0
- package/bundled/dev-pipeline-windows/templates/sections/feature-context.md +31 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-auto.md +72 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-opencli.md +63 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification.md +62 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-commit-full.md +71 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-commit.md +64 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-agent-suffix.md +23 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-base.md +24 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-lite-suffix.md +12 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan-full.md +53 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan.md +32 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-implement-agent.md +37 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-implement-full.md +50 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-implement-lite.md +52 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-plan-agent.md +27 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-plan-lite.md +27 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-review-agent.md +27 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-review-full.md +29 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase-specify-plan-full.md +77 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase0-init.md +13 -0
- package/bundled/dev-pipeline-windows/templates/sections/phase0-test-baseline.md +23 -0
- package/bundled/dev-pipeline-windows/templates/sections/session-context.md +5 -0
- package/bundled/dev-pipeline-windows/templates/sections/subagent-timeout-recovery.md +6 -0
- package/bundled/dev-pipeline-windows/templates/sections/test-failure-recovery-agent.md +67 -0
- package/bundled/dev-pipeline-windows/templates/sections/test-failure-recovery-lite.md +58 -0
- package/bundled/dev-pipeline-windows/templates/session-status-schema.json +83 -0
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/app-planner/SKILL.md +26 -18
- package/bundled/skills/app-planner/references/architecture-decisions.md +9 -5
- package/bundled/skills/app-planner/references/frontend-design-guide.md +1 -1
- package/bundled/skills/feature-planner/SKILL.md +9 -2
- package/bundled/skills/prizmkit-init/SKILL.md +7 -6
- package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +2 -0
- package/bundled/skills-windows/app-planner/SKILL.md +639 -0
- package/bundled/skills-windows/app-planner/assets/app-design-guide.md +101 -0
- package/bundled/skills-windows/app-planner/references/architecture-decisions.md +52 -0
- package/bundled/skills-windows/app-planner/references/brainstorm-guide.md +101 -0
- package/bundled/skills-windows/app-planner/references/frontend-design-guide.md +71 -0
- package/bundled/skills-windows/app-planner/references/project-brief-guide.md +82 -0
- package/bundled/skills-windows/app-planner/references/red-team-checklist.md +40 -0
- package/bundled/skills-windows/app-planner/references/rules/backend/derivation-rules.md +609 -0
- package/bundled/skills-windows/app-planner/references/rules/backend/fixed-rules.md +285 -0
- package/bundled/skills-windows/app-planner/references/rules/backend/question-bank.md +249 -0
- package/bundled/skills-windows/app-planner/references/rules/backend/template.md +173 -0
- package/bundled/skills-windows/app-planner/references/rules/database/derivation-rules.md +373 -0
- package/bundled/skills-windows/app-planner/references/rules/database/fixed-rules.md +211 -0
- package/bundled/skills-windows/app-planner/references/rules/database/question-bank.md +184 -0
- package/bundled/skills-windows/app-planner/references/rules/database/template.md +158 -0
- package/bundled/skills-windows/app-planner/references/rules/frontend/derivation-rules.md +810 -0
- package/bundled/skills-windows/app-planner/references/rules/frontend/fixed-rules.md +188 -0
- package/bundled/skills-windows/app-planner/references/rules/frontend/question-bank.md +302 -0
- package/bundled/skills-windows/app-planner/references/rules/frontend/template.md +320 -0
- package/bundled/skills-windows/app-planner/references/rules/mobile/derivation-rules.md +639 -0
- package/bundled/skills-windows/app-planner/references/rules/mobile/fixed-rules.md +290 -0
- package/bundled/skills-windows/app-planner/references/rules/mobile/question-bank.md +232 -0
- package/bundled/skills-windows/app-planner/references/rules/mobile/template.md +175 -0
- package/bundled/skills-windows/bug-fix-workflow/SKILL.md +415 -0
- package/bundled/skills-windows/bug-planner/SKILL.md +395 -0
- package/bundled/skills-windows/bug-planner/assets/bug-confirmation-template.md +43 -0
- package/bundled/skills-windows/bug-planner/references/critic-and-verification.md +44 -0
- package/bundled/skills-windows/bug-planner/references/error-recovery.md +73 -0
- package/bundled/skills-windows/bug-planner/references/input-formats.md +53 -0
- package/bundled/skills-windows/bug-planner/references/schema-validation.md +25 -0
- package/bundled/skills-windows/bug-planner/references/severity-rules.md +16 -0
- package/bundled/skills-windows/bug-planner/scripts/validate-bug-list.py +322 -0
- package/bundled/skills-windows/bugfix-pipeline-launcher/SKILL.md +380 -0
- package/bundled/skills-windows/feature-pipeline-launcher/SKILL.md +441 -0
- package/bundled/skills-windows/feature-pipeline-launcher/scripts/preflight-check.py +462 -0
- package/bundled/skills-windows/feature-planner/SKILL.md +401 -0
- package/bundled/skills-windows/feature-planner/assets/evaluation-guide.md +64 -0
- package/bundled/skills-windows/feature-planner/assets/planning-guide.md +214 -0
- package/bundled/skills-windows/feature-planner/references/browser-interaction.md +59 -0
- package/bundled/skills-windows/feature-planner/references/completeness-review.md +57 -0
- package/bundled/skills-windows/feature-planner/references/decomposition-patterns.md +75 -0
- package/bundled/skills-windows/feature-planner/references/error-recovery.md +90 -0
- package/bundled/skills-windows/feature-planner/references/incremental-feature-planning.md +112 -0
- package/bundled/skills-windows/feature-planner/references/new-project-planning.md +85 -0
- package/bundled/skills-windows/feature-planner/scripts/validate-and-generate.py +1029 -0
- package/bundled/skills-windows/feature-workflow/SKILL.md +531 -0
- package/bundled/skills-windows/prizmkit-init/SKILL.md +356 -0
- package/bundled/skills-windows/prizmkit-init/assets/project-brief-template.md +82 -0
- package/bundled/skills-windows/prizmkit-init/references/config-schema.md +68 -0
- package/bundled/skills-windows/prizmkit-init/references/rules/layer-detection.md +41 -0
- package/bundled/skills-windows/prizmkit-init/references/tech-stack-catalog.md +13 -0
- package/bundled/skills-windows/prizmkit-init/references/update-supplement.md +9 -0
- package/bundled/skills-windows/recovery-workflow/SKILL.md +456 -0
- package/bundled/skills-windows/recovery-workflow/evals/evals.json +46 -0
- package/bundled/skills-windows/recovery-workflow/scripts/detect-recovery-state.py +544 -0
- package/bundled/skills-windows/refactor-pipeline-launcher/SKILL.md +406 -0
- package/bundled/skills-windows/refactor-planner/SKILL.md +540 -0
- package/bundled/skills-windows/refactor-planner/assets/planning-guide.md +292 -0
- package/bundled/skills-windows/refactor-planner/references/behavior-preservation.md +301 -0
- package/bundled/skills-windows/refactor-planner/references/refactor-scoping-guide.md +221 -0
- package/bundled/skills-windows/refactor-planner/scripts/validate-and-generate-refactor.py +858 -0
- package/bundled/skills-windows/refactor-workflow/SKILL.md +503 -0
- package/package.json +3 -2
- package/src/clean.js +73 -2
- package/src/config.js +159 -50
- package/src/detect-platform.js +16 -8
- package/src/external-skills.js +26 -19
- package/src/index.js +31 -9
- package/src/manifest.js +6 -2
- package/src/metadata.js +43 -5
- package/src/platforms.js +36 -0
- package/src/prompts.js +31 -6
- package/src/runtimes.js +20 -0
- package/src/scaffold.js +314 -110
- 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)))
|