okstra 0.219.0 → 0.220.0

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 (98) hide show
  1. package/README.md +5 -5
  2. package/dist/cli-registry.mjs +18 -10
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/lifecycle/config.mjs +2 -3
  5. package/dist/commands/lifecycle/config.mjs.map +1 -1
  6. package/dist/lib/runtime-payload.mjs +1 -2
  7. package/dist/lib/runtime-payload.mjs.map +1 -1
  8. package/docs/architecture/storage-model.md +1 -1
  9. package/docs/architecture.md +9 -7
  10. package/docs/cli.md +5 -3
  11. package/docs/project-structure-overview.md +3 -4
  12. package/package.json +1 -1
  13. package/runtime/BUILD.json +2 -2
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/okstra-lead-contract.md +5 -4
  16. package/runtime/prompts/lead/report-writer.md +10 -17
  17. package/runtime/prompts/lead/team-contract.md +1 -2
  18. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  19. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
  20. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +1 -1
  21. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +0 -1
  22. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +1 -1
  23. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +0 -1
  24. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +0 -1
  25. package/runtime/python/okstra_ctl/agent/invocation.py +1 -2
  26. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +2 -2
  27. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +0 -2
  28. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +2 -27
  29. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -4
  30. package/runtime/python/okstra_ctl/blocking_checks.py +8 -0
  31. package/runtime/python/okstra_ctl/convergence_provenance.py +8 -2
  32. package/runtime/python/okstra_ctl/correction_cli.py +59 -0
  33. package/runtime/python/okstra_ctl/correction_round.py +168 -0
  34. package/runtime/python/okstra_ctl/dispatch_core.py +21 -349
  35. package/runtime/python/okstra_ctl/dispatch_state.py +0 -13
  36. package/runtime/python/okstra_ctl/domain/provider.py +1 -1
  37. package/runtime/python/okstra_ctl/domain/role.py +0 -6
  38. package/runtime/python/okstra_ctl/final_report_paths.py +0 -16
  39. package/runtime/python/okstra_ctl/i18n.py +2 -2
  40. package/runtime/python/okstra_ctl/path_hints.py +0 -4
  41. package/runtime/python/okstra_ctl/paths.py +0 -3
  42. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -0
  43. package/runtime/python/okstra_ctl/phases/final_verification/spec.md +2 -0
  44. package/runtime/python/okstra_ctl/phases/final_verification/target.py +17 -1
  45. package/runtime/python/okstra_ctl/phases/implementation/entry.py +12 -0
  46. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +30 -6
  47. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
  48. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -0
  49. package/runtime/python/okstra_ctl/phases/implementation/profile.md +2 -1
  50. package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +6 -0
  51. package/runtime/python/okstra_ctl/phases/implementation/spec.md +6 -1
  52. package/runtime/python/okstra_ctl/phases/implementation/validation.py +62 -1
  53. package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +1 -1
  54. package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +24 -3
  55. package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +9 -9
  56. package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +4 -2
  57. package/runtime/python/okstra_ctl/render.py +0 -10
  58. package/runtime/python/okstra_ctl/render_final_report.py +4 -4
  59. package/runtime/python/okstra_ctl/report_finalize.py +37 -76
  60. package/runtime/python/okstra_ctl/report_html/render.py +4 -44
  61. package/runtime/python/okstra_ctl/report_language.py +3 -3
  62. package/runtime/python/okstra_ctl/report_prose_keys.py +153 -0
  63. package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
  64. package/runtime/python/okstra_ctl/run.py +1 -21
  65. package/runtime/python/okstra_ctl/stage_done.py +183 -0
  66. package/runtime/python/okstra_ctl/stage_fail.py +114 -0
  67. package/runtime/python/okstra_ctl/stage_integrate.py +37 -2
  68. package/runtime/python/okstra_ctl/stage_rebase.py +189 -0
  69. package/runtime/python/okstra_ctl/stage_reopen.py +12 -8
  70. package/runtime/python/okstra_ctl/stage_targets.py +29 -62
  71. package/runtime/python/okstra_ctl/team.py +1 -6
  72. package/runtime/python/okstra_ctl/wizard/confirmation.py +0 -8
  73. package/runtime/python/okstra_ctl/wizard/registry.py +2 -3
  74. package/runtime/python/okstra_ctl/wizard/sources.py +10 -0
  75. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -12
  76. package/runtime/python/okstra_ctl/worktree/__init__.py +1 -1
  77. package/runtime/python/okstra_ctl/worktree/decisions.py +3 -4
  78. package/runtime/python/okstra_ctl/worktree/provision.py +27 -0
  79. package/runtime/python/okstra_ctl/worktree/rebase.py +108 -0
  80. package/runtime/python/okstra_ctl/worktree_registry.py +14 -0
  81. package/runtime/schemas/final-report-v3.0.schema.json +78 -0
  82. package/runtime/skills/_fragments/wizard-render-bundle.md +1 -1
  83. package/runtime/skills/okstra-discussion/SKILL.md +1 -1
  84. package/runtime/skills/okstra-run/SKILL.md +1 -1
  85. package/runtime/skills/okstra-setup/SKILL.md +2 -2
  86. package/runtime/skills/okstra-setup/references/project-config.md +2 -2
  87. package/runtime/templates/report-writer-prompt-preamble.md +1 -1
  88. package/runtime/templates/reports/html/i18n/en.json +6 -1
  89. package/runtime/templates/reports/html/i18n/ko.json +6 -1
  90. package/runtime/templates/worker-prompt-preamble.md +1 -1
  91. package/runtime/validators/validate-run.py +3 -28
  92. package/runtime/agents/operations/report-translation.json +0 -6
  93. package/runtime/agents/roles/translator.json +0 -19
  94. package/runtime/bin/okstra-report-translate.py +0 -404
  95. package/runtime/prompts/duties/translator.json +0 -32
  96. package/runtime/python/okstra_ctl/report_translation.py +0 -509
  97. package/runtime/python/okstra_ctl/report_translation_dispatch.py +0 -356
  98. package/runtime/templates/translator-prompt-preamble.md +0 -36
