okstra 0.52.0 → 0.53.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.kr.md +1 -1
- package/README.md +1 -1
- package/docs/kr/architecture.md +1 -0
- package/docs/kr/cli.md +2 -1
- package/docs/superpowers/plans/2026-06-06-final-verification-whole-task-gate.md +993 -0
- package/docs/superpowers/plans/2026-06-06-stage-parallel-and-pending-fixes.md +93 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p1.md +447 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p2.md +289 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p3.md +774 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p4.md +303 -0
- package/docs/superpowers/plans/2026-06-06-stage-worktree-isolation-p5-multidep-base.md +387 -0
- package/docs/superpowers/specs/2026-06-06-final-verification-whole-task-gate-design.md +126 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +180 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -0
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +16 -9
- package/runtime/prompts/profiles/_implementation-verifier.md +4 -1
- package/runtime/prompts/profiles/final-verification.md +7 -7
- package/runtime/prompts/profiles/implementation-planning.md +8 -4
- package/runtime/prompts/wizard/prompts.ko.json +3 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +14 -2
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/run.py +541 -41
- package/runtime/python/okstra_ctl/wizard.py +25 -7
- package/runtime/python/okstra_ctl/worktree.py +126 -9
- package/runtime/python/okstra_ctl/worktree_registry.py +88 -17
- package/runtime/schemas/final-report-v1.0.schema.json +36 -0
- package/runtime/skills/okstra-convergence/SKILL.md +14 -3
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/templates/reports/final-report.template.md +12 -0
- package/runtime/templates/reports/final-verification-input.template.md +8 -5
- package/runtime/templates/reports/i18n/en.json +3 -1
- package/runtime/templates/reports/i18n/ko.json +3 -1
- package/runtime/validators/validate-run.py +143 -1
- package/runtime/validators/validate-workflow.sh +6 -1
|
@@ -88,6 +88,12 @@ APPROVED_FRONTMATTER_PATTERN = re.compile(
|
|
|
88
88
|
re.IGNORECASE | re.MULTILINE,
|
|
89
89
|
)
|
|
90
90
|
|
|
91
|
+
PLAN_BODY_GATE_PATTERN = re.compile(
|
|
92
|
+
r"Gate result[^A-Za-z\n]+(?P<value>[a-z][a-z\-]+)",
|
|
93
|
+
re.IGNORECASE,
|
|
94
|
+
)
|
|
95
|
+
BLOCKING_PLAN_BODY_GATES = {"blocked-by-disagreement", "aborted-non-result"}
|
|
96
|
+
|
|
91
97
|
# Frontmatter implementation-option matcher.
|
|
92
98
|
#
|
|
93
99
|
# `approved:` 바로 아래 줄의 `implementation-option:` 한 줄을 식별한다.
|
|
@@ -112,6 +118,138 @@ def _extract_frontmatter_block(body: str) -> str | None:
|
|
|
112
118
|
return m.group(1) if m else None
|
|
113
119
|
|
|
114
120
|
|
|
121
|
+
def _final_report_data_path(report_path: Path) -> Path:
|
|
122
|
+
name = report_path.name
|
|
123
|
+
if name.endswith(".md"):
|
|
124
|
+
return report_path.with_name(name[:-3] + ".data.json")
|
|
125
|
+
return report_path.with_suffix(".data.json")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_final_report_data_if_present(report_path: Path) -> tuple[Path, dict] | None:
|
|
129
|
+
data_path = _final_report_data_path(report_path)
|
|
130
|
+
if not data_path.is_file():
|
|
131
|
+
return None
|
|
132
|
+
try:
|
|
133
|
+
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
134
|
+
except json.JSONDecodeError as exc:
|
|
135
|
+
raise PrepareError(
|
|
136
|
+
f"approved plan data.json is invalid JSON: {data_path}: {exc}"
|
|
137
|
+
) from exc
|
|
138
|
+
if not isinstance(data, dict):
|
|
139
|
+
raise PrepareError(f"approved plan data.json must be a JSON object: {data_path}")
|
|
140
|
+
return data_path, data
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _data_json_gate_result(data: dict) -> str:
|
|
144
|
+
planning = data.get("implementationPlanning")
|
|
145
|
+
if not isinstance(planning, dict):
|
|
146
|
+
return ""
|
|
147
|
+
verification = planning.get("planBodyVerification")
|
|
148
|
+
if not isinstance(verification, dict):
|
|
149
|
+
return ""
|
|
150
|
+
return str(verification.get("gateResult") or "").strip().lower()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _reject_blocking_plan_body_gate(path: Path, body: str, *, action: str) -> None:
|
|
154
|
+
gate_match = PLAN_BODY_GATE_PATTERN.search(body)
|
|
155
|
+
if gate_match:
|
|
156
|
+
gate_value = gate_match.group("value").strip().lower()
|
|
157
|
+
if gate_value in BLOCKING_PLAN_BODY_GATES:
|
|
158
|
+
raise PrepareError(
|
|
159
|
+
f"{action} rejected because approved plan Gate result is "
|
|
160
|
+
f"`{gate_value}`: {path}\n"
|
|
161
|
+
" resolve the plan-body verification disagreement and regenerate "
|
|
162
|
+
"the implementation-planning report before approval."
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
loaded = _load_final_report_data_if_present(path)
|
|
166
|
+
if loaded is None:
|
|
167
|
+
return
|
|
168
|
+
data_path, data = loaded
|
|
169
|
+
data_gate = _data_json_gate_result(data)
|
|
170
|
+
if data_gate in BLOCKING_PLAN_BODY_GATES:
|
|
171
|
+
raise PrepareError(
|
|
172
|
+
f"{action} rejected because approved plan data.json Gate result is "
|
|
173
|
+
f"`{data_gate}`: {data_path}\n"
|
|
174
|
+
" data.json is the final-report source of truth; regenerate the "
|
|
175
|
+
"report after resolving the verification disagreement."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _validate_data_json_approval_consistency(
|
|
180
|
+
path: Path,
|
|
181
|
+
*,
|
|
182
|
+
markdown_approved: bool,
|
|
183
|
+
) -> None:
|
|
184
|
+
loaded = _load_final_report_data_if_present(path)
|
|
185
|
+
if loaded is None:
|
|
186
|
+
return
|
|
187
|
+
data_path, data = loaded
|
|
188
|
+
frontmatter = data.get("frontmatter")
|
|
189
|
+
if not isinstance(frontmatter, dict) or "approved" not in frontmatter:
|
|
190
|
+
raise PrepareError(
|
|
191
|
+
f"approved plan data.json has no frontmatter.approved field: {data_path}"
|
|
192
|
+
)
|
|
193
|
+
data_approved = frontmatter.get("approved")
|
|
194
|
+
if not isinstance(data_approved, bool):
|
|
195
|
+
raise PrepareError(
|
|
196
|
+
f"approved plan data.json frontmatter.approved is not boolean: {data_path}"
|
|
197
|
+
)
|
|
198
|
+
if data_approved != markdown_approved:
|
|
199
|
+
raise PrepareError(
|
|
200
|
+
"approved plan markdown frontmatter and data.json disagree on "
|
|
201
|
+
f"`approved`: markdown={str(markdown_approved).lower()}, "
|
|
202
|
+
f"data.json={str(data_approved).lower()}\n"
|
|
203
|
+
f" markdown: {path}\n"
|
|
204
|
+
f" data.json: {data_path}\n"
|
|
205
|
+
" regenerate the report from data.json or use `okstra --approve` "
|
|
206
|
+
"so the source of truth is updated first."
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _set_data_json_approved_true_if_present(path: Path) -> bool:
|
|
211
|
+
loaded = _load_final_report_data_if_present(path)
|
|
212
|
+
if loaded is None:
|
|
213
|
+
return False
|
|
214
|
+
data_path, data = loaded
|
|
215
|
+
data_gate = _data_json_gate_result(data)
|
|
216
|
+
if data_gate in BLOCKING_PLAN_BODY_GATES:
|
|
217
|
+
raise PrepareError(
|
|
218
|
+
f"--approve rejected because approved plan data.json Gate result is "
|
|
219
|
+
f"`{data_gate}`: {data_path}"
|
|
220
|
+
)
|
|
221
|
+
frontmatter = data.setdefault("frontmatter", {})
|
|
222
|
+
if not isinstance(frontmatter, dict):
|
|
223
|
+
raise PrepareError(
|
|
224
|
+
f"approved plan data.json frontmatter must be an object: {data_path}"
|
|
225
|
+
)
|
|
226
|
+
frontmatter["approved"] = True
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
from .render_final_report import (
|
|
230
|
+
FinalReportRenderError,
|
|
231
|
+
find_default_template,
|
|
232
|
+
render,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
rendered = render(data, template_path=find_default_template())
|
|
236
|
+
except FinalReportRenderError as exc:
|
|
237
|
+
raise PrepareError(
|
|
238
|
+
f"--approve could not re-render approved plan from data.json: {exc}"
|
|
239
|
+
) from exc
|
|
240
|
+
|
|
241
|
+
data_tmp = data_path.with_suffix(data_path.suffix + f".tmp.{os.getpid()}")
|
|
242
|
+
md_tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
|
|
243
|
+
data_tmp.write_text(
|
|
244
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
245
|
+
encoding="utf-8",
|
|
246
|
+
)
|
|
247
|
+
md_tmp.write_text(rendered, encoding="utf-8")
|
|
248
|
+
data_tmp.replace(data_path)
|
|
249
|
+
md_tmp.replace(path)
|
|
250
|
+
return True
|
|
251
|
+
|
|
252
|
+
|
|
115
253
|
class PrepareError(Exception):
|
|
116
254
|
"""surface to caller — task bundle prepare failed."""
|
|
117
255
|
|
|
@@ -195,6 +333,8 @@ def _validate_approved_plan(path: str) -> None:
|
|
|
195
333
|
"or re-run okstra with `--approve` to flip it from the CLI.\n"
|
|
196
334
|
" resolve any `Blocks=approval` rows in `## 1. Clarification Items` first."
|
|
197
335
|
)
|
|
336
|
+
_reject_blocking_plan_body_gate(p, body, action="approved plan validation")
|
|
337
|
+
_validate_data_json_approval_consistency(p, markdown_approved=True)
|
|
198
338
|
# frontmatter approved == true 상태. §1 Clarification Items 의
|
|
199
339
|
# Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
|
|
200
340
|
blockers = unresolved_approval_blockers(body)
|
|
@@ -235,13 +375,21 @@ def _resolve_effective_stages(
|
|
|
235
375
|
done_stages: set,
|
|
236
376
|
requested: str,
|
|
237
377
|
budget: int = RUN_STEP_BUDGET,
|
|
378
|
+
started_stages: set = None,
|
|
379
|
+
reserved_stages: set = None,
|
|
238
380
|
) -> list:
|
|
239
381
|
"""Return the ordered list of stage numbers this run executes.
|
|
240
382
|
|
|
241
383
|
`requested` is "auto" or a decimal string. For "auto" the run batches all
|
|
242
|
-
ready stages (depends-on all done, itself
|
|
243
|
-
to `budget` effective steps
|
|
244
|
-
single forced stage.
|
|
384
|
+
ready stages (depends-on all done, itself neither done nor occupied) in
|
|
385
|
+
stage-number order up to `budget` effective steps. A numeric request is a
|
|
386
|
+
single forced stage. `started_stages`/`reserved_stages` are stages a
|
|
387
|
+
concurrent run already holds (consumers.jsonl `started` and the registry
|
|
388
|
+
stage reservations); they are excluded so two simultaneous runs never pick
|
|
389
|
+
the same stage. Raises PrepareError on rejection cases."""
|
|
390
|
+
started_stages = started_stages or set()
|
|
391
|
+
reserved_stages = reserved_stages or set()
|
|
392
|
+
occupied = done_stages | started_stages | reserved_stages
|
|
245
393
|
if requested != "auto":
|
|
246
394
|
try:
|
|
247
395
|
n = int(requested)
|
|
@@ -259,11 +407,15 @@ def _resolve_effective_stages(
|
|
|
259
407
|
raise PrepareError(
|
|
260
408
|
f"--stage {n} already completed (consumers.jsonl status:done exists)"
|
|
261
409
|
)
|
|
410
|
+
if n in started_stages or n in reserved_stages:
|
|
411
|
+
raise PrepareError(
|
|
412
|
+
f"--stage {n} already in progress or reserved by another run"
|
|
413
|
+
)
|
|
262
414
|
return [n]
|
|
263
415
|
|
|
264
416
|
ready = [
|
|
265
417
|
s for s in stages
|
|
266
|
-
if s["stage_number"] not in
|
|
418
|
+
if s["stage_number"] not in occupied
|
|
267
419
|
and all(d in done_stages for d in s["depends_on"])
|
|
268
420
|
]
|
|
269
421
|
if not ready:
|
|
@@ -281,6 +433,166 @@ def _resolve_effective_stages(
|
|
|
281
433
|
return batch
|
|
282
434
|
|
|
283
435
|
|
|
436
|
+
def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
|
|
437
|
+
"""True iff `ancestor` is an ancestor of `descendant` in project_root's git
|
|
438
|
+
history (`git merge-base --is-ancestor`, exit 0). Used to detect whether a
|
|
439
|
+
predecessor stage's done commit has been merged into the task worktree HEAD."""
|
|
440
|
+
r = _subprocess.run(
|
|
441
|
+
["git", "merge-base", "--is-ancestor", ancestor, descendant],
|
|
442
|
+
cwd=str(project_root), capture_output=True, text=True,
|
|
443
|
+
)
|
|
444
|
+
return r.returncode == 0
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def _resolve_stage_base_commit(
|
|
448
|
+
stage: dict,
|
|
449
|
+
consumer_done_rows: list,
|
|
450
|
+
anchor_base_commit: str,
|
|
451
|
+
candidate_base: str = "",
|
|
452
|
+
project_root=None,
|
|
453
|
+
) -> str:
|
|
454
|
+
"""Pick the git base commit a stage's isolated worktree branches from.
|
|
455
|
+
|
|
456
|
+
- 독립 (`depends-on (none)`): anchor (= implementation_base_commit fixed
|
|
457
|
+
at first stage entry).
|
|
458
|
+
- 단일 의존 (`depends-on X`): predecessor X 의 `done.head_commit`.
|
|
459
|
+
- 다중 의존 (`depends-on X,Y…`): spec §9 옵션 A 자동 감지. candidate_base
|
|
460
|
+
(= task-key worktree HEAD) 에 모든 선행 done commit 이 ancestor 면
|
|
461
|
+
candidate 를 반환(사용자가 선행을 머지함). 아니면 PrepareError.
|
|
462
|
+
|
|
463
|
+
Raises PrepareError on missing predecessor / anchor / unmerged predecessor."""
|
|
464
|
+
deps = stage.get("depends_on") or []
|
|
465
|
+
if len(deps) >= 2:
|
|
466
|
+
n = stage["stage_number"]
|
|
467
|
+
# 1) 모든 선행의 done head_commit 수집
|
|
468
|
+
pred_commits = {}
|
|
469
|
+
for d in deps:
|
|
470
|
+
head = next(
|
|
471
|
+
(r.get("head_commit") for r in consumer_done_rows
|
|
472
|
+
if r.get("stage") == d and r.get("status") == "done"
|
|
473
|
+
and r.get("head_commit")),
|
|
474
|
+
None,
|
|
475
|
+
)
|
|
476
|
+
if not head:
|
|
477
|
+
raise PrepareError(
|
|
478
|
+
f"predecessor stage {d} has no done row with head_commit "
|
|
479
|
+
f"in consumers.jsonl; multi-dependency stage {n} cannot start"
|
|
480
|
+
)
|
|
481
|
+
pred_commits[d] = head
|
|
482
|
+
# 2) candidate (task-key worktree HEAD) 필요
|
|
483
|
+
if not candidate_base or project_root is None:
|
|
484
|
+
raise PrepareError(
|
|
485
|
+
f"candidate base missing for multi-dependency stage {n}; "
|
|
486
|
+
"task-key worktree HEAD could not be resolved"
|
|
487
|
+
)
|
|
488
|
+
# 3) 모든 선행 done 이 candidate 의 ancestor 인지 (=사용자가 머지함)
|
|
489
|
+
for d, head in pred_commits.items():
|
|
490
|
+
if not _commit_is_ancestor(project_root, head, candidate_base):
|
|
491
|
+
raise PrepareError(
|
|
492
|
+
f"multi-dependency stage {n}: predecessor stage {d} "
|
|
493
|
+
f"({head[:8]}) is not merged into the task worktree "
|
|
494
|
+
f"({candidate_base[:8]}). Merge stage branches "
|
|
495
|
+
f"(e.g. the `-s{d}` branches) into the task worktree "
|
|
496
|
+
"(or into main, then refresh the worktree) and retry."
|
|
497
|
+
)
|
|
498
|
+
return candidate_base
|
|
499
|
+
if not deps:
|
|
500
|
+
if not anchor_base_commit:
|
|
501
|
+
raise PrepareError(
|
|
502
|
+
f"anchor base commit missing for independent stage "
|
|
503
|
+
f"{stage['stage_number']}; first-stage prepare should have "
|
|
504
|
+
"fixed it via worktree_registry.set_implementation_base"
|
|
505
|
+
)
|
|
506
|
+
return anchor_base_commit
|
|
507
|
+
# 단일 의존
|
|
508
|
+
pred = deps[0]
|
|
509
|
+
for row in consumer_done_rows:
|
|
510
|
+
if row.get("stage") == pred and row.get("status") == "done":
|
|
511
|
+
head = row.get("head_commit") or ""
|
|
512
|
+
if head:
|
|
513
|
+
return head
|
|
514
|
+
raise PrepareError(
|
|
515
|
+
f"predecessor stage {pred} has no done row with head_commit in "
|
|
516
|
+
"consumers.jsonl; cannot derive base for stage "
|
|
517
|
+
f"{stage['stage_number']}"
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@dataclass
|
|
522
|
+
class _FVTarget:
|
|
523
|
+
scope: str # "whole-task" | "single-stage"
|
|
524
|
+
base: str
|
|
525
|
+
head: str
|
|
526
|
+
worktree_path: str
|
|
527
|
+
stages: list # list[int]
|
|
528
|
+
reports: list # list[str], report_path 값(없으면 "")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _resolve_whole_task_target(
|
|
532
|
+
*, stage_map: list, done_rows: list, anchor_base: str,
|
|
533
|
+
task_worktree_path: str, task_head: str, task_dirty: bool,
|
|
534
|
+
merged: dict,
|
|
535
|
+
) -> "_FVTarget":
|
|
536
|
+
"""전체-task 검증 target. 모든 Stage Map stage 가 done + HEAD 에 머지 +
|
|
537
|
+
worktree clean 이어야 한다. 위반 시 PrepareError."""
|
|
538
|
+
done_by_stage = {r["stage"]: r for r in done_rows}
|
|
539
|
+
for s in stage_map:
|
|
540
|
+
n = s["stage_number"]
|
|
541
|
+
if n not in done_by_stage:
|
|
542
|
+
raise PrepareError(
|
|
543
|
+
f"final-verification(whole-task): stage {n} not done — "
|
|
544
|
+
f"run implementation --stage {n} first"
|
|
545
|
+
)
|
|
546
|
+
if not merged.get(n, False):
|
|
547
|
+
sha = done_by_stage[n].get("head_commit", "")
|
|
548
|
+
raise PrepareError(
|
|
549
|
+
f"final-verification(whole-task): stage {n} done commit "
|
|
550
|
+
f"{sha} not merged into task worktree HEAD — merge stage "
|
|
551
|
+
"branches then retry"
|
|
552
|
+
)
|
|
553
|
+
if task_dirty:
|
|
554
|
+
raise PrepareError(
|
|
555
|
+
"final-verification: worktree has uncommitted source changes "
|
|
556
|
+
"(outside .okstra/) — commit or stash before verifying"
|
|
557
|
+
)
|
|
558
|
+
stages = [s["stage_number"] for s in stage_map]
|
|
559
|
+
reports = [done_by_stage[n].get("report_path", "") for n in stages]
|
|
560
|
+
return _FVTarget(
|
|
561
|
+
scope="whole-task", base=anchor_base, head=task_head,
|
|
562
|
+
worktree_path=task_worktree_path, stages=stages, reports=reports,
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _resolve_single_stage_target(
|
|
567
|
+
*, requested_stage: str, done_rows: list, stage_base: str,
|
|
568
|
+
stage_worktree_path: str, stage_head: str, stage_dirty: bool,
|
|
569
|
+
) -> "_FVTarget":
|
|
570
|
+
"""단독-stage 검증 target. stage N 만 done + stage worktree 존재 + clean.
|
|
571
|
+
다른 stage 의 done/머지 여부와 무관. 위반 시 PrepareError."""
|
|
572
|
+
n = int(requested_stage)
|
|
573
|
+
done_by_stage = {r["stage"]: r for r in done_rows}
|
|
574
|
+
if n not in done_by_stage:
|
|
575
|
+
raise PrepareError(
|
|
576
|
+
f"final-verification(single-stage): stage {n} not done — "
|
|
577
|
+
f"run implementation --stage {n} first"
|
|
578
|
+
)
|
|
579
|
+
if not stage_worktree_path:
|
|
580
|
+
raise PrepareError(
|
|
581
|
+
f"final-verification(single-stage): stage worktree not found for "
|
|
582
|
+
f"stage {n} (torn down?) — use whole-task mode (--stage auto)"
|
|
583
|
+
)
|
|
584
|
+
if stage_dirty:
|
|
585
|
+
raise PrepareError(
|
|
586
|
+
"final-verification: worktree has uncommitted source changes "
|
|
587
|
+
"(outside .okstra/) — commit or stash before verifying"
|
|
588
|
+
)
|
|
589
|
+
return _FVTarget(
|
|
590
|
+
scope="single-stage", base=stage_base, head=stage_head,
|
|
591
|
+
worktree_path=stage_worktree_path, stages=[n],
|
|
592
|
+
reports=[done_by_stage[n].get("report_path", "")],
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
|
|
284
596
|
def _parse_stage_map_into_ctx(plan_path: str) -> list:
|
|
285
597
|
"""Reuse the validator's parser to extract StageMeta dicts for the ctx."""
|
|
286
598
|
text = Path(plan_path).read_text(encoding="utf-8")
|
|
@@ -340,6 +652,7 @@ def _apply_cli_approval(path: str) -> str:
|
|
|
340
652
|
" expected a single line of the form `approved: false` "
|
|
341
653
|
"(report-writer worker emits this by default)."
|
|
342
654
|
)
|
|
655
|
+
_reject_blocking_plan_body_gate(p, body, action="--approve")
|
|
343
656
|
|
|
344
657
|
audit_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
345
658
|
audit_line = (
|
|
@@ -347,11 +660,26 @@ def _apply_cli_approval(path: str) -> str:
|
|
|
347
660
|
"(user CLI invocation treated as approval signal)"
|
|
348
661
|
)
|
|
349
662
|
|
|
663
|
+
rendered_from_data = False
|
|
664
|
+
if m.group(1).lower() != "true":
|
|
665
|
+
rendered_from_data = _set_data_json_approved_true_if_present(p)
|
|
666
|
+
if rendered_from_data:
|
|
667
|
+
body = p.read_text(encoding="utf-8", errors="replace")
|
|
668
|
+
frontmatter = _extract_frontmatter_block(body) or ""
|
|
669
|
+
m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
|
|
670
|
+
if not m:
|
|
671
|
+
raise PrepareError(
|
|
672
|
+
f"--approve re-rendered the approved-plan but the markdown "
|
|
673
|
+
f"frontmatter has no `approved:` field: {path}"
|
|
674
|
+
)
|
|
675
|
+
|
|
350
676
|
if m.group(1).lower() == "true":
|
|
351
677
|
if audit_line.split(" — ")[1] in body:
|
|
352
678
|
return "already-approved"
|
|
353
679
|
new_body = body.rstrip("\n") + "\n" + audit_line + "\n"
|
|
354
680
|
p.write_text(new_body, encoding="utf-8")
|
|
681
|
+
if rendered_from_data:
|
|
682
|
+
return "frontmatter-flipped"
|
|
355
683
|
return "already-approved-audit-appended"
|
|
356
684
|
|
|
357
685
|
flipped_frontmatter = APPROVED_FRONTMATTER_PATTERN.sub(
|
|
@@ -698,25 +1026,44 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
|
698
1026
|
if not inp.brief_path.is_file():
|
|
699
1027
|
raise PrepareError(f"task brief not found: {inp.brief_path}")
|
|
700
1028
|
ctx_stage_map: list = []
|
|
701
|
-
|
|
1029
|
+
# implementation 과 final-verification 은 둘 다 승인된 plan 의 Stage Map 을
|
|
1030
|
+
# 입력으로 받는다(전자는 실행 scope, 후자는 검증 scope). plan-presence +
|
|
1031
|
+
# stage-map 파싱은 공유하고, frontmatter 승인/option 주입/구조 검증 같은
|
|
1032
|
+
# implementation 전용 단계만 따로 게이트한다.
|
|
1033
|
+
if inp.task_type in ("implementation", "final-verification"):
|
|
702
1034
|
if not inp.approved_plan_path:
|
|
703
1035
|
raise PrepareError(
|
|
704
|
-
"task-type
|
|
1036
|
+
f"task-type {inp.task_type} requires "
|
|
1037
|
+
"--approved-plan <path-to-final-report.md>"
|
|
705
1038
|
)
|
|
706
|
-
if inp.
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1039
|
+
if inp.task_type == "implementation":
|
|
1040
|
+
if inp.approve_plan_ack:
|
|
1041
|
+
# 사용자가 직접 `--approve` 를 입력한 행위 자체를 승인 의사로 모델링한다.
|
|
1042
|
+
# 파일의 frontmatter approved 를 true 로 toggle 한 뒤 동일한 검증
|
|
1043
|
+
# 경로(`_validate_approved_plan`) 를 그대로 통과시킨다.
|
|
1044
|
+
_apply_cli_approval(inp.approved_plan_path)
|
|
1045
|
+
if inp.implementation_option:
|
|
1046
|
+
# 유저가 고른 Option Candidate 이름을 approved-plan frontmatter 의
|
|
1047
|
+
# `implementation-option:` 라인에 기록한다. 빈 값이면 implementation 이
|
|
1048
|
+
# plan 의 `Recommended Option` 으로 폴백하므로 호출하지 않는다.
|
|
1049
|
+
_apply_cli_implementation_option(
|
|
1050
|
+
inp.approved_plan_path, inp.implementation_option
|
|
1051
|
+
)
|
|
1052
|
+
_validate_approved_plan(inp.approved_plan_path)
|
|
1053
|
+
_validate_stage_structure(inp.approved_plan_path)
|
|
1054
|
+
else:
|
|
1055
|
+
# final-verification 에서 --approve / --implementation-option 은
|
|
1056
|
+
# 의미가 없다 (승인은 implementation 진입 시 이미 끝났다).
|
|
1057
|
+
if inp.approve_plan_ack:
|
|
1058
|
+
raise PrepareError(
|
|
1059
|
+
"--approve is only meaningful with --task-type implementation "
|
|
1060
|
+
"and --approved-plan <path>"
|
|
1061
|
+
)
|
|
1062
|
+
if inp.implementation_option:
|
|
1063
|
+
raise PrepareError(
|
|
1064
|
+
"--implementation-option is only meaningful with --task-type "
|
|
1065
|
+
"implementation and --approved-plan <path>"
|
|
1066
|
+
)
|
|
720
1067
|
ctx_stage_map = _parse_stage_map_into_ctx(inp.approved_plan_path)
|
|
721
1068
|
else:
|
|
722
1069
|
if inp.approve_plan_ack:
|
|
@@ -735,8 +1082,8 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
|
735
1082
|
)
|
|
736
1083
|
if inp.stage != "auto":
|
|
737
1084
|
raise PrepareError(
|
|
738
|
-
|
|
739
|
-
f"got {inp.task_type}"
|
|
1085
|
+
"--stage is only meaningful with --task-type implementation or "
|
|
1086
|
+
f"final-verification; got {inp.task_type}"
|
|
740
1087
|
)
|
|
741
1088
|
if inp.clarification_response_path and not Path(inp.clarification_response_path).is_file():
|
|
742
1089
|
raise PrepareError(
|
|
@@ -888,44 +1235,195 @@ def _resolve_model_bindings(inp: PrepareInputs, workers: list[str]) -> _ModelBin
|
|
|
888
1235
|
|
|
889
1236
|
|
|
890
1237
|
def _reserve_implementation_stages(inp: PrepareInputs, ctx: dict, ctx_stage_map: list) -> None:
|
|
891
|
-
"""implementation run 의 ready
|
|
892
|
-
|
|
893
|
-
|
|
1238
|
+
"""implementation run 의 첫 ready stage 를 결정하고 stage 격리 worktree 를
|
|
1239
|
+
발급해 ctx 의 EXECUTOR_WORKTREE_* 를 덮어쓴다. anchor base commit 을 1회
|
|
1240
|
+
고정하고 그 stage 를 consumers.jsonl 에 started 로 append.
|
|
1241
|
+
|
|
1242
|
+
spec §2.3: 한 run = 한 stage. `_resolve_effective_stages` 는 backward
|
|
1243
|
+
compat 로 batch 리스트를 반환하지만 첫 번째만 실행한다 — stage 마다 격리
|
|
1244
|
+
worktree·branch 가 필요해 batch 가 의미를 잃기 때문(cost-aware-design 의
|
|
1245
|
+
run-batch 와의 의도된 트레이드오프)."""
|
|
894
1246
|
from .consumers import read_consumers, append_consumer
|
|
1247
|
+
from . import worktree as _worktree
|
|
1248
|
+
from . import worktree_registry as _reg
|
|
895
1249
|
import datetime as _dt
|
|
896
1250
|
|
|
897
1251
|
ctx["parsed_stage_map"] = ctx_stage_map
|
|
898
1252
|
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
899
1253
|
consumed = read_consumers(plan_run_root)
|
|
900
1254
|
done_stages = {r["stage"] for r in consumed if r.get("status") == "done"}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
1255
|
+
started_stages = {r["stage"] for r in consumed if r.get("status") == "started"}
|
|
1256
|
+
reserved_stages = _reg.list_active_stage_numbers(
|
|
1257
|
+
inp.project_id, inp.task_group, inp.task_id,
|
|
1258
|
+
)
|
|
1259
|
+
|
|
1260
|
+
batch = _resolve_effective_stages(
|
|
1261
|
+
ctx_stage_map, done_stages, inp.stage,
|
|
1262
|
+
started_stages=started_stages, reserved_stages=reserved_stages,
|
|
1263
|
+
)
|
|
1264
|
+
# stage 격리: 한 run = 한 stage. 첫 ready stage 만 실행.
|
|
1265
|
+
selected = batch[0]
|
|
1266
|
+
ctx["effective_stages"] = [selected]
|
|
1267
|
+
csv = str(selected)
|
|
904
1268
|
ctx["EFFECTIVE_STAGES"] = csv
|
|
905
1269
|
ctx["STAGE_BATCH_DIRECTIVE"] = (
|
|
906
|
-
f"- **Stage
|
|
907
|
-
"
|
|
908
|
-
"
|
|
909
|
-
"
|
|
910
|
-
"already selected and reserved this batch."
|
|
1270
|
+
f"- **Stage for this implementation run:** `{csv}`. "
|
|
1271
|
+
"Execute exactly this Stage Map stage — this is the authoritative scope. "
|
|
1272
|
+
"Do NOT recompute from `consumers.jsonl`; the runtime already selected "
|
|
1273
|
+
"and reserved this stage."
|
|
911
1274
|
)
|
|
912
1275
|
inp.stage = csv
|
|
913
1276
|
print(f"selected stages: {csv}", file=sys.stdout)
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
1277
|
+
|
|
1278
|
+
# spec §2.1 degradation: 주변 흐름이 non-git / nested-worktree 로 skipped 면
|
|
1279
|
+
# stage 격리도 동일하게 degrade — consumers 만 기록 (기존 평면 동작 보존).
|
|
1280
|
+
wt_status = ctx.get("EXECUTOR_WORKTREE_STATUS", "")
|
|
1281
|
+
if wt_status.startswith("skipped"):
|
|
1282
|
+
head_proc = _subprocess.run(
|
|
1283
|
+
["git", "rev-parse", "HEAD"],
|
|
1284
|
+
cwd=inp.project_root, capture_output=True, text=True,
|
|
1285
|
+
)
|
|
1286
|
+
head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
|
|
1287
|
+
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
|
921
1288
|
append_consumer(
|
|
922
1289
|
plan_run_root,
|
|
923
1290
|
impl_task_key=ctx["TASK_KEY"],
|
|
924
|
-
stage=
|
|
1291
|
+
stage=selected,
|
|
925
1292
|
status="started",
|
|
926
1293
|
started_at=now,
|
|
927
1294
|
head_commit=head_sha,
|
|
928
1295
|
)
|
|
1296
|
+
return
|
|
1297
|
+
|
|
1298
|
+
# anchor base commit 1회 고정 (task-key worktree HEAD 기준)
|
|
1299
|
+
head_proc = _subprocess.run(
|
|
1300
|
+
["git", "rev-parse", "HEAD"],
|
|
1301
|
+
cwd=inp.project_root, capture_output=True, text=True,
|
|
1302
|
+
)
|
|
1303
|
+
head_sha = head_proc.stdout.strip() if head_proc.returncode == 0 else ""
|
|
1304
|
+
if head_sha:
|
|
1305
|
+
_reg.set_implementation_base(
|
|
1306
|
+
inp.project_id, inp.task_group, inp.task_id, head_sha,
|
|
1307
|
+
)
|
|
1308
|
+
anchor = _reg.get_implementation_base(
|
|
1309
|
+
inp.project_id, inp.task_group, inp.task_id,
|
|
1310
|
+
) or ""
|
|
1311
|
+
|
|
1312
|
+
# stage 격리 worktree 발급 — 의존 종류별 base 결정
|
|
1313
|
+
selected_stage = next(
|
|
1314
|
+
s for s in ctx_stage_map if s["stage_number"] == selected
|
|
1315
|
+
)
|
|
1316
|
+
consumer_done_rows = [r for r in consumed if r.get("status") == "done"]
|
|
1317
|
+
stage_base = _resolve_stage_base_commit(
|
|
1318
|
+
selected_stage, consumer_done_rows, anchor_base_commit=anchor,
|
|
1319
|
+
candidate_base=head_sha, project_root=Path(inp.project_root),
|
|
1320
|
+
)
|
|
1321
|
+
try:
|
|
1322
|
+
provision = _worktree.provision_stage_worktree(
|
|
1323
|
+
project_root=Path(inp.project_root),
|
|
1324
|
+
project_id=inp.project_id,
|
|
1325
|
+
task_group_segment=ctx["TASK_GROUP_SEGMENT"],
|
|
1326
|
+
task_id_segment=ctx["TASK_ID_SEGMENT"],
|
|
1327
|
+
work_category=inp.work_category,
|
|
1328
|
+
stage_number=selected,
|
|
1329
|
+
base_commit=stage_base,
|
|
1330
|
+
)
|
|
1331
|
+
except RuntimeError as exc:
|
|
1332
|
+
raise PrepareError(f"stage worktree provisioning failed: {exc}") from exc
|
|
1333
|
+
|
|
1334
|
+
ctx["EXECUTOR_WORKTREE_PATH"] = provision.path
|
|
1335
|
+
ctx["EXECUTOR_WORKTREE_BRANCH"] = provision.branch
|
|
1336
|
+
ctx["EXECUTOR_WORKTREE_BASE_REF"] = provision.base_ref
|
|
1337
|
+
ctx["EXECUTOR_WORKTREE_STATUS"] = provision.status
|
|
1338
|
+
ctx["EXECUTOR_WORKTREE_NOTE"] = provision.note
|
|
1339
|
+
|
|
1340
|
+
# consumers append — stage worktree base 를 head_commit 으로
|
|
1341
|
+
now = _dt.datetime.now(_dt.timezone.utc).isoformat()
|
|
1342
|
+
append_consumer(
|
|
1343
|
+
plan_run_root,
|
|
1344
|
+
impl_task_key=ctx["TASK_KEY"],
|
|
1345
|
+
stage=selected,
|
|
1346
|
+
status="started",
|
|
1347
|
+
started_at=now,
|
|
1348
|
+
head_commit=provision.base_ref,
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _git_out(cwd, *args) -> str:
|
|
1353
|
+
r = _subprocess.run(["git", "-C", str(cwd), *args],
|
|
1354
|
+
capture_output=True, text=True)
|
|
1355
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def _is_ancestor(cwd, commit, head) -> bool:
|
|
1359
|
+
if not commit or not head:
|
|
1360
|
+
return False
|
|
1361
|
+
r = _subprocess.run(
|
|
1362
|
+
["git", "-C", str(cwd), "merge-base", "--is-ancestor", commit, head],
|
|
1363
|
+
capture_output=True, text=True,
|
|
1364
|
+
)
|
|
1365
|
+
return r.returncode == 0
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def _is_dirty_excluding_okstra(cwd) -> bool:
|
|
1369
|
+
out = _git_out(cwd, "status", "--short", "--", ".", ":(exclude).okstra")
|
|
1370
|
+
return bool(out.strip())
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _reserve_final_verification_target(
|
|
1374
|
+
inp: "PrepareInputs", ctx: dict, ctx_stage_map: list,
|
|
1375
|
+
) -> None:
|
|
1376
|
+
"""final-verification 의 검증 target 을 registry/consumers/git 에서
|
|
1377
|
+
해소하고 gate 를 강제한다. 위반 시 PrepareError. 결과를 ctx 의
|
|
1378
|
+
VERIFICATION_* 키로 주입한다."""
|
|
1379
|
+
from .consumers import read_consumers
|
|
1380
|
+
from . import worktree_registry as _reg
|
|
1381
|
+
|
|
1382
|
+
plan_run_root = Path(inp.approved_plan_path).resolve().parents[1]
|
|
1383
|
+
done_rows = [r for r in read_consumers(plan_run_root)
|
|
1384
|
+
if r.get("status") == "done"]
|
|
1385
|
+
|
|
1386
|
+
if inp.stage and inp.stage != "auto":
|
|
1387
|
+
n = int(inp.stage)
|
|
1388
|
+
row = _reg.get_stage_row(inp.project_id, inp.task_group, inp.task_id, n)
|
|
1389
|
+
wt_path = (row or {}).get("worktree_path", "")
|
|
1390
|
+
stage_base = (row or {}).get("base_ref", "")
|
|
1391
|
+
head = _git_out(wt_path, "rev-parse", "HEAD") if wt_path else ""
|
|
1392
|
+
target = _resolve_single_stage_target(
|
|
1393
|
+
requested_stage=inp.stage, done_rows=done_rows,
|
|
1394
|
+
stage_base=stage_base, stage_worktree_path=wt_path,
|
|
1395
|
+
stage_head=head,
|
|
1396
|
+
stage_dirty=_is_dirty_excluding_okstra(wt_path) if wt_path else False,
|
|
1397
|
+
)
|
|
1398
|
+
ctx["EXECUTOR_WORKTREE_PATH"] = wt_path
|
|
1399
|
+
else:
|
|
1400
|
+
wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
|
|
1401
|
+
anchor = _reg.get_implementation_base(
|
|
1402
|
+
inp.project_id, inp.task_group, inp.task_id) or ""
|
|
1403
|
+
head = _git_out(wt_path, "rev-parse", "HEAD")
|
|
1404
|
+
merged = {r["stage"]: _is_ancestor(wt_path, r.get("head_commit", ""), head)
|
|
1405
|
+
for r in done_rows}
|
|
1406
|
+
target = _resolve_whole_task_target(
|
|
1407
|
+
stage_map=ctx_stage_map, done_rows=done_rows, anchor_base=anchor,
|
|
1408
|
+
task_worktree_path=wt_path, task_head=head,
|
|
1409
|
+
task_dirty=_is_dirty_excluding_okstra(wt_path), merged=merged,
|
|
1410
|
+
)
|
|
1411
|
+
|
|
1412
|
+
diff_stat = _git_out(target.worktree_path, "diff", "--stat",
|
|
1413
|
+
f"{target.base}..{target.head}")
|
|
1414
|
+
reports = "\n".join(
|
|
1415
|
+
f" - stage {s}: `{rp or '(report_path unrecorded)'}`"
|
|
1416
|
+
for s, rp in zip(target.stages, target.reports)
|
|
1417
|
+
)
|
|
1418
|
+
ctx["VERIFICATION_TARGET"] = (
|
|
1419
|
+
f"- **Verification scope:** `{target.scope}`\n"
|
|
1420
|
+
f"- **Worktree:** `{target.worktree_path}`\n"
|
|
1421
|
+
f"- **Verification base ref:** `{target.base}`\n"
|
|
1422
|
+
f"- **Verification head SHA:** `{target.head}`\n"
|
|
1423
|
+
f"- **Stages under verification:** {target.stages}\n"
|
|
1424
|
+
f"- **Source implementation reports:**\n{reports}\n"
|
|
1425
|
+
f"- **Verification diff stat:**\n```\n{diff_stat}\n```"
|
|
1426
|
+
)
|
|
929
1427
|
|
|
930
1428
|
|
|
931
1429
|
def _write_instruction_set_sources(
|
|
@@ -1270,6 +1768,8 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
1270
1768
|
})
|
|
1271
1769
|
if inp.task_type == "implementation":
|
|
1272
1770
|
_reserve_implementation_stages(inp, ctx, ctx_stage_map)
|
|
1771
|
+
elif inp.task_type == "final-verification":
|
|
1772
|
+
_reserve_final_verification_target(inp, ctx, ctx_stage_map)
|
|
1273
1773
|
|
|
1274
1774
|
# ---- prepare directories + cleanup ----
|
|
1275
1775
|
_ensure_task_directories(ctx)
|