okstra 0.96.1 → 0.97.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +22 -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
@@ -0,0 +1,112 @@
1
+ # run-index row 정규화 (runId SSOT화)
2
+
3
+ - 상태: 설계 승인 대기
4
+ - 작성일: 2026-06-20
5
+ - 범위: `~/.okstra/{recent,active}.jsonl` + `~/.okstra/projects/<id>/index.jsonl` 의 run-index row 스키마
6
+
7
+ ## 1. 배경 / 문제
8
+
9
+ run-index row 는 현재 18개 필드를 가지며, 그 중 다수가 `runId` 또는 다른 평면 필드에서 파생 가능한 **중복 값**이다.
10
+ 한 레코드는 `record_start()` 가 [`scripts/okstra_ctl/index.py:74`](../../../scripts/okstra_ctl/index.py:74) 에서 조립하고
11
+ `recent.jsonl`·`active.jsonl`·`projects/<id>/index.jsonl` 세 곳에 동일하게 기록된다.
12
+
13
+ 중복은 두 층위로 나뉜다.
14
+
15
+ - **① 파일 간 row 복제** — 같은 row 가 recent / index / active 에 복사된다. 이는 reader 가 join 없이 한 파일만 읽도록 한 **의도된 비정규화 인덱스**이며 이번 범위에서 **건드리지 않는다**.
16
+ - **② 레코드 내 파생 필드** — 한 row 안에서 다른 필드로부터 계산 가능한 값들. **이번 작업의 대상**이다.
17
+
18
+ `runId` 형식은 [`scripts/okstra_ctl/ids.py:8`](../../../scripts/okstra_ctl/ids.py:8) 에 정의된 `<projectId>/<taskGroup>/<taskId>/<taskType>/r<runSeq>` 슬러그이고,
19
+ 역파서 `parse_run_id()` 가 [`scripts/okstra_ctl/ids.py:34`](../../../scripts/okstra_ctl/ids.py:34) 에 **이미 존재**한다.
20
+
21
+ ## 2. 검증 (설계의 근거)
22
+
23
+ 기존 246개 row(recent 124 + 6개 프로젝트 index + active) 전체에 대해 "평면 필드 == `parse_run_id(runId)` 결과" 및 "경로 필드 == 규칙 재구성" 일치 여부를 전수 검사했다.
24
+
25
+ | 필드 | 결과 | 판정 |
26
+ |---|---|---|
27
+ | `projectId` | 불일치 0 | 무손실 파생 가능 |
28
+ | `taskType` | 불일치 0 | 무손실 파생 가능 |
29
+ | `runSeq` | 불일치 0 | 무손실 파생 가능 |
30
+ | `invocationFile` | 불일치 0 | 규칙 재구성 가능 (slug 기반) |
31
+ | `taskGroup` | 불일치 74 | **파생 불가** — 평면값은 raw 대소문자(`DEV-9388`), runId 세그먼트는 slug(`dev-9388`) |
32
+ | `taskId` | 불일치 48 | **파생 불가** — 동일 사유 |
33
+ | `taskKey` | 불일치 74 | runId 파싱으로는 불가, 단 평면 `taskGroup`+`taskId` 조합으로는 무손실 |
34
+ | `runDirRel` | 불일치 148 | **파생 불가** — 옛 데이터는 `.project-docs/okstra/tasks/...` prefix(artifact-home 변경 이력), 카운터 분리로 규칙 재구성 불가 |
35
+
36
+ **핵심 교훈**: `taskGroup`/`taskId`/`taskKey` 는 raw 대소문자를 보존하므로 runId(slug)에서 복원하면 정보가 손실된다. 따라서 raw 평면값을 유지해야 한다.
37
+
38
+ ## 3. 결정
39
+
40
+ ### 3.1 제거 — 5개 필드 (검증상 무손실 복원 가능)
41
+
42
+ | 제거 필드 | 복원 방법 |
43
+ |---|---|
44
+ | `projectId` | `parse_run_id(runId)["projectId"]` |
45
+ | `taskType` | `parse_run_id(runId)["taskType"]` |
46
+ | `runSeq` | `parse_run_id(runId)["runSeq"]` |
47
+ | `taskKey` | `f"{row['taskGroup']}/{row['taskId']}"` (평면 raw 조합) |
48
+ | `invocationFile` | `projects/<pid>/invocations/<slug taskGroup>/<slug taskId>/<taskType>/r<NN>.json` — `parse_run_id` 의 slug 세그먼트 사용 |
49
+
50
+ ### 3.2 유지 — 검증이 "제거 불가"로 판정
51
+
52
+ `taskGroup`, `taskId` (raw 케이스), `runDirRel`, `finalReportRel`, `finalStatusRel` (실저장값),
53
+ `projectRoot` (meta.json 에 SSOT 가 있으나 **과거 run 당시 경로** 보존 + meta 조회 비용 회피 위해 유지),
54
+ 가변 필드 `status`/`startedAt`/`finishedAt`/`workers`/`leadModel`/`validation`.
55
+
56
+ 결과: **18필드 → 13필드.** 가장 큰 절감은 긴 경로인 `invocationFile`.
57
+
58
+ 남는 slim row 필드:
59
+ `runId`, `projectRoot`, `taskGroup`, `taskId`, `status`, `startedAt`, `finishedAt`, `workers`, `leadModel`, `validation`, `finalReportRel`, `finalStatusRel`, `runDirRel`.
60
+
61
+ ## 4. 구조
62
+
63
+ ### 4.1 단일 참조점 두 개
64
+
65
+ 복원/생성 로직을 두 개의 헬퍼에 집중한다. 신규 모듈 `scripts/okstra_ctl/run_index_row.py`.
66
+
67
+ - **`hydrate_run_row(row) -> dict`** — slim row(또는 기존 full row)를 받아 제거된 5필드를 채운 full dict 반환. full row 를 받으면 기존 값을 덮어쓰지 않고 동일 결과를 보장(멱등). reader 전용.
68
+ - **`build_run_index_row(...) -> dict`** — slim row 를 생성하는 유일한 진입점. 현재 `index.py` 의 row dict 리터럴 두 곳([`record_start`](../../../scripts/okstra_ctl/index.py:74), [`reserve_run_in_active`](../../../scripts/okstra_ctl/index.py:162))과 reconcile/backfill 의 row 생성이 이 헬퍼를 호출하도록 치환. writer 전용.
69
+
70
+ ### 4.2 read 지점 hydrate (reader 로직 무변경)
71
+
72
+ 디스크는 slim, 메모리는 full 을 유지한다. run-index 전용 read 함수 `read_run_index(path) -> list[dict]` 를 도입해 각 row 를 `hydrate_run_row` 로 통과시킨다.
73
+
74
+ run-index row 소비자 6곳의 **read 진입점만** 교체하고, 그 아래 소비 로직은 그대로 둔다.
75
+
76
+ - [`scripts/okstra_ctl/sequence.py`](../../../scripts/okstra_ctl/sequence.py) — `projectId`/`taskType`/`runSeq` 로 next seq 계산
77
+ - [`scripts/okstra_ctl/listing.py`](../../../scripts/okstra_ctl/listing.py) — `projectId`/`taskType`/`invocationFile`/`projectRoot`
78
+ - [`scripts/okstra_ctl/reconcile.py`](../../../scripts/okstra_ctl/reconcile.py) — `projectId`/`taskType`/`runSeq`/`runDirRel`
79
+ - [`scripts/okstra_ctl/pane_reclaim.py`](../../../scripts/okstra_ctl/pane_reclaim.py) — `projectRoot`
80
+ - [`scripts/okstra_ctl/wizard.py`](../../../scripts/okstra_ctl/wizard.py) — `taskKey`/`taskType`
81
+ - [`scripts/okstra_ctl/recap.py`](../../../scripts/okstra_ctl/recap.py) — `taskType`
82
+
83
+ `reconcile.py` 는 row 를 읽어 다시 쓰는데(in-place 갱신), 쓸 때는 `build_run_index_row` 로 slim 화한다 → 자연스러운 lazy 재작성 경로가 된다.
84
+
85
+ > manifest / state / convergence 를 읽는 코드(`context_cost.py`, `render.py`, `handoff.py`, `codex_dispatch.py`, `state.py`, `error_report.py`, `task-show.mjs` 등)는 run-index row 소비자가 **아니므로** 변경 대상이 아니다.
86
+
87
+ ## 5. 마이그레이션 (일괄)
88
+
89
+ `migrate.py` 는 `.project-docs/okstra/ → .okstra/` **경로 이동** 전용 one-shot 이고 project_root 레벨이라, row 스키마 변환과 책임이 다르다. **혼합하지 않는다.**
90
+
91
+ 별도 일괄 변환을 둔다 — `run_index_row.py` 의 `reindex_slim(home) -> int` 함수. 동작:
92
+
93
+ 1. `recent.jsonl`, `active.jsonl`, 모든 `projects/<id>/index.jsonl` 을 읽는다.
94
+ 2. 각 row 를 `slim_run_row` 로 정규화해 slim 으로 재작성한다(원자적 tmp + os.replace).
95
+ 3. 이미 slim 인 row 는 no-op (멱등). `runId` 없는 비-run-index row(옛 event-log)는 무변형 통과.
96
+ 4. `archive/` 는 현재 비어 있어 대상 외. 미래 archive 는 writer 가 이미 slim 을 기록하므로 자동 정합.
97
+
98
+ 이 함수는 기존 데이터 1회 정리용 **일회성 마이그레이션**이라 CLI 서브커맨드로 노출하지 않는다(YAGNI). writer 가 항상 slim 을 기록하므로 새 환경은 처음부터 slim 이고, `hydrate_run_row` 가 full row 도 수용(안전망)해 미마이그레이션 환경도 reader 가 정상 동작한다. pre-v1.0 이라 하위호환 부담도 없다.
99
+
100
+ ## 6. 테스트 (`tests/run/`)
101
+
102
+ - `parse_run_id` ↔ `build_run_id` 라운드트립 (slug 세그먼트 기준).
103
+ - `hydrate_run_row(slim)` == 기존 full row — 실데이터 기반 회귀 스냅샷(`projectId`/`taskType`/`runSeq`/`taskKey`/`invocationFile` 5필드가 원본과 일치).
104
+ - `hydrate_run_row(full)` 멱등 — full row 를 넣어도 동일 결과.
105
+ - `reindex_slim` 멱등 — 두 번 실행해도 동일 출력.
106
+ - raw 케이스 보존 — `taskGroup="DEV-9388"` 인 row 가 마이그레이션 후에도 raw 유지되는지.
107
+
108
+ ## 7. 비범위 (YAGNI)
109
+
110
+ - 파일 간 row 복제(① recent/index/active 통합) — 조회 경로 전면 개편이 필요하고 이번 동기와 무관.
111
+ - `projectRoot`/`runDirRel`/`finalReportRel`/`finalStatusRel` 제거 — 검증상 파생 불가 또는 실익 모호.
112
+ - `runId` 포맷 변경 — fs-safe 슬러그 불변식(충돌 방지)을 유지해야 하므로 불가.
@@ -0,0 +1,145 @@
1
+ # stage 자동 통합(머지) + worktree teardown — 설계
2
+
3
+ - 작성일: 2026-06-20
4
+ - 범위: `implementation` 으로 쌓인 stage 들을 task 브랜치(부모)에 **okstra 가 자동 머지**하고, 역할이 끝난 stage worktree·registry 키·stage 브랜치를 **자동 정리(teardown)** 한다. whole-task final-verification 진입 시 자동 수행하고, 동일 코어를 단독 명령으로도 노출한다.
5
+ - 비범위
6
+ - **stage-group handoff 경로 변경 없음** — release-handoff 의 stage-group(`assemble`) 모드는 그대로 둔다. 본 설계는 whole-task 통합 경로 전용이며, 두 워크플로우는 상호배타다(§2.4).
7
+ - **충돌 자동 해소 없음** — stage 간 머지 충돌은 `git merge --abort` 후 중단하고 사용자 수동 해소로 넘긴다(octopus/전략 머지 없음).
8
+ - **dirty worktree 강제 삭제 없음** — 미커밋 변경이 남은 stage worktree 는 teardown 하지 않고 경고만 남긴다.
9
+ - `implementation` 외 phase 의 worktree 모델 불변 — 기존 task-key worktree 1개 유지.
10
+ - 관계: [`2026-06-06-stage-worktree-isolation-design.md`](2026-06-06-stage-worktree-isolation-design.md) 의 stage worktree 격리 모델 위에 선다. 그 spec 의 **§2 "okstra 자동 머지 없음" 원칙을 본 설계가 의도적으로 전환**한다(§2.1). 그 spec 이 후속 과제로 남긴 **§9-#2 "stage worktree 자동 teardown"** 를 본 설계가 해소한다. [`2026-06-10-stage-group-handoff-design.md`](2026-06-10-stage-group-handoff-design.md) 의 stage-group 수집 머지와는 진입 경로가 분리되어 충돌하지 않는다(§2.4).
11
+
12
+ ## 1. 동기
13
+
14
+ whole-task final-verification 게이트([`stage_targets.resolve_whole_task_target`](../../../scripts/okstra_ctl/stage_targets.py:196))는 두 가지를 강제한다.
15
+
16
+ 1. **merged 게이트** — 모든 stage 의 done commit 이 task worktree HEAD 의 ancestor여야 함. 아니면 "merge stage branches then retry" 로 중단.
17
+ 2. **dirty 게이트** — task worktree 가 깨끗해야 함([`run._is_dirty_excluding_okstra`](../../../scripts/okstra_ctl/run.py:1455)).
18
+
19
+ stage worktree 는 부모 task worktree 하위 디렉터리(`.../<task-id>/stage-<N>/`)에 위치하므로, 부모 입장에서 `git status --short` 가 `?? stage-N/` 로 잡아 **자기 산출물이 자기 dirty 게이트를 막는** 모순이 생긴다([clean gate 제외 목록](../../../scripts/okstra_ctl/worktree.py:411) 에 stage 디렉터리가 없음). 또한 stage 머지는 현재 전적으로 사용자 수동이라, 사용자가 머지를 빠뜨리면 merged 게이트에서도 막힌다.
20
+
21
+ 근본 해결은 "stage done commit 을 task 브랜치에 머지하고, 역할이 끝난 stage worktree 를 정리"하는 절차를 okstra 가 수행하는 것이다. 이 절차가 끝나면 두 게이트가 모두 자연 통과한다.
22
+
23
+ ## 2. 핵심 원칙
24
+
25
+ ### 2.1 자동 머지 원칙 전환
26
+
27
+ [`2026-06-06 §2`](2026-06-06-stage-worktree-isolation-design.md) 는 "okstra 자동 머지 없음 — 머지 충돌 해소 책임을 okstra 에 넣지 않는다"를 못박았다. 본 설계는 이를 **whole-task 통합 경로에 한해 전환**한다: okstra 가 stage done commit 을 task 브랜치에 머지한다. 단 충돌 해소 책임은 여전히 사용자에게 남긴다 — 충돌 시 `merge --abort` 후 중단(§4)하므로, okstra 가 충돌을 해소하지 않는다는 그 spec 의 더 깊은 원칙(octopus 보류)은 유지된다. 즉 "자동 머지"는 **충돌 없는 머지의 자동화**이고, 충돌은 종전대로 사용자 몫이다.
28
+
29
+ ### 2.2 2-페이즈 분리 — 머지(브랜치 기준)와 teardown(worktree 기준)
30
+
31
+ 머지는 stage 브랜치 ref 와 done commit 만 있으면 되고 worktree 가 필요 없다. teardown 은 worktree 디스크 상태에 좌우된다. 둘을 분리하면 "dirty 라 teardown 은 skip 하되 머지는 이미 됨" 같은 상태를 깔끔히 표현할 수 있다.
32
+
33
+ - **Phase A (머지)**: 브랜치/커밋 기준. worktree 존재 불필요.
34
+ - **Phase B (teardown)**: worktree 디스크 기준. Phase A 가 머지/skip 한 stage 만 대상.
35
+
36
+ ### 2.3 코어 1개 + 진입 2개 (single-reference-point)
37
+
38
+ 머지+teardown 로직은 신규 모듈 [`scripts/okstra_ctl/stage_integrate.py`](../../../scripts/okstra_ctl/stage_integrate.py) 의 `integrate_stages` 하나가 SSOT다. 두 진입점이 이 코어로 수렴한다.
39
+
40
+ - **자동**: whole-task final-verification prepare 경로([`run.py:1509`](../../../scripts/okstra_ctl/run.py:1509) 의 `else` 분기)에서 `resolve_whole_task_target` 호출 **직전**에 `integrate_stages` 를 호출한다. 기존 `auto_reconcile_best_effort`([run.py:1485](../../../scripts/okstra_ctl/run.py:1485)) 와 같은 prepare 단계에 위치한다.
41
+ - **단독 명령**: `okstra ... integrate-stages` — 사용자가 final-verification 전에 명시 호출. CLI(`okstra.sh`/`cli.sh`)와 Node CLI 가 같은 코어로 패스스루.
42
+
43
+ ### 2.4 whole-task 통합과 stage-group handoff 의 상호배타성
44
+
45
+ release-handoff 의 stage-group 모드([`handoff._merge_stages`](../../../scripts/okstra_ctl/handoff.py:173))는 `compute_branch_name` 으로 stage 브랜치를 재구성해 머지하므로 **stage 브랜치 ref 에 의존**한다. 본 설계가 stage 브랜치를 삭제(§3 Phase B)해도 이것이 문제되지 않는 이유:
46
+
47
+ - whole-task 통합을 수행하는 순간 **모든 stage 가 task 브랜치 하나로 합쳐진다.** 이 시점에 "stage 부분집합만 골라 collector 브랜치로 릴리스"하는 stage-group 은 의미를 잃는다.
48
+ - stage-group 을 원하는 사용자는 single-stage final-verification(`--stage N`) → stage-group handoff 경로를 타며, whole-task 통합을 호출하지 않는다.
49
+ - 따라서 whole-task 통합 경로의 stage 브랜치 삭제는 stage-group 워크플로우를 막지 않는다 — 두 경로는 애초에 교차하지 않는다.
50
+
51
+ ### 2.5 이력 보존
52
+
53
+ Phase A 머지는 `--no-ff` 로 stage 식별 가능한 머지 커밋을 만든다. stage 원본 커밋들은 머지 커밋을 통해 부모 브랜치에서 reachable하므로, stage 브랜치 ref(포인터)를 삭제해도 커밋은 GC 되지 않고 히스토리로 추적된다. 재작업 시 자식 worktree/브랜치를 다시 발급하면 된다.
54
+
55
+ ## 3. 설계
56
+
57
+ ### Phase A — 위상순 머지
58
+
59
+ 입력: task worktree path, task 브랜치, stage map, `consumers.jsonl` done rows.
60
+
61
+ 1. stage map 을 `depends_on` 위상정렬(= `stage_number` 오름차순, plan 이 보장) 순으로 순회.
62
+ 2. 각 stage 의 done commit(`head_commit`)이 **이미 task HEAD 의 ancestor**면(`git merge-base --is-ancestor`) **머지 skip** (no-op, `already-merged` 로 기록). 기존 `auto_reconcile` 가 patch-id 로 재배치한 경우도 ancestor 로 잡힌다.
63
+ 3. 아니면 `git -C <task-worktree> merge --no-ff --no-edit <stage-branch>` 수행.
64
+ 4. **충돌 시**: 해당 stage 의 `git merge --abort` 로 **그 머지만** 원상복구 → `IntegrateError`(= `PrepareError` 로 surface)로 충돌 파일 경로를 보여주고 중단. 위상순 앞서 머지 성공한 선행 stage 는 task 브랜치에 그대로 남으며(재시도 시 §3-#2 의 `already_merged` 로 skip 되어 진전이 보존된다), **Phase B(teardown)는 진입하지 않는다** — 충돌이 하나라도 있으면 어떤 worktree·브랜치도 지우지 않는다(teardown 원자성). 머지 자체는 부분 진행될 수 있으나 재진입이 idempotent 하므로 안전하다. (release-handoff `assemble` 의 exit 2 / `_merge_stages` abort 패턴과 동일)
65
+
66
+ 산출: `merged: list[int]`, `already_merged: list[int]`.
67
+
68
+ ### Phase B — teardown
69
+
70
+ 대상: Phase A 가 `merged` 또는 `already_merged` 로 처리한 stage. (충돌로 중단되면 이 페이즈는 실행되지 않음.)
71
+
72
+ 각 stage 에 대해:
73
+
74
+ 1. stage worktree 가 **dirty**(`_is_dirty_excluding_okstra` 재사용)면 **skip + 경고** — worktree·브랜치 모두 보류. (done 이후 미커밋 변경 손실 방지)
75
+ 2. clean 이면 순서대로:
76
+ - `git worktree remove <stage-worktree-path>`
77
+ - registry 의 `<task-key>#stage-<N>` 키 status 해제([`worktree_registry.release_status`](../../../scripts/okstra_ctl/worktree_registry.py) — branch 슬롯은 **건드리지 않음**, flock 보호)
78
+ - `git branch -d <stage-branch>` — **safe delete**. 부모에 머지 안 된 브랜치는 git 이 거부하므로, Phase A 머지 성공이 삭제의 전제로 자동 강제된다(force `-D` 미사용).
79
+ - branch -d 가 **성공한 뒤에만** branch 인덱스 슬롯 해제([`worktree_registry.free_branch_slot`](../../../scripts/okstra_ctl/worktree_registry.py)). 불변식: "registry 가 free 로 표시한 브랜치 이름은 항상 물리적으로 삭제돼 있다" — 동시 run 이 살아있는 브랜치 이름을 재예약하는 윈도우를 막는다. branch -d 실패 시 슬롯을 그대로 둔 채 경고만 남긴다.
80
+ 3. per-stage best-effort: 한 stage 의 teardown 실패가 다른 stage 를 막지 않는다(경고 수집 후 계속).
81
+
82
+ 산출: `torn_down: list[int]`, `teardown_skipped: list[(int, reason)]`, `warnings: list[str]`.
83
+
84
+ ### 반환 — `IntegrateResult`
85
+
86
+ ```
87
+ merged : list[int] # 이번에 머지한 stage
88
+ already_merged : list[int] # 이미 ancestor 라 skip
89
+ torn_down : list[int] # worktree+registry+branch 제거됨
90
+ teardown_skipped : list[(int, str)] # dirty 등으로 보류
91
+ warnings : list[str]
92
+ ```
93
+
94
+ final-verification 리포트의 통합 섹션과 단독 명령 출력에 그대로 노출한다.
95
+
96
+ ### non-git / degraded 경로
97
+
98
+ [`2026-06-06 §2.1`](2026-06-06-stage-worktree-isolation-design.md) 의 degrade 신호(`skipped-not-git` / `skipped-in-worktree`)와 동일하게, stage worktree 가 발급된 적 없는 환경에서는 `integrate_stages` 가 no-op(빈 `IntegrateResult`)으로 graceful degrade 한다.
99
+
100
+ ## 4. 에러 처리 요약
101
+
102
+ | 상황 | 처리 |
103
+ |---|---|
104
+ | stage 간 머지 충돌 | 해당 stage `merge --abort` → `IntegrateError`/`PrepareError` 충돌 경로 동봉, 중단. 선행 머지는 유지(재시도 시 skip), teardown 은 미진입(teardown 원자성) |
105
+ | 선행 stage 미완(done row 없음) | merged 게이트가 별도로 잡음 — 통합 전에 stage 미완으로 중단 |
106
+ | stage worktree dirty | 해당 stage teardown skip + 경고, 머지는 유지 |
107
+ | `git branch -d` 거부(미머지) | 발생 불가(Phase A 머지 성공이 전제). 발생 시 경고로 surface, worktree 는 이미 제거됨. branch 슬롯은 해제하지 않아 살아있는 브랜치 이름이 free 로 표시되지 않음(불변식 유지) |
108
+ | worktree remove 실패 | per-stage 경고, 다음 stage 계속 |
109
+
110
+ ## 5. 변경 대상 파일 (seed only)
111
+
112
+ `feedback_okstra_fixes_target_end_users`: `runtime/`·개인 `.claude/` 가 아니라 아래 source 파일에만 가한다.
113
+
114
+ | 파일 | 변경 |
115
+ |---|---|
116
+ | [`scripts/okstra_ctl/stage_integrate.py`](../../../scripts/okstra_ctl/stage_integrate.py) (신규) | `integrate_stages` 코어 + `IntegrateResult` / `IntegrateError` |
117
+ | [`scripts/okstra_ctl/run.py`](../../../scripts/okstra_ctl/run.py:1509) | whole-task final-verification prepare 에서 `integrate_stages` 자동 호출; `integrate-stages` 단독 명령 dispatch |
118
+ | [`scripts/okstra_ctl/worktree.py`](../../../scripts/okstra_ctl/worktree.py) | registry `#stage-N` 키 해제 헬퍼(flock 보호); worktree dirty 검사 재사용 |
119
+ | [`scripts/okstra.sh`](../../../scripts/okstra.sh) · [`scripts/lib/okstra/cli.sh`](../../../scripts/lib/okstra/cli.sh) · Node CLI | `integrate-stages` 진입점 패스스루 |
120
+ | [`prompts/profiles/final-verification.md`](../../../prompts/profiles/final-verification.md) | whole-task 가 통합 머지+teardown 을 수행하는 **mutating** phase 임을 명시 |
121
+ | [`docs/superpowers/specs/2026-06-06-stage-worktree-isolation-design.md`](2026-06-06-stage-worktree-isolation-design.md) | §2 자동 머지 원칙·§9-#2 teardown 후속 과제가 본 spec 으로 전환·해소됨을 상호 참조 |
122
+ | [`CHANGES.md`](../../../CHANGES.md) | `사용자 영향:` 항목(한국어) |
123
+
124
+ ## 6. declaration ↔ enforcement (okstra `CLAUDE.md` rule 5)
125
+
126
+ - "충돌 시 전체 중단" 의 강제 지점은 Phase A 의 `merge --abort` + `IntegrateError` 예외다(프로파일 문구가 아니라 런타임).
127
+ - "머지된 stage 만 브랜치 삭제" 의 강제 지점은 `git branch -d`(safe delete)의 git 자체 거부다.
128
+ - "dirty teardown 금지" 의 강제 지점은 Phase B 의 `_is_dirty_excluding_okstra` 검사다.
129
+ - "free 로 표시한 브랜치 이름은 항상 물리 삭제돼 있다" 의 강제 지점은 `_teardown_stage` 의 호출 순서다 — `release_status`(status 만) → `git branch -d` → 성공 시에만 `free_branch_slot`. 실패하면 슬롯을 그대로 둔다. 회귀 테스트는 `tests/run/test_stage_integrate.py::test_teardown_warns_when_branch_delete_fails`.
130
+
131
+ ## 7. 검증 시나리오 (테스트)
132
+
133
+ | # | 시나리오 | 기대 |
134
+ |---|----------|------|
135
+ | I1 | 미머지 stage 1·2·3, 충돌 없음 | 위상순 `--no-ff` 머지, 모두 `merged`, worktree+registry+branch 제거, dirty 게이트 통과 |
136
+ | I2 | 일부 stage 이미 ancestor | 해당 stage `already_merged`(머지 skip), teardown 은 수행 |
137
+ | I3 | stage 1 머지 성공 후 stage 2 충돌 | stage 2 `merge --abort`, stage 1 머지는 task 브랜치에 유지, `IntegrateError`, teardown 전혀 미실행. 재시도 시 stage 1 `already_merged` |
138
+ | I4 | stage 3 worktree dirty | stage 1·2 teardown, stage 3 는 머지 유지 + teardown skip + 경고 |
139
+ | I5 | `git branch -d` 대상이 부모에 머지됨 | 정상 삭제(force 아님) |
140
+ | I6 | non-git / stage worktree 미발급 | no-op `IntegrateResult`, 오류 없음 |
141
+ | I7 | 단독 명령 `integrate-stages` 와 final-verification 자동 경로 | 동일 `integrate_stages` 코어 호출(결과 동등) |
142
+ | I8 | 통합 후 whole-task final-verification 재진입 | merged·dirty 게이트 모두 통과, 통합 섹션이 결과 보고 |
143
+ | I9 | `git branch -d` 실패(인위적 미머지 브랜치) | 경고 surface, `torn_down` 제외, branch 슬롯 free 안 함, 브랜치 보존 |
144
+ | I10 | `git worktree remove` 실패(dirty 게이트 통과했으나 untracked 잔존) | 경고 surface, `torn_down` 제외, worktree 보존, 이후 단계 미진행 |
145
+ | I11 | registry 에 stage-key 없음(`get_stage_row` None) | 경고 surface, 해당 stage 머지 skip(`merged`/`already_merged` 모두 제외) |
@@ -0,0 +1,113 @@
1
+ # release-handoff `local checkout` 액션 설계
2
+
3
+ - 작성일: 2026-06-21
4
+ - 상태: 설계 확정 대기(사용자 리뷰)
5
+ - 관련 코드: [release-handoff.md](../../../prompts/profiles/release-handoff.md), [handoff.py](../../../scripts/okstra_ctl/handoff.py), [worktree.py](../../../scripts/okstra_ctl/worktree.py), [worktree_registry.py](../../../scripts/okstra_ctl/worktree_registry.py)
6
+
7
+ ## 1. 배경 / 문제
8
+
9
+ okstra 의 task 작업은 사용자 repo 의 **격리된 git worktree**(`~/.okstra/worktrees/<project-id>/<task-group>/<task-id>/`, [worktree.py:764](../../../scripts/okstra_ctl/worktree.py:764) 의 `git worktree add -b`)에서 진행되고, 결과는 task 브랜치에 커밋된다. final-verification(whole-task)이 모든 stage 를 이 task 브랜치에 머지한 뒤에도, 사용자가 그 결과를 **자기 메인 작업 디렉터리에서 로컬 테스트** 하려면 불편이 있다:
10
+
11
+ - task 브랜치는 okstra 워크트리가 점유 중이라, 메인 디렉터리에서 `git checkout <task-branch>` 가 git 규칙(한 브랜치는 한 워크트리만 체크아웃)으로 막힌다.
12
+ - 결국 사용자가 `~/.okstra/worktrees/...` 폴더로 직접 `cd` 해서 테스트해야 하는데, 이게 번거롭다.
13
+
14
+ release-handoff 에는 현재 `local only` / `push + PR` / `skip` 세 액션이 있지만, "검증된 브랜치를 메인 작업 디렉터리로 가져와 로컬 테스트 가능 상태로 만드는" 경로가 없다.
15
+
16
+ ## 2. 목표 / 비목표
17
+
18
+ **목표**
19
+ - release-handoff 에 `local checkout` 액션을 추가해, 확인 후 okstra task-key 워크트리를 제거하고 task 브랜치를 **메인 워크트리에서 checkout** 해 로컬 테스트가 가능하게 한다.
20
+
21
+ **비목표 (설계상 명시적 제외)**
22
+ - base 브랜치(main/master/preprod 등)로의 **자동 머지는 하지 않는다.** main 통합은 PR 리뷰를 거쳐야 하므로 종전대로 `push + PR` 만 담당한다. `local checkout` 은 base 를 건드리지 않는다.
23
+ - stage-group 모드는 v1 범위에서 제외(§5).
24
+
25
+ ## 3. 결정사항 요약 (사용자 합의)
26
+
27
+ | 항목 | 결정 |
28
+ |---|---|
29
+ | 트리거 지점 | release-handoff 의 Action selection 에 신규 액션 추가 |
30
+ | 실행 범위 | okstra 가 확인받고 worktree remove + 메인 `git checkout` 까지 **대신 실행** |
31
+ | 안전장치 | 메인 워크트리 dirty 면 중단·경고 |
32
+ | 액션 구성 | `local checkout`(신규, 첫 번째) / `push + PR` / `skip`. 기존 `local only` 제거(skip 과 중복) |
33
+ | 모드 범위 | **whole-task 모드 전용** |
34
+ | 메커니즘 | [handoff.py](../../../scripts/okstra_ctl/handoff.py) 에 `okstra handoff local-checkout` 서브커맨드 추가 |
35
+
36
+ ## 4. 액션 선택 재편 (release-handoff.md)
37
+
38
+ 기존 "User interaction protocol → Action selection" 의 3지선다를 다음으로 교체:
39
+
40
+ 1. **`local checkout`** — 검증된 task 브랜치를 메인 워크트리로 가져와 로컬 테스트 가능 상태로 만든다(push/PR 없음).
41
+ 2. **`push + PR`** — feature 브랜치 push + PR 생성(기존 흐름 그대로).
42
+ 3. **`skip`** — 어떤 git 명령도 없이 release-handoff 를 의도적으로 건너뛴 것으로 기록하고 final-report self-review 로 라우팅(기존 그대로).
43
+
44
+ `local only` 는 제거한다 — "git 안 건드리고 기록만 하고 끝"이라는 점에서 `skip` 과 사실상 중복이기 때문. skip 의 하위-단계 cancel 경로와 verdict 게이트는 유지되므로 잘못 진입 시 탈출 경로는 남는다.
45
+
46
+ ## 5. 모드 범위 (whole-task 전용)
47
+
48
+ - `HANDOFF_MODE == whole-task` 일 때만 `local checkout` 을 제공한다. whole-task 의 deliverable 은 task 브랜치 그 자체라 바로 checkout 가능.
49
+ - `HANDOFF_MODE == stage-group` 에서 사용자가 `local checkout` 을 고르면, *"`local checkout` 은 whole-task 모드에서만 지원됩니다"* 안내 후 액션 선택을 다시 받는다(stage-group 은 collector 브랜치를 `assemble` 로 별도 생성해야 해 복잡도가 큼 — YAGNI).
50
+
51
+ ## 6. 메커니즘 — `okstra handoff local-checkout`
52
+
53
+ [handoff.py](../../../scripts/okstra_ctl/handoff.py) 에 서브커맨드를 추가한다. release-handoff 프로필은 확인(AskUserQuestion)을 받은 뒤 이 명령 **하나만** 호출한다.
54
+
55
+ ### 6.1 인자
56
+
57
+ ```
58
+ okstra handoff local-checkout \
59
+ --project-root <dir> --project-id <id> --task-group <g> --task-id <t>
60
+ ```
61
+
62
+ 브랜치 이름과 okstra 워크트리 경로는 인자로 받지 않고 **registry 의 task-key 행(SSOT)** 에서 해소한다(`stage_number` 없음 = task-key). 잘못된 브랜치 전달 위험을 없앤다.
63
+
64
+ ### 6.2 단계 (모든 git 은 메인 워크트리 cwd 에서 실행)
65
+
66
+ self-removal 문제(release-handoff 는 okstra task-key 워크트리 안에서 실행됨, §8)를 피하려고, 헬퍼는 자기 cwd 를 **메인 워크트리**로 두고 모든 git 을 거기서 실행한다.
67
+
68
+ 1. **registry 해소**: task-key 행에서 `okstra_wt`(worktree_path)·`branch` 를 읽는다. 행이 없거나 비면 exit 1 + 사유.
69
+ 2. **메인 워크트리 해소**: [worktree.main_worktree_path(project_root)](../../../scripts/okstra_ctl/worktree.py:306).
70
+ 3. **메인 dirty 체크**: [is_dirty_excluding_okstra(main_wt)](../../../scripts/okstra_ctl/worktree.py:465) 가 dirty 면 exit 1 + 경고(사용자 변경 보호). `.okstra` 잡음은 제외.
71
+ 4. **okstra 워크트리 제거**: `git -C <main_wt> worktree remove --force <okstra_wt>`. **브랜치는 보존**(teardown 의 `branch -d` 와 다름 — checkout 대상이므로 절대 삭제 안 함). `--force` 는 추적되지 않은 okstra 산출물(심링크/스냅샷, 재생성 가능)만 정리한다. 실패면 exit 1 + stderr.
72
+ 5. **registry status 해제**: [worktree_registry.release_status(...)](../../../scripts/okstra_ctl/worktree_registry.py) (task-key). **branch 슬롯은 free 하지 않는다** — 브랜치가 메인에 살아있으므로, "registry 가 free 로 표시한 브랜치는 항상 물리 삭제돼 있다"는 불변식을 지킨다([free_branch_slot](../../../scripts/okstra_ctl/worktree_registry.py) 호출하지 않음).
73
+ 6. **메인에서 checkout**: `git -C <main_wt> checkout <branch>`. 실패면(예: 동일 이름 충돌) exit 1 + 사유. 이 시점이면 워크트리는 이미 제거됐고 브랜치는 어디에도 체크아웃돼 있지 않으므로 사용자가 수동 `git checkout` 으로 복구 가능 — 사유에 그 안내를 포함.
74
+ 7. **결과 JSON 출력**: `{ "branch": ..., "mainWorktreePath": ..., "removedWorktree": ..., "status": "checked-out" }`.
75
+
76
+ 함수는 50줄 이하로 유지(필요 시 dirty/remove/checkout 단계 헬퍼 분리).
77
+
78
+ ### 6.3 exit code
79
+
80
+ - `0` 성공.
81
+ - `1` 전제 위반/실패(메인 dirty, registry 행 없음, worktree remove 실패, checkout 실패). 사유는 stderr.
82
+
83
+ ## 7. 프로필의 `local checkout` 핸들러 흐름 (release-handoff.md)
84
+
85
+ 1. whole-task 게이트(§5). stage-group 이면 안내 후 액션 재선택.
86
+ 2. **확인 프롬프트**: 무엇을 할지 명시("okstra 워크트리 제거 + 메인에서 `<branch>` checkout, base 브랜치는 안 건드림")하고 진행/취소.
87
+ 3. `okstra handoff local-checkout ...` 호출. exit 1 이면 사유를 보여주고 액션 선택으로 돌아감.
88
+ 4. 성공 시, **lead 의 cwd(구 okstra 워크트리)는 사라진다** → 이후 모든 단계(final-report 작성 등)는 **메인 워크트리 절대경로 기준**으로 수행하도록 프로필에 명시.
89
+ 5. final-report self-review 로 라우팅.
90
+
91
+ ## 8. 안전장치 / 엣지 케이스
92
+
93
+ - **self-removal**: release-handoff 는 `provision_task_worktree` 로 task-key 워크트리 안에서 실행([run.py:1920](../../../scripts/okstra_ctl/run.py:1920)). 헬퍼가 메인 워크트리 cwd 에서 git 을 실행하므로 자기 자신을 제거하지 않는다. 호출 후 lead 는 메인으로 cwd 를 옮긴다(§7.4).
94
+ - **메인 dirty**: §6.2-3 에서 중단. 사용자 미커밋 변경을 절대 덮어쓰지 않는다.
95
+ - **브랜치 보존 불변식**: §6.2-5. branch 슬롯을 free 하지 않아 동시 run 이 살아있는 브랜치 이름을 재예약하지 못한다.
96
+ - **checkout 실패**: §6.2-6. 부분 상태(워크트리 제거됨)에 대한 수동 복구 안내 포함.
97
+ - **base 브랜치 보호**: `local checkout` 은 base 를 절대 건드리지 않는다(비목표). 메인이 마침 base 브랜치를 체크아웃 중이면 checkout 으로 task 브랜치로 전환될 뿐 base 브랜치 ref 는 불변.
98
+
99
+ ## 9. 영향 파일
100
+
101
+ - [prompts/profiles/release-handoff.md](../../../prompts/profiles/release-handoff.md): 액션 선택 재편 + `local checkout` 핸들러 + final-report 5.6 기록 지시.
102
+ - [scripts/okstra_ctl/handoff.py](../../../scripts/okstra_ctl/handoff.py): `local-checkout` 서브커맨드 + argparse 등록.
103
+ - [tests/handoff/](../../../tests/handoff/): 헬퍼 테스트(워크트리 fixture → 실행 → 메인 checkout·워크트리 제거·브랜치 보존·dirty 중단 검증).
104
+ - [CHANGES.md](../../../CHANGES.md): `사용자 영향:` 항목.
105
+ - `runtime/` 은 빌드 산출물 — 직접 수정 금지. 작업 후 `npm run build`.
106
+
107
+ ## 10. 테스트 계획
108
+
109
+ - **성공 경로**: task-key 워크트리가 있는 fixture repo 에서 헬퍼 실행 → 메인이 task 브랜치 체크아웃됨 + okstra 워크트리 디렉터리 제거됨 + 브랜치 ref 보존됨 + registry status `released`·branch 슬롯 유지.
110
+ - **메인 dirty 중단**: 메인에 미커밋 변경 → exit 1, 워크트리·브랜치 불변.
111
+ - **registry 행 없음**: exit 1.
112
+ - **checkout 충돌**: 메인에 동일 이름 브랜치 선점 → exit 1, 사유 출력.
113
+ - CLI 스모크: `node bin/okstra handoff --help` 에 `local-checkout` 노출.
@@ -97,7 +97,7 @@ lead는 사용자에게 push/PR 여부를 묻기 전에 다음을 확인한다.
97
97
  stateDiagram-v2
98
98
  [*] --> Gate: entry gate
99
99
  Gate --> Q1: action selection
100
- Q1 --> ReportOnly: local only
100
+ Q1 --> LocalCheckout: local checkout
101
101
  Q1 --> Skip: skip
102
102
  Q1 --> Q2: push + PR
103
103
  Q2 --> Probe: choose PR base
@@ -110,7 +110,7 @@ stateDiagram-v2
110
110
  Q3 --> Cancel: cancel
111
111
  Push --> ReuseOrCreate: git push feature branch
112
112
  ReuseOrCreate --> FinalReport: gh pr list / gh pr create
113
- ReportOnly --> FinalReport
113
+ LocalCheckout --> FinalReport
114
114
  Skip --> FinalReport
115
115
  Cancel --> FinalReport
116
116
  FinalReport --> [*]
@@ -118,7 +118,7 @@ stateDiagram-v2
118
118
 
119
119
  사용자 interaction은 정확히 세 단계다.
120
120
 
121
- 1. Q1 action: `local only`, `push + PR`, `skip`
121
+ 1. Q1 action: `local checkout`, `push + PR`, `skip`
122
122
  2. Q2 PR base: `staging`, `preprod`, `main`, 직접 입력 등 profile menu의 branch
123
123
  3. Q3 PR title/body: `use as-is`, `edit then proceed`, `cancel`
124
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.96.1",
3
+ "version": "0.97.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.96.1",
3
- "builtAt": "2026-06-19T15:05:42.570Z",
2
+ "package": "0.97.0",
3
+ "builtAt": "2026-06-21T05:06:22.735Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -212,7 +212,7 @@ while [[ $# -gt 0 ]]; do
212
212
  printf ' hint: did you mean --task-id?\n' >&2
213
213
  ;;
214
214
  esac
215
- printf ' valid options: --render-only --resume-clarification --yes --workers --lead-model --claude-model --codex-model --antigravity-model --report-writer-model --lead-runtime --related-tasks --task-type --project-id --project-root --task-group --task-id --task-brief --directive --fix-cycle --clarification-response --approved-plan --approve --implementation-option --stage --qa-waiver --no-plan-verification -h|--help\n' >&2
215
+ printf ' valid options: --render-only --resume-clarification --yes --workers --lead-model --claude-model --codex-model --antigravity-model --report-writer-model --lead-runtime --executor --critic --related-tasks --work-category --task-type --project-id --project-root --task-group --task-id --task-brief --directive --base-ref --fix-cycle --clarification-response --task-key --approved-plan --approve --implementation-option --stage --stages --qa-waiver --no-plan-verification -h|--help\n' >&2
216
216
  usage
217
217
  exit 1
218
218
  ;;
@@ -11,6 +11,11 @@ is_interactive_session() {
11
11
  [[ -t 0 && -t 1 ]]
12
12
  }
13
13
 
14
+ file_mtime() {
15
+ # Epoch mtime; portable across macOS (stat -f) and GNU (stat -c). 0 on failure.
16
+ stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || printf '0'
17
+ }
18
+
14
19
  split_task_key() {
15
20
  local raw_key=""
16
21
  raw_key="$(trim_whitespace "${TASK_KEY_INPUT-}")"
@@ -298,17 +303,30 @@ find_latest_final_report() {
298
303
  return 1
299
304
  fi
300
305
 
306
+ # Pick the chronologically newest report (mtime first), matching the Python
307
+ # resume path (okstra_ctl.wizard's mtime-max). A pure basename sort conflates
308
+ # phases: across error-analysis/ and requirements-discovery/ dirs the filename
309
+ # embeds the task-type, so a lexicographic max always favors one phase
310
+ # regardless of which run is actually newer. Ties (same-second mtime within a
311
+ # phase dir) fall back to the greater basename so the highest seq/timestamp
312
+ # wins deterministically.
301
313
  local best_path=""
314
+ local best_mtime=-1
302
315
  local best_basename=""
303
316
  local candidate=""
317
+ local candidate_mtime=""
304
318
  local candidate_base=""
305
319
  local d=""
306
320
  for d in "${search_dirs[@]}"; do
307
321
  [[ -d "$d" ]] || continue
308
322
  while IFS= read -r candidate; do
323
+ candidate_mtime="$(file_mtime "$candidate")"
309
324
  candidate_base="$(basename "$candidate")"
310
- if [[ -z "$best_path" || "$candidate_base" > "$best_basename" ]]; then
325
+ if [[ -z "$best_path" \
326
+ || "$candidate_mtime" -gt "$best_mtime" \
327
+ || ( "$candidate_mtime" -eq "$best_mtime" && "$candidate_base" > "$best_basename" ) ]]; then
311
328
  best_path="$candidate"
329
+ best_mtime="$candidate_mtime"
312
330
  best_basename="$candidate_base"
313
331
  fi
314
332
  done < <(find "$d" -maxdepth 1 -type f -name 'final-report-*.md' 2>/dev/null)
@@ -363,9 +381,9 @@ run_resume_clarification() {
363
381
  # implementation-planning 도 §1 Clarification Items(plan-body 결정: 예 C-205
364
382
  # tx-배관 step 누락)를 가지므로, 사용자가 결정란을 채운 뒤 같은 phase 를
365
383
  # clarification-response 자동공급으로 재실행해 plan 을 재생성·gate 재판정할 수
366
- # 있다. 자동검색(task_type_filter 없음)은 파일명 사전순 max implementation-
367
- # planning requirements-discovery 밀리므로, 이 경로는 --task-type
368
- # implementation-planning 을 명시했을 때만 해소된다.
384
+ # 있다. 자동검색(task_type_filter 없음)은 phase 디렉터리 전반에서 mtime 최신
385
+ # 리포트를 고르므로 어느 phase 뽑힐지 보장되지 않는다. 따라서 이 경로는
386
+ # --task-type implementation-planning 을 명시했을 때만 결정적으로 해소된다.
369
387
  case "$resolved_type" in
370
388
  requirements-discovery|error-analysis|implementation-planning) ;;
371
389
  *)
@@ -11,11 +11,13 @@ _okstra_ctl_tail() {
11
11
  import os, sys
12
12
  sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
13
13
  from pathlib import Path
14
- from okstra_ctl import read_jsonl, format_runs_table
14
+ from okstra_ctl import read_run_index, format_runs_table
15
15
  # tail active 는 active.jsonl 자체를 보여줘야 한다 — running 외에도
16
16
  # in-progress(backfill), reserving(rerun 예약), 그리고 어떤 비-터미널
17
17
  # 상태든 active 에 들어있는 것은 사용자가 모니터링해야 할 대상이다.
18
- rows = sorted(read_jsonl(Path(os.environ["OKSTRA_HOME_RESOLVED"]) / "active.jsonl"),
18
+ # 디스크 row 는 slim(파생 taskType 없음)이라 read_run_index 로 hydrate 해야
19
+ # format_runs_table 의 TASK-TYPE 열이 채워진다.
20
+ rows = sorted(read_run_index(Path(os.environ["OKSTRA_HOME_RESOLVED"]) / "active.jsonl"),
19
21
  key=lambda r: r.get("startedAt", ""), reverse=True)
20
22
  print(format_runs_table(rows), end="")
21
23
  PY
@@ -29,17 +31,24 @@ PY
29
31
  # fallback 하고, 그래도 없으면 expected status 파일이 아직 만들어지지
30
32
  # 않았어도 tail -F 로 wait 한다(`tail -F` 는 by-name 으로 파일 등장을
31
33
  # 감지한다).
32
- local resolved
34
+ local resolved resolve_rc
33
35
  resolved="$(OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
34
36
  OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
35
37
  OK_QUERY="$1" python3 -c '
36
38
  import os, sys
37
39
  sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
38
40
  from pathlib import Path
39
- from okstra_ctl import resolve_run_id, find_row_by_run_id
41
+ from okstra_ctl import resolve_run_id, find_row_by_run_id, ResolveError
40
42
  home = Path(os.environ["OKSTRA_HOME_RESOLVED"])
41
- rid = resolve_run_id(home, os.environ["OK_QUERY"])
43
+ try:
44
+ rid = resolve_run_id(home, os.environ["OK_QUERY"])
45
+ except ResolveError as e:
46
+ print(f"tail: {e}", file=sys.stderr)
47
+ for c in e.candidates: print(f" - {c}", file=sys.stderr)
48
+ sys.exit(2)
42
49
  row = find_row_by_run_id(home, rid)
50
+ if row is None:
51
+ print(f"tail: row not found for {rid}", file=sys.stderr); sys.exit(2)
43
52
  project_root = Path(row["projectRoot"])
44
53
  status_rel = row.get("finalStatusRel") or ""
45
54
  status_abs = str(project_root / status_rel) if status_rel else ""
@@ -48,6 +57,10 @@ print(row.get("taskType", ""))
48
57
  print(int(row.get("runSeq", 0)))
49
58
  print(status_abs)
50
59
  ')"
60
+ resolve_rc=$?
61
+ # resolve 단계가 ResolveError/누락 row 로 exit≠0 한 경우 stderr 의 깔끔한
62
+ # `tail: ...` 메시지를 살리고 fallback glob 으로 넘어가지 않는다.
63
+ if [[ $resolve_rc -ne 0 ]]; then exit "$resolve_rc"; fi
51
64
  local rid task_type seq status_abs
52
65
  rid="$(printf '%s\n' "$resolved" | sed -n '1p')"
53
66
  task_type="$(printf '%s\n' "$resolved" | sed -n '2p')"
@@ -8,10 +8,10 @@ Usage:
8
8
  [--template <path>]
9
9
 
10
10
  When ``--output`` is omitted, derives the markdown sibling by stripping
11
- the ``.data.json`` suffix and appending ``.md``. Refuses to overwrite an
12
- output path that already exists unless ``--force`` is given — the
13
- renderer is idempotent under data.json mutations, but we never want a
14
- typo to silently clobber an earlier run.
11
+ the ``.data.json`` suffix and appending ``.md``. The render is idempotent
12
+ under data.json mutations and intentionally overwrites any existing
13
+ output — the markdown is regenerated whenever the data.json changes
14
+ (Phase 7 token substitution, re-renders).
15
15
  """
16
16
  from __future__ import annotations
17
17
 
@@ -78,20 +78,9 @@ def main(argv: list[str]) -> int:
78
78
  "the renderer reads data.json.meta.reportLanguage (fallback 'en')."
79
79
  ),
80
80
  )
81
- parser.add_argument(
82
- "--force",
83
- action="store_true",
84
- help="Allow overwriting an existing output path.",
85
- )
86
81
  args = parser.parse_args(argv)
87
82
 
88
83
  output = args.output or _derive_output_path(args.data)
89
- if output.exists() and not args.force:
90
- # The data → markdown flow is meant to be idempotent: the same
91
- # data.json always renders to the same markdown. Overwriting is
92
- # the intended behaviour for Phase 7 token substitution and for
93
- # re-renders. Require --force to make accidental clobbers loud.
94
- pass # fall through to write; idempotent rewrite is intended.
95
84
 
96
85
  try:
97
86
  bytes_written = render_to_file(
@@ -137,8 +137,11 @@ if [[ "$RENDER_ONLY" == "true" ]]; then
137
137
  fi
138
138
 
139
139
  # Non-render-only: capture summary + launch JSON, then exec claude.
140
- PREPARE_OUTPUT="$(okstra_py -m okstra_ctl.run "${PY_ARGS[@]}")"
141
- PY_RC=$?
140
+ # `set -e` aborts on a failing command-substitution assignment, which would
141
+ # skip the PY_RC branch below and swallow any stdout the prepare step buffered.
142
+ # Guard the assignment so the explicit return-code check can surface it.
143
+ PY_RC=0
144
+ PREPARE_OUTPUT="$(okstra_py -m okstra_ctl.run "${PY_ARGS[@]}")" || PY_RC=$?
142
145
  if [[ $PY_RC -ne 0 ]]; then
143
146
  printf '%s\n' "$PREPARE_OUTPUT"
144
147
  exit $PY_RC
@@ -17,6 +17,7 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
17
17
  - This run executes `{{WORKFLOW_CURRENT_PHASE}}` only. Do not start `{{WORKFLOW_NEXT_RECOMMENDED_PHASE}}` or any later phase inside this run, even if the user says "다음 단계 진행해" or similar.
18
18
  {{STAGE_BATCH_DIRECTIVE}}
19
19
  {{VERIFICATION_TARGET}}
20
+ {{STAGE_INTEGRATION}}
20
21
  - Phase advancement requires a new okstra invocation launched with `--task-type {{WORKFLOW_NEXT_RECOMMENDED_PHASE}}` after this run's final report is written and approved. The lead must not write source code, run builds/migrations/deployments, or otherwise produce artifacts of a different phase from inside this run.
21
22
  - See `Lifecycle Phase Boundaries` in the okstra lead contract (`{{OKSTRA_LEAD_CONTRACT_PATH}}`) for the canonical rules and the phase-transition checklist.
22
23
 
@@ -242,7 +242,7 @@ The final-report template `templates/reports/final-report.template.md` Section 7
242
242
 
243
243
  ### Release-handoff section contract (release-handoff runs only)
244
244
 
245
- When the run's `task-type` is `release-handoff`, the final report MUST include Section `## 5.6 Release Handoff Deliverables` with all eight sub-sections (`5.6.1` Source Verification Report, `5.6.2` Feature Branch & Working-Tree State, `5.6.3` User Selections, `5.6.4` Executed Commands, `5.6.5` Commit List, `5.6.6` Merge Conflict Probe, `5.6.7` Pull Request Outcome, `5.6.8` Routing Recommendation). Every entry is dictated by the lead's recorded git/gh command log and the user's verbatim answers to the H1/H2/H3 menu prompts. H1 choices are `local only`, `push + PR`, or `skip`; release-handoff records existing implementation commits and MUST NOT create new commits. If the user picked `skip` (H1) or `cancel` (H3), keep 5.6.3 populated but leave 5.6.4–5.6.6 explicitly empty per the template's empty-state lines.
245
+ When the run's `task-type` is `release-handoff`, the final report MUST include Section `## 5.6 Release Handoff Deliverables` with all eight sub-sections (`5.6.1` Source Verification Report, `5.6.2` Feature Branch & Working-Tree State, `5.6.3` User Selections, `5.6.4` Executed Commands, `5.6.5` Commit List, `5.6.6` Merge Conflict Probe, `5.6.7` Pull Request Outcome, `5.6.8` Routing Recommendation). Every entry is dictated by the lead's recorded git/gh command log and the user's verbatim answers to the H1/H2/H3 menu prompts. H1 choices are `local checkout`, `push + PR`, or `skip`; release-handoff records existing implementation commits and MUST NOT create new commits. If the user picked `skip` (H1) or `cancel` (H3), keep 5.6.3 populated but leave 5.6.4–5.6.6 explicitly empty per the template's empty-state lines.
246
246
 
247
247
  **Single-lead authorship (release-handoff only):** release-handoff has no worker roster (no `Report writer worker`, no `Claude worker` drafter). The Claude lead authors the final-report file directly — there is no `Report writer worker` dispatch to perform in Phase 6, no resume-safe dispatch concern, and no mandatory worker-results file for a report-writer role. The rest of this contract's dispatch / resume / fallback machinery applies ONLY when `Report writer worker` is in the roster (i.e. every task-type other than `release-handoff`).
248
248