@xenonbyte/req-2-plan 0.2.3
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/LICENSE +21 -0
- package/README.md +172 -0
- package/README.zh-CN.md +158 -0
- package/bin/r2p.js +38 -0
- package/docs/req-to-plan-design.md +277 -0
- package/package.json +47 -0
- package/requirements.txt +1 -0
- package/tools/r2p +10 -0
- package/tools/r2p-continue +10 -0
- package/tools/r2p-gap-open +10 -0
- package/tools/r2p-gap-resolve +10 -0
- package/tools/r2p-reopen +10 -0
- package/tools/r2p-start +10 -0
- package/tools/r2p-status +10 -0
- package/tools/r2p-switch +10 -0
- package/tools/r2p-tier-lock +10 -0
- package/tools/workflow_cli/__init__.py +0 -0
- package/tools/workflow_cli/__main__.py +5 -0
- package/tools/workflow_cli/agent_shortcuts.py +778 -0
- package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
- package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
- package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
- package/tools/workflow_cli/artifact.py +228 -0
- package/tools/workflow_cli/cli.py +1779 -0
- package/tools/workflow_cli/gates.py +471 -0
- package/tools/workflow_cli/install.py +900 -0
- package/tools/workflow_cli/install_cli.py +158 -0
- package/tools/workflow_cli/link_expander.py +102 -0
- package/tools/workflow_cli/models.py +504 -0
- package/tools/workflow_cli/output.py +91 -0
- package/tools/workflow_cli/repo_baseline.py +137 -0
- package/tools/workflow_cli/state.py +621 -0
- package/tools/workflow_cli/tier.py +201 -0
- package/tools/workflow_cli/tier_keywords.yaml +45 -0
- package/tools/workflow_cli/version.py +1 -0
|
@@ -0,0 +1,1779 @@
|
|
|
1
|
+
"""
|
|
2
|
+
req-to-plan workflow CLI — internal Agent command router.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 -m tools.workflow_cli [--base-path <dir>] <command> [options]
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from tools.workflow_cli.models import (
|
|
16
|
+
RunStatus,
|
|
17
|
+
Stage,
|
|
18
|
+
TierBase,
|
|
19
|
+
TierModifier,
|
|
20
|
+
WorkId,
|
|
21
|
+
STAGE_ARTIFACT_MAP,
|
|
22
|
+
STAGE_ORDER,
|
|
23
|
+
is_command_allowed,
|
|
24
|
+
is_transition_allowed,
|
|
25
|
+
)
|
|
26
|
+
from tools.workflow_cli.state import (
|
|
27
|
+
RunStateManager,
|
|
28
|
+
create_run_record,
|
|
29
|
+
update_run_status,
|
|
30
|
+
upsert_active_artifact,
|
|
31
|
+
update_resume_context,
|
|
32
|
+
get_active_artifact,
|
|
33
|
+
add_checkpoint,
|
|
34
|
+
add_open_route,
|
|
35
|
+
close_route,
|
|
36
|
+
record_stale_artifact,
|
|
37
|
+
)
|
|
38
|
+
from tools.workflow_cli.artifact import (
|
|
39
|
+
ArtifactManager,
|
|
40
|
+
write_artifact,
|
|
41
|
+
read_artifact,
|
|
42
|
+
get_artifact_version,
|
|
43
|
+
update_artifact_status,
|
|
44
|
+
)
|
|
45
|
+
from tools.workflow_cli.gates import check_entry_gate, check_quality_gate, check_forced_subagent_review
|
|
46
|
+
from tools.workflow_cli.output import (
|
|
47
|
+
format_success,
|
|
48
|
+
format_error,
|
|
49
|
+
format_gate_result,
|
|
50
|
+
print_and_exit,
|
|
51
|
+
EXIT_OK,
|
|
52
|
+
EXIT_CLI_ERR,
|
|
53
|
+
EXIT_GATE_FAIL,
|
|
54
|
+
EXIT_REVIEW_REQ,
|
|
55
|
+
EXIT_CONFLICT,
|
|
56
|
+
EXIT_NOT_FOUND,
|
|
57
|
+
)
|
|
58
|
+
from tools.workflow_cli.tier import estimate_tier, scan_keywords
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# Path helpers
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _get_run_dir(work_id: str, base_path: Path | None = None) -> Path:
|
|
67
|
+
root = base_path or Path.cwd()
|
|
68
|
+
valid_work_id = _validate_work_id(str(work_id))
|
|
69
|
+
return root / ".req-to-plan" / str(valid_work_id)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _load_run(work_id: str, base_path: Path | None = None):
|
|
73
|
+
"""Load RunRecord; exit with EXIT_NOT_FOUND if not found."""
|
|
74
|
+
run_dir = _get_run_dir(work_id, base_path)
|
|
75
|
+
mgr = RunStateManager(run_dir)
|
|
76
|
+
try:
|
|
77
|
+
return mgr.load(), mgr, run_dir
|
|
78
|
+
except FileNotFoundError:
|
|
79
|
+
print_and_exit(
|
|
80
|
+
format_error(f"Run not found: {work_id}", exit_code=EXIT_NOT_FOUND),
|
|
81
|
+
EXIT_NOT_FOUND,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _validate_work_id(raw: str) -> WorkId:
|
|
86
|
+
"""Parse WorkId or exit with CLI error."""
|
|
87
|
+
try:
|
|
88
|
+
return WorkId(raw)
|
|
89
|
+
except ValueError as e:
|
|
90
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_stage(raw: str) -> Stage:
|
|
94
|
+
"""Parse Stage enum or exit with CLI error."""
|
|
95
|
+
try:
|
|
96
|
+
return Stage(raw)
|
|
97
|
+
except ValueError:
|
|
98
|
+
valid = [s.value for s in Stage]
|
|
99
|
+
print_and_exit(
|
|
100
|
+
format_error(f"Unknown stage {raw!r}. Valid: {valid}", exit_code=EXIT_CLI_ERR),
|
|
101
|
+
EXIT_CLI_ERR,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _parse_reopen_stage(raw: str) -> Stage:
|
|
106
|
+
"""Parse a stage that can be used as the active target of a reopened run."""
|
|
107
|
+
stage = _parse_stage(raw)
|
|
108
|
+
if stage not in STAGE_ORDER:
|
|
109
|
+
valid = [s.value for s in STAGE_ORDER]
|
|
110
|
+
print_and_exit(
|
|
111
|
+
format_error(
|
|
112
|
+
f"Stage {raw!r} cannot be reopened into. Valid: {valid}",
|
|
113
|
+
exit_code=EXIT_CLI_ERR,
|
|
114
|
+
),
|
|
115
|
+
EXIT_CLI_ERR,
|
|
116
|
+
)
|
|
117
|
+
return stage
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_tier_base(raw: str) -> TierBase:
|
|
121
|
+
"""Parse TierBase enum or exit with CLI error."""
|
|
122
|
+
try:
|
|
123
|
+
return TierBase(raw)
|
|
124
|
+
except ValueError:
|
|
125
|
+
valid = [b.value for b in TierBase]
|
|
126
|
+
print_and_exit(
|
|
127
|
+
format_error(f"Unknown tier base {raw!r}. Valid: {valid}", exit_code=EXIT_CLI_ERR),
|
|
128
|
+
EXIT_CLI_ERR,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_modifiers(raw: str | None) -> frozenset[TierModifier]:
|
|
133
|
+
"""Parse comma-separated modifier list or return empty set."""
|
|
134
|
+
if not raw:
|
|
135
|
+
return frozenset()
|
|
136
|
+
mods: list[TierModifier] = []
|
|
137
|
+
for part in raw.split(","):
|
|
138
|
+
part = part.strip()
|
|
139
|
+
if not part:
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
mods.append(TierModifier(part))
|
|
143
|
+
except ValueError:
|
|
144
|
+
valid = [m.value for m in TierModifier]
|
|
145
|
+
print_and_exit(
|
|
146
|
+
format_error(f"Unknown modifier {part!r}. Valid: {valid}", exit_code=EXIT_CLI_ERR),
|
|
147
|
+
EXIT_CLI_ERR,
|
|
148
|
+
)
|
|
149
|
+
return frozenset(mods)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------------------
|
|
153
|
+
# Run lifecycle commands
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _read_requirement_arg(args) -> str:
|
|
158
|
+
"""Return the raw requirement text from --requirement or --requirement-file.
|
|
159
|
+
|
|
160
|
+
--requirement-file reads the file's contents (deterministic input ingestion).
|
|
161
|
+
A missing file fails loudly with EXIT_CLI_ERR rather than silently falling back.
|
|
162
|
+
"""
|
|
163
|
+
req_file = getattr(args, "requirement_file", None)
|
|
164
|
+
if req_file:
|
|
165
|
+
path = Path(req_file)
|
|
166
|
+
if not path.is_file():
|
|
167
|
+
print_and_exit(
|
|
168
|
+
format_error(f"Requirement file not found: {req_file}", exit_code=EXIT_CLI_ERR),
|
|
169
|
+
EXIT_CLI_ERR,
|
|
170
|
+
)
|
|
171
|
+
return path.read_text(encoding="utf-8")
|
|
172
|
+
return args.requirement or ""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_run_start(args):
|
|
176
|
+
work_id = _validate_work_id(args.work_id)
|
|
177
|
+
requirement = _read_requirement_arg(args)
|
|
178
|
+
if not requirement.strip():
|
|
179
|
+
print_and_exit(
|
|
180
|
+
format_error("Requirement must not be blank", exit_code=EXIT_CLI_ERR),
|
|
181
|
+
EXIT_CLI_ERR,
|
|
182
|
+
)
|
|
183
|
+
run_dir = _get_run_dir(work_id, args.base_path)
|
|
184
|
+
mgr = RunStateManager(run_dir)
|
|
185
|
+
|
|
186
|
+
run_dir_occupied = False
|
|
187
|
+
if run_dir.exists():
|
|
188
|
+
run_dir_occupied = (
|
|
189
|
+
True
|
|
190
|
+
if run_dir.is_symlink() or not run_dir.is_dir()
|
|
191
|
+
else any(run_dir.iterdir())
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if run_dir_occupied and not getattr(args, "overwrite", False):
|
|
195
|
+
print_and_exit(
|
|
196
|
+
format_error(
|
|
197
|
+
f"Run {work_id!r} already exists. Use --overwrite to reset or choose a different --work-id.",
|
|
198
|
+
exit_code=EXIT_CONFLICT,
|
|
199
|
+
),
|
|
200
|
+
EXIT_CONFLICT,
|
|
201
|
+
)
|
|
202
|
+
if run_dir_occupied and getattr(args, "overwrite", False):
|
|
203
|
+
if run_dir.is_symlink() or run_dir.is_file():
|
|
204
|
+
run_dir.unlink()
|
|
205
|
+
else:
|
|
206
|
+
shutil.rmtree(run_dir)
|
|
207
|
+
|
|
208
|
+
# Create run record
|
|
209
|
+
record = create_run_record(work_id)
|
|
210
|
+
|
|
211
|
+
# Write raw requirement artifact
|
|
212
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
write_artifact(run_dir, Stage.RAW_REQUIREMENT, requirement, version=1, status="draft")
|
|
214
|
+
|
|
215
|
+
# Tier estimation
|
|
216
|
+
repo_path = Path(args.repo_path) if args.repo_path else None
|
|
217
|
+
tier_estimate, evidence = estimate_tier(requirement, repo_path=repo_path)
|
|
218
|
+
record.tier_estimate = tier_estimate
|
|
219
|
+
|
|
220
|
+
# Update active artifacts in record
|
|
221
|
+
artifact_file = STAGE_ARTIFACT_MAP[Stage.RAW_REQUIREMENT]
|
|
222
|
+
upsert_active_artifact(record, Stage.RAW_REQUIREMENT, artifact_file, 1, "draft")
|
|
223
|
+
|
|
224
|
+
# Write intake brief (01-intake-brief.md) with evidence block
|
|
225
|
+
brief_lines = [
|
|
226
|
+
"# Intake Brief",
|
|
227
|
+
"",
|
|
228
|
+
f"work_id: {work_id}",
|
|
229
|
+
f"requirement: {requirement}",
|
|
230
|
+
"",
|
|
231
|
+
"## Tier Estimate",
|
|
232
|
+
f"base: {tier_estimate.base.value}",
|
|
233
|
+
f"modifiers: {', '.join(sorted(m.value for m in tier_estimate.modifiers))}",
|
|
234
|
+
"",
|
|
235
|
+
"## Evidence Block",
|
|
236
|
+
f"keywords_hit: {evidence.keywords_hit or []}",
|
|
237
|
+
f"repo_baseline_summary: {evidence.repo_baseline_summary}",
|
|
238
|
+
f"linked_context: {evidence.linked_context}",
|
|
239
|
+
f"scope_signals: {evidence.scope_signals or []}",
|
|
240
|
+
f"escalation_candidates: {evidence.escalation_candidates or []}",
|
|
241
|
+
f"confirm_status: {evidence.confirm_status}",
|
|
242
|
+
]
|
|
243
|
+
(run_dir / "01-intake-brief.md").write_text("\n".join(brief_lines), encoding="utf-8")
|
|
244
|
+
|
|
245
|
+
# Save run record
|
|
246
|
+
mgr.save(record)
|
|
247
|
+
|
|
248
|
+
print_and_exit(
|
|
249
|
+
format_success(
|
|
250
|
+
{
|
|
251
|
+
"work_id": str(work_id),
|
|
252
|
+
"tier_floor": tier_estimate.base.value,
|
|
253
|
+
"modifiers": sorted(m.value for m in tier_estimate.modifiers),
|
|
254
|
+
"run_dir": str(run_dir),
|
|
255
|
+
},
|
|
256
|
+
message=f"Run started: {work_id}",
|
|
257
|
+
),
|
|
258
|
+
EXIT_OK,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _cmd_run_resume(args):
|
|
263
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
264
|
+
rc = record.resume_context
|
|
265
|
+
print_and_exit(
|
|
266
|
+
format_success(
|
|
267
|
+
{
|
|
268
|
+
"work_id": record.work_id,
|
|
269
|
+
"status": record.status.value,
|
|
270
|
+
"current_stage": record.current_stage.value,
|
|
271
|
+
"last_operation": rc.last_completed_operation,
|
|
272
|
+
"next_operation": rc.next_allowed_operation,
|
|
273
|
+
"active_item": rc.active_item,
|
|
274
|
+
"resume_reason": rc.resume_reason,
|
|
275
|
+
},
|
|
276
|
+
message="Resume context",
|
|
277
|
+
),
|
|
278
|
+
EXIT_OK,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _cmd_run_close(args):
|
|
283
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
284
|
+
if record.status != RunStatus.CHECKPOINT_APPROVED:
|
|
285
|
+
print_and_exit(
|
|
286
|
+
format_error(
|
|
287
|
+
f"Cannot close run in status {record.status.value!r}; "
|
|
288
|
+
"must be in checkpoint_approved",
|
|
289
|
+
exit_code=EXIT_CONFLICT,
|
|
290
|
+
),
|
|
291
|
+
EXIT_CONFLICT,
|
|
292
|
+
)
|
|
293
|
+
if record.current_stage != Stage.PLAN:
|
|
294
|
+
print_and_exit(
|
|
295
|
+
format_error(
|
|
296
|
+
f"Cannot close run at stage {record.current_stage.value!r}; "
|
|
297
|
+
"current stage must be plan",
|
|
298
|
+
exit_code=EXIT_CONFLICT,
|
|
299
|
+
),
|
|
300
|
+
EXIT_CONFLICT,
|
|
301
|
+
)
|
|
302
|
+
aa = get_active_artifact(record, Stage.PLAN)
|
|
303
|
+
if aa is None or aa.status != "approved":
|
|
304
|
+
print_and_exit(
|
|
305
|
+
format_error("PLAN active artifact must be approved before run-close", exit_code=EXIT_CONFLICT),
|
|
306
|
+
EXIT_CONFLICT,
|
|
307
|
+
)
|
|
308
|
+
disk_version = get_artifact_version(run_dir, Stage.PLAN)
|
|
309
|
+
if disk_version != aa.version:
|
|
310
|
+
print_and_exit(
|
|
311
|
+
format_error(
|
|
312
|
+
f"Active artifact version v{aa.version} does not match on-disk v{disk_version}",
|
|
313
|
+
exit_code=EXIT_CONFLICT,
|
|
314
|
+
),
|
|
315
|
+
EXIT_CONFLICT,
|
|
316
|
+
)
|
|
317
|
+
has_matching_plan_checkpoint = any(
|
|
318
|
+
cp.stage == Stage.PLAN
|
|
319
|
+
and cp.artifact == aa.artifact
|
|
320
|
+
and cp.version == aa.version
|
|
321
|
+
for cp in record.approved_checkpoints
|
|
322
|
+
)
|
|
323
|
+
if not has_matching_plan_checkpoint:
|
|
324
|
+
print_and_exit(
|
|
325
|
+
format_error(
|
|
326
|
+
f"No approved PLAN checkpoint matching {aa.artifact} v{aa.version}",
|
|
327
|
+
exit_code=EXIT_CONFLICT,
|
|
328
|
+
),
|
|
329
|
+
EXIT_CONFLICT,
|
|
330
|
+
)
|
|
331
|
+
open_routes = [r.route_id for r in record.open_routes if r.status == "open"]
|
|
332
|
+
if open_routes:
|
|
333
|
+
print_and_exit(
|
|
334
|
+
format_error(
|
|
335
|
+
"Cannot close run while routes remain open",
|
|
336
|
+
details=open_routes,
|
|
337
|
+
exit_code=EXIT_CONFLICT,
|
|
338
|
+
),
|
|
339
|
+
EXIT_CONFLICT,
|
|
340
|
+
)
|
|
341
|
+
try:
|
|
342
|
+
record = update_run_status(record, RunStatus.CLOSED_AT_PLAN_CHECKPOINT)
|
|
343
|
+
except ValueError as e:
|
|
344
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
345
|
+
record.current_stage = Stage.CLOSED
|
|
346
|
+
update_resume_context(record, last_operation="close_at_plan_checkpoint")
|
|
347
|
+
mgr.save(record)
|
|
348
|
+
print_and_exit(
|
|
349
|
+
format_success({"work_id": str(record.work_id), "status": record.status.value}, message="Run closed"),
|
|
350
|
+
EXIT_OK,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _cmd_run_reopen(args):
|
|
355
|
+
source_id = str(_validate_work_id(args.from_id))
|
|
356
|
+
target_stage = _parse_reopen_stage(args.stage)
|
|
357
|
+
|
|
358
|
+
# Load source run
|
|
359
|
+
source_dir = _get_run_dir(source_id, args.base_path)
|
|
360
|
+
source_mgr = RunStateManager(source_dir)
|
|
361
|
+
try:
|
|
362
|
+
source_record = source_mgr.load()
|
|
363
|
+
except FileNotFoundError:
|
|
364
|
+
print_and_exit(
|
|
365
|
+
format_error(f"Source run not found: {source_id}", exit_code=EXIT_NOT_FOUND),
|
|
366
|
+
EXIT_NOT_FOUND,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
if source_record.status != RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
|
|
370
|
+
print_and_exit(
|
|
371
|
+
format_error(
|
|
372
|
+
f"Source run {source_id!r} is not CLOSED_AT_PLAN_CHECKPOINT "
|
|
373
|
+
f"(status={source_record.status.value!r})",
|
|
374
|
+
exit_code=EXIT_CONFLICT,
|
|
375
|
+
),
|
|
376
|
+
EXIT_CONFLICT,
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
# Determine new work_id suffix: -r1, -r2, etc.
|
|
380
|
+
base = source_id
|
|
381
|
+
suffix = 1
|
|
382
|
+
# Remove existing -rN suffix from base if present
|
|
383
|
+
m = re.match(r"^(WF-\d{8}-.+?)-r(\d+)$", source_id)
|
|
384
|
+
if m:
|
|
385
|
+
base = m.group(1)
|
|
386
|
+
suffix = int(m.group(2)) + 1
|
|
387
|
+
|
|
388
|
+
new_work_id = None
|
|
389
|
+
new_work_id_str = ""
|
|
390
|
+
new_run_dir = None
|
|
391
|
+
for candidate_suffix in range(suffix, 100):
|
|
392
|
+
candidate = f"{base}-r{candidate_suffix}"
|
|
393
|
+
try:
|
|
394
|
+
candidate_work_id = WorkId(candidate)
|
|
395
|
+
except ValueError as e:
|
|
396
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
397
|
+
candidate_dir = _get_run_dir(candidate, args.base_path)
|
|
398
|
+
if not candidate_dir.exists():
|
|
399
|
+
new_work_id = candidate_work_id
|
|
400
|
+
new_work_id_str = candidate
|
|
401
|
+
new_run_dir = candidate_dir
|
|
402
|
+
break
|
|
403
|
+
if new_work_id is None or new_run_dir is None:
|
|
404
|
+
print_and_exit(
|
|
405
|
+
format_error(
|
|
406
|
+
f"Could not find an unused reopen work ID for {source_id!r}",
|
|
407
|
+
exit_code=EXIT_CONFLICT,
|
|
408
|
+
),
|
|
409
|
+
EXIT_CONFLICT,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
new_run_dir.mkdir(parents=True, exist_ok=False)
|
|
413
|
+
|
|
414
|
+
# Copy artifacts up to (not including) target_stage
|
|
415
|
+
import shutil
|
|
416
|
+
for stage in STAGE_ORDER:
|
|
417
|
+
if stage == target_stage:
|
|
418
|
+
break
|
|
419
|
+
artifact_file = STAGE_ARTIFACT_MAP.get(stage)
|
|
420
|
+
if artifact_file:
|
|
421
|
+
src_path = source_dir / artifact_file
|
|
422
|
+
if src_path.exists():
|
|
423
|
+
shutil.copy2(src_path, new_run_dir / artifact_file)
|
|
424
|
+
|
|
425
|
+
# Create new run record
|
|
426
|
+
new_record = create_run_record(new_work_id)
|
|
427
|
+
new_record.tier_estimate = source_record.tier_estimate
|
|
428
|
+
new_record.tier_locked = source_record.tier_locked
|
|
429
|
+
new_record.reopen_lineage = f"reopened_from: {source_id}@plan_checkpoint reason: {args.reason}"
|
|
430
|
+
new_record.current_stage = target_stage
|
|
431
|
+
|
|
432
|
+
# Copy approved checkpoints before target stage
|
|
433
|
+
for cp in source_record.approved_checkpoints:
|
|
434
|
+
cp_stage_idx = STAGE_ORDER.index(cp.stage) if cp.stage in STAGE_ORDER else -1
|
|
435
|
+
target_idx = STAGE_ORDER.index(target_stage) if target_stage in STAGE_ORDER else 999
|
|
436
|
+
if cp_stage_idx < target_idx:
|
|
437
|
+
new_record.approved_checkpoints.append(cp)
|
|
438
|
+
|
|
439
|
+
new_mgr = RunStateManager(new_run_dir)
|
|
440
|
+
new_mgr.save(new_record)
|
|
441
|
+
|
|
442
|
+
print_and_exit(
|
|
443
|
+
format_success(
|
|
444
|
+
{
|
|
445
|
+
"new_work_id": str(new_work_id),
|
|
446
|
+
"source_work_id": source_id,
|
|
447
|
+
"target_stage": target_stage.value,
|
|
448
|
+
"run_dir": str(new_run_dir),
|
|
449
|
+
},
|
|
450
|
+
message=f"Run reopened as {new_work_id}",
|
|
451
|
+
),
|
|
452
|
+
EXIT_OK,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _cmd_gap_resolve(args):
|
|
457
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
458
|
+
route = next(
|
|
459
|
+
(r for r in record.open_routes if r.route_id == args.route_id and r.status == "open"),
|
|
460
|
+
None,
|
|
461
|
+
)
|
|
462
|
+
if route is None:
|
|
463
|
+
print_and_exit(
|
|
464
|
+
format_error(f"No open route with id {args.route_id!r}", exit_code=EXIT_NOT_FOUND),
|
|
465
|
+
EXIT_NOT_FOUND,
|
|
466
|
+
)
|
|
467
|
+
owner = route.owner_stage
|
|
468
|
+
aa = get_active_artifact(record, owner)
|
|
469
|
+
checkpoint_ready_statuses = {
|
|
470
|
+
RunStatus.READY_FOR_CHECKPOINT_REVIEW,
|
|
471
|
+
RunStatus.CHECKPOINT_REVIEW,
|
|
472
|
+
}
|
|
473
|
+
if aa is None or aa.status != "ready" or record.status not in checkpoint_ready_statuses:
|
|
474
|
+
print_and_exit(
|
|
475
|
+
format_error(
|
|
476
|
+
f"Owner stage {owner.value!r} must pass gate-quality before resolving the route",
|
|
477
|
+
exit_code=EXIT_CONFLICT,
|
|
478
|
+
),
|
|
479
|
+
EXIT_CONFLICT,
|
|
480
|
+
)
|
|
481
|
+
close_route(record, args.route_id)
|
|
482
|
+
next_operation = (
|
|
483
|
+
"checkpoint_decide"
|
|
484
|
+
if record.status == RunStatus.CHECKPOINT_REVIEW
|
|
485
|
+
else "checkpoint_review"
|
|
486
|
+
)
|
|
487
|
+
update_resume_context(
|
|
488
|
+
record, last_operation=f"gap_resolve_{args.route_id}",
|
|
489
|
+
next_operation=next_operation, active_item=owner.value,
|
|
490
|
+
reason=f"owner repaired for {args.route_id}; resume checkpoint approval",
|
|
491
|
+
)
|
|
492
|
+
mgr.save(record)
|
|
493
|
+
print_and_exit(
|
|
494
|
+
format_success(
|
|
495
|
+
{"route_id": args.route_id, "status": "repaired", "owner_stage": owner.value,
|
|
496
|
+
"resume_from": owner.value},
|
|
497
|
+
message=f"Route {args.route_id} resolved; continue owner checkpoint approval",
|
|
498
|
+
),
|
|
499
|
+
EXIT_OK,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _cmd_gap_open(args):
|
|
504
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
505
|
+
owner = _parse_stage(args.owner_stage)
|
|
506
|
+
|
|
507
|
+
if record.status == RunStatus.CLOSED_AT_PLAN_CHECKPOINT:
|
|
508
|
+
print_and_exit(
|
|
509
|
+
format_error("Cannot gap-open a closed run; use run-reopen", exit_code=EXIT_CONFLICT),
|
|
510
|
+
EXIT_CONFLICT,
|
|
511
|
+
)
|
|
512
|
+
if not args.required_action or not args.required_action.strip():
|
|
513
|
+
print_and_exit(
|
|
514
|
+
format_error("--required-action must be non-empty", exit_code=EXIT_CLI_ERR),
|
|
515
|
+
EXIT_CLI_ERR,
|
|
516
|
+
)
|
|
517
|
+
if "\n" in args.required_action or "\r" in args.required_action:
|
|
518
|
+
print_and_exit(
|
|
519
|
+
format_error("--required-action must be a single line", exit_code=EXIT_CLI_ERR),
|
|
520
|
+
EXIT_CLI_ERR,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
cur = record.current_stage
|
|
524
|
+
if owner not in STAGE_ORDER or cur not in STAGE_ORDER:
|
|
525
|
+
print_and_exit(
|
|
526
|
+
format_error(f"Stage {owner.value!r} not in stage order", exit_code=EXIT_CONFLICT),
|
|
527
|
+
EXIT_CONFLICT,
|
|
528
|
+
)
|
|
529
|
+
if STAGE_ORDER.index(owner) >= STAGE_ORDER.index(cur):
|
|
530
|
+
print_and_exit(
|
|
531
|
+
format_error(
|
|
532
|
+
f"owner-stage {owner.value!r} must be strictly upstream of current stage {cur.value!r}",
|
|
533
|
+
exit_code=EXIT_CONFLICT,
|
|
534
|
+
),
|
|
535
|
+
EXIT_CONFLICT,
|
|
536
|
+
)
|
|
537
|
+
if not is_transition_allowed(record.status, RunStatus.UPSTREAM_GAP_ROUTING):
|
|
538
|
+
print_and_exit(
|
|
539
|
+
format_error(
|
|
540
|
+
f"Cannot route a gap from status {record.status.value!r}; resolve the current step first",
|
|
541
|
+
exit_code=EXIT_CONFLICT,
|
|
542
|
+
),
|
|
543
|
+
EXIT_CONFLICT,
|
|
544
|
+
)
|
|
545
|
+
open_route_ids = [r.route_id for r in record.open_routes if r.status == "open"]
|
|
546
|
+
if open_route_ids:
|
|
547
|
+
print_and_exit(
|
|
548
|
+
format_error(
|
|
549
|
+
"Cannot gap-open while another route is open; resolve it before opening a new route",
|
|
550
|
+
details=open_route_ids,
|
|
551
|
+
exit_code=EXIT_CONFLICT,
|
|
552
|
+
),
|
|
553
|
+
EXIT_CONFLICT,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
run_md_path = run_dir / "run.md"
|
|
557
|
+
run_md_before = run_md_path.read_text(encoding="utf-8")
|
|
558
|
+
affected = []
|
|
559
|
+
for d in STAGE_ORDER[STAGE_ORDER.index(owner): STAGE_ORDER.index(cur) + 1]:
|
|
560
|
+
aa = get_active_artifact(record, d)
|
|
561
|
+
cp = next(
|
|
562
|
+
(checkpoint for checkpoint in record.approved_checkpoints if checkpoint.stage == d),
|
|
563
|
+
None,
|
|
564
|
+
)
|
|
565
|
+
artifact_file = STAGE_ARTIFACT_MAP[d]
|
|
566
|
+
artifact_path = run_dir / artifact_file
|
|
567
|
+
if aa is None and cp is None and not artifact_path.exists():
|
|
568
|
+
continue
|
|
569
|
+
version = aa.version if aa is not None else (
|
|
570
|
+
cp.version if cp is not None else get_artifact_version(run_dir, d)
|
|
571
|
+
)
|
|
572
|
+
if not artifact_path.exists():
|
|
573
|
+
print_and_exit(
|
|
574
|
+
format_error(
|
|
575
|
+
f"Cannot gap-open: downstream artifact file missing for {d.value!r}: {artifact_file}",
|
|
576
|
+
exit_code=EXIT_NOT_FOUND,
|
|
577
|
+
),
|
|
578
|
+
EXIT_NOT_FOUND,
|
|
579
|
+
)
|
|
580
|
+
affected.append(
|
|
581
|
+
(
|
|
582
|
+
d,
|
|
583
|
+
version,
|
|
584
|
+
artifact_file,
|
|
585
|
+
artifact_path,
|
|
586
|
+
artifact_path.read_text(encoding="utf-8"),
|
|
587
|
+
)
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
route_id = f"R-{len(record.open_routes) + 1}"
|
|
591
|
+
am = ArtifactManager(run_dir)
|
|
592
|
+
reason = f"upstream gap at {owner.value}"
|
|
593
|
+
staled = []
|
|
594
|
+
try:
|
|
595
|
+
add_open_route(record, route_id, from_stage=cur, owner_stage=owner, required_action=args.required_action)
|
|
596
|
+
for d, version, artifact_file, _artifact_path, _artifact_before in affected:
|
|
597
|
+
record_stale_artifact(
|
|
598
|
+
record, artifact=artifact_file, reason=reason,
|
|
599
|
+
replaced_by="(pending re-derivation)", required_action=route_id,
|
|
600
|
+
)
|
|
601
|
+
am.mark_stale(d, reason, "(pending re-derivation)")
|
|
602
|
+
upsert_active_artifact(record, d, artifact_file, version, "stale")
|
|
603
|
+
record.approved_checkpoints = [cp for cp in record.approved_checkpoints if cp.stage != d]
|
|
604
|
+
staled.append(d.value)
|
|
605
|
+
|
|
606
|
+
record.current_stage = owner
|
|
607
|
+
record = update_run_status(record, RunStatus.UPSTREAM_GAP_ROUTING)
|
|
608
|
+
record = update_run_status(record, RunStatus.ACTIVE_STAGE_DRAFT)
|
|
609
|
+
update_resume_context(
|
|
610
|
+
record, last_operation=f"gap_open_{route_id}",
|
|
611
|
+
next_operation="stage_update", active_item=owner.value,
|
|
612
|
+
reason=f"repair owner for {route_id}",
|
|
613
|
+
)
|
|
614
|
+
mgr.save(record)
|
|
615
|
+
except Exception as e:
|
|
616
|
+
run_md_path.write_text(run_md_before, encoding="utf-8")
|
|
617
|
+
for _d, _aa, _artifact_file, artifact_path, artifact_before in reversed(affected):
|
|
618
|
+
artifact_path.write_text(artifact_before, encoding="utf-8")
|
|
619
|
+
print_and_exit(
|
|
620
|
+
format_error(
|
|
621
|
+
f"Cannot gap-open: failed to mark downstream stale atomically ({e})",
|
|
622
|
+
exit_code=EXIT_CONFLICT,
|
|
623
|
+
),
|
|
624
|
+
EXIT_CONFLICT,
|
|
625
|
+
)
|
|
626
|
+
print_and_exit(
|
|
627
|
+
format_success(
|
|
628
|
+
{"route_id": route_id, "owner_stage": owner.value, "from_stage": cur.value, "staled_stages": staled},
|
|
629
|
+
message=f"Gap routed to {owner.value}; repair it, then gap-resolve --route-id {route_id}",
|
|
630
|
+
),
|
|
631
|
+
EXIT_OK,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
# ---------------------------------------------------------------------------
|
|
636
|
+
# Tier commands
|
|
637
|
+
# ---------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _cmd_tier_estimate(args):
|
|
641
|
+
repo_path = Path(args.repo_path) if args.repo_path else None
|
|
642
|
+
tier, evidence = estimate_tier(args.text, repo_path=repo_path)
|
|
643
|
+
print_and_exit(
|
|
644
|
+
format_success(
|
|
645
|
+
{
|
|
646
|
+
"tier_base": tier.base.value,
|
|
647
|
+
"modifiers": sorted(m.value for m in tier.modifiers),
|
|
648
|
+
"keywords_hit": evidence.keywords_hit or [],
|
|
649
|
+
"repo_summary": evidence.repo_baseline_summary,
|
|
650
|
+
"confirm_status": evidence.confirm_status,
|
|
651
|
+
},
|
|
652
|
+
message=f"Tier estimate: {tier.base.value}",
|
|
653
|
+
),
|
|
654
|
+
EXIT_OK,
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _cmd_tier_lock(args):
|
|
659
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
660
|
+
|
|
661
|
+
if not is_command_allowed(record.status, "CMD-TIER-LOCK"):
|
|
662
|
+
print_and_exit(
|
|
663
|
+
format_error(
|
|
664
|
+
f"Cannot tier-lock in status {record.status.value!r}; "
|
|
665
|
+
"must be active_stage_draft",
|
|
666
|
+
exit_code=EXIT_CONFLICT,
|
|
667
|
+
),
|
|
668
|
+
EXIT_CONFLICT,
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
if not args.confirm:
|
|
672
|
+
print_and_exit(
|
|
673
|
+
format_error(
|
|
674
|
+
"Locking tier requires --confirm",
|
|
675
|
+
exit_code=EXIT_CLI_ERR,
|
|
676
|
+
),
|
|
677
|
+
EXIT_CLI_ERR,
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
base = _parse_tier_base(args.base)
|
|
681
|
+
modifiers = _parse_modifiers(args.modifiers)
|
|
682
|
+
|
|
683
|
+
if record.tier_estimate is None:
|
|
684
|
+
# If no estimate, create a minimal one
|
|
685
|
+
from tools.workflow_cli.models import TierEstimate
|
|
686
|
+
record.tier_estimate = TierEstimate(base=TierBase.LIGHT, modifiers=frozenset())
|
|
687
|
+
|
|
688
|
+
if args.override_floor:
|
|
689
|
+
if not args.confirm:
|
|
690
|
+
print_and_exit(
|
|
691
|
+
format_error(
|
|
692
|
+
"Overriding floor requires --confirm flag as well",
|
|
693
|
+
exit_code=EXIT_CLI_ERR,
|
|
694
|
+
),
|
|
695
|
+
EXIT_CLI_ERR,
|
|
696
|
+
)
|
|
697
|
+
from tools.workflow_cli.models import TierEstimate
|
|
698
|
+
record.tier_locked = TierEstimate(base=base, modifiers=modifiers)
|
|
699
|
+
else:
|
|
700
|
+
try:
|
|
701
|
+
record.tier_locked = record.tier_estimate.lock(base, modifiers)
|
|
702
|
+
except ValueError as e:
|
|
703
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CLI_ERR), EXIT_CLI_ERR)
|
|
704
|
+
|
|
705
|
+
mgr.save(record)
|
|
706
|
+
print_and_exit(
|
|
707
|
+
format_success(
|
|
708
|
+
{
|
|
709
|
+
"work_id": str(record.work_id),
|
|
710
|
+
"tier_base": record.tier_locked.base.value,
|
|
711
|
+
"modifiers": sorted(m.value for m in record.tier_locked.modifiers),
|
|
712
|
+
},
|
|
713
|
+
message="Tier locked",
|
|
714
|
+
),
|
|
715
|
+
EXIT_OK,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _cmd_tier_escalate(args):
|
|
720
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
modifier = TierModifier(args.modifier)
|
|
724
|
+
except ValueError:
|
|
725
|
+
valid = [m.value for m in TierModifier]
|
|
726
|
+
print_and_exit(
|
|
727
|
+
format_error(f"Unknown modifier {args.modifier!r}. Valid: {valid}", exit_code=EXIT_CLI_ERR),
|
|
728
|
+
EXIT_CLI_ERR,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
if record.tier_locked is None:
|
|
732
|
+
print_and_exit(
|
|
733
|
+
format_error("Tier is not locked; run tier-lock first", exit_code=EXIT_CLI_ERR),
|
|
734
|
+
EXIT_CLI_ERR,
|
|
735
|
+
)
|
|
736
|
+
if record.status == RunStatus.CHECKPOINT_APPROVED:
|
|
737
|
+
print_and_exit(
|
|
738
|
+
format_error(
|
|
739
|
+
"Cannot escalate tier after checkpoint approval; advance or reopen the run first",
|
|
740
|
+
exit_code=EXIT_CONFLICT,
|
|
741
|
+
),
|
|
742
|
+
EXIT_CONFLICT,
|
|
743
|
+
)
|
|
744
|
+
if not is_command_allowed(record.status, "CMD-TIER-ESCALATE"):
|
|
745
|
+
print_and_exit(
|
|
746
|
+
format_error(
|
|
747
|
+
f"Cannot tier-escalate in status {record.status.value!r}",
|
|
748
|
+
exit_code=EXIT_CONFLICT,
|
|
749
|
+
),
|
|
750
|
+
EXIT_CONFLICT,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
previous_tier = record.tier_locked
|
|
754
|
+
record.tier_locked = record.tier_locked.escalate(modifier)
|
|
755
|
+
|
|
756
|
+
plan_gate_passed_statuses = {
|
|
757
|
+
RunStatus.READY_FOR_CHECKPOINT_REVIEW,
|
|
758
|
+
RunStatus.CHECKPOINT_REVIEW,
|
|
759
|
+
}
|
|
760
|
+
standard_plan_gate_became_applicable = (
|
|
761
|
+
record.current_stage == Stage.PLAN
|
|
762
|
+
and previous_tier.base != TierBase.STANDARD
|
|
763
|
+
and record.tier_locked.base == TierBase.STANDARD
|
|
764
|
+
and record.status in plan_gate_passed_statuses
|
|
765
|
+
)
|
|
766
|
+
if standard_plan_gate_became_applicable:
|
|
767
|
+
record = update_run_status(record, RunStatus.ACTIVE_STAGE_DRAFT)
|
|
768
|
+
update_resume_context(
|
|
769
|
+
record,
|
|
770
|
+
last_operation=f"tier_escalated_{modifier.value}",
|
|
771
|
+
next_operation="gate_quality",
|
|
772
|
+
active_item=Stage.PLAN.value,
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
# Revoke affected bundle authorizations that cover high-tier stages
|
|
776
|
+
from tools.workflow_cli.gates import _FORCED_REVIEW_MODIFIERS
|
|
777
|
+
high_modifiers = {TierModifier.MIGRATION, TierModifier.SAFETY, TierModifier.CROSS_PROJECT}
|
|
778
|
+
from datetime import datetime, timezone
|
|
779
|
+
if modifier in high_modifiers:
|
|
780
|
+
revoke_ts = datetime.now(timezone.utc).isoformat()
|
|
781
|
+
from tools.workflow_cli.models import STAGE_REQUIRED_UPSTREAM_CHECKPOINTS
|
|
782
|
+
affected_stages = {Stage.DESIGN, Stage.SPEC, Stage.PLAN}
|
|
783
|
+
for ba in record.bundle_authorizations:
|
|
784
|
+
if ba.stages & affected_stages and ba.revoked_at is None:
|
|
785
|
+
ba.revoked_at = revoke_ts
|
|
786
|
+
|
|
787
|
+
mgr.save(record)
|
|
788
|
+
print_and_exit(
|
|
789
|
+
format_success(
|
|
790
|
+
{
|
|
791
|
+
"work_id": str(record.work_id),
|
|
792
|
+
"tier_base": record.tier_locked.base.value,
|
|
793
|
+
"modifiers": sorted(m.value for m in record.tier_locked.modifiers),
|
|
794
|
+
"added_modifier": modifier.value,
|
|
795
|
+
},
|
|
796
|
+
message=f"Tier escalated with modifier: {modifier.value}",
|
|
797
|
+
),
|
|
798
|
+
EXIT_OK,
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _cmd_tier_status(args):
|
|
803
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
804
|
+
|
|
805
|
+
est = record.tier_estimate
|
|
806
|
+
locked = record.tier_locked
|
|
807
|
+
|
|
808
|
+
data = {
|
|
809
|
+
"work_id": str(record.work_id),
|
|
810
|
+
"tier_estimate": (
|
|
811
|
+
{"base": est.base.value, "modifiers": sorted(m.value for m in est.modifiers)}
|
|
812
|
+
if est else "none"
|
|
813
|
+
),
|
|
814
|
+
"tier_locked": (
|
|
815
|
+
{"base": locked.base.value, "modifiers": sorted(m.value for m in locked.modifiers)}
|
|
816
|
+
if locked else "unlocked"
|
|
817
|
+
),
|
|
818
|
+
}
|
|
819
|
+
print_and_exit(format_success(data, message="Tier status"), EXIT_OK)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
# ---------------------------------------------------------------------------
|
|
823
|
+
# Gate commands
|
|
824
|
+
# ---------------------------------------------------------------------------
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _cmd_gate_entry(args):
|
|
828
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
829
|
+
stage = _parse_stage(args.stage)
|
|
830
|
+
|
|
831
|
+
# Precondition: if in NEXT_STAGE or ENTRY_GATE_FAILED, stage must match current_stage
|
|
832
|
+
if record.status in (RunStatus.NEXT_STAGE, RunStatus.ENTRY_GATE_FAILED) and stage != record.current_stage:
|
|
833
|
+
print_and_exit(
|
|
834
|
+
format_error(
|
|
835
|
+
f"Cannot run entry gate for {stage.value!r}; current stage is {record.current_stage.value!r}",
|
|
836
|
+
exit_code=EXIT_CONFLICT,
|
|
837
|
+
),
|
|
838
|
+
EXIT_CONFLICT,
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
result = check_entry_gate(
|
|
842
|
+
run_dir,
|
|
843
|
+
stage,
|
|
844
|
+
record.approved_checkpoints,
|
|
845
|
+
record.bundle_authorizations,
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
# Persist state if in NEXT_STAGE or ENTRY_GATE_FAILED
|
|
849
|
+
if record.status in (RunStatus.NEXT_STAGE, RunStatus.ENTRY_GATE_FAILED):
|
|
850
|
+
target = RunStatus.ACTIVE_STAGE_DRAFT if result.passed else RunStatus.ENTRY_GATE_FAILED
|
|
851
|
+
if target != record.status:
|
|
852
|
+
record = update_run_status(record, target)
|
|
853
|
+
update_resume_context(
|
|
854
|
+
record,
|
|
855
|
+
last_operation=f"entry_gate_{'passed' if result.passed else 'failed'}_{stage.value}",
|
|
856
|
+
next_operation="produce_stage_artifact" if result.passed else "repair_upstream_checkpoint",
|
|
857
|
+
active_item=stage.value,
|
|
858
|
+
)
|
|
859
|
+
mgr.save(record)
|
|
860
|
+
|
|
861
|
+
output = format_gate_result(result, gate_type="entry-gate")
|
|
862
|
+
exit_code = EXIT_OK if result.passed else result.exit_code
|
|
863
|
+
print_and_exit(output, exit_code)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _cmd_gate_quality(args):
|
|
867
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
868
|
+
stage = _parse_stage(args.stage)
|
|
869
|
+
|
|
870
|
+
# Precondition: only an active draft can be quality-gated. Re-running after a
|
|
871
|
+
# pass (ready_for_checkpoint_review) or failure (quality_gate_failed) would
|
|
872
|
+
# otherwise attempt an illegal self-transition; return a clean conflict instead.
|
|
873
|
+
if record.status != RunStatus.ACTIVE_STAGE_DRAFT:
|
|
874
|
+
print_and_exit(
|
|
875
|
+
format_error(
|
|
876
|
+
f"Cannot run quality gate in status {record.status.value!r}; "
|
|
877
|
+
"must be active_stage_draft",
|
|
878
|
+
exit_code=EXIT_CONFLICT,
|
|
879
|
+
),
|
|
880
|
+
EXIT_CONFLICT,
|
|
881
|
+
)
|
|
882
|
+
|
|
883
|
+
# Precondition: stage must be current and artifact must be ready
|
|
884
|
+
if stage != record.current_stage:
|
|
885
|
+
print_and_exit(
|
|
886
|
+
format_error(
|
|
887
|
+
f"Stage {stage.value!r} is not the current stage {record.current_stage.value!r}",
|
|
888
|
+
exit_code=EXIT_CONFLICT,
|
|
889
|
+
),
|
|
890
|
+
EXIT_CONFLICT,
|
|
891
|
+
)
|
|
892
|
+
aa = get_active_artifact(record, stage)
|
|
893
|
+
if aa is None or aa.status != "ready":
|
|
894
|
+
print_and_exit(
|
|
895
|
+
format_error(
|
|
896
|
+
f"Stage {stage.value!r} artifact is not ready; run stage-ready first",
|
|
897
|
+
exit_code=EXIT_CONFLICT,
|
|
898
|
+
),
|
|
899
|
+
EXIT_CONFLICT,
|
|
900
|
+
)
|
|
901
|
+
version = get_artifact_version(run_dir, stage)
|
|
902
|
+
if version != aa.version:
|
|
903
|
+
print_and_exit(
|
|
904
|
+
format_error(
|
|
905
|
+
f"Active artifact version v{aa.version} does not match on-disk v{version}",
|
|
906
|
+
exit_code=EXIT_CONFLICT,
|
|
907
|
+
),
|
|
908
|
+
EXIT_CONFLICT,
|
|
909
|
+
)
|
|
910
|
+
|
|
911
|
+
# Read artifact content
|
|
912
|
+
try:
|
|
913
|
+
content = read_artifact(run_dir, stage)
|
|
914
|
+
except FileNotFoundError:
|
|
915
|
+
print_and_exit(
|
|
916
|
+
format_error(
|
|
917
|
+
f"Artifact not found for stage {stage.value!r}",
|
|
918
|
+
exit_code=EXIT_NOT_FOUND,
|
|
919
|
+
),
|
|
920
|
+
EXIT_NOT_FOUND,
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
# Check quality gate
|
|
924
|
+
result = check_quality_gate(
|
|
925
|
+
run_dir,
|
|
926
|
+
stage,
|
|
927
|
+
record.tier_locked,
|
|
928
|
+
record.approved_checkpoints,
|
|
929
|
+
content,
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
if not result.passed:
|
|
933
|
+
if record.tier_locked is None:
|
|
934
|
+
print_and_exit(format_gate_result(result, gate_type="quality-gate"), result.exit_code)
|
|
935
|
+
|
|
936
|
+
record = update_run_status(record, RunStatus.QUALITY_GATE_FAILED)
|
|
937
|
+
update_resume_context(
|
|
938
|
+
record,
|
|
939
|
+
last_operation=f"quality_gate_failed_{stage.value}",
|
|
940
|
+
next_operation="repair_stage_artifact",
|
|
941
|
+
active_item=stage.value,
|
|
942
|
+
)
|
|
943
|
+
mgr.save(record)
|
|
944
|
+
print_and_exit(format_gate_result(result, gate_type="quality-gate"), result.exit_code)
|
|
945
|
+
|
|
946
|
+
record = update_run_status(record, RunStatus.READY_FOR_CHECKPOINT_REVIEW)
|
|
947
|
+
update_resume_context(
|
|
948
|
+
record,
|
|
949
|
+
last_operation=f"quality_gate_passed_{stage.value}",
|
|
950
|
+
next_operation="checkpoint_review",
|
|
951
|
+
active_item=stage.value,
|
|
952
|
+
)
|
|
953
|
+
mgr.save(record)
|
|
954
|
+
print_and_exit(format_gate_result(result, gate_type="quality-gate"), EXIT_OK)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
# ---------------------------------------------------------------------------
|
|
958
|
+
# Status commands
|
|
959
|
+
# ---------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _cmd_status_run(args):
|
|
963
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
964
|
+
|
|
965
|
+
open_route_ids = [r.route_id for r in record.open_routes if r.status == "open"]
|
|
966
|
+
open_routes_detail = [
|
|
967
|
+
{
|
|
968
|
+
"route_id": r.route_id,
|
|
969
|
+
"from_stage": r.from_stage.value,
|
|
970
|
+
"owner_stage": r.owner_stage.value,
|
|
971
|
+
"required_action": r.required_action,
|
|
972
|
+
"status": r.status,
|
|
973
|
+
}
|
|
974
|
+
for r in record.open_routes
|
|
975
|
+
if r.status == "open"
|
|
976
|
+
]
|
|
977
|
+
stale_artifacts = [
|
|
978
|
+
{
|
|
979
|
+
"artifact": s.artifact,
|
|
980
|
+
"reason": s.reason,
|
|
981
|
+
"replaced_by": s.replaced_by,
|
|
982
|
+
"required_action": s.required_action,
|
|
983
|
+
}
|
|
984
|
+
for s in record.stale_artifacts
|
|
985
|
+
]
|
|
986
|
+
outstanding_stale = [aa.stage.value for aa in record.active_artifacts if aa.status == "stale"]
|
|
987
|
+
|
|
988
|
+
print_and_exit(
|
|
989
|
+
format_success(
|
|
990
|
+
{
|
|
991
|
+
"work_id": str(record.work_id),
|
|
992
|
+
"status": record.status.value,
|
|
993
|
+
"current_stage": record.current_stage.value,
|
|
994
|
+
"tier_locked": (
|
|
995
|
+
record.tier_locked.base.value if record.tier_locked else "unlocked"
|
|
996
|
+
),
|
|
997
|
+
"open_routes": open_route_ids,
|
|
998
|
+
"open_routes_detail": open_routes_detail,
|
|
999
|
+
"stale_artifacts": stale_artifacts,
|
|
1000
|
+
"outstanding_stale": outstanding_stale,
|
|
1001
|
+
"approved_checkpoints": [cp.stage.value for cp in record.approved_checkpoints],
|
|
1002
|
+
},
|
|
1003
|
+
message="Run status",
|
|
1004
|
+
),
|
|
1005
|
+
EXIT_OK,
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _cmd_status_next(args):
|
|
1010
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
1011
|
+
rc = record.resume_context
|
|
1012
|
+
print_and_exit(
|
|
1013
|
+
format_success(
|
|
1014
|
+
{
|
|
1015
|
+
"work_id": str(record.work_id),
|
|
1016
|
+
"next_allowed_operation": rc.next_allowed_operation,
|
|
1017
|
+
"active_item": rc.active_item,
|
|
1018
|
+
"last_completed_operation": rc.last_completed_operation,
|
|
1019
|
+
"resume_reason": rc.resume_reason,
|
|
1020
|
+
},
|
|
1021
|
+
message="Next operation",
|
|
1022
|
+
),
|
|
1023
|
+
EXIT_OK,
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
# ---------------------------------------------------------------------------
|
|
1028
|
+
# Stage commands
|
|
1029
|
+
# ---------------------------------------------------------------------------
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _resolve_content(args) -> str:
|
|
1033
|
+
"""Resolve content from --content or --content-file option."""
|
|
1034
|
+
if args.content:
|
|
1035
|
+
return args.content
|
|
1036
|
+
if args.content_file:
|
|
1037
|
+
path = Path(args.content_file)
|
|
1038
|
+
if not path.exists():
|
|
1039
|
+
print_and_exit(
|
|
1040
|
+
format_error(f"Content file not found: {path}", exit_code=EXIT_NOT_FOUND),
|
|
1041
|
+
EXIT_NOT_FOUND,
|
|
1042
|
+
)
|
|
1043
|
+
return path.read_text(encoding="utf-8")
|
|
1044
|
+
print_and_exit(
|
|
1045
|
+
format_error("Either --content or --content-file is required", exit_code=EXIT_CLI_ERR),
|
|
1046
|
+
EXIT_CLI_ERR,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _cmd_stage_produce(args):
|
|
1051
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
1052
|
+
stage = _parse_stage(args.stage)
|
|
1053
|
+
repair_state = record.status in (
|
|
1054
|
+
RunStatus.CHECKPOINT_CHANGES_REQUESTED,
|
|
1055
|
+
RunStatus.QUALITY_GATE_FAILED,
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
if record.status in (RunStatus.NEXT_STAGE, RunStatus.ENTRY_GATE_FAILED):
|
|
1059
|
+
next_step = "gate-entry first to enter the stage draft"
|
|
1060
|
+
if record.status == RunStatus.ENTRY_GATE_FAILED:
|
|
1061
|
+
next_step = "gate-entry after repairing upstream checkpoints"
|
|
1062
|
+
print_and_exit(
|
|
1063
|
+
format_error(
|
|
1064
|
+
f"Cannot produce in {record.status.value}; run {next_step}",
|
|
1065
|
+
exit_code=EXIT_CONFLICT,
|
|
1066
|
+
),
|
|
1067
|
+
EXIT_CONFLICT,
|
|
1068
|
+
)
|
|
1069
|
+
|
|
1070
|
+
if stage != record.current_stage:
|
|
1071
|
+
print_and_exit(
|
|
1072
|
+
format_error(
|
|
1073
|
+
f"Cannot produce stage {stage.value!r}; current stage is "
|
|
1074
|
+
f"{record.current_stage.value!r}",
|
|
1075
|
+
exit_code=EXIT_CONFLICT,
|
|
1076
|
+
),
|
|
1077
|
+
EXIT_CONFLICT,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
entry_result = check_entry_gate(
|
|
1081
|
+
run_dir,
|
|
1082
|
+
stage,
|
|
1083
|
+
record.approved_checkpoints,
|
|
1084
|
+
record.bundle_authorizations,
|
|
1085
|
+
)
|
|
1086
|
+
if not entry_result.passed:
|
|
1087
|
+
if not repair_state:
|
|
1088
|
+
record = update_run_status(record, RunStatus.ENTRY_GATE_FAILED)
|
|
1089
|
+
update_resume_context(
|
|
1090
|
+
record,
|
|
1091
|
+
last_operation=f"entry_gate_failed_{stage.value}",
|
|
1092
|
+
next_operation="repair_upstream_checkpoint",
|
|
1093
|
+
active_item=stage.value,
|
|
1094
|
+
)
|
|
1095
|
+
mgr.save(record)
|
|
1096
|
+
print_and_exit(
|
|
1097
|
+
format_gate_result(entry_result, gate_type="entry-gate"),
|
|
1098
|
+
entry_result.exit_code,
|
|
1099
|
+
)
|
|
1100
|
+
|
|
1101
|
+
content = _resolve_content(args)
|
|
1102
|
+
|
|
1103
|
+
am = ArtifactManager(run_dir)
|
|
1104
|
+
artifact_file = STAGE_ARTIFACT_MAP[stage]
|
|
1105
|
+
try:
|
|
1106
|
+
if repair_state:
|
|
1107
|
+
path = am.stage_update(stage, content)
|
|
1108
|
+
from tools.workflow_cli.artifact import get_artifact_version
|
|
1109
|
+
new_version = get_artifact_version(run_dir, stage)
|
|
1110
|
+
else:
|
|
1111
|
+
path = am.stage_produce(stage, content)
|
|
1112
|
+
new_version = 1
|
|
1113
|
+
except FileExistsError as e:
|
|
1114
|
+
print_and_exit(format_error(str(e), exit_code=EXIT_CONFLICT), EXIT_CONFLICT)
|
|
1115
|
+
|
|
1116
|
+
# Update run record active artifacts
|
|
1117
|
+
record = update_run_status(record, RunStatus.ACTIVE_STAGE_DRAFT)
|
|
1118
|
+
upsert_active_artifact(record, stage, artifact_file, new_version, "draft")
|
|
1119
|
+
update_resume_context(record, last_operation=f"produce_{stage.value}")
|
|
1120
|
+
mgr.save(record)
|
|
1121
|
+
|
|
1122
|
+
print_and_exit(
|
|
1123
|
+
format_success(
|
|
1124
|
+
{"work_id": str(record.work_id), "stage": stage.value, "artifact": str(path)},
|
|
1125
|
+
message=f"Artifact produced for stage: {stage.value}",
|
|
1126
|
+
),
|
|
1127
|
+
EXIT_OK,
|
|
1128
|
+
)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _cmd_stage_update(args):
|
|
1132
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
1133
|
+
stage = _parse_stage(args.stage)
|
|
1134
|
+
|
|
1135
|
+
if record.status in (RunStatus.NEXT_STAGE, RunStatus.ENTRY_GATE_FAILED):
|
|
1136
|
+
next_step = "gate-entry first to enter the stage draft"
|
|
1137
|
+
if record.status == RunStatus.ENTRY_GATE_FAILED:
|
|
1138
|
+
next_step = "gate-entry after repairing upstream checkpoints"
|
|
1139
|
+
print_and_exit(
|
|
1140
|
+
format_error(
|
|
1141
|
+
f"Cannot update in {record.status.value}; run {next_step}",
|
|
1142
|
+
exit_code=EXIT_CONFLICT,
|
|
1143
|
+
),
|
|
1144
|
+
EXIT_CONFLICT,
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
if record.status == RunStatus.CHECKPOINT_REVIEW:
|
|
1148
|
+
print_and_exit(
|
|
1149
|
+
format_error(
|
|
1150
|
+
"Cannot update artifacts while checkpoint review is open; "
|
|
1151
|
+
"use checkpoint-decide to approve or request changes first",
|
|
1152
|
+
exit_code=EXIT_CONFLICT,
|
|
1153
|
+
),
|
|
1154
|
+
EXIT_CONFLICT,
|
|
1155
|
+
)
|
|
1156
|
+
if record.status == RunStatus.CHECKPOINT_APPROVED:
|
|
1157
|
+
print_and_exit(
|
|
1158
|
+
format_error(
|
|
1159
|
+
"Cannot update artifacts after checkpoint approval; "
|
|
1160
|
+
"use stage-advance or run-close",
|
|
1161
|
+
exit_code=EXIT_CONFLICT,
|
|
1162
|
+
),
|
|
1163
|
+
EXIT_CONFLICT,
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
if stage != record.current_stage:
|
|
1167
|
+
print_and_exit(
|
|
1168
|
+
format_error(
|
|
1169
|
+
f"Cannot update stage {stage.value!r}; current stage is "
|
|
1170
|
+
f"{record.current_stage.value!r}",
|
|
1171
|
+
exit_code=EXIT_CONFLICT,
|
|
1172
|
+
),
|
|
1173
|
+
EXIT_CONFLICT,
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
# Repair and post-gate edits invalidate the current artifact's gate/review readiness.
|
|
1177
|
+
repair_state = record.status in (
|
|
1178
|
+
RunStatus.CHECKPOINT_CHANGES_REQUESTED,
|
|
1179
|
+
RunStatus.QUALITY_GATE_FAILED,
|
|
1180
|
+
)
|
|
1181
|
+
review_ready_state = record.status == RunStatus.READY_FOR_CHECKPOINT_REVIEW
|
|
1182
|
+
needs_draft_reset = repair_state or review_ready_state
|
|
1183
|
+
|
|
1184
|
+
content = _resolve_content(args)
|
|
1185
|
+
|
|
1186
|
+
am = ArtifactManager(run_dir)
|
|
1187
|
+
path = am.stage_update(stage, content)
|
|
1188
|
+
|
|
1189
|
+
# Update run record
|
|
1190
|
+
from tools.workflow_cli.artifact import get_artifact_version
|
|
1191
|
+
new_version = get_artifact_version(run_dir, stage)
|
|
1192
|
+
artifact_file = STAGE_ARTIFACT_MAP[stage]
|
|
1193
|
+
upsert_active_artifact(record, stage, artifact_file, new_version, "draft")
|
|
1194
|
+
|
|
1195
|
+
# Any post-gate edit invalidates the previous gate/review readiness.
|
|
1196
|
+
# The stage must be marked ready and pass gate-quality again.
|
|
1197
|
+
if needs_draft_reset:
|
|
1198
|
+
record = update_run_status(record, RunStatus.ACTIVE_STAGE_DRAFT)
|
|
1199
|
+
|
|
1200
|
+
update_resume_context(record, last_operation=f"update_{stage.value}")
|
|
1201
|
+
mgr.save(record)
|
|
1202
|
+
|
|
1203
|
+
print_and_exit(
|
|
1204
|
+
format_success(
|
|
1205
|
+
{
|
|
1206
|
+
"work_id": str(record.work_id),
|
|
1207
|
+
"stage": stage.value,
|
|
1208
|
+
"artifact": str(path),
|
|
1209
|
+
"version": new_version,
|
|
1210
|
+
},
|
|
1211
|
+
message=f"Artifact updated for stage: {stage.value}",
|
|
1212
|
+
),
|
|
1213
|
+
EXIT_OK,
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def _cmd_stage_ready(args):
|
|
1218
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
1219
|
+
stage = _parse_stage(args.stage)
|
|
1220
|
+
|
|
1221
|
+
stage_ready_statuses = {
|
|
1222
|
+
RunStatus.ACTIVE_STAGE_DRAFT,
|
|
1223
|
+
RunStatus.QUALITY_GATE_FAILED,
|
|
1224
|
+
}
|
|
1225
|
+
if record.status not in stage_ready_statuses:
|
|
1226
|
+
print_and_exit(
|
|
1227
|
+
format_error(
|
|
1228
|
+
f"Cannot mark artifacts ready in status {record.status.value!r}; "
|
|
1229
|
+
"stage-ready is only allowed while drafting or after quality-gate failure; "
|
|
1230
|
+
"checkpoint changes_requested requires stage-update or stage-produce first",
|
|
1231
|
+
exit_code=EXIT_CONFLICT,
|
|
1232
|
+
),
|
|
1233
|
+
EXIT_CONFLICT,
|
|
1234
|
+
)
|
|
1235
|
+
|
|
1236
|
+
repair_state = record.status == RunStatus.QUALITY_GATE_FAILED
|
|
1237
|
+
|
|
1238
|
+
if stage != record.current_stage:
|
|
1239
|
+
print_and_exit(
|
|
1240
|
+
format_error(
|
|
1241
|
+
f"Cannot mark stage {stage.value!r} ready while current stage is "
|
|
1242
|
+
f"{record.current_stage.value!r}",
|
|
1243
|
+
exit_code=EXIT_CONFLICT,
|
|
1244
|
+
),
|
|
1245
|
+
EXIT_CONFLICT,
|
|
1246
|
+
)
|
|
1247
|
+
|
|
1248
|
+
am = ArtifactManager(run_dir)
|
|
1249
|
+
try:
|
|
1250
|
+
am.stage_ready(stage)
|
|
1251
|
+
except FileNotFoundError:
|
|
1252
|
+
print_and_exit(
|
|
1253
|
+
format_error(
|
|
1254
|
+
f"Artifact not found for stage {stage.value!r}; produce it first",
|
|
1255
|
+
exit_code=EXIT_NOT_FOUND,
|
|
1256
|
+
),
|
|
1257
|
+
EXIT_NOT_FOUND,
|
|
1258
|
+
)
|
|
1259
|
+
except ValueError as e:
|
|
1260
|
+
print_and_exit(
|
|
1261
|
+
format_error(str(e), exit_code=EXIT_CONFLICT),
|
|
1262
|
+
EXIT_CONFLICT,
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
# Update run record
|
|
1266
|
+
from tools.workflow_cli.artifact import get_artifact_version
|
|
1267
|
+
version = get_artifact_version(run_dir, stage)
|
|
1268
|
+
artifact_file = STAGE_ARTIFACT_MAP[stage]
|
|
1269
|
+
upsert_active_artifact(record, stage, artifact_file, version, "ready")
|
|
1270
|
+
if repair_state:
|
|
1271
|
+
record = update_run_status(record, RunStatus.ACTIVE_STAGE_DRAFT)
|
|
1272
|
+
update_resume_context(record, last_operation=f"ready_{stage.value}")
|
|
1273
|
+
mgr.save(record)
|
|
1274
|
+
|
|
1275
|
+
print_and_exit(
|
|
1276
|
+
format_success(
|
|
1277
|
+
{"work_id": str(record.work_id), "stage": stage.value},
|
|
1278
|
+
message=f"Stage marked ready: {stage.value}",
|
|
1279
|
+
),
|
|
1280
|
+
EXIT_OK,
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
# ---------------------------------------------------------------------------
|
|
1285
|
+
# Subparser registration
|
|
1286
|
+
# ---------------------------------------------------------------------------
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
def _register_run_commands(subparsers):
|
|
1290
|
+
# run-start
|
|
1291
|
+
p = subparsers.add_parser("run-start", help="Start a new workflow run")
|
|
1292
|
+
p.add_argument("--work-id", required=True, help="Workflow ID (WF-YYYYMMDD-slug)")
|
|
1293
|
+
src = p.add_mutually_exclusive_group(required=True)
|
|
1294
|
+
src.add_argument("--requirement", default=None, help="Raw requirement text")
|
|
1295
|
+
src.add_argument(
|
|
1296
|
+
"--requirement-file",
|
|
1297
|
+
dest="requirement_file",
|
|
1298
|
+
default=None,
|
|
1299
|
+
help="Path to a file whose contents are the raw requirement",
|
|
1300
|
+
)
|
|
1301
|
+
p.add_argument("--repo-path", default=None, help="Path to repository for baseline scan")
|
|
1302
|
+
p.add_argument("--overwrite", action="store_true", help="Overwrite an existing run")
|
|
1303
|
+
p.set_defaults(func=_cmd_run_start)
|
|
1304
|
+
|
|
1305
|
+
# run-resume
|
|
1306
|
+
p = subparsers.add_parser("run-resume", help="Resume a workflow run")
|
|
1307
|
+
p.add_argument("--work-id", required=True)
|
|
1308
|
+
p.set_defaults(func=_cmd_run_resume)
|
|
1309
|
+
|
|
1310
|
+
# run-close
|
|
1311
|
+
p = subparsers.add_parser("run-close", help="Close a workflow run")
|
|
1312
|
+
p.add_argument("--work-id", required=True)
|
|
1313
|
+
p.set_defaults(func=_cmd_run_close)
|
|
1314
|
+
|
|
1315
|
+
# run-reopen
|
|
1316
|
+
p = subparsers.add_parser("run-reopen", help="Reopen a closed workflow run")
|
|
1317
|
+
p.add_argument("--from", dest="from_id", required=True, help="Source work-id to reopen from")
|
|
1318
|
+
p.add_argument("--stage", required=True, help="Target stage to reopen at")
|
|
1319
|
+
p.add_argument("--reason", required=True, help="Reason for reopening")
|
|
1320
|
+
p.set_defaults(func=_cmd_run_reopen)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _register_tier_commands(subparsers):
|
|
1324
|
+
# tier-estimate
|
|
1325
|
+
p = subparsers.add_parser("tier-estimate", help="Estimate tier for requirement text")
|
|
1326
|
+
p.add_argument("--text", required=True, help="Requirement text to estimate")
|
|
1327
|
+
p.add_argument("--repo-path", default=None, help="Path to repository for baseline scan")
|
|
1328
|
+
p.set_defaults(func=_cmd_tier_estimate)
|
|
1329
|
+
|
|
1330
|
+
# tier-lock
|
|
1331
|
+
p = subparsers.add_parser("tier-lock", help="Lock tier for a run")
|
|
1332
|
+
p.add_argument("--work-id", required=True)
|
|
1333
|
+
p.add_argument("--base", required=True, choices=["light", "standard"])
|
|
1334
|
+
p.add_argument("--modifiers", default=None, help="Comma-separated modifiers")
|
|
1335
|
+
p.add_argument("--override-floor", action="store_true", help="Allow locking below floor")
|
|
1336
|
+
p.add_argument("--confirm", action="store_true", help="Required confirmation before locking tier")
|
|
1337
|
+
p.set_defaults(func=_cmd_tier_lock)
|
|
1338
|
+
|
|
1339
|
+
# tier-escalate
|
|
1340
|
+
p = subparsers.add_parser("tier-escalate", help="Add a modifier to locked tier")
|
|
1341
|
+
p.add_argument("--work-id", required=True)
|
|
1342
|
+
p.add_argument(
|
|
1343
|
+
"--modifier",
|
|
1344
|
+
required=True,
|
|
1345
|
+
choices=[m.value for m in TierModifier],
|
|
1346
|
+
help="Modifier to add",
|
|
1347
|
+
)
|
|
1348
|
+
p.set_defaults(func=_cmd_tier_escalate)
|
|
1349
|
+
|
|
1350
|
+
# tier-status
|
|
1351
|
+
p = subparsers.add_parser("tier-status", help="Print tier estimate and lock status")
|
|
1352
|
+
p.add_argument("--work-id", required=True)
|
|
1353
|
+
p.set_defaults(func=_cmd_tier_status)
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _register_gate_commands(subparsers):
|
|
1357
|
+
# gate-entry
|
|
1358
|
+
p = subparsers.add_parser("gate-entry", help="Check entry gate for a stage")
|
|
1359
|
+
p.add_argument("--work-id", required=True)
|
|
1360
|
+
p.add_argument("--stage", required=True)
|
|
1361
|
+
p.set_defaults(func=_cmd_gate_entry)
|
|
1362
|
+
|
|
1363
|
+
# gate-quality
|
|
1364
|
+
p = subparsers.add_parser("gate-quality", help="Check quality gate for a stage")
|
|
1365
|
+
p.add_argument("--work-id", required=True)
|
|
1366
|
+
p.add_argument("--stage", required=True)
|
|
1367
|
+
p.set_defaults(func=_cmd_gate_quality)
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
def _register_status_commands(subparsers):
|
|
1371
|
+
# status-run
|
|
1372
|
+
p = subparsers.add_parser("status-run", help="Print run state, stage, tier, open routes")
|
|
1373
|
+
p.add_argument("--work-id", required=True)
|
|
1374
|
+
p.set_defaults(func=_cmd_status_run)
|
|
1375
|
+
|
|
1376
|
+
# status-next
|
|
1377
|
+
p = subparsers.add_parser("status-next", help="Print next allowed operation from resume context")
|
|
1378
|
+
p.add_argument("--work-id", required=True)
|
|
1379
|
+
p.set_defaults(func=_cmd_status_next)
|
|
1380
|
+
|
|
1381
|
+
|
|
1382
|
+
def _cmd_review_checkpoint(args):
|
|
1383
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
1384
|
+
stage = _parse_stage(args.stage)
|
|
1385
|
+
|
|
1386
|
+
if record.status not in {
|
|
1387
|
+
RunStatus.READY_FOR_CHECKPOINT_REVIEW,
|
|
1388
|
+
RunStatus.CHECKPOINT_REVIEW,
|
|
1389
|
+
}:
|
|
1390
|
+
print_and_exit(
|
|
1391
|
+
format_error(
|
|
1392
|
+
f"Cannot review-checkpoint in status {record.status.value!r}; "
|
|
1393
|
+
"must be ready_for_checkpoint_review or checkpoint_review",
|
|
1394
|
+
exit_code=EXIT_CONFLICT,
|
|
1395
|
+
),
|
|
1396
|
+
EXIT_CONFLICT,
|
|
1397
|
+
)
|
|
1398
|
+
if stage != record.current_stage:
|
|
1399
|
+
print_and_exit(
|
|
1400
|
+
format_error(
|
|
1401
|
+
f"Stage {stage.value!r} is not the current stage {record.current_stage.value!r}",
|
|
1402
|
+
exit_code=EXIT_CONFLICT,
|
|
1403
|
+
),
|
|
1404
|
+
EXIT_CONFLICT,
|
|
1405
|
+
)
|
|
1406
|
+
|
|
1407
|
+
aa = get_active_artifact(record, stage)
|
|
1408
|
+
if aa is None:
|
|
1409
|
+
print_and_exit(
|
|
1410
|
+
format_error(f"No active artifact for stage {stage.value!r}", exit_code=EXIT_CONFLICT),
|
|
1411
|
+
EXIT_CONFLICT,
|
|
1412
|
+
)
|
|
1413
|
+
if aa.status != "ready":
|
|
1414
|
+
print_and_exit(
|
|
1415
|
+
format_error(
|
|
1416
|
+
f"Stage {stage.value!r} artifact must be ready before checkpoint review",
|
|
1417
|
+
exit_code=EXIT_CONFLICT,
|
|
1418
|
+
),
|
|
1419
|
+
EXIT_CONFLICT,
|
|
1420
|
+
)
|
|
1421
|
+
version = get_artifact_version(run_dir, stage)
|
|
1422
|
+
if version != aa.version:
|
|
1423
|
+
print_and_exit(
|
|
1424
|
+
format_error(
|
|
1425
|
+
f"Active artifact version v{aa.version} does not match on-disk v{version}",
|
|
1426
|
+
exit_code=EXIT_CONFLICT,
|
|
1427
|
+
),
|
|
1428
|
+
EXIT_CONFLICT,
|
|
1429
|
+
)
|
|
1430
|
+
reviews_dir = run_dir / "reviews"
|
|
1431
|
+
reviews_dir.mkdir(parents=True, exist_ok=True)
|
|
1432
|
+
marker = reviews_dir / f"{stage.value}-checkpoint-review-v{aa.version}.md"
|
|
1433
|
+
marker.write_text(
|
|
1434
|
+
f"# Checkpoint review marker\n\nstage: {stage.value}\nversion: {aa.version}\n"
|
|
1435
|
+
"status: ready for human decision\n",
|
|
1436
|
+
encoding="utf-8",
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
if record.status == RunStatus.READY_FOR_CHECKPOINT_REVIEW:
|
|
1440
|
+
record = update_run_status(record, RunStatus.CHECKPOINT_REVIEW)
|
|
1441
|
+
update_resume_context(
|
|
1442
|
+
record,
|
|
1443
|
+
last_operation=f"review_checkpoint_{stage.value}",
|
|
1444
|
+
next_operation="checkpoint_decide",
|
|
1445
|
+
active_item=stage.value,
|
|
1446
|
+
)
|
|
1447
|
+
mgr.save(record)
|
|
1448
|
+
print_and_exit(
|
|
1449
|
+
format_success(
|
|
1450
|
+
{"work_id": str(record.work_id), "stage": stage.value, "marker": str(marker)},
|
|
1451
|
+
message=f"Checkpoint review opened: {stage.value}",
|
|
1452
|
+
),
|
|
1453
|
+
EXIT_OK,
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def _register_stage_commands(subparsers):
|
|
1458
|
+
def _add_content_args(p):
|
|
1459
|
+
grp = p.add_mutually_exclusive_group()
|
|
1460
|
+
grp.add_argument("--content", default=None, help="Artifact content (inline)")
|
|
1461
|
+
grp.add_argument("--content-file", default=None, help="Path to file containing content")
|
|
1462
|
+
|
|
1463
|
+
# stage-produce
|
|
1464
|
+
p = subparsers.add_parser("stage-produce", help="Write initial draft of a stage artifact")
|
|
1465
|
+
p.add_argument("--work-id", required=True)
|
|
1466
|
+
p.add_argument("--stage", required=True)
|
|
1467
|
+
_add_content_args(p)
|
|
1468
|
+
p.set_defaults(func=_cmd_stage_produce)
|
|
1469
|
+
|
|
1470
|
+
# stage-update
|
|
1471
|
+
p = subparsers.add_parser("stage-update", help="Increment version and update stage artifact")
|
|
1472
|
+
p.add_argument("--work-id", required=True)
|
|
1473
|
+
p.add_argument("--stage", required=True)
|
|
1474
|
+
_add_content_args(p)
|
|
1475
|
+
p.set_defaults(func=_cmd_stage_update)
|
|
1476
|
+
|
|
1477
|
+
# stage-ready
|
|
1478
|
+
p = subparsers.add_parser("stage-ready", help="Mark a stage artifact as ready")
|
|
1479
|
+
p.add_argument("--work-id", required=True)
|
|
1480
|
+
p.add_argument("--stage", required=True)
|
|
1481
|
+
p.set_defaults(func=_cmd_stage_ready)
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def _cmd_checkpoint_decide(args):
|
|
1485
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
1486
|
+
stage = _parse_stage(args.stage)
|
|
1487
|
+
|
|
1488
|
+
if record.status != RunStatus.CHECKPOINT_REVIEW:
|
|
1489
|
+
print_and_exit(
|
|
1490
|
+
format_error(
|
|
1491
|
+
f"Cannot decide in status {record.status.value!r}; must be checkpoint_review",
|
|
1492
|
+
exit_code=EXIT_CONFLICT,
|
|
1493
|
+
),
|
|
1494
|
+
EXIT_CONFLICT,
|
|
1495
|
+
)
|
|
1496
|
+
if stage != record.current_stage:
|
|
1497
|
+
print_and_exit(
|
|
1498
|
+
format_error(
|
|
1499
|
+
f"Stage {stage.value!r} is not the current stage {record.current_stage.value!r}",
|
|
1500
|
+
exit_code=EXIT_CONFLICT,
|
|
1501
|
+
),
|
|
1502
|
+
EXIT_CONFLICT,
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
aa = get_active_artifact(record, stage)
|
|
1506
|
+
if aa is None:
|
|
1507
|
+
print_and_exit(
|
|
1508
|
+
format_error(f"No active artifact for stage {stage.value!r}", exit_code=EXIT_CONFLICT),
|
|
1509
|
+
EXIT_CONFLICT,
|
|
1510
|
+
)
|
|
1511
|
+
if aa.status != "ready":
|
|
1512
|
+
print_and_exit(
|
|
1513
|
+
format_error(
|
|
1514
|
+
f"Stage {stage.value!r} artifact must be ready before checkpoint decision",
|
|
1515
|
+
exit_code=EXIT_CONFLICT,
|
|
1516
|
+
),
|
|
1517
|
+
EXIT_CONFLICT,
|
|
1518
|
+
)
|
|
1519
|
+
version = get_artifact_version(run_dir, stage)
|
|
1520
|
+
if version != aa.version:
|
|
1521
|
+
print_and_exit(
|
|
1522
|
+
format_error(
|
|
1523
|
+
f"Active artifact version v{aa.version} does not match on-disk v{version}",
|
|
1524
|
+
exit_code=EXIT_CONFLICT,
|
|
1525
|
+
),
|
|
1526
|
+
EXIT_CONFLICT,
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
if args.decision == "changes_requested":
|
|
1530
|
+
record = update_run_status(record, RunStatus.CHECKPOINT_CHANGES_REQUESTED)
|
|
1531
|
+
update_resume_context(
|
|
1532
|
+
record,
|
|
1533
|
+
last_operation=f"changes_requested_{stage.value}",
|
|
1534
|
+
next_operation="repair_stage_artifact",
|
|
1535
|
+
active_item=stage.value,
|
|
1536
|
+
)
|
|
1537
|
+
mgr.save(record)
|
|
1538
|
+
print_and_exit(
|
|
1539
|
+
format_success(
|
|
1540
|
+
{"work_id": str(record.work_id), "stage": stage.value, "decision": "changes_requested"},
|
|
1541
|
+
message=f"Changes requested: {stage.value}",
|
|
1542
|
+
),
|
|
1543
|
+
EXIT_OK,
|
|
1544
|
+
)
|
|
1545
|
+
|
|
1546
|
+
# decision == "approved"
|
|
1547
|
+
if not args.confirm:
|
|
1548
|
+
print_and_exit(
|
|
1549
|
+
format_error(
|
|
1550
|
+
"Approval requires --confirm",
|
|
1551
|
+
exit_code=EXIT_REVIEW_REQ,
|
|
1552
|
+
),
|
|
1553
|
+
EXIT_REVIEW_REQ,
|
|
1554
|
+
)
|
|
1555
|
+
|
|
1556
|
+
open_routes = [r.route_id for r in record.open_routes if r.status == "open"]
|
|
1557
|
+
if open_routes:
|
|
1558
|
+
print_and_exit(
|
|
1559
|
+
format_error(
|
|
1560
|
+
"Cannot approve checkpoint while routes remain open",
|
|
1561
|
+
details=open_routes,
|
|
1562
|
+
exit_code=EXIT_CONFLICT,
|
|
1563
|
+
),
|
|
1564
|
+
EXIT_CONFLICT,
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
# Duplicate-approval guard.
|
|
1568
|
+
if any(cp.stage == stage and cp.version == aa.version for cp in record.approved_checkpoints):
|
|
1569
|
+
print_and_exit(
|
|
1570
|
+
format_error(
|
|
1571
|
+
f"Stage {stage.value!r} v{aa.version} already has an approved checkpoint",
|
|
1572
|
+
exit_code=EXIT_CONFLICT,
|
|
1573
|
+
),
|
|
1574
|
+
EXIT_CONFLICT,
|
|
1575
|
+
)
|
|
1576
|
+
|
|
1577
|
+
reviews_dir = run_dir / "reviews"
|
|
1578
|
+
# Precondition: checkpoint-review marker for this version must exist (GR5).
|
|
1579
|
+
marker = reviews_dir / f"{stage.value}-checkpoint-review-v{aa.version}.md"
|
|
1580
|
+
if not marker.exists():
|
|
1581
|
+
print_and_exit(
|
|
1582
|
+
format_error(
|
|
1583
|
+
f"Missing checkpoint-review marker for {stage.value} v{aa.version}; "
|
|
1584
|
+
"run review-checkpoint first",
|
|
1585
|
+
exit_code=EXIT_REVIEW_REQ,
|
|
1586
|
+
),
|
|
1587
|
+
EXIT_REVIEW_REQ,
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
# Forced-review guard (version-aware; subagent file required).
|
|
1591
|
+
review_result = check_forced_subagent_review(stage, record.tier_locked, reviews_dir, aa.version)
|
|
1592
|
+
if not review_result.passed:
|
|
1593
|
+
print_and_exit(
|
|
1594
|
+
format_gate_result(review_result, gate_type="subagent-review"),
|
|
1595
|
+
EXIT_REVIEW_REQ,
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
from tools.workflow_cli.models import NEXT_STAGE_MAP
|
|
1599
|
+
next_stage = NEXT_STAGE_MAP.get(stage)
|
|
1600
|
+
downstream = args.downstream_authorization or (next_stage.value if next_stage else "close_workflow_run")
|
|
1601
|
+
artifact_file = STAGE_ARTIFACT_MAP[stage]
|
|
1602
|
+
add_checkpoint(record, stage, artifact_file, aa.version, downstream)
|
|
1603
|
+
upsert_active_artifact(record, stage, artifact_file, aa.version, "approved")
|
|
1604
|
+
update_artifact_status(run_dir, stage, "approved")
|
|
1605
|
+
record = update_run_status(record, RunStatus.CHECKPOINT_APPROVED)
|
|
1606
|
+
update_resume_context(
|
|
1607
|
+
record,
|
|
1608
|
+
last_operation=f"approved_{stage.value}",
|
|
1609
|
+
next_operation="stage_advance" if next_stage else "run_close",
|
|
1610
|
+
active_item=stage.value,
|
|
1611
|
+
)
|
|
1612
|
+
mgr.save(record)
|
|
1613
|
+
print_and_exit(
|
|
1614
|
+
format_success(
|
|
1615
|
+
{"work_id": str(record.work_id), "stage": stage.value, "decision": "approved"},
|
|
1616
|
+
message=f"Checkpoint approved: {stage.value}",
|
|
1617
|
+
),
|
|
1618
|
+
EXIT_OK,
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
|
|
1622
|
+
def _cmd_stage_advance(args):
|
|
1623
|
+
record, mgr, run_dir = _load_run(args.work_id, args.base_path)
|
|
1624
|
+
|
|
1625
|
+
if record.status != RunStatus.CHECKPOINT_APPROVED:
|
|
1626
|
+
print_and_exit(
|
|
1627
|
+
format_error(
|
|
1628
|
+
f"Cannot advance in status {record.status.value!r}; must be checkpoint_approved",
|
|
1629
|
+
exit_code=EXIT_CONFLICT,
|
|
1630
|
+
),
|
|
1631
|
+
EXIT_CONFLICT,
|
|
1632
|
+
)
|
|
1633
|
+
|
|
1634
|
+
stage = record.current_stage
|
|
1635
|
+
if stage == Stage.PLAN:
|
|
1636
|
+
print_and_exit(
|
|
1637
|
+
format_error(
|
|
1638
|
+
"Cannot advance past plan; use run-close",
|
|
1639
|
+
exit_code=EXIT_CONFLICT,
|
|
1640
|
+
),
|
|
1641
|
+
EXIT_CONFLICT,
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
aa = get_active_artifact(record, stage)
|
|
1645
|
+
if aa is None:
|
|
1646
|
+
print_and_exit(
|
|
1647
|
+
format_error(
|
|
1648
|
+
f"No active artifact for stage {stage.value!r}",
|
|
1649
|
+
exit_code=EXIT_CONFLICT,
|
|
1650
|
+
),
|
|
1651
|
+
EXIT_CONFLICT,
|
|
1652
|
+
)
|
|
1653
|
+
if aa.status != "approved":
|
|
1654
|
+
print_and_exit(
|
|
1655
|
+
format_error(
|
|
1656
|
+
f"Stage {stage.value!r} artifact must be approved before stage-advance",
|
|
1657
|
+
exit_code=EXIT_CONFLICT,
|
|
1658
|
+
),
|
|
1659
|
+
EXIT_CONFLICT,
|
|
1660
|
+
)
|
|
1661
|
+
version = get_artifact_version(run_dir, stage)
|
|
1662
|
+
if version != aa.version:
|
|
1663
|
+
print_and_exit(
|
|
1664
|
+
format_error(
|
|
1665
|
+
f"Active artifact version v{aa.version} does not match on-disk v{version}",
|
|
1666
|
+
exit_code=EXIT_CONFLICT,
|
|
1667
|
+
),
|
|
1668
|
+
EXIT_CONFLICT,
|
|
1669
|
+
)
|
|
1670
|
+
if not any(
|
|
1671
|
+
cp.stage == stage and cp.artifact == aa.artifact and cp.version == aa.version
|
|
1672
|
+
for cp in record.approved_checkpoints
|
|
1673
|
+
):
|
|
1674
|
+
print_and_exit(
|
|
1675
|
+
format_error(
|
|
1676
|
+
f"No approved checkpoint matching {stage.value} v{aa.version}",
|
|
1677
|
+
exit_code=EXIT_CONFLICT,
|
|
1678
|
+
),
|
|
1679
|
+
EXIT_CONFLICT,
|
|
1680
|
+
)
|
|
1681
|
+
open_routes = [r.route_id for r in record.open_routes if r.status == "open"]
|
|
1682
|
+
if open_routes:
|
|
1683
|
+
print_and_exit(
|
|
1684
|
+
format_error(
|
|
1685
|
+
"Cannot advance stage while routes remain open",
|
|
1686
|
+
details=open_routes,
|
|
1687
|
+
exit_code=EXIT_CONFLICT,
|
|
1688
|
+
),
|
|
1689
|
+
EXIT_CONFLICT,
|
|
1690
|
+
)
|
|
1691
|
+
|
|
1692
|
+
from tools.workflow_cli.models import NEXT_STAGE_MAP
|
|
1693
|
+
next_stage = NEXT_STAGE_MAP[stage]
|
|
1694
|
+
record = update_run_status(record, RunStatus.NEXT_STAGE)
|
|
1695
|
+
record.current_stage = next_stage
|
|
1696
|
+
update_resume_context(
|
|
1697
|
+
record,
|
|
1698
|
+
last_operation=f"advance_to_{next_stage.value}",
|
|
1699
|
+
next_operation="gate_entry",
|
|
1700
|
+
active_item=next_stage.value,
|
|
1701
|
+
)
|
|
1702
|
+
mgr.save(record)
|
|
1703
|
+
print_and_exit(
|
|
1704
|
+
format_success(
|
|
1705
|
+
{"work_id": str(record.work_id), "current_stage": next_stage.value},
|
|
1706
|
+
message=f"Advanced to {next_stage.value}",
|
|
1707
|
+
),
|
|
1708
|
+
EXIT_OK,
|
|
1709
|
+
)
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def _register_checkpoint_commands(subparsers):
|
|
1713
|
+
p = subparsers.add_parser("review-checkpoint", help="Open checkpoint review for a stage")
|
|
1714
|
+
p.add_argument("--work-id", required=True)
|
|
1715
|
+
p.add_argument("--stage", required=True)
|
|
1716
|
+
p.set_defaults(func=_cmd_review_checkpoint)
|
|
1717
|
+
|
|
1718
|
+
p = subparsers.add_parser("checkpoint-decide", help="Approve or request changes on a checkpoint")
|
|
1719
|
+
p.add_argument("--work-id", required=True)
|
|
1720
|
+
p.add_argument("--stage", required=True)
|
|
1721
|
+
p.add_argument("--decision", required=True, choices=["approved", "changes_requested"])
|
|
1722
|
+
p.add_argument("--confirm", action="store_true")
|
|
1723
|
+
p.add_argument("--downstream-authorization", default=None)
|
|
1724
|
+
p.set_defaults(func=_cmd_checkpoint_decide)
|
|
1725
|
+
|
|
1726
|
+
p = subparsers.add_parser("stage-advance", help="Advance an approved stage to the next stage")
|
|
1727
|
+
p.add_argument("--work-id", required=True)
|
|
1728
|
+
p.set_defaults(func=_cmd_stage_advance)
|
|
1729
|
+
|
|
1730
|
+
|
|
1731
|
+
def _register_route_commands(subparsers):
|
|
1732
|
+
# gap-open
|
|
1733
|
+
p = subparsers.add_parser("gap-open", help="Route an upstream gap back to an owner stage")
|
|
1734
|
+
p.add_argument("--work-id", required=True)
|
|
1735
|
+
p.add_argument("--owner-stage", required=True)
|
|
1736
|
+
p.add_argument("--required-action", required=True)
|
|
1737
|
+
p.add_argument("--confirm", action="store_true")
|
|
1738
|
+
p.set_defaults(func=_cmd_gap_open)
|
|
1739
|
+
|
|
1740
|
+
# gap-resolve
|
|
1741
|
+
p = subparsers.add_parser("gap-resolve", help="Resolve an open upstream-gap route")
|
|
1742
|
+
p.add_argument("--work-id", required=True)
|
|
1743
|
+
p.add_argument("--route-id", required=True)
|
|
1744
|
+
p.add_argument("--confirm", action="store_true")
|
|
1745
|
+
p.set_defaults(func=_cmd_gap_resolve)
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
# ---------------------------------------------------------------------------
|
|
1749
|
+
# main
|
|
1750
|
+
# ---------------------------------------------------------------------------
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def main(args=None):
|
|
1754
|
+
parser = argparse.ArgumentParser(
|
|
1755
|
+
prog="workflow",
|
|
1756
|
+
description="req-to-plan CLI — internal Agent commands",
|
|
1757
|
+
)
|
|
1758
|
+
parser.add_argument(
|
|
1759
|
+
"--base-path",
|
|
1760
|
+
type=Path,
|
|
1761
|
+
default=None,
|
|
1762
|
+
help="Override base path for .req-to-plan/ directory (for testing)",
|
|
1763
|
+
)
|
|
1764
|
+
|
|
1765
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1766
|
+
_register_run_commands(subparsers)
|
|
1767
|
+
_register_route_commands(subparsers)
|
|
1768
|
+
_register_tier_commands(subparsers)
|
|
1769
|
+
_register_gate_commands(subparsers)
|
|
1770
|
+
_register_status_commands(subparsers)
|
|
1771
|
+
_register_stage_commands(subparsers)
|
|
1772
|
+
_register_checkpoint_commands(subparsers)
|
|
1773
|
+
|
|
1774
|
+
parsed = parser.parse_args(args)
|
|
1775
|
+
parsed.func(parsed)
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
if __name__ == "__main__":
|
|
1779
|
+
main()
|