prizmkit 1.0.144 → 1.0.148
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/bundled/VERSION.json +3 -3
- package/bundled/dev-pipeline/run.sh +54 -1
- package/bundled/dev-pipeline/scripts/update-feature-status.py +287 -7
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +20 -8
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +20 -8
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +20 -8
- package/bundled/dev-pipeline/templates/feature-list-schema.json +1 -1
- package/bundled/dev-pipeline/tests/conftest.py +1 -0
- package/bundled/dev-pipeline/tests/test_auto_skip.py +446 -0
- package/bundled/skills/_metadata.json +16 -1
- package/bundled/skills/app-planner/SKILL.md +110 -28
- package/bundled/skills/app-planner/scripts/validate-and-generate.py +1 -1
- package/bundled/skills/prizm-kit/SKILL.md +3 -1
- package/bundled/skills/prizmkit-committer/SKILL.md +1 -1
- package/bundled/skills/prizmkit-deploy/SKILL.md +112 -0
- package/bundled/skills/prizmkit-deploy/assets/deploy-template.md +108 -0
- package/bundled/skills/prizmkit-plan/SKILL.md +30 -8
- package/bundled/skills/prizmkit-plan/assets/plan-template.md +19 -0
- package/bundled/skills/prizmkit-retrospective/SKILL.md +3 -1
- package/bundled/skills/recovery-workflow/SKILL.md +428 -0
- package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +483 -0
- package/package.json +1 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
detect-recovery-state.py — Scan filesystem for partial work from an interrupted
|
|
4
|
+
feature pipeline session and output a structured recovery report.
|
|
5
|
+
|
|
6
|
+
Checks four state categories:
|
|
7
|
+
1. Pipeline state (dev-pipeline/state/)
|
|
8
|
+
2. PrizmKit artifacts (.prizmkit/specs/{slug}/)
|
|
9
|
+
3. Git state (branches, uncommitted changes, commits ahead)
|
|
10
|
+
4. Code changes (file counts, directories touched)
|
|
11
|
+
|
|
12
|
+
Does NOT run tests — that's left to the skill so the user sees output in real time.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
python3 detect-recovery-state.py --feature-id F-007 --feature-list feature-list.json
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import subprocess
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_git(args, cwd=None):
|
|
27
|
+
"""Run a git command and return stdout, or empty string on failure."""
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
["git"] + args,
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
cwd=cwd,
|
|
34
|
+
timeout=10,
|
|
35
|
+
)
|
|
36
|
+
return result.stdout.strip()
|
|
37
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compute_slug(feature_id, title):
|
|
42
|
+
"""Compute feature slug using the same algorithm as the pipeline."""
|
|
43
|
+
numeric = feature_id.replace("F-", "").replace("f-", "").zfill(3)
|
|
44
|
+
slug = title.lower()
|
|
45
|
+
slug = re.sub(r"[^a-z0-9\s-]", "", slug)
|
|
46
|
+
slug = re.sub(r"[\s]+", "-", slug.strip())
|
|
47
|
+
slug = re.sub(r"-+", "-", slug).strip("-") or "feature"
|
|
48
|
+
return f"{numeric}-{slug}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_feature(feature_list_path, feature_id):
|
|
52
|
+
"""Find feature in feature-list.json."""
|
|
53
|
+
with open(feature_list_path) as f:
|
|
54
|
+
data = json.load(f)
|
|
55
|
+
for feat in data.get("features", []):
|
|
56
|
+
if feat.get("id") == feature_id:
|
|
57
|
+
return feat
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def detect_pipeline_state(state_dir, feature_id):
|
|
62
|
+
"""Check dev-pipeline/state/ for feature status."""
|
|
63
|
+
result = {
|
|
64
|
+
"status": "unknown",
|
|
65
|
+
"retry_count": 0,
|
|
66
|
+
"last_session_id": None,
|
|
67
|
+
"last_session_dir": None,
|
|
68
|
+
"has_state": False,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
status_file = os.path.join(state_dir, "features", feature_id, "status.json")
|
|
72
|
+
if not os.path.isfile(status_file):
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
with open(status_file) as f:
|
|
77
|
+
status_data = json.load(f)
|
|
78
|
+
result["has_state"] = True
|
|
79
|
+
result["status"] = status_data.get("status", "unknown")
|
|
80
|
+
result["retry_count"] = status_data.get("retry_count", 0)
|
|
81
|
+
|
|
82
|
+
sessions = status_data.get("sessions", [])
|
|
83
|
+
if sessions:
|
|
84
|
+
last = sessions[-1]
|
|
85
|
+
sid = last.get("session_id", "")
|
|
86
|
+
result["last_session_id"] = sid
|
|
87
|
+
result["last_session_dir"] = os.path.join(
|
|
88
|
+
state_dir, "features", feature_id, "sessions", sid
|
|
89
|
+
)
|
|
90
|
+
except (json.JSONDecodeError, IOError):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
return result
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def detect_artifacts(project_root, feature_slug):
|
|
97
|
+
"""Check .prizmkit/specs/{slug}/ for planning artifacts."""
|
|
98
|
+
specs_dir = os.path.join(project_root, ".prizmkit", "specs", feature_slug)
|
|
99
|
+
result = {
|
|
100
|
+
"spec_exists": False,
|
|
101
|
+
"plan_exists": False,
|
|
102
|
+
"spec_path": None,
|
|
103
|
+
"plan_path": None,
|
|
104
|
+
"plan_tasks_total": 0,
|
|
105
|
+
"plan_tasks_completed": 0,
|
|
106
|
+
"other_artifacts": [],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if not os.path.isdir(specs_dir):
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
spec_path = os.path.join(specs_dir, "spec.md")
|
|
113
|
+
plan_path = os.path.join(specs_dir, "plan.md")
|
|
114
|
+
|
|
115
|
+
if os.path.isfile(spec_path):
|
|
116
|
+
result["spec_exists"] = True
|
|
117
|
+
result["spec_path"] = os.path.relpath(spec_path, project_root)
|
|
118
|
+
|
|
119
|
+
if os.path.isfile(plan_path):
|
|
120
|
+
result["plan_exists"] = True
|
|
121
|
+
result["plan_path"] = os.path.relpath(plan_path, project_root)
|
|
122
|
+
|
|
123
|
+
# Count tasks in plan.md (look for checkbox pattern)
|
|
124
|
+
try:
|
|
125
|
+
with open(plan_path) as f:
|
|
126
|
+
content = f.read()
|
|
127
|
+
# Match both [x] and [ ] patterns (task checkboxes)
|
|
128
|
+
completed = len(re.findall(r"^\s*-\s*\[x\]", content, re.MULTILINE | re.IGNORECASE))
|
|
129
|
+
pending = len(re.findall(r"^\s*-\s*\[ \]", content, re.MULTILINE))
|
|
130
|
+
result["plan_tasks_total"] = completed + pending
|
|
131
|
+
result["plan_tasks_completed"] = completed
|
|
132
|
+
except IOError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
# Check for other artifacts
|
|
136
|
+
try:
|
|
137
|
+
for name in os.listdir(specs_dir):
|
|
138
|
+
if name not in ("spec.md", "plan.md") and os.path.isfile(
|
|
139
|
+
os.path.join(specs_dir, name)
|
|
140
|
+
):
|
|
141
|
+
result["other_artifacts"].append(name)
|
|
142
|
+
except IOError:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def detect_git_state(project_root, feature_slug):
|
|
149
|
+
"""Check git for branch existence, uncommitted changes, commits ahead."""
|
|
150
|
+
result = {
|
|
151
|
+
"feature_branch": f"feat/{feature_slug}",
|
|
152
|
+
"branch_exists": False,
|
|
153
|
+
"on_feature_branch": False,
|
|
154
|
+
"uncommitted_files": 0,
|
|
155
|
+
"staged_files": 0,
|
|
156
|
+
"commits_ahead_of_main": 0,
|
|
157
|
+
"current_branch": "",
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
# Current branch
|
|
161
|
+
current = run_git(["branch", "--show-current"], cwd=project_root)
|
|
162
|
+
result["current_branch"] = current
|
|
163
|
+
|
|
164
|
+
# Check if feature branch exists
|
|
165
|
+
branch_name = f"feat/{feature_slug}"
|
|
166
|
+
branches_output = run_git(["branch", "--list", branch_name], cwd=project_root)
|
|
167
|
+
if branches_output.strip():
|
|
168
|
+
result["branch_exists"] = True
|
|
169
|
+
|
|
170
|
+
# Also check without feat/ prefix (some pipelines use different naming)
|
|
171
|
+
if not result["branch_exists"]:
|
|
172
|
+
alt_branch = f"feature/{feature_slug}"
|
|
173
|
+
alt_output = run_git(["branch", "--list", alt_branch], cwd=project_root)
|
|
174
|
+
if alt_output.strip():
|
|
175
|
+
result["branch_exists"] = True
|
|
176
|
+
result["feature_branch"] = alt_branch
|
|
177
|
+
|
|
178
|
+
result["on_feature_branch"] = current == result["feature_branch"]
|
|
179
|
+
|
|
180
|
+
# Uncommitted changes (working tree)
|
|
181
|
+
diff_stat = run_git(["diff", "--stat"], cwd=project_root)
|
|
182
|
+
if diff_stat:
|
|
183
|
+
# Count "N files changed" from the summary line
|
|
184
|
+
lines = diff_stat.strip().split("\n")
|
|
185
|
+
result["uncommitted_files"] = max(0, len(lines) - 1) # exclude summary line
|
|
186
|
+
|
|
187
|
+
# Staged changes
|
|
188
|
+
staged_stat = run_git(["diff", "--cached", "--stat"], cwd=project_root)
|
|
189
|
+
if staged_stat:
|
|
190
|
+
lines = staged_stat.strip().split("\n")
|
|
191
|
+
result["staged_files"] = max(0, len(lines) - 1)
|
|
192
|
+
|
|
193
|
+
# Commits ahead of main
|
|
194
|
+
main_branch = "main"
|
|
195
|
+
# Try to detect default branch
|
|
196
|
+
for candidate in ["main", "master"]:
|
|
197
|
+
check = run_git(["rev-parse", "--verify", candidate], cwd=project_root)
|
|
198
|
+
if check:
|
|
199
|
+
main_branch = candidate
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
log_output = run_git(
|
|
203
|
+
["log", f"{main_branch}..HEAD", "--oneline"], cwd=project_root
|
|
204
|
+
)
|
|
205
|
+
if log_output:
|
|
206
|
+
result["commits_ahead_of_main"] = len(log_output.strip().split("\n"))
|
|
207
|
+
|
|
208
|
+
return result
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def detect_code_changes(project_root, main_branch="main"):
|
|
212
|
+
"""Analyze code changes relative to main branch.
|
|
213
|
+
|
|
214
|
+
Filters out pipeline/config files that aren't source code — only counts
|
|
215
|
+
files that represent actual implementation work.
|
|
216
|
+
"""
|
|
217
|
+
# Files/patterns that are pipeline artifacts, not implementation code
|
|
218
|
+
IGNORED_FILES = {
|
|
219
|
+
"feature-list.json",
|
|
220
|
+
"bug-fix-list.json",
|
|
221
|
+
"package-lock.json",
|
|
222
|
+
"yarn.lock",
|
|
223
|
+
"pnpm-lock.yaml",
|
|
224
|
+
}
|
|
225
|
+
IGNORED_PREFIXES = (
|
|
226
|
+
".prizmkit/",
|
|
227
|
+
"dev-pipeline/state/",
|
|
228
|
+
".prizm-docs/",
|
|
229
|
+
".claude/",
|
|
230
|
+
".codebuddy/",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def is_source_file(filepath):
|
|
234
|
+
"""Return True if this file represents implementation code."""
|
|
235
|
+
basename = os.path.basename(filepath)
|
|
236
|
+
if basename in IGNORED_FILES:
|
|
237
|
+
return False
|
|
238
|
+
for prefix in IGNORED_PREFIXES:
|
|
239
|
+
if filepath.startswith(prefix):
|
|
240
|
+
return False
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
result = {
|
|
244
|
+
"files_modified": 0,
|
|
245
|
+
"files_added": 0,
|
|
246
|
+
"files_deleted": 0,
|
|
247
|
+
"test_files_touched": 0,
|
|
248
|
+
"directories_touched": [],
|
|
249
|
+
"has_changes": False,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
# Get diff stat relative to main
|
|
253
|
+
diff_output = run_git(
|
|
254
|
+
["diff", main_branch, "--name-status"], cwd=project_root
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
# Also include uncommitted changes
|
|
258
|
+
uncommitted = run_git(["diff", "--name-status"], cwd=project_root)
|
|
259
|
+
untracked = run_git(
|
|
260
|
+
["ls-files", "--others", "--exclude-standard"], cwd=project_root
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
all_files = set()
|
|
264
|
+
dirs = set()
|
|
265
|
+
|
|
266
|
+
if diff_output:
|
|
267
|
+
for line in diff_output.strip().split("\n"):
|
|
268
|
+
if not line.strip():
|
|
269
|
+
continue
|
|
270
|
+
parts = line.split("\t", 1)
|
|
271
|
+
if len(parts) < 2:
|
|
272
|
+
continue
|
|
273
|
+
status, filepath = parts[0], parts[1]
|
|
274
|
+
if not is_source_file(filepath):
|
|
275
|
+
continue
|
|
276
|
+
all_files.add(filepath)
|
|
277
|
+
if status.startswith("M"):
|
|
278
|
+
result["files_modified"] += 1
|
|
279
|
+
elif status.startswith("A"):
|
|
280
|
+
result["files_added"] += 1
|
|
281
|
+
elif status.startswith("D"):
|
|
282
|
+
result["files_deleted"] += 1
|
|
283
|
+
|
|
284
|
+
if uncommitted:
|
|
285
|
+
for line in uncommitted.strip().split("\n"):
|
|
286
|
+
if not line.strip():
|
|
287
|
+
continue
|
|
288
|
+
parts = line.split("\t", 1)
|
|
289
|
+
if len(parts) >= 2:
|
|
290
|
+
filepath = parts[1]
|
|
291
|
+
if not is_source_file(filepath):
|
|
292
|
+
continue
|
|
293
|
+
all_files.add(filepath)
|
|
294
|
+
result["files_modified"] += 1
|
|
295
|
+
|
|
296
|
+
if untracked:
|
|
297
|
+
for filepath in untracked.strip().split("\n"):
|
|
298
|
+
if filepath.strip() and is_source_file(filepath.strip()):
|
|
299
|
+
all_files.add(filepath.strip())
|
|
300
|
+
result["files_added"] += 1
|
|
301
|
+
|
|
302
|
+
# Analyze file set
|
|
303
|
+
test_patterns = re.compile(
|
|
304
|
+
r"(test|spec|__tests__|\.test\.|\.spec\.)", re.IGNORECASE
|
|
305
|
+
)
|
|
306
|
+
for filepath in all_files:
|
|
307
|
+
if test_patterns.search(filepath):
|
|
308
|
+
result["test_files_touched"] += 1
|
|
309
|
+
parent = os.path.dirname(filepath)
|
|
310
|
+
if parent:
|
|
311
|
+
# Keep first two levels for readability
|
|
312
|
+
parts = parent.split(os.sep)
|
|
313
|
+
dirs.add(os.sep.join(parts[:2]) + "/")
|
|
314
|
+
|
|
315
|
+
result["directories_touched"] = sorted(dirs)
|
|
316
|
+
result["has_changes"] = len(all_files) > 0
|
|
317
|
+
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def determine_recovery(artifacts, git_state, code_changes, pipeline):
|
|
322
|
+
"""Recommend a recovery action based on detected state."""
|
|
323
|
+
has_spec = artifacts["spec_exists"]
|
|
324
|
+
has_plan = artifacts["plan_exists"]
|
|
325
|
+
has_code = code_changes["has_changes"]
|
|
326
|
+
has_commits = git_state["commits_ahead_of_main"] > 0
|
|
327
|
+
tasks_total = artifacts["plan_tasks_total"]
|
|
328
|
+
tasks_done = artifacts["plan_tasks_completed"]
|
|
329
|
+
|
|
330
|
+
# Scenario D: Already committed
|
|
331
|
+
if has_commits:
|
|
332
|
+
return {
|
|
333
|
+
"recommended_action": "complete_post_commit",
|
|
334
|
+
"recommended_phase": "review",
|
|
335
|
+
"scenario": "D",
|
|
336
|
+
"reason": f"{git_state['commits_ahead_of_main']} commit(s) ahead of main — implementation may be complete",
|
|
337
|
+
"remaining_work": "code review + retrospective + merge",
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
# Scenario A: Implementation in progress
|
|
341
|
+
if has_plan and has_code:
|
|
342
|
+
if tasks_total > 0 and tasks_done == tasks_total:
|
|
343
|
+
return {
|
|
344
|
+
"recommended_action": "review_and_commit",
|
|
345
|
+
"recommended_phase": "review",
|
|
346
|
+
"scenario": "A",
|
|
347
|
+
"reason": f"all {tasks_total} plan tasks completed, code changes present",
|
|
348
|
+
"remaining_work": "code review + commit",
|
|
349
|
+
}
|
|
350
|
+
else:
|
|
351
|
+
tasks_remaining = tasks_total - tasks_done if tasks_total > 0 else "unknown"
|
|
352
|
+
return {
|
|
353
|
+
"recommended_action": "continue_implementation",
|
|
354
|
+
"recommended_phase": "implement",
|
|
355
|
+
"scenario": "A",
|
|
356
|
+
"reason": f"spec and plan exist, {tasks_done}/{tasks_total} tasks completed, code changes present",
|
|
357
|
+
"remaining_work": f"{tasks_remaining} tasks + review + commit",
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
# Scenario B: Only planning artifacts
|
|
361
|
+
if has_spec or has_plan:
|
|
362
|
+
if has_plan:
|
|
363
|
+
return {
|
|
364
|
+
"recommended_action": "start_implementation",
|
|
365
|
+
"recommended_phase": "implement",
|
|
366
|
+
"scenario": "B",
|
|
367
|
+
"reason": "spec and plan exist, no code changes yet",
|
|
368
|
+
"remaining_work": f"{tasks_total} tasks + review + commit",
|
|
369
|
+
}
|
|
370
|
+
else:
|
|
371
|
+
return {
|
|
372
|
+
"recommended_action": "generate_plan",
|
|
373
|
+
"recommended_phase": "plan",
|
|
374
|
+
"scenario": "B",
|
|
375
|
+
"reason": "spec exists but no plan — session interrupted during planning",
|
|
376
|
+
"remaining_work": "plan + implement + review + commit",
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
# Scenario C: Code changes but no artifacts
|
|
380
|
+
if has_code:
|
|
381
|
+
return {
|
|
382
|
+
"recommended_action": "adopt_and_continue",
|
|
383
|
+
"recommended_phase": "review",
|
|
384
|
+
"scenario": "C",
|
|
385
|
+
"reason": "code changes found but no prizmkit artifacts — possible manual work or artifacts cleaned",
|
|
386
|
+
"remaining_work": "review + commit",
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
# Scenario E: Nothing found
|
|
390
|
+
return {
|
|
391
|
+
"recommended_action": "start_fresh",
|
|
392
|
+
"recommended_phase": "none",
|
|
393
|
+
"scenario": "E",
|
|
394
|
+
"reason": "no artifacts, no code changes, no commits — feature was never executed or fully cleaned",
|
|
395
|
+
"remaining_work": "full pipeline run",
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def main():
|
|
400
|
+
parser = argparse.ArgumentParser(
|
|
401
|
+
description="Detect recovery state for an interrupted feature session"
|
|
402
|
+
)
|
|
403
|
+
parser.add_argument("--feature-id", required=True, help="Feature ID (e.g., F-007)")
|
|
404
|
+
parser.add_argument(
|
|
405
|
+
"--feature-list",
|
|
406
|
+
default="feature-list.json",
|
|
407
|
+
help="Path to feature-list.json (default: feature-list.json)",
|
|
408
|
+
)
|
|
409
|
+
parser.add_argument(
|
|
410
|
+
"--state-dir",
|
|
411
|
+
default=None,
|
|
412
|
+
help="Pipeline state directory (default: dev-pipeline/state)",
|
|
413
|
+
)
|
|
414
|
+
parser.add_argument(
|
|
415
|
+
"--project-root",
|
|
416
|
+
default=None,
|
|
417
|
+
help="Project root directory (default: auto-detect from git)",
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
args = parser.parse_args()
|
|
421
|
+
|
|
422
|
+
# Resolve project root
|
|
423
|
+
if args.project_root:
|
|
424
|
+
project_root = os.path.abspath(args.project_root)
|
|
425
|
+
else:
|
|
426
|
+
git_root = run_git(["rev-parse", "--show-toplevel"])
|
|
427
|
+
project_root = git_root if git_root else os.getcwd()
|
|
428
|
+
|
|
429
|
+
# Resolve state dir
|
|
430
|
+
state_dir = args.state_dir or os.path.join(project_root, "dev-pipeline", "state")
|
|
431
|
+
|
|
432
|
+
# Resolve feature list path
|
|
433
|
+
feature_list_path = args.feature_list
|
|
434
|
+
if not os.path.isabs(feature_list_path):
|
|
435
|
+
feature_list_path = os.path.join(project_root, feature_list_path)
|
|
436
|
+
|
|
437
|
+
# Find feature
|
|
438
|
+
if not os.path.isfile(feature_list_path):
|
|
439
|
+
print(
|
|
440
|
+
json.dumps({"error": f"Feature list not found: {feature_list_path}"}),
|
|
441
|
+
file=sys.stderr,
|
|
442
|
+
)
|
|
443
|
+
sys.exit(1)
|
|
444
|
+
|
|
445
|
+
feature = find_feature(feature_list_path, args.feature_id)
|
|
446
|
+
if not feature:
|
|
447
|
+
print(
|
|
448
|
+
json.dumps(
|
|
449
|
+
{
|
|
450
|
+
"error": f"Feature {args.feature_id} not found in {feature_list_path}"
|
|
451
|
+
}
|
|
452
|
+
),
|
|
453
|
+
file=sys.stderr,
|
|
454
|
+
)
|
|
455
|
+
sys.exit(1)
|
|
456
|
+
|
|
457
|
+
title = feature.get("title", "untitled")
|
|
458
|
+
slug = compute_slug(args.feature_id, title)
|
|
459
|
+
|
|
460
|
+
# Run all detection phases
|
|
461
|
+
pipeline = detect_pipeline_state(state_dir, args.feature_id)
|
|
462
|
+
artifacts = detect_artifacts(project_root, slug)
|
|
463
|
+
git_state = detect_git_state(project_root, slug)
|
|
464
|
+
code_changes = detect_code_changes(project_root)
|
|
465
|
+
recovery = determine_recovery(artifacts, git_state, code_changes, pipeline)
|
|
466
|
+
|
|
467
|
+
# Build report
|
|
468
|
+
report = {
|
|
469
|
+
"feature_id": args.feature_id,
|
|
470
|
+
"feature_title": title,
|
|
471
|
+
"feature_slug": slug,
|
|
472
|
+
"pipeline": pipeline,
|
|
473
|
+
"artifacts": artifacts,
|
|
474
|
+
"git": git_state,
|
|
475
|
+
"code": code_changes,
|
|
476
|
+
"recovery": recovery,
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
print(json.dumps(report, indent=2))
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
if __name__ == "__main__":
|
|
483
|
+
main()
|