okstra 0.96.1 → 0.97.1

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 (121) hide show
  1. package/README.kr.md +1 -1
  2. package/README.md +1 -1
  3. package/bin/okstra +1 -1
  4. package/docs/contributor-change-matrix.md +1 -1
  5. package/docs/kr/architecture/storage-model.md +273 -0
  6. package/docs/kr/architecture.md +6 -277
  7. package/docs/kr/cli.md +1 -1
  8. package/docs/project-structure-overview.md +26 -22
  9. package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
  10. package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
  11. package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
  12. package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
  13. package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
  14. package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
  15. package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
  16. package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
  17. package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
  18. package/docs/task-process/release-handoff.md +3 -3
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/bin/lib/okstra/cli.sh +1 -1
  22. package/runtime/bin/lib/okstra/interactive.sh +30 -4
  23. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
  24. package/runtime/bin/okstra-render-final-report.py +4 -15
  25. package/runtime/bin/okstra.sh +5 -2
  26. package/runtime/prompts/launch.template.md +1 -0
  27. package/runtime/prompts/lead/report-writer.md +1 -1
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/final-verification.md +1 -0
  30. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  31. package/runtime/prompts/profiles/release-handoff.md +10 -4
  32. package/runtime/python/okstra_ctl/__init__.py +3 -2
  33. package/runtime/python/okstra_ctl/backfill.py +12 -21
  34. package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
  35. package/runtime/python/okstra_ctl/conformance.py +21 -3
  36. package/runtime/python/okstra_ctl/consumers.py +7 -1
  37. package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
  38. package/runtime/python/okstra_ctl/doctor.py +29 -12
  39. package/runtime/python/okstra_ctl/error_log_core.py +72 -0
  40. package/runtime/python/okstra_ctl/error_report.py +8 -58
  41. package/runtime/python/okstra_ctl/error_zip.py +319 -0
  42. package/runtime/python/okstra_ctl/fanout.py +5 -2
  43. package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
  44. package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
  45. package/runtime/python/okstra_ctl/handoff.py +125 -26
  46. package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
  47. package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
  48. package/runtime/python/okstra_ctl/index.py +34 -91
  49. package/runtime/python/okstra_ctl/jsonl.py +29 -18
  50. package/runtime/python/okstra_ctl/listing.py +30 -5
  51. package/runtime/python/okstra_ctl/md_table.py +1 -1
  52. package/runtime/python/okstra_ctl/migrate.py +17 -7
  53. package/runtime/python/okstra_ctl/paths.py +1 -1
  54. package/runtime/python/okstra_ctl/recap.py +12 -3
  55. package/runtime/python/okstra_ctl/reconcile.py +13 -11
  56. package/runtime/python/okstra_ctl/render.py +49 -12
  57. package/runtime/python/okstra_ctl/render_final_report.py +4 -0
  58. package/runtime/python/okstra_ctl/report_views.py +47 -6
  59. package/runtime/python/okstra_ctl/run.py +128 -100
  60. package/runtime/python/okstra_ctl/run_index_row.py +118 -0
  61. package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
  62. package/runtime/python/okstra_ctl/sequence.py +2 -2
  63. package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
  64. package/runtime/python/okstra_ctl/stage_targets.py +2 -8
  65. package/runtime/python/okstra_ctl/task_target.py +4 -1
  66. package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
  67. package/runtime/python/okstra_ctl/wizard.py +27 -12
  68. package/runtime/python/okstra_ctl/workflow.py +9 -1
  69. package/runtime/python/okstra_ctl/worktree.py +93 -4
  70. package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
  71. package/runtime/python/okstra_token_usage/collect.py +43 -38
  72. package/runtime/skills/okstra-brief/SKILL.md +43 -12
  73. package/runtime/skills/okstra-inspect/SKILL.md +44 -1
  74. package/runtime/templates/reports/final-report.template.md +2 -2
  75. package/runtime/templates/reports/release-handoff-input.template.md +1 -1
  76. package/runtime/validators/forbidden_actions.py +8 -1
  77. package/runtime/validators/validate-brief.py +13 -2
  78. package/runtime/validators/validate-run.py +27 -6
  79. package/runtime/validators/validate-schedule.py +1 -2
  80. package/runtime/validators/validate_improvement_report.py +14 -0
  81. package/src/cli-registry.mjs +45 -31
  82. package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
  83. package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
  84. package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
  85. package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
  86. package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
  87. package/src/commands/execute/integrate-stages.mjs +25 -0
  88. package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
  89. package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
  90. package/src/{run.mjs → commands/execute/run.mjs} +4 -4
  91. package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
  92. package/src/{team.mjs → commands/execute/team.mjs} +3 -3
  93. package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
  94. package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
  95. package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
  96. package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
  97. package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
  98. package/src/commands/inspect/error-zip.mjs +25 -0
  99. package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
  100. package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
  101. package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
  102. package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
  103. package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
  104. package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
  105. package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
  106. package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
  107. package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
  108. package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
  109. package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
  110. package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
  111. package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
  112. package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
  113. package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
  114. package/src/lib/paths.mjs +60 -0
  115. package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
  116. package/src/{version.mjs → lib/version.mjs} +2 -2
  117. /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
  118. /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
  119. /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
  120. /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
  121. /package/src/{skill-catalog.mjs → lib/skill-catalog.mjs} +0 -0