@@ -0,0 +1,183 @@
1
+ """`okstra stage-done` — settle a passing implementation stage: carry sidecar, then `done` row.
2
+
3
+ Both writes were lead hand-work. The `done` row needed a python call to `append_consumer`,
4
+ which Claude Code auto mode blocks, and the carry path in `_implementation-deliverable.md`
5
+ (`runs/<impl-task-key>/carry/stage-<N>.json`) read as the staged run directory: measured
6
+ 2026-10-17 (figma-plugin dev-11377 run 002), the lead wrote
7
+ `runs/implementation/stage-1/carry/stage-1.json`, validate-run looked for the flat
8
+ `runs/implementation/carry/stage-1.json`, and the `done` row kept the wrong `carry_path`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import re
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ from okstra_project import ResolverError, StateError, resolve_project_root
19
+
20
+ from .consumers import (
21
+ FAILED_CARRY_STATUSES,
22
+ append_consumer,
23
+ carry_head_commit,
24
+ read_consumers,
25
+ )
26
+ from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
27
+ from .paths import RunRef
28
+ from .stage_reopen import resolve_planned_stage
29
+
30
+ _REPORT_LABEL_RE = re.compile(r"^final-report-(?P<label>[a-z-]+-\d+)\.")
31
+
32
+
33
+ def _read_carry(carry_file: Path, stage: int) -> dict:
34
+ try:
35
+ carry = load_owned_object(carry_file, artifact="implementation stage carry")
36
+ except (JsonBoundaryError, OSError) as exc:
37
+ raise StateError(
38
+ f"--carry must name the executor's `### Stage Carry Evidence` JSON saved under "
39
+ f"this run's .okstra tree: {exc}", stage="carry") from exc
40
+ recorded = carry.get("stage", carry.get("stageNumber"))
41
+ if isinstance(recorded, int) and recorded != stage:
42
+ raise StateError(f"--carry records stage {recorded}, not {stage}", stage="carry")
43
+ if str(carry.get("status") or "").lower() in FAILED_CARRY_STATUSES:
44
+ raise StateError(
45
+ f"--carry records status {carry['status']!r}; a failed stage is recorded with "
46
+ "`okstra stage-fail`", stage="carry")
47
+ if not carry_head_commit(carry):
48
+ raise StateError("--carry names no head commit (stageCommitRange.head)", stage="carry")
49
+ return carry
50
+
51
+
52
+ def _supersede(target: Path, rows: list[dict], stage: int) -> Path:
53
+ """Move the sidecar of the run that first settled a reopened stage aside, unchanged."""
54
+ settled = [r for r in rows if r.get("stage") == stage and r.get("status") == "done"]
55
+ if not settled:
56
+ raise StateError(
57
+ f"{target} exists but stage {stage} was never settled — one stage holds one "
58
+ "sidecar; inspect it before replacing it by hand", stage="carry")
59
+ match = _REPORT_LABEL_RE.match(Path(str(settled[-1].get("report_path") or "")).name)
60
+ if not match:
61
+ raise StateError(
62
+ f"cannot name the superseded sidecar: the earlier done row's report_path "
63
+ f"{settled[-1].get('report_path')!r} is not a final-report path", stage="carry")
64
+ moved = target.parent / "superseded" / f"stage-{stage}-from-{match['label']}.json"
65
+ if moved.exists():
66
+ raise StateError(f"{moved} already exists", stage="carry")
67
+ moved.parent.mkdir(parents=True, exist_ok=True)
68
+ target.rename(moved)
69
+ return moved
70
+
71
+
72
+ def done_stage(
73
+ project_root: Path, task_key: str, stage: int, carry_file: Path, report: Path,
74
+ ) -> dict:
75
+ """Write the stage's carry sidecar at its canonical path and append the `done` row."""
76
+ identity, plan_run_root = resolve_planned_stage(project_root, task_key, stage)
77
+ carry = _read_carry(carry_file, stage)
78
+ head = carry_head_commit(carry)
79
+ report = report if report.is_absolute() else project_root / report
80
+ try:
81
+ report_rel = report.relative_to(RunRef.from_report_path(report).run_dir).as_posix()
82
+ except ValueError as exc:
83
+ raise StateError(f"--report is not a final-report path: {report}", stage="report") from exc
84
+
85
+ rows = read_consumers(plan_run_root)
86
+ last = [r for r in rows if r.get("stage") == stage and r.get("status") in ("done", "failed")]
87
+ if last and last[-1].get("status") == "done" and last[-1].get("head_commit") != head:
88
+ raise StateError(
89
+ f"stage {stage} is already done at {last[-1].get('head_commit')}; withdraw it with "
90
+ "`okstra stage-reopen` before settling another head", stage="consumers")
91
+
92
+ task_root = Path(identity["taskRoot"])
93
+ target = RunRef.from_task_root(task_root, "implementation").carry(stage)
94
+ superseded = None
95
+ if target.exists():
96
+ existing = load_owned_object(target, artifact="implementation stage carry")
97
+ if carry_head_commit(existing) != head:
98
+ superseded = _supersede(target, rows, stage)
99
+ if not target.exists():
100
+ target.parent.mkdir(parents=True, exist_ok=True)
101
+ write_owned_object_atomic(target, carry, artifact="implementation stage carry")
102
+ carry_rel = target.relative_to(task_root).as_posix()
103
+ append_consumer(
104
+ plan_run_root,
105
+ impl_task_key=identity["taskKey"],
106
+ stage=stage,
107
+ status="done",
108
+ head_commit=head,
109
+ carry_path=carry_rel,
110
+ report_path=report_rel,
111
+ completed_at=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
112
+ )
113
+ return {
114
+ "taskKey": identity["taskKey"],
115
+ "stage": stage,
116
+ "headCommit": head,
117
+ "carryPath": str(target),
118
+ "supersededCarryPath": str(superseded) if superseded else "",
119
+ "reportPath": report_rel,
120
+ "consumersPath": str(plan_run_root / "consumers.jsonl"),
121
+ }
122
+
123
+
124
+ _CLI_EPILOG = r"""Usage:
125
+ okstra stage-done <task-key> --stage <N> --carry <carry.json> --report <final-report path>
126
+
127
+ Settles an implementation stage whose synthesised verdict is not FAIL. Writes the
128
+ executor's `### Stage Carry Evidence` JSON (saved by the lead under the run's
129
+ .okstra tree, with the post-fix head after a lead minor fix) to the stage's
130
+ canonical sidecar `runs/implementation/carry/stage-<N>.json`, then appends the
131
+ `done` row with that carry path, the report path relative to its run directory,
132
+ the carry's head and the completion time. A reopened stage's previous sidecar
133
+ is moved to `carry/superseded/stage-<N>-from-<task-type>-<seq>.json` first.
134
+ Refuses a carry for another stage, a failed carry, and a stage already done at
135
+ another head. Re-running with the same carry writes nothing new.
136
+
137
+ Output: JSON { ok, taskKey, stage, headCommit, carryPath, supersededCarryPath,
138
+ reportPath, consumersPath }. Exit 1 on a refusal, 2 when
139
+ PROJECT_ROOT cannot be resolved.
140
+ """
141
+
142
+
143
+ def main(argv: list[str] | None = None) -> int:
144
+ parser = argparse.ArgumentParser(
145
+ description="Settle a passing implementation stage (carry sidecar + done row).",
146
+ epilog=_CLI_EPILOG,
147
+ formatter_class=argparse.RawDescriptionHelpFormatter,
148
+ prog="okstra stage-done")
149
+ parser.add_argument("task_key", metavar="task-key",
150
+ help="project-id:task-group:task-id")
151
+ parser.add_argument("--stage", type=int, required=True,
152
+ help="the Stage Map number this run implemented")
153
+ parser.add_argument("--carry", type=Path, required=True,
154
+ help="the Stage Carry Evidence JSON saved under the run's .okstra tree")
155
+ parser.add_argument("--report", type=Path, required=True,
156
+ help="this run's final-report path")
157
+ parser.add_argument("--project-root", "--project", default="",
158
+ help="use this directory as PROJECT_ROOT")
159
+ parser.add_argument("--cwd", default=".",
160
+ help="resolve PROJECT_ROOT starting from here")
161
+ args = parser.parse_args(argv)
162
+
163
+ def emit(payload: dict) -> None:
164
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
165
+
166
+ try:
167
+ resolved = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
168
+ except ResolverError as exc:
169
+ emit({"ok": False, "stage": "resolve", "reason": str(exc)})
170
+ return 2
171
+ try:
172
+ settled = done_stage(Path(resolved), args.task_key, args.stage,
173
+ args.carry, args.report)
174
+ except StateError as exc:
175
+ emit({"ok": False, "stage": getattr(exc, "stage", None) or "stage-done",
176
+ "reason": str(exc)})
177
+ return 1
178
+ emit({"ok": True, **settled})
179
+ return 0
180
+
181
+
182
+ if __name__ == "__main__":
183
+ raise SystemExit(main())
@@ -0,0 +1,114 @@
1
+ """`okstra stage-fail` — FAIL 로 끝난 implementation run 의 stage 에 `failed` 행을 남긴다.
2
+
3
+ 종합 verdict 가 FAIL 이면 리드는 carry sidecar 대신 `failed` 행을 써야 한다
4
+ (`_implementation-deliverable.md` §"Lead post-stage persistence"). 그 행을 쓰는 명령이
5
+ 없어 리드가 `append_consumer` 를 python 으로 직접 불렀고, Claude Code auto-mode 가 그
6
+ 호출을 막아 사용자가 손으로 실행해야 했다 — 실측(2026-10-17, figma-plugin dev-11377).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ from pathlib import Path
13
+
14
+ from okstra_project import ResolverError, StateError, resolve_project_root
15
+
16
+ from .consumers import append_consumer, last_lifecycle_status_by_stage, read_consumers
17
+ from .stage_reopen import resolve_planned_stage
18
+
19
+
20
+ def fail_stage(
21
+ project_root: Path, task_key: str, stage: int, head_commit: str, report_path: str,
22
+ reason: str,
23
+ ) -> dict:
24
+ """아직 `done` 이 아닌 stage 에 `failed` 행을 붙인다."""
25
+ for flag, value in (("--head-commit", head_commit), ("--report", report_path),
26
+ ("--reason", reason)):
27
+ if not value.strip():
28
+ raise StateError(f"{flag} is required", stage=flag.lstrip("-"))
29
+ identity, plan_run_root = resolve_planned_stage(project_root, task_key, stage)
30
+ recorded = last_lifecycle_status_by_stage(read_consumers(plan_run_root)).get(stage)
31
+ if recorded == "done":
32
+ raise StateError(
33
+ f"stage {stage} is recorded 'done' — withdraw a settled stage with "
34
+ "`okstra stage-reopen`, which records the done head it withdraws",
35
+ stage="consumers",
36
+ )
37
+ append_consumer(
38
+ plan_run_root,
39
+ impl_task_key=identity["taskKey"],
40
+ stage=stage,
41
+ status="failed",
42
+ head_commit=head_commit.strip(),
43
+ report_path=report_path.strip(),
44
+ reason=reason.strip(),
45
+ )
46
+ return {
47
+ "taskKey": identity["taskKey"],
48
+ "stage": stage,
49
+ "headCommit": head_commit.strip(),
50
+ "reportPath": report_path.strip(),
51
+ "reason": reason.strip(),
52
+ "consumersPath": str(plan_run_root / "consumers.jsonl"),
53
+ }
54
+
55
+
56
+ _CLI_EPILOG = r"""Usage:
57
+ okstra stage-fail <task-key> --stage <N> --head-commit <sha> --report <path> --reason "<why>"
58
+
59
+ Appends the `failed` row an implementation run writes when its synthesised verdict
60
+ is FAIL, or when it ends with no verifier result: dependents stay blocked, the
61
+ stage's worktree occupancy is released, and `/okstra-run` offers the stage for a
62
+ fix run that reuses its worktree and branch. --report is this run's final-report
63
+ path as the `done` row would record it. Refuses a stage that is not in the latest
64
+ Stage Map, is cancelled, or is already `done` (use `okstra stage-reopen`).
65
+ Appending the same row twice writes it once.
66
+
67
+ Output: JSON { ok, taskKey, stage, headCommit, reportPath, reason, consumersPath }.
68
+ Exit 1 on a refusal, 2 when PROJECT_ROOT cannot be resolved.
69
+ """
70
+
71
+
72
+ def main(argv: list[str] | None = None) -> int:
73
+ parser = argparse.ArgumentParser(
74
+ description="Record a FAIL-ended implementation stage as failed.",
75
+ epilog=_CLI_EPILOG,
76
+ formatter_class=argparse.RawDescriptionHelpFormatter,
77
+ prog="okstra stage-fail")
78
+ parser.add_argument("task_key", metavar="task-key",
79
+ help="project-id:task-group:task-id")
80
+ parser.add_argument("--stage", type=int, required=True,
81
+ help="the Stage Map number this run implemented")
82
+ parser.add_argument("--head-commit", required=True,
83
+ help="the stage worktree HEAD at the end of this run")
84
+ parser.add_argument("--report", required=True,
85
+ help="this run's final-report path, as a done row records it")
86
+ parser.add_argument("--reason", required=True,
87
+ help="why the stage failed; recorded on the row")
88
+ parser.add_argument("--project-root", "--project", default="",
89
+ help="use this directory as PROJECT_ROOT")
90
+ parser.add_argument("--cwd", default=".",
91
+ help="resolve PROJECT_ROOT starting from here")
92
+ args = parser.parse_args(argv)
93
+
94
+ def emit(payload: dict) -> None:
95
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
96
+
97
+ try:
98
+ resolved = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
99
+ except ResolverError as exc:
100
+ emit({"ok": False, "stage": "resolve", "reason": str(exc)})
101
+ return 2
102
+ try:
103
+ failed = fail_stage(Path(resolved), args.task_key, args.stage,
104
+ args.head_commit, args.report, args.reason)
105
+ except StateError as exc:
106
+ emit({"ok": False, "stage": getattr(exc, "stage", None) or "stage-fail",
107
+ "reason": str(exc)})
108
+ return 1
109
+ emit({"ok": True, **failed})
110
+ return 0
111
+
112
+
113
+ if __name__ == "__main__":
114
+ raise SystemExit(main())
@@ -283,8 +283,14 @@ def _teardown_all(project_id, task_group, task_id, task_wt: str,
283
283
 
284
284
  def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, str] | None:
285
285
  """clean stage worktree 를 물리 제거. dirty/실패면 (outcome, detail), 성공이면 None."""
286
- from .worktree import is_dirty_excluding_okstra, remove_worktree_force
287
-
286
+ from .worktree import (
287
+ is_dirty_excluding_okstra,
288
+ nested_worktree_excludes,
289
+ remove_worktree_force,
290
+ )
291
+
292
+ if nested_worktree_excludes(wt):
293
+ return ("skipped", "nested worktrees remain — preserved")
288
294
  if is_dirty_excluding_okstra(wt):
289
295
  return ("skipped", "dirty — 미커밋 변경 보존")
290
296
  rm = remove_worktree_force(task_wt, wt)
@@ -293,6 +299,35 @@ def _remove_clean_worktree(task_wt: str, wt: str, stage_n: int) -> tuple[str, st
293
299
  return None
294
300
 
295
301
 
302
+ def reclaim_completed_stage(
303
+ project_root: Path, project_id: str, task_group: str, task_id: str,
304
+ stage: int, expected_head: str, done: dict | None, status: str,
305
+ ) -> tuple[str, str]:
306
+ from . import worktree_registry
307
+ from .worktree import main_worktree_path
308
+
309
+ row = worktree_registry.get_stage_row(project_id, task_group, task_id, stage)
310
+ if not row:
311
+ return "skipped", "stage worktree is not registered"
312
+ if status != "done" or row.get("status") == "active":
313
+ return "skipped", "stage is unfinished or active"
314
+ if not expected_head or (done or {}).get("head_commit") != expected_head:
315
+ return "skipped", "completion commit does not match this report"
316
+ tip = _git(project_root, "rev-parse", "--verify", f"refs/heads/{row['branch']}")
317
+ if tip.returncode or tip.stdout.strip() != expected_head:
318
+ return "skipped", "stage branch moved after completion"
319
+ wt = Path(row["worktree_path"])
320
+ if wt.resolve() == main_worktree_path(project_root).resolve():
321
+ return "skipped", "main worktree is preserved"
322
+ if wt.exists():
323
+ current = _git(wt, "symbolic-ref", "--short", "HEAD")
324
+ if current.returncode or current.stdout.strip() != row["branch"]:
325
+ return "skipped", "worktree no longer holds the registered stage branch"
326
+ return _teardown_stage(
327
+ project_id, task_group, task_id, str(project_root), stage, row, expected_head,
328
+ )
329
+
330
+
296
331
  def _unmerged_branch_tip_warning(task_wt: str, stage_n: int, branch: str,
297
332
  verified_head: str = "") -> str:
298
333
  """Warn text when the stage branch tip is not an ancestor of the commit
