@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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/README.zh-CN.md +158 -0
  4. package/bin/r2p.js +38 -0
  5. package/docs/req-to-plan-design.md +277 -0
  6. package/package.json +47 -0
  7. package/requirements.txt +1 -0
  8. package/tools/r2p +10 -0
  9. package/tools/r2p-continue +10 -0
  10. package/tools/r2p-gap-open +10 -0
  11. package/tools/r2p-gap-resolve +10 -0
  12. package/tools/r2p-reopen +10 -0
  13. package/tools/r2p-start +10 -0
  14. package/tools/r2p-status +10 -0
  15. package/tools/r2p-switch +10 -0
  16. package/tools/r2p-tier-lock +10 -0
  17. package/tools/workflow_cli/__init__.py +0 -0
  18. package/tools/workflow_cli/__main__.py +5 -0
  19. package/tools/workflow_cli/agent_shortcuts.py +778 -0
  20. package/tools/workflow_cli/agent_templates/claude/SKILL.md +34 -0
  21. package/tools/workflow_cli/agent_templates/claude/commands/r2p-continue.md +16 -0
  22. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-open.md +8 -0
  23. package/tools/workflow_cli/agent_templates/claude/commands/r2p-gap-resolve.md +8 -0
  24. package/tools/workflow_cli/agent_templates/claude/commands/r2p-reopen.md +8 -0
  25. package/tools/workflow_cli/agent_templates/claude/commands/r2p-start.md +10 -0
  26. package/tools/workflow_cli/agent_templates/claude/commands/r2p-status.md +8 -0
  27. package/tools/workflow_cli/agent_templates/claude/commands/r2p-switch.md +8 -0
  28. package/tools/workflow_cli/agent_templates/claude/commands/r2p-tier-lock.md +8 -0
  29. package/tools/workflow_cli/agent_templates/codex/skills/r2p-continue/SKILL.md +12 -0
  30. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-open/SKILL.md +12 -0
  31. package/tools/workflow_cli/agent_templates/codex/skills/r2p-gap-resolve/SKILL.md +12 -0
  32. package/tools/workflow_cli/agent_templates/codex/skills/r2p-reopen/SKILL.md +12 -0
  33. package/tools/workflow_cli/agent_templates/codex/skills/r2p-start/SKILL.md +14 -0
  34. package/tools/workflow_cli/agent_templates/codex/skills/r2p-status/SKILL.md +12 -0
  35. package/tools/workflow_cli/agent_templates/codex/skills/r2p-switch/SKILL.md +12 -0
  36. package/tools/workflow_cli/agent_templates/codex/skills/r2p-tier-lock/SKILL.md +12 -0
  37. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-continue.toml +4 -0
  38. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-open.toml +4 -0
  39. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-gap-resolve.toml +4 -0
  40. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-reopen.toml +4 -0
  41. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-start.toml +4 -0
  42. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-status.toml +4 -0
  43. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-switch.toml +4 -0
  44. package/tools/workflow_cli/agent_templates/gemini/commands/r2p-tier-lock.toml +4 -0
  45. package/tools/workflow_cli/artifact.py +228 -0
  46. package/tools/workflow_cli/cli.py +1779 -0
  47. package/tools/workflow_cli/gates.py +471 -0
  48. package/tools/workflow_cli/install.py +900 -0
  49. package/tools/workflow_cli/install_cli.py +158 -0
  50. package/tools/workflow_cli/link_expander.py +102 -0
  51. package/tools/workflow_cli/models.py +504 -0
  52. package/tools/workflow_cli/output.py +91 -0
  53. package/tools/workflow_cli/repo_baseline.py +137 -0
  54. package/tools/workflow_cli/state.py +621 -0
  55. package/tools/workflow_cli/tier.py +201 -0
  56. package/tools/workflow_cli/tier_keywords.yaml +45 -0
  57. 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()