@@ -63,6 +63,7 @@ from .clarification_items import (
63
63
  parse_clarification_items,
64
64
  parse_meta_cell,
65
65
  scan_approval_gate,
66
+ section_1_present_but_unparsed,
66
67
  )
67
68
  from .md_table import is_separator_row as _is_separator_row
68
69
 
@@ -716,6 +717,28 @@ def _col_class(col_idx: int, narrow_cols: set[int]) -> str:
716
717
  return ' class="td-narrow"' if col_idx in narrow_cols else ""
717
718
 
718
719
 
720
+ _DANGEROUS_URL_SCHEME_RE = re.compile(
721
+ r"^(?:javascript|data|vbscript):", re.IGNORECASE
722
+ )
723
+ _URL_CONTROL_CHARS_RE = re.compile(r"[\x00-\x20]")
724
+
725
+
726
+ def _safe_href(url: str) -> str:
727
+ """Neutralise a markdown link target so it cannot become a script
728
+ vector in the offline HTML report.
729
+
730
+ The self-contained report is opened locally from disk, so a
731
+ ``javascript:``/``data:``/``vbscript:`` href in any worker-authored
732
+ cell would execute attacker JS on click. ``html.escape`` in
733
+ ``_inline`` only neutralises quotes, not the scheme, so strip such
734
+ schemes here. Browsers strip ASCII whitespace/control chars from an
735
+ href before resolving the scheme (so ``java\\tscript:`` executes); strip
736
+ that same class before matching to close the bypass. Relative paths,
737
+ fragments, ``http(s):`` and ``mailto:`` pass through unchanged."""
738
+ stripped = _URL_CONTROL_CHARS_RE.sub("", url or "")
739
+ return "#" if _DANGEROUS_URL_SCHEME_RE.match(stripped) else url
740
+
741
+
719
742
  def _inline(text: str) -> str:
720
743
  out = html.escape(text)
721
744
  # Restore inline markdown after escaping (we re-process the escaped
@@ -723,7 +746,7 @@ def _inline(text: str) -> str:
723
746
  out = _INLINE_CODE_PATTERN.sub(lambda m: f"<code>{m.group(1)}</code>", out)
724
747
  out = _BOLD_PATTERN.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
725
748
  out = _LINK_PATTERN.sub(
726
- lambda m: f'<a href="{m.group(2)}">{m.group(1)}</a>', out
749
+ lambda m: f'<a href="{_safe_href(m.group(2))}">{m.group(1)}</a>', out
727
750
  )
728
751
  # Preserve explicit <br> line breaks used inside compact meta cells (the
729
752
  # markdown source intentionally stacks short fields with <br>). html.escape
