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.
- package/README.kr.md +1 -1
- package/README.md +1 -1
- package/bin/okstra +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/kr/architecture/storage-model.md +273 -0
- package/docs/kr/architecture.md +6 -277
- package/docs/kr/cli.md +1 -1
- package/docs/project-structure-overview.md +26 -22
- package/docs/superpowers/plans/2026-06-20-error-feedback-loop.md +1019 -0
- package/docs/superpowers/plans/2026-06-20-run-index-row-normalization.md +583 -0
- package/docs/superpowers/plans/2026-06-20-stage-auto-integrate-teardown.md +819 -0
- package/docs/superpowers/plans/2026-06-21-release-handoff-local-checkout.md +348 -0
- package/docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md +2 -2
- package/docs/superpowers/specs/2026-06-20-error-feedback-loop-design.md +171 -0
- package/docs/superpowers/specs/2026-06-20-run-index-row-normalization-design.md +112 -0
- package/docs/superpowers/specs/2026-06-20-stage-auto-integrate-teardown-design.md +145 -0
- package/docs/superpowers/specs/2026-06-21-release-handoff-local-checkout-design.md +113 -0
- package/docs/task-process/release-handoff.md +3 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +1 -1
- package/runtime/bin/lib/okstra/interactive.sh +30 -4
- package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +18 -5
- package/runtime/bin/okstra-render-final-report.py +4 -15
- package/runtime/bin/okstra.sh +5 -2
- package/runtime/prompts/launch.template.md +1 -0
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +1 -0
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/release-handoff.md +10 -4
- package/runtime/python/okstra_ctl/__init__.py +3 -2
- package/runtime/python/okstra_ctl/backfill.py +12 -21
- package/runtime/python/okstra_ctl/codex_dispatch.py +7 -2
- package/runtime/python/okstra_ctl/conformance.py +21 -3
- package/runtime/python/okstra_ctl/consumers.py +7 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +10 -2
- package/runtime/python/okstra_ctl/doctor.py +29 -12
- package/runtime/python/okstra_ctl/error_log_core.py +72 -0
- package/runtime/python/okstra_ctl/error_report.py +8 -58
- package/runtime/python/okstra_ctl/error_zip.py +319 -0
- package/runtime/python/okstra_ctl/fanout.py +5 -2
- package/runtime/python/okstra_ctl/fix_cycles.py +14 -2
- package/runtime/python/okstra_ctl/git_reconcile.py +7 -1
- package/runtime/python/okstra_ctl/handoff.py +125 -26
- package/runtime/python/okstra_ctl/implementation_stage.py +9 -2
- package/runtime/python/okstra_ctl/improvement_lenses.py +2 -0
- package/runtime/python/okstra_ctl/index.py +34 -91
- package/runtime/python/okstra_ctl/jsonl.py +29 -18
- package/runtime/python/okstra_ctl/listing.py +30 -5
- package/runtime/python/okstra_ctl/md_table.py +1 -1
- package/runtime/python/okstra_ctl/migrate.py +17 -7
- package/runtime/python/okstra_ctl/paths.py +1 -1
- package/runtime/python/okstra_ctl/recap.py +12 -3
- package/runtime/python/okstra_ctl/reconcile.py +13 -11
- package/runtime/python/okstra_ctl/render.py +49 -12
- package/runtime/python/okstra_ctl/render_final_report.py +4 -0
- package/runtime/python/okstra_ctl/report_views.py +47 -6
- package/runtime/python/okstra_ctl/run.py +128 -100
- package/runtime/python/okstra_ctl/run_index_row.py +118 -0
- package/runtime/python/okstra_ctl/schema_excerpt.py +8 -2
- package/runtime/python/okstra_ctl/sequence.py +2 -2
- package/runtime/python/okstra_ctl/stage_integrate.py +193 -0
- package/runtime/python/okstra_ctl/stage_targets.py +2 -8
- package/runtime/python/okstra_ctl/task_target.py +4 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +6 -1
- package/runtime/python/okstra_ctl/wizard.py +27 -12
- package/runtime/python/okstra_ctl/workflow.py +9 -1
- package/runtime/python/okstra_ctl/worktree.py +93 -4
- package/runtime/python/okstra_ctl/worktree_registry.py +57 -8
- package/runtime/python/okstra_token_usage/collect.py +43 -38
- package/runtime/skills/okstra-brief/SKILL.md +43 -12
- package/runtime/skills/okstra-inspect/SKILL.md +44 -1
- package/runtime/templates/reports/final-report.template.md +2 -2
- package/runtime/templates/reports/release-handoff-input.template.md +1 -1
- package/runtime/validators/forbidden_actions.py +8 -1
- package/runtime/validators/validate-brief.py +13 -2
- package/runtime/validators/validate-run.py +27 -6
- package/runtime/validators/validate-schedule.py +1 -2
- package/runtime/validators/validate_improvement_report.py +14 -0
- package/src/cli-registry.mjs +45 -31
- package/src/{codex-dispatch.mjs → commands/execute/codex-dispatch.mjs} +3 -3
- package/src/{codex-run.mjs → commands/execute/codex-run.mjs} +3 -3
- package/src/{error-log.mjs → commands/execute/error-log.mjs} +1 -1
- package/src/{git-reconcile.mjs → commands/execute/git-reconcile.mjs} +1 -1
- package/src/{handoff.mjs → commands/execute/handoff.mjs} +3 -1
- package/src/commands/execute/integrate-stages.mjs +25 -0
- package/src/{plan-validate.mjs → commands/execute/plan-validate.mjs} +1 -1
- package/src/{render-bundle.mjs → commands/execute/render-bundle.mjs} +4 -4
- package/src/{run.mjs → commands/execute/run.mjs} +4 -4
- package/src/{spawn-followups.mjs → commands/execute/spawn-followups.mjs} +1 -1
- package/src/{team.mjs → commands/execute/team.mjs} +3 -3
- package/src/{token-usage.mjs → commands/execute/token-usage.mjs} +1 -1
- package/src/{wizard.mjs → commands/execute/wizard.mjs} +15 -6
- package/src/{worktree-lookup.mjs → commands/execute/worktree-lookup.mjs} +1 -1
- package/src/{context-cost.mjs → commands/inspect/context-cost.mjs} +2 -2
- package/src/{error-report.mjs → commands/inspect/error-report.mjs} +2 -2
- package/src/commands/inspect/error-zip.mjs +25 -0
- package/src/{recap.mjs → commands/inspect/recap.mjs} +2 -2
- package/src/{task-list.mjs → commands/inspect/task-list.mjs} +1 -1
- package/src/{task-show.mjs → commands/inspect/task-show.mjs} +1 -1
- package/src/{check-project.mjs → commands/lifecycle/check-project.mjs} +2 -2
- package/src/{config.mjs → commands/lifecycle/config.mjs} +3 -3
- package/src/{doctor.mjs → commands/lifecycle/doctor.mjs} +4 -4
- package/src/{install.mjs → commands/lifecycle/install.mjs} +18 -13
- package/src/{migrate.mjs → commands/lifecycle/migrate.mjs} +1 -1
- package/src/{paths.mjs → commands/lifecycle/paths.mjs} +1 -60
- package/src/{setup.mjs → commands/lifecycle/setup.mjs} +4 -4
- package/src/{uninstall.mjs → commands/lifecycle/uninstall.mjs} +20 -32
- package/src/{memory.mjs → commands/memory/memory.mjs} +32 -7
- package/src/{inject-report-index.mjs → commands/report/inject-report-index.mjs} +1 -1
- package/src/{render-final-report.mjs → commands/report/render-final-report.mjs} +1 -1
- package/src/{render-views.mjs → commands/report/render-views.mjs} +1 -1
- package/src/lib/paths.mjs +60 -0
- package/src/{_python-helper.mjs → lib/python-helper.mjs} +35 -13
- package/src/{version.mjs → lib/version.mjs} +2 -2
- /package/src/{okstra-dirs.mjs → lib/okstra-dirs.mjs} +0 -0
- /package/src/{_proc.mjs → lib/proc.mjs} +0 -0
- /package/src/{runtime-manifest.mjs → lib/runtime-manifest.mjs} +0 -0
- /package/src/{runtime-resolver.mjs → lib/runtime-resolver.mjs} +0 -0
- /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
|
-
|
|
889
|
-
|
|
911
|
+
허용하되, 후보 이름이 서로의 접두여서 둘 이상이 매칭되면 모호하므로 채택하지
|
|
912
|
+
않고 첫 후보로 폴백한다 — 임의의 한 후보를 권장으로 잘못 표시하느니 결정적인
|
|
913
|
+
폴백이 낫다. 항상 정확히 한 옵션이 selected 가 되어 브라우저 자동선택분의 묵시
|
|
914
|
+
Export 를 차단한다."""
|
|
890
915
|
if rec_name in names:
|
|
891
916
|
return rec_name
|
|
892
917
|
if rec_name:
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
791
|
-
|
|
792
|
-
with
|
|
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
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
1447
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|