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.
@@ -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()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prizmkit",
3
- "version": "1.0.144",
3
+ "version": "1.0.148",
4
4
  "description": "Create a new PrizmKit-powered project with clean initialization — no framework dev files, just what you need.",
5
5
  "type": "module",
6
6
  "bin": {