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.
- package/README.md +5 -5
- package/dist/cli-registry.mjs +18 -10
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/lifecycle/config.mjs +2 -3
- package/dist/commands/lifecycle/config.mjs.map +1 -1
- package/dist/lib/runtime-payload.mjs +1 -2
- package/dist/lib/runtime-payload.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +9 -7
- package/docs/cli.md +5 -3
- package/docs/project-structure-overview.md +3 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +5 -4
- package/runtime/prompts/lead/report-writer.md +10 -17
- package/runtime/prompts/lead/team-contract.md +1 -2
- package/runtime/prompts/wizard/prompts.ko.json +0 -1
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +0 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +0 -1
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +0 -1
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +2 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +0 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +2 -27
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +0 -4
- package/runtime/python/okstra_ctl/blocking_checks.py +8 -0
- package/runtime/python/okstra_ctl/convergence_provenance.py +8 -2
- package/runtime/python/okstra_ctl/correction_cli.py +59 -0
- package/runtime/python/okstra_ctl/correction_round.py +168 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +21 -349
- package/runtime/python/okstra_ctl/dispatch_state.py +0 -13
- package/runtime/python/okstra_ctl/domain/provider.py +1 -1
- package/runtime/python/okstra_ctl/domain/role.py +0 -6
- package/runtime/python/okstra_ctl/final_report_paths.py +0 -16
- package/runtime/python/okstra_ctl/i18n.py +2 -2
- package/runtime/python/okstra_ctl/path_hints.py +0 -4
- package/runtime/python/okstra_ctl/paths.py +0 -3
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -0
- package/runtime/python/okstra_ctl/phases/final_verification/spec.md +2 -0
- package/runtime/python/okstra_ctl/phases/final_verification/target.py +17 -1
- package/runtime/python/okstra_ctl/phases/implementation/entry.py +12 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +30 -6
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -0
- package/runtime/python/okstra_ctl/phases/implementation/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/implementation/report_assets/implementation.template.html +6 -0
- package/runtime/python/okstra_ctl/phases/implementation/spec.md +6 -1
- package/runtime/python/okstra_ctl/phases/implementation/validation.py +62 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/release_handoff/operations.py +24 -3
- package/runtime/python/okstra_ctl/phases/release_handoff/profile.md +9 -9
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +4 -2
- package/runtime/python/okstra_ctl/render.py +0 -10
- package/runtime/python/okstra_ctl/render_final_report.py +4 -4
- package/runtime/python/okstra_ctl/report_finalize.py +37 -76
- package/runtime/python/okstra_ctl/report_html/render.py +4 -44
- package/runtime/python/okstra_ctl/report_language.py +3 -3
- package/runtime/python/okstra_ctl/report_prose_keys.py +153 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +1 -1
- package/runtime/python/okstra_ctl/run.py +1 -21
- package/runtime/python/okstra_ctl/stage_done.py +183 -0
- package/runtime/python/okstra_ctl/stage_fail.py +114 -0
- package/runtime/python/okstra_ctl/stage_integrate.py +37 -2
- package/runtime/python/okstra_ctl/stage_rebase.py +189 -0
- package/runtime/python/okstra_ctl/stage_reopen.py +12 -8
- package/runtime/python/okstra_ctl/stage_targets.py +29 -62
- package/runtime/python/okstra_ctl/team.py +1 -6
- package/runtime/python/okstra_ctl/wizard/confirmation.py +0 -8
- package/runtime/python/okstra_ctl/wizard/registry.py +2 -3
- package/runtime/python/okstra_ctl/wizard/sources.py +10 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -12
- package/runtime/python/okstra_ctl/worktree/__init__.py +1 -1
- package/runtime/python/okstra_ctl/worktree/decisions.py +3 -4
- package/runtime/python/okstra_ctl/worktree/provision.py +27 -0
- package/runtime/python/okstra_ctl/worktree/rebase.py +108 -0
- package/runtime/python/okstra_ctl/worktree_registry.py +14 -0
- package/runtime/schemas/final-report-v3.0.schema.json +78 -0
- package/runtime/skills/_fragments/wizard-render-bundle.md +1 -1
- package/runtime/skills/okstra-discussion/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-setup/SKILL.md +2 -2
- package/runtime/skills/okstra-setup/references/project-config.md +2 -2
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/templates/reports/html/i18n/en.json +6 -1
- package/runtime/templates/reports/html/i18n/ko.json +6 -1
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-run.py +3 -28
- package/runtime/agents/operations/report-translation.json +0 -6
- package/runtime/agents/roles/translator.json +0 -19
- package/runtime/bin/okstra-report-translate.py +0 -404
- package/runtime/prompts/duties/translator.json +0 -32
- package/runtime/python/okstra_ctl/report_translation.py +0 -509
- package/runtime/python/okstra_ctl/report_translation_dispatch.py +0 -356
- 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
|
|
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
|
|
35
|
-
|
|
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":
|