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