@@ -885,14 +908,19 @@ def plan_approval_context(
885
908
  def _resolve_recommended_option(rec_name: str, names: tuple[str, ...]) -> str:
886
909
  """``recommendedOption.name`` 을 후보 이름에 맞춘다. 정확 일치가 없으면
887
910
  접두 일치(후보가 ``(RECOMMENDED)`` 같은 표식 접미를 더 달고 있는 흔한 경우)를
888
- 허용하고, 그래도 없으면 후보로 폴백한다 항상 정확히 한 옵션이 selected 가
889
- 되어 브라우저 자동선택분의 묵시 Export 차단한다."""
911
+ 허용하되, 후보 이름이 서로의 접두여서 이상이 매칭되면 모호하므로 채택하지
912
+ 않고 후보로 폴백한다 임의의 한 후보를 권장으로 잘못 표시하느니 결정적인
913
+ 폴백이 낫다. 항상 정확히 한 옵션이 selected 가 되어 브라우저 자동선택분의 묵시
914
+ Export 를 차단한다."""
890
915
  if rec_name in names:
891
916
  return rec_name
892
917
  if rec_name:
893
- for name in names:
894
- if name.startswith(rec_name) or rec_name.startswith(name):
895
- return name
918
+ matches = [
919
+ name for name in names
920
+ if name.startswith(rec_name) or rec_name.startswith(name)
921
+ ]
922
+ if len(matches) == 1:
923
+ return matches[0]
896
924
  return names[0]
897
925
 
898
926
 
@@ -964,6 +992,19 @@ def render_html_view(
964
992
  one when the report no longer needs a view."""
965
993
  src_text = src_md_path.read_text(encoding="utf-8")
966
994
  html_path = src_md_path.with_name(src_md_path.stem + ".html")
995
+ # Fail-closed on §1 heading drift: when a `## 1. Clarification Items`
996
+ # heading exists but its strict form does not parse,
997
+ # `report_has_clarification_items` returns False (lenient parse), so a
998
+ # report with real C-rows would silently get no interactive view and the
999
+ # user would lose the only way to answer. `validate-report-views.py`
1000
+ # already refuses this case; refuse loudly here too instead of skipping.
1001
+ if section_1_present_but_unparsed(src_text):
1002
+ raise ValueError(
1003
+ "final-report has a `## 1. Clarification Items` heading but its "
1004
+ "strict format does not parse (heading/anchor/format drift) — "
1005
+ "re-render the report so §1 matches the schema before generating "
1006
+ "the HTML view."
1007
+ )
967
1008
  approval_ctx = plan_approval_context(src_md_path, src_text)
968
1009
  if not report_has_clarification_items(src_text) and approval_ctx is None:
969
1010
  if html_path.is_file():
@@ -51,6 +51,7 @@ from .path_resolve import relative_to_project_root, resolve_user_file
51
51
  from .render import (
52
52
  apply_lead_prompt_defaults,
53
53
  inject_lead_prompt_computed_tokens,
54
+ migrate_legacy_run_artifacts,
54
55
  render_active_run_context,
55
56
  render_latest_task_discovery,
56
57
  render_reference_expectations,
@@ -90,13 +91,13 @@ from .workers import (
90
91
  from .workflow import compute_workflow_state, load_phase_forbidden
91
92
  from .locks import worktree_provision_mutex
92
93
  from . import stage_targets as _stage_targets
94
+ from . import stage_integrate as _stage_integrate
93
95
  from . import implementation_stage as _implementation_stage
94
96
  from .stage_reconcile import (
95
97
  auto_reconcile_best_effort as _stage_auto_reconcile_best_effort,
96
98
  )
97
99
  from .worktree import (
98
100
  WorktreeProvision,
99
- okstra_clean_gate_excludes,
100
101
  provision_task_worktree,
101
102
  )
102
103
 
@@ -129,7 +130,9 @@ IMPLEMENTATION_OPTION_FRONTMATTER_PATTERN = re.compile(
129
130
  re.MULTILINE,
130
131
  )
131
132
 
132
- _FRONTMATTER_BLOCK_PATTERN = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
133
+ # validators/validate-run.py:_FRONTMATTER_BLOCK_RE 의 미러 — 선행 BOM/빈 줄을
134
+ # 허용해 두 모듈이 같은 리포트의 frontmatter 게이트를 동일하게 판정하게 한다.
135
+ _FRONTMATTER_BLOCK_PATTERN = re.compile(r"\A\ufeff?\s*---\n(.*?)\n---\n", re.DOTALL)
133
136
 
134
137
 
135
138
  def _extract_frontmatter_block(body: str) -> str | None:
@@ -269,8 +272,18 @@ def _set_data_json_approved_true_if_present(path: Path) -> bool:
269
272
  encoding="utf-8",
270
273
  )
271
274
  md_tmp.write_text(rendered, encoding="utf-8")
275
+ # 두 파일을 같이 atomic 하게 commit 할 수 없으므로, 첫 replace 직전의
276
+ # data.json 바이트를 보관했다가 markdown replace 가 실패하면 data.json 을
277
+ # 되돌린다. 이렇게 하면 부분 실패가 "둘 다 approved 이전(consistent)" 상태로
278
+ # 수렴해 이후 _validate_data_json_approval_consistency 의 hard-fail 을 막는다
279
+ # (남는 불일치 창은 두 replace 사이의 hard process kill 뿐).
280
+ data_prev = data_path.read_bytes()
272
281
  data_tmp.replace(data_path)
273
- md_tmp.replace(path)
282
+ try:
283
+ md_tmp.replace(path)
284
+ except OSError:
285
+ data_path.write_bytes(data_prev)
286
+ raise
274
287
  return True
275
288
 
276
289
 
@@ -453,11 +466,6 @@ def _resolve_effective_stages(
453
466
  raise _stage_target_prepare_error(exc) from exc
454
467
 
455
468
 
456
- def _commit_is_ancestor(project_root, ancestor: str, descendant: str) -> bool:
457
- """Compatibility wrapper for tests and older internal callers."""
458
- return _stage_targets.commit_is_ancestor(project_root, ancestor, descendant)
459
-
460
-
461
469
  def _check_multi_dep_merged(project_root, plan_run_root, latest,
462
470
  pred_commits: dict, candidate_base: str,
463
471
  stage_n: int) -> None:
@@ -699,61 +707,6 @@ def _ensure_task_directories(ctx: dict) -> None:
699
707
  Path(ctx[key]).mkdir(parents=True, exist_ok=True)
700
708
 
701
709
 
702
- def _migrate_legacy_run_artifacts(ctx: dict) -> None:
703
- """run/<task-type>/ 바로 아래에 남아 있을 수 있는 legacy 파일을 카테고리
704
- 하위 디렉터리(`manifests/`, `state/`, ...) 로 이동한다.
705
- """
706
- project_root = Path(ctx["PROJECT_ROOT"])
707
- task_root = Path(ctx["TASK_ROOT"])
708
- run_dir = Path(ctx["RUN_DIR"])
709
- if not run_dir.is_dir():
710
- return
711
- legacy = [
712
- ("run-manifest-", ".json", Path(ctx["RUN_MANIFESTS_DIR"])),
713
- ("team-state-", ".json", Path(ctx["RUN_STATE_DIR"])),
714
- ("claude-execution-prompt-", ".md", Path(ctx["RUN_PROMPTS_DIR"])),
715
- ("final-report-", ".md", Path(ctx["RUN_REPORTS_DIR"])),
716
- ("final-", ".status", Path(ctx["RUN_STATUS_DIR"])),
717
- ("claude-resume-", ".sh", Path(ctx["RUN_SESSIONS_DIR"])),
718
- ]
719
- rewrites: dict[str, str] = {}
720
- for entry in run_dir.iterdir():
721
- if not entry.is_file():
722
- continue
723
- for prefix, suffix, target_dir in legacy:
724
- if not (entry.name.startswith(prefix) and entry.name.endswith(suffix)):
725
- continue
726
- target_dir.mkdir(parents=True, exist_ok=True)
727
- dest = target_dir / entry.name
728
- old_rel = str(entry.relative_to(project_root))
729
- new_rel = str(dest.relative_to(project_root))
730
- if dest.exists():
731
- try:
732
- if entry.read_bytes() == dest.read_bytes():
733
- entry.unlink()
734
- rewrites[old_rel] = new_rel
735
- except Exception:
736
- pass
737
- break
738
- shutil.move(str(entry), str(dest))
739
- rewrites[old_rel] = new_rel
740
- break
741
- if not rewrites:
742
- return
743
- for path in task_root.rglob("*"):
744
- if not path.is_file() or path.suffix not in {".json", ".md", ".txt", ".sh"}:
745
- continue
746
- try:
747
- original = path.read_text(encoding="utf-8")
748
- except Exception:
749
- continue
750
- updated = original
751
- for o, n in rewrites.items():
752
- updated = updated.replace(o, n)
753
- if updated != original:
754
- path.write_text(updated, encoding="utf-8")
755
-
756
-
757
710
  def _record_start(
758
711
  *,
759
712
  workspace_root: Path,
@@ -766,10 +719,10 @@ def _record_start(
766
719
  """record_start hook 호출. okstra-central.sh 의 bash wrapper 와 같은 동작
767
720
  이지만 python 직접 호출이라 환경 변수 의존 없음.
768
721
  """
769
- import fcntl
770
722
  import json as _json
771
723
  from datetime import datetime, timezone
772
724
  from okstra_ctl import record_start
725
+ from .locks import central_lock
773
726
 
774
727
  home = okstra_home()
775
728
  home.mkdir(mode=0o700, parents=True, exist_ok=True)
@@ -787,10 +740,9 @@ def _record_start(
787
740
  "backfilledAt": None,
788
741
  }, indent=2) + "\n", encoding="utf-8")
789
742
  os.chmod(state_file, 0o600)
790
- lockfile = home / ".lock"
791
- lockfile.touch()
792
- with lockfile.open("r+") as lock:
793
- fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
743
+ # 중앙 인덱스 락은 reconcile.py 와 공유하는 central_lock 단일 기준점을 쓴다.
744
+ # 직접 flock 을 손으로 깔면 락 파일/모드가 두 경로에서 갈라질 수 있다.
745
+ with central_lock(home):
794
746
  record_start(
795
747
  home,
796
748
  project_id=ctx["PROJECT_ID"],
@@ -814,6 +766,31 @@ def _record_start(
814
766
  )
815
767
 
816
768
 
769
+ def _remove_leaked_reservation(ctx: dict, run_seq: int) -> None:
770
+ """record_start 가 실패해 'reserving' row 를 'running' 으로 승격하지 못했을 때,
771
+ rerun 이 미리 박아 둔 예약 row 를 중앙 락 아래에서 제거해 누수를 막는다.
772
+ cleanup 자체가 실패해도 best-effort — prepare 결과를 무르지 않는다."""
773
+ from okstra_ctl import remove_reservation
774
+ from .locks import central_lock
775
+
776
+ try:
777
+ home = okstra_home()
778
+ with central_lock(home):
779
+ remove_reservation(
780
+ home,
781
+ project_id=ctx["PROJECT_ID"],
782
+ task_group=ctx["TASK_GROUP"],
783
+ task_id=ctx["TASK_ID"],
784
+ task_type=ctx.get("TASK_TYPE", ""),
785
+ run_seq=run_seq,
786
+ )
787
+ except Exception as exc: # noqa: BLE001 — cleanup 실패는 비치명적
788
+ print(
789
+ f"okstra-central: reservation cleanup failed after record_start error ({exc})",
790
+ file=sys.stderr,
791
+ )
792
+
793
+
817
794
  def _brief_sha256(path: Path) -> str:
818
795
  import hashlib
819
796
 
@@ -1012,18 +989,28 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1012
989
  "--approved-plan <path-to-final-report.md>"
1013
990
  )
1014
991
  if inp.task_type == "implementation":
1015
- if inp.approve_plan_ack:
1016
- # 사용자가 직접 `--approve` 입력한 행위 자체를 승인 의사로 모델링한다.
1017
- # 파일의 frontmatter approved true toggle 동일한 검증
1018
- # 경로(`_validate_approved_plan`) 그대로 통과시킨다.
1019
- _apply_cli_approval(inp.approved_plan_path)
1020
- if inp.implementation_option:
1021
- # 유저가 고른 Option Candidate 이름을 approved-plan frontmatter 의
1022
- # `implementation-option:` 라인에 기록한다. 빈 값이면 implementation 이
1023
- # plan 의 `Recommended Option` 으로 폴백하므로 호출하지 않는다.
1024
- _apply_cli_implementation_option(
1025
- inp.approved_plan_path, inp.implementation_option
1026
- )
992
+ # --approve / --implementation-option 은 공유 approved-plan 파일에
993
+ # read-modify-write 한다. 같은 task-key 동시 stage run 둘이 둘 다
994
+ # 플래그를 주면 후발 write 선발의 frontmatter 변경을 덮어쓴다
995
+ # (lost update). mutation 을 per-task-key 락으로 직렬화한다.
996
+ if inp.approve_plan_ack or inp.implementation_option:
997
+ with worktree_provision_mutex(
998
+ okstra_home(), inp.project_id,
999
+ slugify(inp.task_group), slugify(inp.task_id),
1000
+ ):
1001
+ if inp.approve_plan_ack:
1002
+ # 사용자가 직접 `--approve` 를 입력한 행위 자체를 승인 의사로
1003
+ # 모델링한다. frontmatter approved 를 true 로 toggle 한 뒤
1004
+ # 동일한 검증 경로(`_validate_approved_plan`)를 통과시킨다.
1005
+ _apply_cli_approval(inp.approved_plan_path)
1006
+ if inp.implementation_option:
1007
+ # 유저가 고른 Option Candidate 이름을 approved-plan
1008
+ # frontmatter 의 `implementation-option:` 라인에 기록한다.
1009
+ # 빈 값이면 implementation 이 plan 의 `Recommended Option`
1010
+ # 으로 폴백하므로 호출하지 않는다.
1011
+ _apply_cli_implementation_option(
1012
+ inp.approved_plan_path, inp.implementation_option
1013
+ )
1027
1014
  _validate_approved_plan(inp.approved_plan_path)
1028
1015
  _validate_stage_structure(inp.approved_plan_path)
1029
1016
  else:
@@ -1177,7 +1164,15 @@ def _materialize_release_handoff_input(
1177
1164
 
1178
1165
 
1179
1166
  def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1180
- """`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다."""
1167
+ """`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다.
1168
+
1169
+ 이 read-modify-write 는 같은 task-key 의 동시 implementation run 이 거치는
1170
+ `_clear_stale_stage_waiver` 와 동일한 conformance-manifest.json 을 건드린다.
1171
+ `_clear_stale_stage_waiver` 는 prepare_task_bundle 의 worktree_provision_mutex
1172
+ 안에서 실행되므로, 이 함수도 같은 per-task-key 락을 잡아 두 writer 를
1173
+ 직렬화한다. 락이 없으면 후발 write 가 선발의 waiver 변경을 덮어써(lost update)
1174
+ verifier 가 Tier 3 conformance 를 건너뛰고 stage 를 가린다.
1175
+ """
1181
1176
  if not inp.qa_waiver:
1182
1177
  return
1183
1178
  from .conformance import apply_qa_waiver, parse_qa_waiver_arg
@@ -1189,13 +1184,16 @@ def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> N
1189
1184
  )
1190
1185
  stage_key, reason = parsed
1191
1186
  manifest_path = task_dir(project_root, inp.task_group, inp.task_id) / "qa" / "conformance-manifest.json"
1192
- if not manifest_path.is_file():
1193
- raise PrepareError(f"--qa-waiver: conformance manifest not found at {manifest_path}")
1194
- manifest = json.loads(manifest_path.read_text())
1195
- when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1196
- if not apply_qa_waiver(manifest, stage_key, reason, at=when):
1197
- raise PrepareError(f"--qa-waiver: stageKey {stage_key!r} not in manifest {manifest_path}")
1198
- manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
1187
+ with worktree_provision_mutex(
1188
+ okstra_home(), inp.project_id, slugify(inp.task_group), slugify(inp.task_id),
1189
+ ):
1190
+ if not manifest_path.is_file():
1191
+ raise PrepareError(f"--qa-waiver: conformance manifest not found at {manifest_path}")
1192
+ manifest = json.loads(manifest_path.read_text())
1193
+ when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1194
+ if not apply_qa_waiver(manifest, stage_key, reason, at=when):
1195
+ raise PrepareError(f"--qa-waiver: stageKey {stage_key!r} not in manifest {manifest_path}")
1196
+ manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
1199
1197
 
1200
1198
 
1201
1199
  def _clear_stale_stage_waiver(inp: "PrepareInputs", project_root: Path, stage: int) -> None:
@@ -1443,19 +1441,13 @@ def _auto_reconcile_best_effort(inp: "PrepareInputs", plan_run_root: Path) -> No
1443
1441
 
1444
1442
 
1445
1443
  def _is_ancestor(cwd, commit, head) -> bool:
1446
- if not commit or not head:
1447
- return False
1448
- r = _subprocess.run(
1449
- ["git", "-C", str(cwd), "merge-base", "--is-ancestor", commit, head],
1450
- capture_output=True, text=True,
1451
- )
1452
- return r.returncode == 0
1444
+ from .worktree import is_ancestor
1445
+ return is_ancestor(cwd, commit, head)
1453
1446
 
1454
1447
 
1455
1448
  def _is_dirty_excluding_okstra(cwd) -> bool:
1456
- excludes = [f":(exclude){p}" for p in okstra_clean_gate_excludes(Path(cwd))]
1457
- out = _git_out(cwd, "status", "--short", "--", ".", *excludes)
1458
- return bool(out.strip())
1449
+ from .worktree import is_dirty_excluding_okstra
1450
+ return is_dirty_excluding_okstra(cwd)
1459
1451
 
1460
1452
 
1461
1453
  def _single_stage_final_verification_worktree(inp: "PrepareInputs") -> WorktreeProvision:
@@ -1469,6 +1461,24 @@ def _single_stage_final_verification_worktree(inp: "PrepareInputs") -> WorktreeP
1469
1461
  )
1470
1462
 
1471
1463
 
1464
+ def _format_integration(integ) -> str:
1465
+ """stage 자동 통합 결과를 사람이 읽을 한국어 마크다운 블록으로 만든다."""
1466
+ def _csv(xs):
1467
+ return ", ".join(str(x) for x in xs) if xs else "없음"
1468
+
1469
+ skipped = "; ".join(f"stage {n}({why})" for n, why in integ.teardown_skipped)
1470
+ lines = [
1471
+ "- **Stage 자동 통합 결과** (whole-task final-verification):",
1472
+ "- **머지된 stage:** " + _csv(integ.merged),
1473
+ "- **이미 머지됨:** " + _csv(integ.already_merged),
1474
+ "- **정리(teardown)된 stage:** " + _csv(integ.torn_down),
1475
+ "- **정리 보류:** " + (skipped or "없음"),
1476
+ ]
1477
+ for w in integ.warnings:
1478
+ lines.append("- **경고:** " + w)
1479
+ return "\n".join(lines)
1480
+
1481
+
1472
1482
  def _reserve_final_verification_target(
1473
1483
  inp: "PrepareInputs", ctx: dict, ctx_stage_map: list,
1474
1484
  ) -> None:
@@ -1510,7 +1520,19 @@ def _reserve_final_verification_target(
1510
1520
  wt_path = ctx["EXECUTOR_WORKTREE_PATH"]
1511
1521
  anchor = _reg.get_implementation_base(
1512
1522
  inp.project_id, inp.task_group, inp.task_id) or ""
1523
+ try:
1524
+ integ = _stage_integrate.integrate_stages(
1525
+ project_id=inp.project_id, task_group=inp.task_group,
1526
+ task_id=inp.task_id, task_worktree_path=wt_path,
1527
+ stage_map=ctx_stage_map, done_rows=done_rows,
1528
+ )
1529
+ except _stage_integrate.IntegrateError as exc:
1530
+ raise PrepareError(str(exc)) from exc
1531
+ ctx["STAGE_INTEGRATION"] = _format_integration(integ)
1513
1532
  head = _git_out(wt_path, "rev-parse", "HEAD")
1533
+ # integrate 가 registry 기반으로 머지/skip 한 것과 별개로, 각 stage done
1534
+ # commit 이 실제 task HEAD 의 ancestor 인지 ground-truth 로 확인한다 —
1535
+ # stage-key 가 이미 teardown 된 stage 라도 커밋이 HEAD 에 남아있으면 통과.
1514
1536
  from .consumers import latest_done_by_stage
1515
1537
  merged = {s: _is_ancestor(wt_path, r.get("head_commit", ""), head)
1516
1538
  for s, r in latest_done_by_stage(done_rows).items()}
@@ -2060,7 +2082,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2060
2082
 
2061
2083
  # ---- prepare directories + cleanup ----
2062
2084
  _ensure_task_directories(ctx)
2063
- _migrate_legacy_run_artifacts(ctx)
2085
+ migrate_legacy_run_artifacts(ctx)
2064
2086
  cleanup_obsolete_generated_docs(
2065
2087
  project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_PATH"]),
2066
2088
  )
@@ -2121,8 +2143,14 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2121
2143
  except Exception as exc:
2122
2144
  print(
2123
2145
  f"okstra-central: record_start failed; central index will be incomplete ({exc})",
2124
- file=__import__("sys").stderr,
2146
+ file=sys.stderr,
2125
2147
  )
2148
+ # rerun 경로는 spawn 전에 'reserving' row 를 미리 박아 둔다
2149
+ # (OKSTRA_RUN_SEQ_OVERRIDE 가 그 seq 를 강제한다). record_start 가
2150
+ # 'reserving' → 'running' 승격에 실패하면 그 row 가 active.jsonl 에
2151
+ # 영구히 남아 activeCount 를 부풀린다. 예약 seq 를 알 때만 정리한다.
2152
+ if run_seq_override is not None:
2153
+ _remove_leaked_reservation(ctx, run_seq_override)
2126
2154
 
2127
2155
  if not inp.render_only:
2128
2156
  _provision_settings_symlink(inp)
@@ -0,0 +1,118 @@
1
+ """run-index row 의 생성/축약/복원 단일 참조점.
2
+
3
+ 디스크에는 slim(14필드)로 저장하고, reader 는 hydrate 로 full(18필드)을 받는다.
4
+ 파생 4필드(taskType/runSeq/taskKey/invocationFile)는 runId 와 평면
5
+ taskGroup/taskId 로부터 무손실 복원 가능하다. projectId 는 runId 슬러그가
6
+ 대소문자를 정규화(lowercase/slugify)해 raw 값을 잃으므로 파생이 아니라
7
+ slim row 에 raw 로 보존한다 — taskGroup/taskId 와 동일한 이유다.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from .ids import build_run_id, parse_run_id
17
+ from .invocation import invocation_path
18
+ from .jsonl import read_jsonl
19
+
20
+ DERIVED_FIELDS = ("taskType", "runSeq", "taskKey", "invocationFile")
21
+
22
+
23
+ def build_run_index_row(*, project_id: str, project_root: str,
24
+ task_group: str, task_id: str, task_type: str,
25
+ run_seq: int, status: str, started_at: str,
26
+ finished_at: Optional[str], workers: list, lead_model: str,
27
+ validation: str, run_dir_rel: str,
28
+ final_report_rel: str, final_status_rel: str) -> dict:
29
+ """slim run-index row(14필드)를 만든다. 파생 4필드는 포함하지 않는다."""
30
+ return {
31
+ "runId": build_run_id(project_id, task_group, task_id,
32
+ task_type, run_seq),
33
+ "projectId": project_id,
34
+ "projectRoot": project_root,
35
+ "taskGroup": task_group,
36
+ "taskId": task_id,
37
+ "status": status,
38
+ "startedAt": started_at,
39
+ "finishedAt": finished_at,
40
+ "workers": workers,
41
+ "leadModel": lead_model,
42
+ "validation": validation,
43
+ "finalReportRel": final_report_rel,
44
+ "finalStatusRel": final_status_rel,
45
+ "runDirRel": run_dir_rel,
46
+ }
47
+
48
+
49
+ def slim_run_row(row: dict) -> dict:
50
+ """파생 4필드를 제거한 새 dict. 이미 slim 이면 동일(멱등).
51
+ runId 가 없는 row(예: 옛 event-log 스키마)는 변형 없이 사본 반환."""
52
+ if "runId" not in row:
53
+ return dict(row)
54
+ return {k: v for k, v in row.items() if k not in DERIVED_FIELDS}
55
+
56
+
57
+ def hydrate_run_row(row: dict) -> dict:
58
+ """slim/full row 를 받아 파생 4필드를 채운 full dict. 멱등.
59
+ taskKey 는 runId(slug)가 아닌 평면 raw taskGroup/taskId 로 조합한다.
60
+ projectId 는 raw 값이 slim row 에 보존되므로 runId 에서 복원하지 않는다 —
61
+ 슬러그가 대소문자를 잃기 때문. 누락 시(방어용)만 parsed 값으로 채운다.
62
+ runId 가 없는 row(예: 옛 event-log 스키마)는 변형 없이 사본 반환.
63
+ runId 가 있으나 형식이 깨진 row 는 5-세그먼트 구조가 남아있으면 taskType/
64
+ runSeq 만 best-effort 로 복원한다 — slim row 는 이 둘을 따로 저장하지 않으므로,
65
+ 버전 차이/손상으로 strict 파싱이 실패하면 predict_next_run_seq 가 이 row 를
66
+ max-seq 스캔에서 통째로 누락해 seq 가 충돌할 수 있기 때문. 그래도 한 줄의
67
+ 손상이 read_run_index 의 다른 소비자(list_runs/reconcile)를 죽이지 않는다.
68
+ """
69
+ if "runId" not in row:
70
+ return dict(row)
71
+ try:
72
+ parsed = parse_run_id(row["runId"])
73
+ except ValueError:
74
+ out = dict(row)
75
+ parts = str(row["runId"]).split("/")
76
+ if len(parts) >= 5 and parts[-1][:1] == "r" and parts[-1][1:].isdigit():
77
+ out.setdefault("taskType", parts[-2])
78
+ out.setdefault("runSeq", int(parts[-1][1:]))
79
+ return out
80
+ out = dict(row)
81
+ out.setdefault("projectId", parsed["projectId"])
82
+ out["taskType"] = parsed["taskType"]
83
+ out["runSeq"] = parsed["runSeq"]
84
+ out["taskKey"] = f"{row.get('taskGroup', '')}/{row.get('taskId', '')}"
85
+ out["invocationFile"] = str(invocation_path(
86
+ Path(""), parsed["projectId"], parsed["taskGroup"], parsed["taskId"],
87
+ parsed["taskType"], parsed["runSeq"]))
88
+ return out
89
+
90
+
91
+ def read_run_index(path: Path) -> list:
92
+ """run-index jsonl 을 읽어 각 row 를 hydrate 한 full row 목록 반환."""
93
+ return [hydrate_run_row(r) for r in read_jsonl(path)]
94
+
95
+
96
+ def _rewrite_slim(path: Path) -> int:
97
+ rows = read_jsonl(path)
98
+ if not rows:
99
+ return 0
100
+ tmp = path.with_suffix(path.suffix + ".tmp")
101
+ with tmp.open("w") as f:
102
+ for r in rows:
103
+ f.write(json.dumps(slim_run_row(r), separators=(",", ":")) + "\n")
104
+ os.replace(tmp, path)
105
+ return len(rows)
106
+
107
+
108
+ def reindex_slim(home: Path) -> int:
109
+ """기존 run-index 파일들을 slim 으로 일괄 재작성. 멱등. 변환 row 총수 반환."""
110
+ total = 0
111
+ targets = [home / "recent.jsonl", home / "active.jsonl"]
112
+ projects = home / "projects"
113
+ if projects.is_dir():
114
+ targets += sorted(projects.glob("*/index.jsonl"))
115
+ for path in targets:
116
+ if path.is_file():
117
+ total += _rewrite_slim(path)
118
+ return total
@@ -39,8 +39,14 @@ _REF_RE = re.compile(r'"\$ref"\s*:\s*"#/\$defs/([^"]+)"')
39
39
 
40
40
 
41
41
  def _refs_in(obj) -> set[str]:
42
- """Every ``#/$defs/<name>`` referenced anywhere inside ``obj``."""
43
- return set(_REF_RE.findall(json.dumps(obj)))
42
+ """Every ``#/$defs/<name>`` referenced anywhere inside ``obj``.
43
+
44
+ ``ensure_ascii=False`` keeps non-ASCII ``$defs`` names in their literal
45
+ form so the captured ref matches the actual ``$defs`` dict key; with the
46
+ default escaping a non-ASCII name would dump as ``\\uXXXX`` and the closure
47
+ would prune the def as unreachable, leaving a dangling ``$ref``.
48
+ """
49
+ return set(_REF_RE.findall(json.dumps(obj, ensure_ascii=False)))
44
50
 
45
51
 
46
52
  def _conditional_applies(entry: dict, task_type: str) -> bool:
@@ -6,8 +6,8 @@ from pathlib import Path
6
6
  from typing import Optional
7
7
 
8
8
  from .ids import slugify_task_segment
9
- from .jsonl import read_jsonl
10
9
  from .paths import task_runs_dir
10
+ from .run_index_row import read_run_index
11
11
 
12
12
 
13
13
  def predict_next_run_seq(project_root: Path, task_group: str, task_id: str,
@@ -41,7 +41,7 @@ def predict_next_run_seq(project_root: Path, task_group: str, task_id: str,
41
41
  max_seq = n
42
42
  if home is not None and project_id is not None:
43
43
  for src in ("active.jsonl", "recent.jsonl"):
44
- for r in read_jsonl(home / src):
44
+ for r in read_run_index(home / src):
45
45
  if (r.get("projectId") == project_id
46
46
  and r.get("taskGroup") == task_group
47
47
  and r.get("taskId") == task_id