prizmkit 1.1.57 → 1.1.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. package/bin/create-prizmkit.js +8 -6
  2. package/bundled/VERSION.json +3 -3
  3. package/bundled/adapters/codex/agent-adapter.js +38 -0
  4. package/bundled/adapters/codex/paths.js +27 -0
  5. package/bundled/adapters/codex/rules-adapter.js +30 -0
  6. package/bundled/adapters/codex/settings-adapter.js +27 -0
  7. package/bundled/adapters/codex/skill-adapter.js +65 -0
  8. package/bundled/adapters/codex/team-adapter.js +37 -0
  9. package/bundled/dev-pipeline/.env.example +2 -1
  10. package/bundled/dev-pipeline/README.md +10 -7
  11. package/bundled/dev-pipeline/lib/common.sh +278 -37
  12. package/bundled/dev-pipeline/run-bugfix.sh +10 -61
  13. package/bundled/dev-pipeline/run-feature.sh +10 -78
  14. package/bundled/dev-pipeline/run-recovery.sh +10 -46
  15. package/bundled/dev-pipeline/run-refactor.sh +10 -61
  16. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +17 -7
  17. package/bundled/dev-pipeline/scripts/generate-bugfix-prompt.py +9 -3
  18. package/bundled/dev-pipeline/scripts/generate-refactor-prompt.py +9 -3
  19. package/bundled/dev-pipeline/scripts/utils.py +6 -4
  20. package/bundled/dev-pipeline-windows/.env.example +28 -0
  21. package/bundled/dev-pipeline-windows/README.md +30 -0
  22. package/bundled/dev-pipeline-windows/SCHEMA_ANALYSIS.md +525 -0
  23. package/bundled/dev-pipeline-windows/assets/feature-list-example.json +146 -0
  24. package/bundled/dev-pipeline-windows/assets/prizm-dev-team-integration.md +138 -0
  25. package/bundled/dev-pipeline-windows/launch-bugfix-daemon.ps1 +9 -0
  26. package/bundled/dev-pipeline-windows/launch-feature-daemon.ps1 +9 -0
  27. package/bundled/dev-pipeline-windows/launch-refactor-daemon.ps1 +9 -0
  28. package/bundled/dev-pipeline-windows/lib/common.ps1 +432 -0
  29. package/bundled/dev-pipeline-windows/lib/daemon.ps1 +140 -0
  30. package/bundled/dev-pipeline-windows/lib/pipeline.ps1 +446 -0
  31. package/bundled/dev-pipeline-windows/lib/reset.ps1 +87 -0
  32. package/bundled/dev-pipeline-windows/reset-bug.ps1 +9 -0
  33. package/bundled/dev-pipeline-windows/reset-feature.ps1 +9 -0
  34. package/bundled/dev-pipeline-windows/reset-refactor.ps1 +9 -0
  35. package/bundled/dev-pipeline-windows/run-bugfix.ps1 +9 -0
  36. package/bundled/dev-pipeline-windows/run-feature.ps1 +9 -0
  37. package/bundled/dev-pipeline-windows/run-recovery.ps1 +76 -0
  38. package/bundled/dev-pipeline-windows/run-refactor.ps1 +9 -0
  39. package/bundled/dev-pipeline-windows/scripts/check-session-status.py +228 -0
  40. package/bundled/dev-pipeline-windows/scripts/cleanup-logs.py +192 -0
  41. package/bundled/dev-pipeline-windows/scripts/detect-stuck.py +530 -0
  42. package/bundled/dev-pipeline-windows/scripts/generate-bootstrap-prompt.py +1737 -0
  43. package/bundled/dev-pipeline-windows/scripts/generate-bugfix-prompt.py +685 -0
  44. package/bundled/dev-pipeline-windows/scripts/generate-recovery-prompt.py +805 -0
  45. package/bundled/dev-pipeline-windows/scripts/generate-refactor-prompt.py +763 -0
  46. package/bundled/dev-pipeline-windows/scripts/init-bugfix-pipeline.py +316 -0
  47. package/bundled/dev-pipeline-windows/scripts/init-dev-team.py +134 -0
  48. package/bundled/dev-pipeline-windows/scripts/init-pipeline.py +380 -0
  49. package/bundled/dev-pipeline-windows/scripts/init-refactor-pipeline.py +399 -0
  50. package/bundled/dev-pipeline-windows/scripts/parse-stream-progress.py +388 -0
  51. package/bundled/dev-pipeline-windows/scripts/patch-completion-notes.py +191 -0
  52. package/bundled/dev-pipeline-windows/scripts/update-bug-status.py +864 -0
  53. package/bundled/dev-pipeline-windows/scripts/update-checkpoint.py +173 -0
  54. package/bundled/dev-pipeline-windows/scripts/update-feature-status.py +1501 -0
  55. package/bundled/dev-pipeline-windows/scripts/update-refactor-status.py +1073 -0
  56. package/bundled/dev-pipeline-windows/scripts/utils.py +542 -0
  57. package/bundled/dev-pipeline-windows/templates/agent-prompts/critic-plan-challenge.md +7 -0
  58. package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-fix.md +7 -0
  59. package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-implement.md +30 -0
  60. package/bundled/dev-pipeline-windows/templates/agent-prompts/dev-resume.md +5 -0
  61. package/bundled/dev-pipeline-windows/templates/agent-prompts/reviewer-review.md +7 -0
  62. package/bundled/dev-pipeline-windows/templates/bootstrap-prompt.md +46 -0
  63. package/bundled/dev-pipeline-windows/templates/bootstrap-tier1.md +43 -0
  64. package/bundled/dev-pipeline-windows/templates/bootstrap-tier2.md +43 -0
  65. package/bundled/dev-pipeline-windows/templates/bootstrap-tier3.md +43 -0
  66. package/bundled/dev-pipeline-windows/templates/bug-fix-list-schema.json +263 -0
  67. package/bundled/dev-pipeline-windows/templates/bugfix-bootstrap-prompt.md +320 -0
  68. package/bundled/dev-pipeline-windows/templates/feature-list-schema.json +237 -0
  69. package/bundled/dev-pipeline-windows/templates/refactor-bootstrap-prompt.md +331 -0
  70. package/bundled/dev-pipeline-windows/templates/refactor-list-schema.json +270 -0
  71. package/bundled/dev-pipeline-windows/templates/sections/ac-verification-checklist.md +13 -0
  72. package/bundled/dev-pipeline-windows/templates/sections/checkpoint-system.md +91 -0
  73. package/bundled/dev-pipeline-windows/templates/sections/context-budget-rules.md +33 -0
  74. package/bundled/dev-pipeline-windows/templates/sections/critical-paths-agent.md +10 -0
  75. package/bundled/dev-pipeline-windows/templates/sections/critical-paths-full.md +12 -0
  76. package/bundled/dev-pipeline-windows/templates/sections/critical-paths-lite.md +7 -0
  77. package/bundled/dev-pipeline-windows/templates/sections/directory-convention-agent.md +8 -0
  78. package/bundled/dev-pipeline-windows/templates/sections/directory-convention-full.md +9 -0
  79. package/bundled/dev-pipeline-windows/templates/sections/directory-convention-lite.md +6 -0
  80. package/bundled/dev-pipeline-windows/templates/sections/failure-capture.md +21 -0
  81. package/bundled/dev-pipeline-windows/templates/sections/feature-context.md +31 -0
  82. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-auto.md +72 -0
  83. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification-opencli.md +63 -0
  84. package/bundled/dev-pipeline-windows/templates/sections/phase-browser-verification.md +62 -0
  85. package/bundled/dev-pipeline-windows/templates/sections/phase-commit-full.md +71 -0
  86. package/bundled/dev-pipeline-windows/templates/sections/phase-commit.md +64 -0
  87. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-agent-suffix.md +23 -0
  88. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-base.md +24 -0
  89. package/bundled/dev-pipeline-windows/templates/sections/phase-context-snapshot-lite-suffix.md +12 -0
  90. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan-full.md +53 -0
  91. package/bundled/dev-pipeline-windows/templates/sections/phase-critic-plan.md +32 -0
  92. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-agent.md +37 -0
  93. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-full.md +50 -0
  94. package/bundled/dev-pipeline-windows/templates/sections/phase-implement-lite.md +52 -0
  95. package/bundled/dev-pipeline-windows/templates/sections/phase-plan-agent.md +27 -0
  96. package/bundled/dev-pipeline-windows/templates/sections/phase-plan-lite.md +27 -0
  97. package/bundled/dev-pipeline-windows/templates/sections/phase-review-agent.md +27 -0
  98. package/bundled/dev-pipeline-windows/templates/sections/phase-review-full.md +29 -0
  99. package/bundled/dev-pipeline-windows/templates/sections/phase-specify-plan-full.md +77 -0
  100. package/bundled/dev-pipeline-windows/templates/sections/phase0-init.md +13 -0
  101. package/bundled/dev-pipeline-windows/templates/sections/phase0-test-baseline.md +23 -0
  102. package/bundled/dev-pipeline-windows/templates/sections/session-context.md +5 -0
  103. package/bundled/dev-pipeline-windows/templates/sections/subagent-timeout-recovery.md +6 -0
  104. package/bundled/dev-pipeline-windows/templates/sections/test-failure-recovery-agent.md +67 -0
  105. package/bundled/dev-pipeline-windows/templates/sections/test-failure-recovery-lite.md +58 -0
  106. package/bundled/dev-pipeline-windows/templates/session-status-schema.json +83 -0
  107. package/bundled/skills/_metadata.json +1 -1
  108. package/bundled/skills/app-planner/SKILL.md +26 -18
  109. package/bundled/skills/app-planner/references/architecture-decisions.md +9 -5
  110. package/bundled/skills/app-planner/references/frontend-design-guide.md +1 -1
  111. package/bundled/skills/feature-planner/SKILL.md +9 -2
  112. package/bundled/skills/prizmkit-init/SKILL.md +7 -6
  113. package/bundled/skills/recovery-workflow/scripts/detect-recovery-state.py +2 -0
  114. package/bundled/skills-windows/app-planner/SKILL.md +639 -0
  115. package/bundled/skills-windows/app-planner/assets/app-design-guide.md +101 -0
  116. package/bundled/skills-windows/app-planner/references/architecture-decisions.md +52 -0
  117. package/bundled/skills-windows/app-planner/references/brainstorm-guide.md +101 -0
  118. package/bundled/skills-windows/app-planner/references/frontend-design-guide.md +71 -0
  119. package/bundled/skills-windows/app-planner/references/project-brief-guide.md +82 -0
  120. package/bundled/skills-windows/app-planner/references/red-team-checklist.md +40 -0
  121. package/bundled/skills-windows/app-planner/references/rules/backend/derivation-rules.md +609 -0
  122. package/bundled/skills-windows/app-planner/references/rules/backend/fixed-rules.md +285 -0
  123. package/bundled/skills-windows/app-planner/references/rules/backend/question-bank.md +249 -0
  124. package/bundled/skills-windows/app-planner/references/rules/backend/template.md +173 -0
  125. package/bundled/skills-windows/app-planner/references/rules/database/derivation-rules.md +373 -0
  126. package/bundled/skills-windows/app-planner/references/rules/database/fixed-rules.md +211 -0
  127. package/bundled/skills-windows/app-planner/references/rules/database/question-bank.md +184 -0
  128. package/bundled/skills-windows/app-planner/references/rules/database/template.md +158 -0
  129. package/bundled/skills-windows/app-planner/references/rules/frontend/derivation-rules.md +810 -0
  130. package/bundled/skills-windows/app-planner/references/rules/frontend/fixed-rules.md +188 -0
  131. package/bundled/skills-windows/app-planner/references/rules/frontend/question-bank.md +302 -0
  132. package/bundled/skills-windows/app-planner/references/rules/frontend/template.md +320 -0
  133. package/bundled/skills-windows/app-planner/references/rules/mobile/derivation-rules.md +639 -0
  134. package/bundled/skills-windows/app-planner/references/rules/mobile/fixed-rules.md +290 -0
  135. package/bundled/skills-windows/app-planner/references/rules/mobile/question-bank.md +232 -0
  136. package/bundled/skills-windows/app-planner/references/rules/mobile/template.md +175 -0
  137. package/bundled/skills-windows/bug-fix-workflow/SKILL.md +415 -0
  138. package/bundled/skills-windows/bug-planner/SKILL.md +395 -0
  139. package/bundled/skills-windows/bug-planner/assets/bug-confirmation-template.md +43 -0
  140. package/bundled/skills-windows/bug-planner/references/critic-and-verification.md +44 -0
  141. package/bundled/skills-windows/bug-planner/references/error-recovery.md +73 -0
  142. package/bundled/skills-windows/bug-planner/references/input-formats.md +53 -0
  143. package/bundled/skills-windows/bug-planner/references/schema-validation.md +25 -0
  144. package/bundled/skills-windows/bug-planner/references/severity-rules.md +16 -0
  145. package/bundled/skills-windows/bug-planner/scripts/validate-bug-list.py +322 -0
  146. package/bundled/skills-windows/bugfix-pipeline-launcher/SKILL.md +380 -0
  147. package/bundled/skills-windows/feature-pipeline-launcher/SKILL.md +441 -0
  148. package/bundled/skills-windows/feature-pipeline-launcher/scripts/preflight-check.py +462 -0
  149. package/bundled/skills-windows/feature-planner/SKILL.md +401 -0
  150. package/bundled/skills-windows/feature-planner/assets/evaluation-guide.md +64 -0
  151. package/bundled/skills-windows/feature-planner/assets/planning-guide.md +214 -0
  152. package/bundled/skills-windows/feature-planner/references/browser-interaction.md +59 -0
  153. package/bundled/skills-windows/feature-planner/references/completeness-review.md +57 -0
  154. package/bundled/skills-windows/feature-planner/references/decomposition-patterns.md +75 -0
  155. package/bundled/skills-windows/feature-planner/references/error-recovery.md +90 -0
  156. package/bundled/skills-windows/feature-planner/references/incremental-feature-planning.md +112 -0
  157. package/bundled/skills-windows/feature-planner/references/new-project-planning.md +85 -0
  158. package/bundled/skills-windows/feature-planner/scripts/validate-and-generate.py +1029 -0
  159. package/bundled/skills-windows/feature-workflow/SKILL.md +531 -0
  160. package/bundled/skills-windows/prizmkit-init/SKILL.md +356 -0
  161. package/bundled/skills-windows/prizmkit-init/assets/project-brief-template.md +82 -0
  162. package/bundled/skills-windows/prizmkit-init/references/config-schema.md +68 -0
  163. package/bundled/skills-windows/prizmkit-init/references/rules/layer-detection.md +41 -0
  164. package/bundled/skills-windows/prizmkit-init/references/tech-stack-catalog.md +13 -0
  165. package/bundled/skills-windows/prizmkit-init/references/update-supplement.md +9 -0
  166. package/bundled/skills-windows/recovery-workflow/SKILL.md +456 -0
  167. package/bundled/skills-windows/recovery-workflow/evals/evals.json +46 -0
  168. package/bundled/skills-windows/recovery-workflow/scripts/detect-recovery-state.py +544 -0
  169. package/bundled/skills-windows/refactor-pipeline-launcher/SKILL.md +406 -0
  170. package/bundled/skills-windows/refactor-planner/SKILL.md +540 -0
  171. package/bundled/skills-windows/refactor-planner/assets/planning-guide.md +292 -0
  172. package/bundled/skills-windows/refactor-planner/references/behavior-preservation.md +301 -0
  173. package/bundled/skills-windows/refactor-planner/references/refactor-scoping-guide.md +221 -0
  174. package/bundled/skills-windows/refactor-planner/scripts/validate-and-generate-refactor.py +858 -0
  175. package/bundled/skills-windows/refactor-workflow/SKILL.md +503 -0
  176. package/package.json +3 -2
  177. package/src/clean.js +73 -2
  178. package/src/config.js +159 -50
  179. package/src/detect-platform.js +16 -8
  180. package/src/external-skills.js +26 -19
  181. package/src/index.js +31 -9
  182. package/src/manifest.js +6 -2
  183. package/src/metadata.js +43 -5
  184. package/src/platforms.js +36 -0
  185. package/src/prompts.js +31 -6
  186. package/src/runtimes.js +20 -0
  187. package/src/scaffold.js +314 -110
  188. package/src/upgrade.js +81 -41