@@ -0,0 +1,189 @@
1
+ """선행 기준이 이동한 단계 브랜치를 의존 순서로 재배치한다."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from . import consumers, worktree_registry
8
+ from .stage_targets import (
9
+ StageWorktreeIdentity,
10
+ order_stage_closure,
11
+ resolve_stage_base_commit,
12
+ )
13
+ from .worktree import is_ancestor
14
+ from .worktree.rebase import _checked_git, rebase_stage_branch
15
+
16
+
17
+ def _rebase_completed_stage(
18
+ project_root: Path,
19
+ plan_run_root: Path,
20
+ identity: StageWorktreeIdentity,
21
+ row: dict,
22
+ done_row: dict,
23
+ base: str,
24
+ ) -> dict:
25
+ number, branch = row["stage"], row["branch"]
26
+ tip = _checked_git(project_root, "rev-parse", f"refs/heads/{branch}")
27
+ if tip != done_row.get("head_commit"):
28
+ raise RuntimeError(
29
+ f"stage {number} branch moved beyond its done commit; reconcile it before rebasing"
30
+ )
31
+ head = rebase_stage_branch(project_root, branch, row["base_ref"], base)
32
+ worktree_registry.update_stage_base(
33
+ identity.project_id,
34
+ identity.task_group,
35
+ identity.task_id,
36
+ number,
37
+ expected_base=row["base_ref"],
38
+ base_ref=base,
39
+ )
40
+ consumers.append_consumer(
41
+ plan_run_root,
42
+ impl_task_key=done_row["impl_task_key"],
43
+ stage=number,
44
+ status="done",
45
+ force_reappend=True,
46
+ head_commit=head,
47
+ replaced_commit=tip,
48
+ reconciled=True,
49
+ reconcile_reason="dependency-rebase",
50
+ report_path=done_row.get("report_path", ""),
51
+ )
52
+ return {**done_row, "head_commit": head}
53
+
54
+
55
+ def _refresh_predecessor_head(
56
+ root: Path,
57
+ plan: Path,
58
+ row: dict,
59
+ done_row: dict,
60
+ active: bool,
61
+ ) -> dict:
62
+ tip = _checked_git(root, "rev-parse", f"refs/heads/{row['branch']}")
63
+ previous = done_row["head_commit"]
64
+ if tip == previous:
65
+ return done_row
66
+ if active or not is_ancestor(root, previous, tip):
67
+ raise RuntimeError(
68
+ f"predecessor stage {row['stage']} is active or rewritten; finish or reconcile it first"
69
+ )
70
+ consumers.append_consumer(
71
+ plan,
72
+ impl_task_key=done_row["impl_task_key"],
73
+ stage=row["stage"],
74
+ status="done",
75
+ force_reappend=True,
76
+ head_commit=tip,
77
+ replaced_commit=previous,
78
+ reconciled=True,
79
+ reconcile_reason="predecessor-advance",
80
+ report_path=done_row.get("report_path", ""),
81
+ )
82
+ return {**done_row, "head_commit": tip}
83
+
84
+
85
+ def synchronize_stage_stack(
86
+ *,
87
+ project_root: Path,
88
+ plan_run_root: Path,
89
+ stage_map: list[dict],
90
+ identity: StageWorktreeIdentity,
91
+ ) -> list[int]:
92
+ """호출자가 task-key provision 잠금을 잡은 상태에서 실행한다."""
93
+ rows = consumers.read_consumers(plan_run_root)
94
+ done = consumers.latest_done_by_stage(rows)
95
+ statuses = consumers.last_lifecycle_status_by_stage(rows)
96
+ anchor = (
97
+ worktree_registry.get_implementation_base(
98
+ identity.project_id,
99
+ identity.task_group,
100
+ identity.task_id,
101
+ )
102
+ or ""
103
+ )
104
+ graph = [(s["stage_number"], s.get("depends_on") or []) for s in stage_map]
105
+ order = order_stage_closure(graph, {n for n, _ in graph}, set())
106
+ if len(order) != len(graph):
107
+ raise RuntimeError("cannot synchronize a cyclic Stage Map")
108
+ by_number = {s["stage_number"]: s for s in stage_map}
109
+ predecessors = {dep for _, deps in graph for dep in deps}
110
+ changed = []
111
+ for number in order:
112
+ row = worktree_registry.get_stage_row(
113
+ identity.project_id,
114
+ identity.task_group,
115
+ identity.task_id,
116
+ number,
117
+ )
118
+ stage = by_number[number]
119
+ if not row or number not in done or stage.get("cancelled"):
120
+ continue
121
+ active = statuses.get(number) == "started" or row.get("status") == "active"
122
+ if _synchronize_recorded_stage(
123
+ project_root,
124
+ plan_run_root,
125
+ identity,
126
+ stage,
127
+ row,
128
+ done,
129
+ anchor,
130
+ active,
131
+ number in predecessors,
132
+ ):
133
+ changed.append(number)
134
+ return changed
135
+
136
+
137
+ def _synchronize_recorded_stage(
138
+ root: Path,
139
+ plan: Path,
140
+ identity: StageWorktreeIdentity,
141
+ stage: dict,
142
+ row: dict,
143
+ done: dict[int, dict],
144
+ anchor: str,
145
+ active: bool,
146
+ predecessor: bool,
147
+ ) -> bool:
148
+ number = stage["stage_number"]
149
+ previous = done[number]["head_commit"]
150
+ if predecessor:
151
+ done[number] = _refresh_predecessor_head(root, plan, row, done[number], active)
152
+ if stage.get("depends_on"):
153
+ done[number] = _synchronize_dependent(
154
+ root,
155
+ plan,
156
+ identity,
157
+ stage,
158
+ row,
159
+ done,
160
+ anchor,
161
+ active,
162
+ )
163
+ return previous != done[number]["head_commit"]
164
+
165
+
166
+ def _synchronize_dependent(
167
+ root: Path,
168
+ plan: Path,
169
+ identity: StageWorktreeIdentity,
170
+ stage: dict,
171
+ row: dict,
172
+ done: dict[int, dict],
173
+ anchor: str,
174
+ active: bool,
175
+ ) -> dict:
176
+ number = stage["stage_number"]
177
+ base = resolve_stage_base_commit(
178
+ stage,
179
+ list(done.values()),
180
+ anchor,
181
+ project_root=root,
182
+ plan_run_root=plan,
183
+ identity=identity,
184
+ )
185
+ if row["base_ref"] == base:
186
+ return done[number]
187
+ if active:
188
+ raise RuntimeError(f"cannot rebase active stage {number}; finish its run first")
189
+ return _rebase_completed_stage(root, plan, identity, row, done[number], base)
@@ -31,16 +31,10 @@ from .paths import RunRef
31
31
  from .stage_map import StageMapError, load_latest_plan_stage_map
32
32
 
33
33
 
34
- def reopen_stage(
35
- project_root: Path, task_key: str, stage: int, reason: str, evidence: str = "",
36
- ) -> dict:
37
- """마지막 행이 `done` 인 stage 에 `failed` 행을 붙이고 그 근거를 돌려준다."""
38
- if not reason.strip():
39
- raise StateError("--reason is required: say why the stage is reopened",
40
- stage="reason")
34
+ def resolve_planned_stage(project_root: Path, task_key: str, stage: int) -> tuple[dict, Path]:
35
+ """task identity 와 plan run root 를 돌려준다. 최신 Stage Map 에 없거나 취소된 stage 는 거절한다."""
41
36
  identity = resolve_task_identity(project_root, task_key)
42
37
  task_root = Path(identity["taskRoot"])
43
- plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
44
38
  try:
45
39
  stage_map = load_latest_plan_stage_map(task_root)
46
40
  except StageMapError as exc:
@@ -50,7 +44,17 @@ def reopen_stage(
50
44
  raise StateError(f"stage {stage} is not in this task's Stage Map", stage="stage-map")
51
45
  if any(row.get("stage_number") == stage and row.get("cancelled") for row in rows):
52
46
  raise StateError(f"stage {stage} is cancelled in the latest plan", stage="stage-map")
47
+ return identity, RunRef.from_task_root(task_root, "implementation-planning").run_dir
48
+
53
49
 
50
+ def reopen_stage(
51
+ project_root: Path, task_key: str, stage: int, reason: str, evidence: str = "",
52
+ ) -> dict:
53
+ """마지막 행이 `done` 인 stage 에 `failed` 행을 붙이고 그 근거를 돌려준다."""
54
+ if not reason.strip():
55
+ raise StateError("--reason is required: say why the stage is reopened",
56
+ stage="reason")
57
+ identity, plan_run_root = resolve_planned_stage(project_root, task_key, stage)
54
58
  consumers = read_consumers(plan_run_root)
55
59
  recorded = last_lifecycle_status_by_stage(consumers).get(stage)
56
60
  if recorded != "done":