@@ -0,0 +1,1501 @@
1
+ #!/usr/bin/env python3
2
+ """Core state machine for updating feature status in the dev-pipeline.
3
+
4
+ Handles nine actions:
5
+ - get_next: Find the next feature to process based on priority and dependencies
6
+ - start: Mark a feature as in_progress when a session starts
7
+ - update: Update a feature's status based on session outcome
8
+ - status: Print a formatted overview of all features
9
+ - pause: Save pipeline state for graceful shutdown
10
+ - reset: Reset a feature to pending (status + retry count)
11
+ - clean: Reset + delete session history + delete prizmkit artifacts
12
+ - complete: Shortcut for manually marking a feature as completed
13
+ - unskip: Recover auto-skipped features (reset failed/skipped upstream + auto_skipped downstream)
14
+
15
+ Usage:
16
+ python3 update-feature-status.py \
17
+ --feature-list <path> --state-dir <path> \
18
+ --action <get_next|start|update|status|pause|reset|clean|complete|unskip> \
19
+ [--feature-id <id>] [--session-status <status>] \
20
+ [--session-id <id>] [--max-retries <n>] \
21
+ [--features <filter>]
22
+ """
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import re
28
+ import shutil
29
+ import sys
30
+ from datetime import datetime, timezone
31
+
32
+ from utils import (
33
+ load_json_file,
34
+ write_json_file,
35
+ error_out,
36
+ pad_right,
37
+ _build_progress_bar,
38
+ )
39
+
40
+
41
+ SESSION_STATUS_VALUES = [
42
+ "success",
43
+ "partial_resumable",
44
+ "partial_not_resumable",
45
+ "failed",
46
+ "crashed",
47
+ "timed_out",
48
+ "commit_missing",
49
+ "docs_missing",
50
+ "merge_conflict",
51
+ ]
52
+
53
+ TERMINAL_STATUSES = {"completed", "failed", "skipped", "auto_skipped", "split"}
54
+
55
+
56
+ def parse_args():
57
+ parser = argparse.ArgumentParser(
58
+ description="Core state machine for dev-pipeline feature status management."
59
+ )
60
+ parser.add_argument(
61
+ "--feature-list",
62
+ required=True,
63
+ help="Path to the .prizmkit/plans/feature-list.json file",
64
+ )
65
+ parser.add_argument(
66
+ "--state-dir",
67
+ required=True,
68
+ help="Path to the state directory (default: .prizmkit/state/features)",
69
+ )
70
+ parser.add_argument(
71
+ "--action",
72
+ required=True,
73
+ choices=["get_next", "start", "update", "status", "pause", "reset", "clean", "complete", "unskip"],
74
+ help="Action to perform",
75
+ )
76
+ parser.add_argument(
77
+ "--feature-id",
78
+ default=None,
79
+ help="Feature ID (required for start/reset/clean/complete/update actions)",
80
+ )
81
+ parser.add_argument(
82
+ "--session-status",
83
+ default=None,
84
+ choices=SESSION_STATUS_VALUES,
85
+ help="Session outcome status (required for 'update' action)",
86
+ )
87
+ parser.add_argument(
88
+ "--session-id",
89
+ default=None,
90
+ help="Session ID (optional, for 'update' action)",
91
+ )
92
+ parser.add_argument(
93
+ "--max-retries",
94
+ type=int,
95
+ default=3,
96
+ help="Maximum retry count before marking as failed (default: 3)",
97
+ )
98
+ parser.add_argument(
99
+ "--feature-slug",
100
+ default=None,
101
+ help="Feature slug (e.g. 007-import-export-desktop). Required for 'clean' action.",
102
+ )
103
+ parser.add_argument(
104
+ "--project-root",
105
+ default=None,
106
+ help="Project root directory. Required for 'clean' action.",
107
+ )
108
+ parser.add_argument(
109
+ "--features",
110
+ default=None,
111
+ help="Feature filter: comma-separated IDs (F-001,F-003) or range (F-001:F-010), or mixed.",
112
+ )
113
+ return parser.parse_args()
114
+
115
+
116
+ def parse_feature_filter(features_str):
117
+ """Parse --features argument into a set of feature IDs.
118
+
119
+ Supported formats:
120
+ F-001,F-003,F-005 -> {"F-001", "F-003", "F-005"}
121
+ F-001:F-010 -> {"F-001", "F-002", ..., "F-010"}
122
+ F-001,F-005:F-010 -> mixed, union of both
123
+
124
+ Returns None if features_str is None/empty (meaning no filter).
125
+ """
126
+ if not features_str:
127
+ return None
128
+
129
+ result = set()
130
+ for part in features_str.split(","):
131
+ part = part.strip()
132
+ if not part:
133
+ continue
134
+ if ":" in part:
135
+ tokens = part.split(":", 1)
136
+ m_start = re.search(r"\d+", tokens[0])
137
+ m_end = re.search(r"\d+", tokens[1])
138
+ if not m_start or not m_end:
139
+ error_out("Invalid range format: {}".format(part))
140
+ start_num = int(m_start.group())
141
+ end_num = int(m_end.group())
142
+ if start_num > end_num:
143
+ start_num, end_num = end_num, start_num
144
+ for i in range(start_num, end_num + 1):
145
+ result.add("F-{:03d}".format(i))
146
+ else:
147
+ result.add(part.upper())
148
+ return result if result else None
149
+
150
+
151
+ def now_iso():
152
+ """Return the current UTC time in ISO8601 format."""
153
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
154
+
155
+
156
+ def load_feature_status(state_dir, feature_id):
157
+ """Load the runtime state from status.json for a feature.
158
+
159
+ Returns runtime fields only (retry_count, sessions, etc.).
160
+ The 'status' field is NOT included — status lives exclusively
161
+ in feature-list.json.
162
+ """
163
+ status_path = os.path.join(
164
+ state_dir, "features", feature_id, "status.json"
165
+ )
166
+ if not os.path.isfile(status_path):
167
+ now = now_iso()
168
+ return {
169
+ "feature_id": feature_id,
170
+ "retry_count": 0,
171
+ "max_retries": 3,
172
+ "sessions": [],
173
+ "last_session_id": None,
174
+ "resume_from_phase": None,
175
+ "created_at": now,
176
+ "updated_at": now,
177
+ }
178
+ data, err = load_json_file(status_path)
179
+ if err:
180
+ now = now_iso()
181
+ return {
182
+ "feature_id": feature_id,
183
+ "retry_count": 0,
184
+ "max_retries": 3,
185
+ "sessions": [],
186
+ "last_session_id": None,
187
+ "resume_from_phase": None,
188
+ "created_at": now,
189
+ "updated_at": now,
190
+ }
191
+ # Defensively remove status if present (legacy data)
192
+ data.pop("status", None)
193
+ return data
194
+
195
+
196
+ def save_feature_status(state_dir, feature_id, status_data):
197
+ """Write the status.json for a feature (runtime fields only)."""
198
+ # Defensively strip status — it belongs in feature-list.json
199
+ status_data.pop("status", None)
200
+ status_path = os.path.join(
201
+ state_dir, "features", feature_id, "status.json"
202
+ )
203
+ return write_json_file(status_path, status_data)
204
+
205
+
206
+ def get_feature_status_from_list(feature_list_path, feature_id):
207
+ """Read a single feature's status from feature-list.json."""
208
+ data, err = load_json_file(feature_list_path)
209
+ if err:
210
+ return "pending"
211
+ for f in data.get("features", []):
212
+ if isinstance(f, dict) and f.get("id") == feature_id:
213
+ return f.get("status", "pending")
214
+ return "pending"
215
+
216
+
217
+ def update_feature_in_list(feature_list_path, feature_id, new_status):
218
+ """Update a feature's status field in .prizmkit/plans/feature-list.json.
219
+
220
+ Reads the whole file, modifies the target feature's status, writes back.
221
+ Returns an error string on failure, None on success.
222
+ """
223
+ data, err = load_json_file(feature_list_path)
224
+ if err:
225
+ return err
226
+ features = data.get("features", [])
227
+ found = False
228
+ for feature in features:
229
+ if isinstance(feature, dict) and feature.get("id") == feature_id:
230
+ feature["status"] = new_status
231
+ found = True
232
+ break
233
+ if not found:
234
+ return "Feature '{}' not found in .prizmkit/plans/feature-list.json".format(feature_id)
235
+ return write_json_file(feature_list_path, data)
236
+
237
+
238
+ def _default_project_root():
239
+ # Script lives at <pipeline>/scripts/update-feature-status.py.
240
+ # Pipeline may be either <project>/.prizmkit/dev-pipeline (user install)
241
+ # or <repo>/dev-pipeline (framework source). Auto-detect by checking if
242
+ # the parent of dev-pipeline is named ".prizmkit".
243
+ env = os.environ.get("PROJECT_ROOT")
244
+ if env:
245
+ return os.path.abspath(env)
246
+ script_dir = os.path.dirname(os.path.abspath(__file__))
247
+ pipeline_dir = os.path.dirname(script_dir)
248
+ pipeline_parent = os.path.dirname(pipeline_dir)
249
+ if os.path.basename(pipeline_parent) == ".prizmkit":
250
+ return os.path.dirname(pipeline_parent)
251
+ return pipeline_parent
252
+
253
+
254
+ def _build_feature_slug(feature_id, title):
255
+ numeric = feature_id.replace("F-", "").replace("f-", "").zfill(3)
256
+ cleaned = re.sub(r"[^a-z0-9\s-]", "", (title or "").lower())
257
+ cleaned = re.sub(r"[\s]+", "-", cleaned.strip())
258
+ cleaned = re.sub(r"-+", "-", cleaned).strip("-")
259
+ if not cleaned:
260
+ cleaned = "feature"
261
+ return "{}-{}".format(numeric, cleaned)
262
+
263
+
264
+ def _get_feature_slug(feature_list_path, feature_id):
265
+ data, err = load_json_file(feature_list_path)
266
+ if err:
267
+ return None
268
+ for feature in data.get("features", []):
269
+ if isinstance(feature, dict) and feature.get("id") == feature_id:
270
+ return _build_feature_slug(feature_id, feature.get("title", ""))
271
+ return None
272
+
273
+
274
+ def cleanup_feature_artifacts(feature_list_path, state_dir, feature_id, project_root=None):
275
+ """Delete intermediate artifacts for a failed feature run.
276
+
277
+ Cleans session history, per-feature transient state, generated specs,
278
+ current-session pointer, and .dev-team workspace to avoid context pollution.
279
+ """
280
+ if not project_root:
281
+ project_root = _default_project_root()
282
+
283
+ cleaned = []
284
+
285
+ # 1) Remove all session history
286
+ sessions_dir = os.path.join(state_dir, "features", feature_id, "sessions")
287
+ sessions_deleted = 0
288
+ if os.path.isdir(sessions_dir):
289
+ for entry in os.listdir(sessions_dir):
290
+ entry_path = os.path.join(sessions_dir, entry)
291
+ if os.path.isdir(entry_path):
292
+ shutil.rmtree(entry_path)
293
+ sessions_deleted += 1
294
+ cleaned.append("Deleted {} session(s) from {}".format(sessions_deleted, sessions_dir))
295
+
296
+ # 2) Remove transient files under feature state dir (keep status.json)
297
+ feature_dir = os.path.join(state_dir, "features", feature_id)
298
+ if os.path.isdir(feature_dir):
299
+ for entry in os.listdir(feature_dir):
300
+ if entry == "status.json" or entry == "sessions":
301
+ continue
302
+ entry_path = os.path.join(feature_dir, entry)
303
+ if os.path.isdir(entry_path):
304
+ shutil.rmtree(entry_path)
305
+ cleaned.append("Deleted directory {}".format(entry_path))
306
+ elif os.path.isfile(entry_path):
307
+ os.remove(entry_path)
308
+ cleaned.append("Deleted file {}".format(entry_path))
309
+
310
+ # 3) Remove generated prizm specs for this feature
311
+ feature_slug = _get_feature_slug(feature_list_path, feature_id)
312
+ if feature_slug:
313
+ specs_dir = os.path.join(project_root, ".prizmkit", "specs", feature_slug)
314
+ if os.path.isdir(specs_dir):
315
+ file_count = sum(len(files) for _, _, files in os.walk(specs_dir))
316
+ shutil.rmtree(specs_dir)
317
+ cleaned.append("Deleted {} ({} files)".format(specs_dir, file_count))
318
+
319
+ # 4) Remove global dev-team workspace to avoid stale context contamination
320
+ dev_team_dir = os.path.join(project_root, ".dev-team")
321
+ if os.path.isdir(dev_team_dir):
322
+ file_count = sum(len(files) for _, _, files in os.walk(dev_team_dir))
323
+ shutil.rmtree(dev_team_dir)
324
+ cleaned.append("Deleted {} ({} files)".format(dev_team_dir, file_count))
325
+
326
+ # 5) Clear current-session pointer if it points to this feature
327
+ # (no-op: current-session.json has been removed from the pipeline)
328
+
329
+ return cleaned
330
+
331
+
332
+ def load_session_status(state_dir, feature_id, session_id):
333
+ """Load a session's session-status.json file."""
334
+ session_status_path = os.path.join(
335
+ state_dir, "features", feature_id, "sessions",
336
+ session_id, "session-status.json"
337
+ )
338
+ data, err = load_json_file(session_status_path)
339
+ if err:
340
+ return None, err
341
+ return data, None
342
+
343
+
344
+ # ---------------------------------------------------------------------------
345
+ # Auto-skip: cascade failure to blocked downstream features
346
+ # ---------------------------------------------------------------------------
347
+
348
+ def auto_skip_blocked_features(feature_list_path, state_dir, failed_feature_id):
349
+ """Recursively mark all downstream features blocked by a failed feature as auto_skipped.
350
+
351
+ When a feature is marked as failed, any feature whose dependency chain includes
352
+ the failed feature can never be executed. This function propagates the failure
353
+ by marking those blocked features as auto_skipped, allowing the pipeline to
354
+ continue processing unblocked features and eventually reach PIPELINE_COMPLETE.
355
+
356
+ Re-reads .prizmkit/plans/feature-list.json from disk to get the latest state (including the
357
+ just-written failed status from update_feature_in_list).
358
+
359
+ NOTE: This function performs a read-modify-write on .prizmkit/plans/feature-list.json without
360
+ file locking. The caller (action_update) also writes to .prizmkit/plans/feature-list.json
361
+ immediately before calling this. Safe for single-pipeline execution, but if
362
+ multiple pipeline instances share the same .prizmkit/plans/feature-list.json concurrently,
363
+ a race condition may cause lost writes. Add file locking if parallel pipelines
364
+ are introduced.
365
+ """
366
+ data, err = load_json_file(feature_list_path)
367
+ if err:
368
+ return []
369
+ features = data.get("features", [])
370
+
371
+ # Build current status map
372
+ status_map = {}
373
+ for f in features:
374
+ if isinstance(f, dict) and f.get("id"):
375
+ status_map[f["id"]] = f.get("status", "pending")
376
+
377
+ # Collect all features to auto-skip (recursive propagation)
378
+ to_skip = set()
379
+ changed = True
380
+ while changed:
381
+ changed = False
382
+ for f in features:
383
+ if not isinstance(f, dict):
384
+ continue
385
+ fid = f.get("id")
386
+ if not fid or fid in to_skip:
387
+ continue
388
+ current = status_map.get(fid, "pending")
389
+ if current in TERMINAL_STATUSES:
390
+ continue
391
+ deps = f.get("dependencies", [])
392
+ for dep_id in deps:
393
+ dep_status = status_map.get(dep_id, "pending")
394
+ if dep_status in ("failed", "skipped", "auto_skipped") or dep_id in to_skip:
395
+ to_skip.add(fid)
396
+ status_map[fid] = "auto_skipped"
397
+ changed = True
398
+ break
399
+
400
+ if not to_skip:
401
+ return []
402
+
403
+ # Batch-write to .prizmkit/plans/feature-list.json
404
+ for f in features:
405
+ if isinstance(f, dict) and f.get("id") in to_skip:
406
+ f["status"] = "auto_skipped"
407
+ write_json_file(feature_list_path, data)
408
+
409
+ # Update timestamps in status.json for each auto-skipped feature
410
+ for fid in to_skip:
411
+ fs = load_feature_status(state_dir, fid)
412
+ fs["updated_at"] = now_iso()
413
+ save_feature_status(state_dir, fid, fs)
414
+
415
+ # Build blocking reason map for logging
416
+ skipped_info = []
417
+ for f in features:
418
+ if not isinstance(f, dict):
419
+ continue
420
+ fid = f.get("id")
421
+ if fid not in to_skip:
422
+ continue
423
+ deps = f.get("dependencies", [])
424
+ blockers = [
425
+ d for d in deps
426
+ if d == failed_feature_id or d in to_skip
427
+ ]
428
+ skipped_info.append({
429
+ "feature_id": fid,
430
+ "title": f.get("title", ""),
431
+ "blocked_by": blockers,
432
+ })
433
+
434
+ print(
435
+ "[auto-skip] {} feature(s) auto-skipped due to failed {}:".format(
436
+ len(skipped_info), failed_feature_id
437
+ ),
438
+ file=sys.stderr,
439
+ )
440
+ for info in skipped_info:
441
+ print(
442
+ " {} ({}) — blocked by {}".format(
443
+ info["feature_id"],
444
+ info["title"],
445
+ ", ".join(info["blocked_by"]),
446
+ ),
447
+ file=sys.stderr,
448
+ )
449
+
450
+ return skipped_info
451
+
452
+
453
+ # ---------------------------------------------------------------------------
454
+ # Action: get_next
455
+ # ---------------------------------------------------------------------------
456
+
457
+ def action_get_next(feature_list_data, state_dir, feature_filter=None):
458
+ """Find the next feature to process.
459
+
460
+ Priority logic:
461
+ 1. Skip terminal statuses (completed, failed, skipped, auto_skipped, split)
462
+ 2. If feature_filter is set, skip features not in the filter
463
+ 3. Check that all dependencies are completed
464
+ 4. Prefer in_progress features over pending ones (interrupted session resume)
465
+ 5. Among eligible features, pick highest priority (high > medium > low)
466
+ """
467
+ features = feature_list_data.get("features", [])
468
+ if not features:
469
+ print("PIPELINE_COMPLETE")
470
+ return
471
+
472
+ # Build status map from ALL features (for dependency checking).
473
+ # Status comes from feature-list.json (the single source of truth).
474
+ # This must happen BEFORE the feature filter is applied, because
475
+ # filtered features may depend on features outside the filter.
476
+ status_map = {} # feature_id -> status string
477
+ status_data_map = {} # feature_id -> runtime status data (retry_count, etc.)
478
+ for feature in features:
479
+ if not isinstance(feature, dict):
480
+ continue
481
+ fid = feature.get("id")
482
+ if not fid:
483
+ continue
484
+ status_map[fid] = feature.get("status", "pending")
485
+ fs = load_feature_status(state_dir, fid)
486
+ status_data_map[fid] = fs
487
+
488
+ # Apply feature filter: only consider these features as candidates
489
+ # for execution, but dependency checking still uses the full status_map
490
+ if feature_filter is not None:
491
+ features = [
492
+ f for f in features
493
+ if isinstance(f, dict) and f.get("id") in feature_filter
494
+ ]
495
+ if not features:
496
+ print("PIPELINE_COMPLETE")
497
+ return
498
+
499
+ # Check if all features are in terminal state
500
+ non_terminal = [
501
+ f for f in features
502
+ if isinstance(f, dict) and f.get("id")
503
+ and status_map.get(f["id"], "pending") not in TERMINAL_STATUSES
504
+ ]
505
+ if not non_terminal:
506
+ print("PIPELINE_COMPLETE")
507
+ return
508
+
509
+ # Find eligible features (dependencies all completed)
510
+ eligible = []
511
+ has_remaining = False
512
+ for feature in non_terminal:
513
+ fid = feature.get("id")
514
+ if not fid:
515
+ continue
516
+ has_remaining = True
517
+ deps = feature.get("dependencies", [])
518
+ all_deps_completed = True
519
+ for dep_id in deps:
520
+ if status_map.get(dep_id, "pending") != "completed":
521
+ all_deps_completed = False
522
+ break
523
+ if all_deps_completed:
524
+ eligible.append(feature)
525
+
526
+ if not eligible:
527
+ if has_remaining:
528
+ print("PIPELINE_BLOCKED")
529
+ else:
530
+ print("PIPELINE_COMPLETE")
531
+ return
532
+
533
+ # Separate in_progress from pending
534
+ in_progress_features = []
535
+ pending_features = []
536
+ for feature in eligible:
537
+ fid = feature.get("id")
538
+ fstatus = status_map.get(fid, "pending")
539
+ if fstatus == "in_progress":
540
+ in_progress_features.append(feature)
541
+ else:
542
+ pending_features.append(feature)
543
+
544
+ # Priority mapping: string enum → sort order (critical first)
545
+ _PRIORITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
546
+
547
+ # Prefer in_progress features, then pending; sort by priority (high > medium > low)
548
+ if in_progress_features:
549
+ candidates = sorted(
550
+ in_progress_features,
551
+ key=lambda f: _PRIORITY_ORDER.get(f.get("priority", "low"), 3)
552
+ )
553
+ else:
554
+ candidates = sorted(
555
+ pending_features,
556
+ key=lambda f: _PRIORITY_ORDER.get(f.get("priority", "low"), 3)
557
+ )
558
+
559
+ chosen = candidates[0]
560
+ chosen_id = chosen["id"]
561
+ chosen_status_data = status_data_map.get(chosen_id, {})
562
+
563
+ result = {
564
+ "feature_id": chosen_id,
565
+ "title": chosen.get("title", ""),
566
+ "retry_count": chosen_status_data.get("retry_count", 0),
567
+ "resume_from_phase": chosen_status_data.get("resume_from_phase", None),
568
+ }
569
+ print(json.dumps(result, indent=2, ensure_ascii=False))
570
+
571
+
572
+ # ---------------------------------------------------------------------------
573
+ # Action: update
574
+ # ---------------------------------------------------------------------------
575
+
576
+ def action_update(args, feature_list_path, state_dir):
577
+ """Update a feature's status based on session outcome.
578
+
579
+ Failure policy:
580
+ - Never continue from partial/failed session context
581
+ - Always clean intermediate artifacts and restart from scratch
582
+ """
583
+ feature_id = args.feature_id
584
+ session_status = args.session_status
585
+ session_id = args.session_id
586
+ max_retries = args.max_retries
587
+
588
+ if not feature_id:
589
+ error_out("--feature-id is required for 'update' action")
590
+ return
591
+ if not session_status:
592
+ error_out("--session-status is required for 'update' action")
593
+ return
594
+
595
+ fs = load_feature_status(state_dir, feature_id)
596
+ current_list_status = get_feature_status_from_list(feature_list_path, feature_id)
597
+
598
+ # Track what status we write to feature-list.json
599
+ new_status = current_list_status
600
+
601
+ if session_status == "success":
602
+ # No-op guard: if this exact successful session was already recorded,
603
+ # avoid rewriting state files again (prevents post-commit dirty changes).
604
+ existing_sessions = fs.get("sessions", [])
605
+ already_completed = current_list_status == "completed" and fs.get("resume_from_phase") is None
606
+ same_session_already_recorded = (
607
+ session_id
608
+ and session_id in existing_sessions
609
+ and fs.get("last_session_id") == session_id
610
+ )
611
+ if already_completed and (same_session_already_recorded or not session_id):
612
+ summary = {
613
+ "action": "update",
614
+ "feature_id": feature_id,
615
+ "session_status": session_status,
616
+ "new_status": "completed",
617
+ "retry_count": fs.get("retry_count", 0),
618
+ "resume_from_phase": fs.get("resume_from_phase"),
619
+ "updated_at": fs.get("updated_at"),
620
+ "no_op": True,
621
+ }
622
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
623
+ return
624
+
625
+ new_status = "completed"
626
+ fs["resume_from_phase"] = None
627
+ err = update_feature_in_list(feature_list_path, feature_id, "completed")
628
+ if err:
629
+ error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
630
+ return
631
+ elif session_status in ("commit_missing", "docs_missing", "merge_conflict"):
632
+ # Degraded outcome: keep artifacts for retry.
633
+ # Store granular reason in status.json (internal state),
634
+ # but write only schema-valid status to feature-list.json.
635
+ fs["retry_count"] = fs.get("retry_count", 0) + 1
636
+
637
+ if fs["retry_count"] >= max_retries:
638
+ new_status = "failed"
639
+ else:
640
+ # feature-list.json gets schema-valid "pending" (will be retried)
641
+ new_status = "pending"
642
+
643
+ fs["degraded_reason"] = session_status
644
+ fs["resume_from_phase"] = None
645
+ fs["sessions"] = []
646
+ fs["last_session_id"] = None
647
+
648
+ err = update_feature_in_list(feature_list_path, feature_id, new_status)
649
+ if err:
650
+ error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
651
+ return
652
+ else:
653
+ # crashed / failed / timed_out — preserve all artifacts for debugging.
654
+ fs["retry_count"] = fs.get("retry_count", 0) + 1
655
+
656
+ if fs["retry_count"] >= max_retries:
657
+ new_status = "failed"
658
+ else:
659
+ new_status = "pending"
660
+
661
+ fs["resume_from_phase"] = None
662
+ # Keep sessions list and last_session_id for debugging
663
+
664
+ err = update_feature_in_list(feature_list_path, feature_id, new_status)
665
+ if err:
666
+ error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
667
+ return
668
+
669
+ if session_status == "success" and session_id:
670
+ sessions = fs.get("sessions", [])
671
+ if session_id not in sessions:
672
+ sessions.append(session_id)
673
+ fs["sessions"] = sessions
674
+ fs["last_session_id"] = session_id
675
+
676
+ fs["updated_at"] = now_iso()
677
+
678
+ err = save_feature_status(state_dir, feature_id, fs)
679
+ if err:
680
+ error_out("Failed to save feature status: {}".format(err))
681
+ return
682
+
683
+ # Auto-skip downstream features when this feature is marked as failed or skipped
684
+ auto_skipped_features = []
685
+ if new_status in ("failed", "skipped"):
686
+ auto_skipped_features = auto_skip_blocked_features(
687
+ feature_list_path, state_dir, feature_id
688
+ )
689
+
690
+ summary = {
691
+ "action": "update",
692
+ "feature_id": feature_id,
693
+ "session_status": session_status,
694
+ "new_status": new_status,
695
+ "retry_count": fs["retry_count"],
696
+ "resume_from_phase": fs.get("resume_from_phase"),
697
+ "updated_at": fs["updated_at"],
698
+ }
699
+ if auto_skipped_features:
700
+ summary["auto_skipped"] = [info["feature_id"] for info in auto_skipped_features]
701
+ if session_status in ("commit_missing", "docs_missing", "merge_conflict"):
702
+ summary["degraded_reason"] = session_status
703
+ summary["restart_policy"] = "finalization_retry"
704
+ elif session_status != "success":
705
+ summary["restart_policy"] = "preserve_and_retry"
706
+ summary["artifacts_preserved"] = True
707
+
708
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
709
+
710
+
711
+ # ---------------------------------------------------------------------------
712
+ # Action: status
713
+ # ---------------------------------------------------------------------------
714
+
715
+ # ANSI color codes
716
+ COLOR_GREEN = "\033[92m"
717
+ COLOR_YELLOW = "\033[93m"
718
+ COLOR_RED = "\033[91m"
719
+ COLOR_GRAY = "\033[90m"
720
+ COLOR_BOLD = "\033[1m"
721
+ COLOR_RESET = "\033[0m"
722
+
723
+ BOX_WIDTH = 68
724
+
725
+
726
+ def _calc_feature_duration(state_dir, feature_id):
727
+ """Calculate the duration (in seconds) of a completed feature.
728
+
729
+ Computes duration from status.json's created_at and updated_at fields.
730
+ If session records exist, attempts to use the first session's started_at
731
+ to the last update time for the calculation.
732
+ Returns None if the duration cannot be calculated.
733
+ """
734
+ fs_path = os.path.join(state_dir, "features", feature_id, "status.json")
735
+ if not os.path.isfile(fs_path):
736
+ return None
737
+ data, err = load_json_file(fs_path)
738
+ if err or not data:
739
+ return None
740
+
741
+ created_at = data.get("created_at")
742
+ updated_at = data.get("updated_at")
743
+ if not created_at or not updated_at:
744
+ return None
745
+
746
+ try:
747
+ fmt = "%Y-%m-%dT%H:%M:%SZ"
748
+ t_start = datetime.strptime(created_at, fmt)
749
+ t_end = datetime.strptime(updated_at, fmt)
750
+ delta = (t_end - t_start).total_seconds()
751
+ # Filter outliers: ignore durations less than 10s or more than 24h
752
+ if delta < 10 or delta > 86400:
753
+ return None
754
+ return delta
755
+ except (ValueError, TypeError):
756
+ return None
757
+
758
+
759
+ def _format_duration(seconds):
760
+ """Format seconds into a human-readable duration string."""
761
+ if seconds is None:
762
+ return "N/A"
763
+ seconds = int(seconds)
764
+ if seconds < 60:
765
+ return "{}s".format(seconds)
766
+ elif seconds < 3600:
767
+ m = seconds // 60
768
+ s = seconds % 60
769
+ return "{}m{}s".format(m, s)
770
+ else:
771
+ h = seconds // 3600
772
+ m = (seconds % 3600) // 60
773
+ return "{}h{}m".format(h, m)
774
+
775
+
776
+ def _estimate_remaining_time(features, state_dir, counts, feature_list_data=None):
777
+ """Estimate remaining time based on completed feature durations, weighted by complexity.
778
+
779
+ Strategy:
780
+ 1. Collect durations of all completed features, grouped by complexity
781
+ 2. For remaining pending/in_progress features, estimate using the average duration
782
+ of the corresponding complexity level
783
+ 3. If no historical data exists for a complexity level, fall back to the global average
784
+
785
+ Returns an (estimated_seconds, confidence) tuple.
786
+ confidence: "high" (>=50% completed), "medium" (>=25%), "low" (<25%)
787
+ """
788
+ # Complexity weights (used for estimation when no historical data is available)
789
+ COMPLEXITY_WEIGHT = {"low": 1.0, "medium": 2.0, "high": 4.0}
790
+
791
+ # Build feature-list status map (status lives in feature-list.json)
792
+ fl_status_map = {}
793
+ if feature_list_data:
794
+ for f in feature_list_data.get("features", []):
795
+ if isinstance(f, dict) and f.get("id"):
796
+ fl_status_map[f["id"]] = f.get("status", "pending")
797
+
798
+ # Collect completed feature durations grouped by complexity
799
+ duration_by_complexity = {} # complexity -> [duration_seconds]
800
+ feature_complexity_map = {} # feature_id -> complexity
801
+
802
+ for feature in features:
803
+ if not isinstance(feature, dict):
804
+ continue
805
+ fid = feature.get("id")
806
+ if not fid:
807
+ continue
808
+ complexity = feature.get("estimated_complexity", "medium")
809
+ feature_complexity_map[fid] = complexity
810
+
811
+ all_durations = []
812
+ for feature in features:
813
+ if not isinstance(feature, dict):
814
+ continue
815
+ fid = feature.get("id")
816
+ if not fid:
817
+ continue
818
+ if fl_status_map.get(fid) != "completed":
819
+ continue
820
+ duration = _calc_feature_duration(state_dir, fid)
821
+ if duration is None:
822
+ continue
823
+ complexity = feature_complexity_map.get(fid, "medium")
824
+ if complexity not in duration_by_complexity:
825
+ duration_by_complexity[complexity] = []
826
+ duration_by_complexity[complexity].append(duration)
827
+ all_durations.append(duration)
828
+
829
+ if not all_durations:
830
+ return None, "low"
831
+
832
+ # Calculate average duration per complexity level
833
+ avg_by_complexity = {}
834
+ for c, durations in duration_by_complexity.items():
835
+ avg_by_complexity[c] = sum(durations) / len(durations)
836
+ global_avg = sum(all_durations) / len(all_durations)
837
+
838
+ # Estimate duration for remaining features
839
+ remaining_seconds = 0.0
840
+ remaining_count = 0
841
+ for feature in features:
842
+ if not isinstance(feature, dict):
843
+ continue
844
+ fid = feature.get("id")
845
+ if not fid:
846
+ continue
847
+ fstatus = fl_status_map.get(fid, "pending")
848
+ if fstatus in TERMINAL_STATUSES:
849
+ continue
850
+ remaining_count += 1
851
+ complexity = feature_complexity_map.get(fid, "medium")
852
+ if complexity in avg_by_complexity:
853
+ remaining_seconds += avg_by_complexity[complexity]
854
+ else:
855
+ # No historical data for this complexity; estimate using global avg × weight ratio
856
+ weight = COMPLEXITY_WEIGHT.get(complexity, 2.0)
857
+ base_weight = COMPLEXITY_WEIGHT.get("medium", 2.0)
858
+ remaining_seconds += global_avg * (weight / base_weight)
859
+
860
+ # Calculate confidence level
861
+ total = len([f for f in features if isinstance(f, dict) and f.get("id")])
862
+ completed = counts.get("completed", 0)
863
+ if total > 0:
864
+ ratio = completed / total
865
+ if ratio >= 0.5:
866
+ confidence = "high"
867
+ elif ratio >= 0.25:
868
+ confidence = "medium"
869
+ else:
870
+ confidence = "low"
871
+ else:
872
+ confidence = "low"
873
+
874
+ return remaining_seconds, confidence
875
+
876
+
877
+ def action_status(feature_list_data, state_dir, feature_filter=None):
878
+ """Print a formatted overview of all features and their status.
879
+
880
+ Status is read exclusively from .prizmkit/plans/feature-list.json (the single source of
881
+ truth). state_dir is only used for ETA estimation when session history
882
+ is available.
883
+ """
884
+ features = feature_list_data.get("features", [])
885
+ app_name = feature_list_data.get("project_name", feature_list_data.get("app_name", "Unknown"))
886
+
887
+ # Apply feature filter
888
+ if feature_filter is not None:
889
+ features = [
890
+ f for f in features
891
+ if isinstance(f, dict) and f.get("id") in feature_filter
892
+ ]
893
+
894
+ # Gather status info
895
+ counts = {
896
+ "completed": 0,
897
+ "in_progress": 0,
898
+ "failed": 0,
899
+ "pending": 0,
900
+ "skipped": 0,
901
+ "auto_skipped": 0,
902
+ }
903
+ feature_lines = []
904
+
905
+ # Build status map from .prizmkit/plans/feature-list.json only
906
+ status_map = {}
907
+ for feature in features:
908
+ if not isinstance(feature, dict):
909
+ continue
910
+ fid = feature.get("id")
911
+ if not fid:
912
+ continue
913
+ status_map[fid] = feature.get("status", "pending")
914
+
915
+ # Build degraded_reason map from status.json (internal pipeline state)
916
+ degraded_reason_map = {}
917
+ for feature in features:
918
+ if not isinstance(feature, dict):
919
+ continue
920
+ fid = feature.get("id")
921
+ if not fid:
922
+ continue
923
+ fs = load_feature_status(state_dir, fid)
924
+ dr = fs.get("degraded_reason")
925
+ if dr:
926
+ degraded_reason_map[fid] = dr
927
+
928
+ for feature in features:
929
+ if not isinstance(feature, dict):
930
+ continue
931
+ fid = feature.get("id")
932
+ title = feature.get("title", "Untitled")
933
+ if not fid:
934
+ continue
935
+
936
+ fstatus = feature.get("status", "pending")
937
+ degraded_reason = degraded_reason_map.get(fid)
938
+
939
+ # Count statuses
940
+ if fstatus in counts:
941
+ counts[fstatus] += 1
942
+ else:
943
+ counts["pending"] += 1
944
+
945
+ # Build status indicator and color
946
+ # Show degraded reason via icon when a pending feature has one
947
+ if fstatus == "pending" and degraded_reason == "commit_missing":
948
+ icon = COLOR_RED + "[↑]" + COLOR_RESET
949
+ elif fstatus == "pending" and degraded_reason == "docs_missing":
950
+ icon = COLOR_RED + "[D]" + COLOR_RESET
951
+ elif fstatus == "pending" and degraded_reason == "merge_conflict":
952
+ icon = COLOR_RED + "[⚡]" + COLOR_RESET
953
+ elif fstatus == "completed":
954
+ icon = COLOR_GREEN + "[✓]" + COLOR_RESET
955
+ elif fstatus == "in_progress":
956
+ icon = COLOR_YELLOW + "[→]" + COLOR_RESET
957
+ elif fstatus == "failed":
958
+ icon = COLOR_RED + "[✗]" + COLOR_RESET
959
+ elif fstatus == "skipped":
960
+ icon = COLOR_GRAY + "[—]" + COLOR_RESET
961
+ elif fstatus == "auto_skipped":
962
+ icon = COLOR_GRAY + "[⊘]" + COLOR_RESET
963
+ else:
964
+ icon = COLOR_GRAY + "[ ]" + COLOR_RESET
965
+
966
+ # Build detail suffix
967
+ detail = ""
968
+ if fstatus == "pending" and degraded_reason:
969
+ detail = " ({}, retrying)".format(degraded_reason)
970
+ # Also check if blocked by dependencies
971
+ deps = feature.get("dependencies", [])
972
+ blocking = [
973
+ d for d in deps
974
+ if status_map.get(d, "pending") != "completed"
975
+ ]
976
+ if blocking:
977
+ detail = " ({}, blocked by {})".format(degraded_reason, ", ".join(blocking))
978
+ elif fstatus == "pending":
979
+ # Check if blocked by dependencies
980
+ deps = feature.get("dependencies", [])
981
+ blocking = [
982
+ d for d in deps
983
+ if status_map.get(d, "pending") != "completed"
984
+ ]
985
+ if blocking:
986
+ detail = " (blocked by {})".format(", ".join(blocking))
987
+ elif fstatus == "auto_skipped":
988
+ deps = feature.get("dependencies", [])
989
+ blockers = [
990
+ d for d in deps
991
+ if status_map.get(d, "pending") in ("failed", "skipped", "auto_skipped")
992
+ ]
993
+ if blockers:
994
+ detail = " (auto-skipped: blocked by {})".format(", ".join(blockers))
995
+ elif fstatus == "failed" and degraded_reason:
996
+ detail = " (last failure: {})".format(degraded_reason)
997
+
998
+ # Apply color to the whole line content
999
+ if fstatus == "completed":
1000
+ line_content = "{} {} {}{}".format(
1001
+ fid, icon, COLOR_GREEN + title + COLOR_RESET, detail
1002
+ )
1003
+ elif fstatus == "in_progress":
1004
+ line_content = "{} {} {}{}".format(
1005
+ fid, icon, COLOR_YELLOW + title + COLOR_RESET, detail
1006
+ )
1007
+ elif fstatus == "failed":
1008
+ line_content = "{} {} {}{}".format(
1009
+ fid, icon, COLOR_RED + title + COLOR_RESET, detail
1010
+ )
1011
+ elif degraded_reason:
1012
+ line_content = "{} {} {}{}".format(
1013
+ fid, icon, COLOR_RED + title + COLOR_RESET, detail
1014
+ )
1015
+ else:
1016
+ line_content = "{} {} {}{}".format(
1017
+ fid, icon, COLOR_GRAY + title + COLOR_RESET, detail
1018
+ )
1019
+
1020
+ feature_lines.append(line_content)
1021
+
1022
+ total = len(features)
1023
+ completed = counts["completed"]
1024
+
1025
+ # Calculate percentage
1026
+ if total > 0:
1027
+ percent = round(completed / total * 100, 1)
1028
+ else:
1029
+ percent = 0.0
1030
+
1031
+ # Generate progress bar
1032
+ progress_bar = _build_progress_bar(percent, width=24)
1033
+
1034
+ # Estimate remaining time
1035
+ est_remaining, confidence = _estimate_remaining_time(
1036
+ features, state_dir, counts, feature_list_data
1037
+ )
1038
+
1039
+ summary_line = "Total: {} features | Completed: {} | In Progress: {}".format(
1040
+ total, completed, counts["in_progress"]
1041
+ )
1042
+ summary_line2 = "Failed: {} | Pending: {} | Skipped: {} | Auto-skipped: {}".format(
1043
+ counts["failed"], counts["pending"], counts["skipped"], counts["auto_skipped"]
1044
+ )
1045
+
1046
+ # Count degraded features (pending with a degraded_reason from status.json)
1047
+ degraded_count = sum(
1048
+ 1 for fid, dr in degraded_reason_map.items()
1049
+ if status_map.get(fid) == "pending" and dr
1050
+ )
1051
+ if degraded_count > 0:
1052
+ summary_line3 = "Degraded (retrying): {}".format(degraded_count)
1053
+ else:
1054
+ summary_line3 = None
1055
+
1056
+ # Build estimated remaining time line
1057
+ CONFIDENCE_ICONS = {"high": "●", "medium": "◐", "low": "○"}
1058
+ if est_remaining is not None:
1059
+ eta_str = _format_duration(est_remaining)
1060
+ conf_icon = CONFIDENCE_ICONS.get(confidence, "○")
1061
+ eta_line = "ETA: ~{} (confidence: {} {})".format(
1062
+ eta_str, conf_icon, confidence
1063
+ )
1064
+ else:
1065
+ eta_line = "ETA: calculating... (need >=1 completed feature)"
1066
+
1067
+ # Print the box
1068
+ inner = BOX_WIDTH - 2 # space inside the vertical bars
1069
+ print("╔" + "═" * BOX_WIDTH + "╗")
1070
+ print("║" + pad_right(COLOR_BOLD + " Dev-Pipeline Status" + COLOR_RESET, inner) + " ║")
1071
+ print("╠" + "═" * BOX_WIDTH + "╣")
1072
+ print("║" + pad_right(" Project: {}".format(app_name), inner) + " ║")
1073
+ print("║" + pad_right(" {}".format(summary_line), inner) + " ║")
1074
+ print("║" + pad_right(" {}".format(summary_line2), inner) + " ║")
1075
+ if summary_line3:
1076
+ print("║" + pad_right(" {}".format(summary_line3), inner) + " ║")
1077
+ print("╠" + "─" * BOX_WIDTH + "╣")
1078
+ print("║" + pad_right(" Progress: {}".format(progress_bar), inner) + " ║")
1079
+ print("║" + pad_right(" {}".format(eta_line), inner) + " ║")
1080
+ print("╠" + "═" * BOX_WIDTH + "╣")
1081
+ for line in feature_lines:
1082
+ print("║" + pad_right(" {}".format(line), inner) + " ║")
1083
+ print("╚" + "═" * BOX_WIDTH + "╝")
1084
+
1085
+
1086
+ # ---------------------------------------------------------------------------
1087
+ # Action: start
1088
+ # ---------------------------------------------------------------------------
1089
+
1090
+ def action_start(args, feature_list_path, state_dir):
1091
+ """Mark a feature as in_progress at session start.
1092
+
1093
+ This keeps .prizmkit/plans/feature-list.json/state status in sync during execution,
1094
+ instead of only updating after session end.
1095
+ """
1096
+ feature_id = args.feature_id
1097
+ if not feature_id:
1098
+ error_out("--feature-id is required for 'start' action")
1099
+ return
1100
+
1101
+ fs = load_feature_status(state_dir, feature_id)
1102
+ old_status = get_feature_status_from_list(feature_list_path, feature_id)
1103
+
1104
+ fs["updated_at"] = now_iso()
1105
+
1106
+ err = save_feature_status(state_dir, feature_id, fs)
1107
+ if err:
1108
+ error_out("Failed to save feature status: {}".format(err))
1109
+ return
1110
+
1111
+ err = update_feature_in_list(feature_list_path, feature_id, "in_progress")
1112
+ if err:
1113
+ error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
1114
+ return
1115
+
1116
+ result = {
1117
+ "action": "start",
1118
+ "feature_id": feature_id,
1119
+ "old_status": old_status,
1120
+ "new_status": "in_progress",
1121
+ "updated_at": fs["updated_at"],
1122
+ }
1123
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1124
+
1125
+
1126
+ # ---------------------------------------------------------------------------
1127
+ # Action: reset
1128
+ # ---------------------------------------------------------------------------
1129
+
1130
+ def action_reset(args, feature_list_path, state_dir):
1131
+ """Reset a feature to pending state.
1132
+
1133
+ Resets status.json runtime fields (retry_count -> 0, clear sessions,
1134
+ clear resume_from_phase) and updates .prizmkit/plans/feature-list.json status to pending.
1135
+ Does NOT delete any files on disk.
1136
+ """
1137
+ feature_id = args.feature_id
1138
+ if not feature_id:
1139
+ error_out("--feature-id is required for 'reset' action")
1140
+ return
1141
+
1142
+ # Load current status to preserve created_at
1143
+ fs = load_feature_status(state_dir, feature_id)
1144
+ old_status = get_feature_status_from_list(feature_list_path, feature_id)
1145
+ old_retry = fs.get("retry_count", 0)
1146
+
1147
+ # Reset runtime fields
1148
+ fs["retry_count"] = 0
1149
+ fs["sessions"] = []
1150
+ fs["last_session_id"] = None
1151
+ fs["resume_from_phase"] = None
1152
+ fs["updated_at"] = now_iso()
1153
+
1154
+ # Write back status.json
1155
+ err = save_feature_status(state_dir, feature_id, fs)
1156
+ if err:
1157
+ error_out("Failed to save feature status: {}".format(err))
1158
+ return
1159
+
1160
+ # Update .prizmkit/plans/feature-list.json
1161
+ err = update_feature_in_list(feature_list_path, feature_id, "pending")
1162
+ if err:
1163
+ error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
1164
+ return
1165
+
1166
+ result = {
1167
+ "action": "reset",
1168
+ "feature_id": feature_id,
1169
+ "old_status": old_status,
1170
+ "old_retry_count": old_retry,
1171
+ "new_status": "pending",
1172
+ }
1173
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1174
+
1175
+
1176
+ # ---------------------------------------------------------------------------
1177
+ # Action: clean
1178
+ # ---------------------------------------------------------------------------
1179
+
1180
+ def action_clean(args, feature_list_path, state_dir):
1181
+ """Reset a feature AND delete all associated artifacts.
1182
+
1183
+ Deletes:
1184
+ - state/features/F-XXX/sessions/ (all session history)
1185
+ - .prizmkit/specs/{slug}/ (spec, plan, tasks, contracts)
1186
+
1187
+ Then performs a full reset (same as action_reset).
1188
+ """
1189
+ feature_id = args.feature_id
1190
+ feature_slug = args.feature_slug
1191
+ project_root = args.project_root
1192
+
1193
+ if not feature_id:
1194
+ error_out("--feature-id is required for 'clean' action")
1195
+ return
1196
+ if not feature_slug:
1197
+ error_out("--feature-slug is required for 'clean' action")
1198
+ return
1199
+ if not project_root:
1200
+ error_out("--project-root is required for 'clean' action")
1201
+ return
1202
+
1203
+ cleaned = []
1204
+
1205
+ # 1. Delete session history
1206
+ sessions_dir = os.path.join(state_dir, "features", feature_id, "sessions")
1207
+ sessions_deleted = 0
1208
+ if os.path.isdir(sessions_dir):
1209
+ for entry in os.listdir(sessions_dir):
1210
+ entry_path = os.path.join(sessions_dir, entry)
1211
+ if os.path.isdir(entry_path):
1212
+ shutil.rmtree(entry_path)
1213
+ sessions_deleted += 1
1214
+ cleaned.append("Deleted {} session(s) from {}".format(
1215
+ sessions_deleted, sessions_dir
1216
+ ))
1217
+
1218
+ # 2. Delete prizmkit specs for this feature
1219
+ specs_dir = os.path.join(project_root, ".prizmkit", "specs", feature_slug)
1220
+ if os.path.isdir(specs_dir):
1221
+ file_count = sum(
1222
+ len(files) for _, _, files in os.walk(specs_dir)
1223
+ )
1224
+ shutil.rmtree(specs_dir)
1225
+ cleaned.append("Deleted {} ({} files)".format(specs_dir, file_count))
1226
+
1227
+ # 3. Delete global dev-team workspace (shared AI transient context)
1228
+ dev_team_dir = os.path.join(project_root, ".dev-team")
1229
+ if os.path.isdir(dev_team_dir):
1230
+ file_count = sum(len(files) for _, _, files in os.walk(dev_team_dir))
1231
+ shutil.rmtree(dev_team_dir)
1232
+ cleaned.append("Deleted {} ({} files)".format(dev_team_dir, file_count))
1233
+
1234
+ # 4. (removed: current-session.json no longer used)
1235
+
1236
+ # 5. Reset status (reuse reset logic)
1237
+ fs = load_feature_status(state_dir, feature_id)
1238
+ old_status = get_feature_status_from_list(feature_list_path, feature_id)
1239
+ old_retry = fs.get("retry_count", 0)
1240
+
1241
+ fs["retry_count"] = 0
1242
+ fs["sessions"] = []
1243
+ fs["last_session_id"] = None
1244
+ fs["resume_from_phase"] = None
1245
+ fs["updated_at"] = now_iso()
1246
+
1247
+ err = save_feature_status(state_dir, feature_id, fs)
1248
+ if err:
1249
+ error_out("Failed to save feature status: {}".format(err))
1250
+ return
1251
+
1252
+ err = update_feature_in_list(feature_list_path, feature_id, "pending")
1253
+ if err:
1254
+ error_out("Failed to update .prizmkit/plans/feature-list.json: {}".format(err))
1255
+ return
1256
+
1257
+ result = {
1258
+ "action": "clean",
1259
+ "feature_id": feature_id,
1260
+ "feature_slug": feature_slug,
1261
+ "old_status": old_status,
1262
+ "old_retry_count": old_retry,
1263
+ "new_status": "pending",
1264
+ "sessions_deleted": sessions_deleted,
1265
+ "cleaned": cleaned,
1266
+ }
1267
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1268
+
1269
+
1270
+ # ---------------------------------------------------------------------------
1271
+ # Action: unskip
1272
+ # ---------------------------------------------------------------------------
1273
+
1274
+ def action_unskip(args, feature_list_path, state_dir):
1275
+ """Recover auto-skipped features by resetting them and their failed upstream.
1276
+
1277
+ Two modes:
1278
+ - --feature-id F-032: Reset the specified failed/skipped feature + all auto_skipped
1279
+ features whose dependency chain includes it.
1280
+ - No --feature-id: Reset ALL failed, skipped, and auto_skipped features to pending.
1281
+ """
1282
+ feature_id = args.feature_id
1283
+
1284
+ data, err = load_json_file(feature_list_path)
1285
+ if err:
1286
+ error_out("Cannot load feature list: {}".format(err))
1287
+ return
1288
+ features = data.get("features", [])
1289
+
1290
+ to_reset = set()
1291
+
1292
+ if feature_id:
1293
+ # Find the target feature
1294
+ target = None
1295
+ for f in features:
1296
+ if isinstance(f, dict) and f.get("id") == feature_id:
1297
+ target = f
1298
+ break
1299
+ if not target:
1300
+ error_out("Feature '{}' not found in .prizmkit/plans/feature-list.json".format(feature_id))
1301
+ return
1302
+ if target.get("status") not in ("failed", "skipped", "auto_skipped"):
1303
+ error_out(
1304
+ "Feature '{}' has status '{}', expected 'failed', 'skipped', or 'auto_skipped'".format(
1305
+ feature_id, target.get("status", "unknown")
1306
+ )
1307
+ )
1308
+ return
1309
+
1310
+ # If target is failed or skipped, reset it and find all auto_skipped descendants
1311
+ if target.get("status") in ("failed", "skipped"):
1312
+ to_reset.add(feature_id)
1313
+ # Find all auto_skipped features that depend (transitively) on this one
1314
+ changed = True
1315
+ while changed:
1316
+ changed = False
1317
+ for f in features:
1318
+ if not isinstance(f, dict):
1319
+ continue
1320
+ fid = f.get("id")
1321
+ if not fid or fid in to_reset:
1322
+ continue
1323
+ if f.get("status") != "auto_skipped":
1324
+ continue
1325
+ deps = f.get("dependencies", [])
1326
+ if any(d in to_reset for d in deps):
1327
+ to_reset.add(fid)
1328
+ changed = True
1329
+
1330
+ # If target is auto_skipped, reset it and its failed upstream + siblings
1331
+ elif target.get("status") == "auto_skipped":
1332
+ to_reset.add(feature_id)
1333
+ # Transitively walk upstream to find ALL failed/auto_skipped ancestors
1334
+ # (e.g., F-001 failed → F-002 auto_skipped → F-003 auto_skipped;
1335
+ # unskip F-003 must also find and reset F-001)
1336
+ upstream_changed = True
1337
+ while upstream_changed:
1338
+ upstream_changed = False
1339
+ for f in features:
1340
+ if not isinstance(f, dict):
1341
+ continue
1342
+ fid = f.get("id")
1343
+ if not fid or fid not in to_reset:
1344
+ continue
1345
+ for dep_id in f.get("dependencies", []):
1346
+ if dep_id in to_reset:
1347
+ continue
1348
+ for dep_f in features:
1349
+ if isinstance(dep_f, dict) and dep_f.get("id") == dep_id:
1350
+ if dep_f.get("status") in ("failed", "skipped", "auto_skipped"):
1351
+ to_reset.add(dep_id)
1352
+ upstream_changed = True
1353
+ # Also reset downstream auto_skipped features blocked by the same upstreams
1354
+ changed = True
1355
+ while changed:
1356
+ changed = False
1357
+ for f in features:
1358
+ if not isinstance(f, dict):
1359
+ continue
1360
+ fid = f.get("id")
1361
+ if not fid or fid in to_reset:
1362
+ continue
1363
+ if f.get("status") != "auto_skipped":
1364
+ continue
1365
+ fdeps = f.get("dependencies", [])
1366
+ if any(d in to_reset for d in fdeps):
1367
+ to_reset.add(fid)
1368
+ changed = True
1369
+ else:
1370
+ # No feature-id: reset ALL failed + skipped + auto_skipped
1371
+ for f in features:
1372
+ if isinstance(f, dict) and f.get("id"):
1373
+ if f.get("status") in ("failed", "skipped", "auto_skipped"):
1374
+ to_reset.add(f["id"])
1375
+
1376
+ if not to_reset:
1377
+ error_out("No features to unskip")
1378
+ return
1379
+
1380
+ # Reset all collected features in .prizmkit/plans/feature-list.json
1381
+ reset_details = []
1382
+ for f in features:
1383
+ if isinstance(f, dict) and f.get("id") in to_reset:
1384
+ old_status = f.get("status", "unknown")
1385
+ f["status"] = "pending"
1386
+ reset_details.append({
1387
+ "feature_id": f["id"],
1388
+ "title": f.get("title", ""),
1389
+ "old_status": old_status,
1390
+ })
1391
+
1392
+ err = write_json_file(feature_list_path, data)
1393
+ if err:
1394
+ error_out("Failed to write .prizmkit/plans/feature-list.json: {}".format(err))
1395
+ return
1396
+
1397
+ # Reset runtime fields in status.json for each feature
1398
+ for fid in to_reset:
1399
+ fs = load_feature_status(state_dir, fid)
1400
+ fs["retry_count"] = 0
1401
+ fs["sessions"] = []
1402
+ fs["last_session_id"] = None
1403
+ fs["resume_from_phase"] = None
1404
+ fs["updated_at"] = now_iso()
1405
+ save_feature_status(state_dir, fid, fs)
1406
+
1407
+ result = {
1408
+ "action": "unskip",
1409
+ "reset_count": len(to_reset),
1410
+ "features": reset_details,
1411
+ }
1412
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1413
+
1414
+
1415
+ # ---------------------------------------------------------------------------
1416
+ # Action: pause
1417
+ # ---------------------------------------------------------------------------
1418
+
1419
+ def action_pause(state_dir):
1420
+ """Save current pipeline state for graceful shutdown."""
1421
+ pipeline_path = os.path.join(state_dir, "pipeline.json")
1422
+
1423
+ data, err = load_json_file(pipeline_path)
1424
+ if err:
1425
+ # If pipeline.json doesn't exist, create a minimal one
1426
+ data = {
1427
+ "status": "paused",
1428
+ "paused_at": now_iso(),
1429
+ }
1430
+ else:
1431
+ data["status"] = "paused"
1432
+ data["paused_at"] = now_iso()
1433
+
1434
+ err = write_json_file(pipeline_path, data)
1435
+ if err:
1436
+ error_out("Failed to write pipeline.json: {}".format(err))
1437
+ return
1438
+
1439
+ result = {
1440
+ "action": "pause",
1441
+ "status": "paused",
1442
+ "paused_at": data["paused_at"],
1443
+ }
1444
+ print(json.dumps(result, indent=2, ensure_ascii=False))
1445
+
1446
+
1447
+ # ---------------------------------------------------------------------------
1448
+ # Main
1449
+ # ---------------------------------------------------------------------------
1450
+
1451
+ def main():
1452
+ args = parse_args()
1453
+
1454
+ # Validate action-specific requirements
1455
+ if args.action == "update":
1456
+ if not args.feature_id:
1457
+ error_out("--feature-id is required for 'update' action")
1458
+ if not args.session_status:
1459
+ error_out("--session-status is required for 'update' action")
1460
+ if args.action in ("start", "reset", "clean", "complete"):
1461
+ if not args.feature_id:
1462
+ error_out("--feature-id is required for '{}' action".format(args.action))
1463
+ if args.action == "clean":
1464
+ if not args.feature_slug:
1465
+ error_out("--feature-slug is required for 'clean' action")
1466
+ if not args.project_root:
1467
+ error_out("--project-root is required for 'clean' action")
1468
+
1469
+ # Load feature list
1470
+ feature_list_data, err = load_json_file(args.feature_list)
1471
+ if err:
1472
+ error_out("Cannot load feature list: {}".format(err))
1473
+
1474
+ # Parse feature filter (used by get_next and status)
1475
+ feature_filter = parse_feature_filter(args.features)
1476
+
1477
+ # Dispatch action
1478
+ if args.action == "get_next":
1479
+ action_get_next(feature_list_data, args.state_dir, feature_filter)
1480
+ elif args.action == "start":
1481
+ action_start(args, args.feature_list, args.state_dir)
1482
+ elif args.action == "update":
1483
+ action_update(args, args.feature_list, args.state_dir)
1484
+ elif args.action == "status":
1485
+ action_status(feature_list_data, args.state_dir, feature_filter)
1486
+ elif args.action == "reset":
1487
+ action_reset(args, args.feature_list, args.state_dir)
1488
+ elif args.action == "clean":
1489
+ action_clean(args, args.feature_list, args.state_dir)
1490
+ elif args.action == "complete":
1491
+ # Shortcut: 'complete' is equivalent to 'update --session-status success'
1492
+ args.session_status = "success"
1493
+ action_update(args, args.feature_list, args.state_dir)
1494
+ elif args.action == "pause":
1495
+ action_pause(args.state_dir)
1496
+ elif args.action == "unskip":
1497
+ action_unskip(args, args.feature_list, args.state_dir)
1498
+
1499
+
1500
+ if __name__ == "__main__":
1501
+ main()