okstra 0.95.0 → 0.96.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.
@@ -0,0 +1,96 @@
1
+ # okstra-inspect `recap` facet 설계 — task-id 전/후 요약 + 자유 Q&A
2
+
3
+ > 작성: 2026-06-19 · 상태: 설계 승인 대기
4
+
5
+ ## 1. 배경 / 문제
6
+
7
+ okstra 는 작업을 `task-id` 단위로 나누고, task-id 하나에 충분한 맥락이 이미 축적된다:
8
+
9
+ - `task-manifest.json` 의 `workflow.phaseStates` 등 현재 시점 스냅샷 ([scripts/okstra_ctl/render.py:1010](../../../scripts/okstra_ctl/render.py))
10
+ - `history/timeline.json` 의 `runs[]` — 각 run 기록 시점의 `workflowSnapshot`(`currentPhase / currentPhaseState / lastCompletedPhase / nextRecommendedPhase`)이 append-only 로 누적 ([render.py:1388](../../../scripts/okstra_ctl/render.py))
11
+ - 각 phase 의 `runs/<task-type>/reports/final-report-*.md` ([paths.py:180](../../../scripts/okstra_ctl/paths.py))
12
+
13
+ 읽기 기능은 `okstra-inspect` 스킬에 7개 facet(status / history / report / time / logs / cost / errors)이 있으나 전부 **정형 출력**이다. `report` facet 은 final-report **원문을 그대로 보여줄 뿐** 내용 요약·질의는 하지 않는다 ([skills/okstra-inspect/SKILL.md:285](../../../skills/okstra-inspect/SKILL.md)).
14
+
15
+ 빠진 것: **"이 task-id 에 쌓인 맥락 위에서 처리 전/후를 요약하고, 그 작업에 대해 자유롭게 묻고 답하는"** 읽기 기능. 현재 task-id 단위 자유 Q&A 는 존재하지 않는다 — `task-root/qa/` 는 이름과 달리 구현 검증 산출물 저장소이고([paths.py:127](../../../scripts/okstra_ctl/paths.py)), `clarification` 은 final-report 의 정해진 섹션을 다음 run 으로 carry-in 하는 구조화 플로우라 임의 질문과 다르다.
16
+
17
+ 목표: task-id 단위 **전/후 요약 + 자유 Q&A** 를 담는 8번째 facet `recap` 추가.
18
+
19
+ ## 2. 범위 / 결정 (brainstorming 합의)
20
+
21
+ | 항목 | 결정 |
22
+ |---|---|
23
+ | 전달 형태 | `okstra-inspect` 의 **8번째 facet `recap`** — 기존 7개와 동일한 dispatch·task-key 해소 경로 재사용 |
24
+ | 동작 | (a) 진입 시 **전/후 요약** 기본 출력 + (b) 이어지는 **자유 Q&A 루프** |
25
+ | 요약 깊이 | **run 단위 변화 추적** — `timeline.runs[].workflowSnapshot` 을 인접 run 끼리 비교 |
26
+ | 맥락 범위 | **2-모드**: 기본 = `.okstra/` 서브트리만 / 코드 모드 = 사용자가 명시 요청할 때만 해당 task worktree 의 `git diff`·`git log` 추가 |
27
+ | 결과 저장 | **`.okstra/` 안에 저장** — `<task-root>/recap/recap-log.jsonl` append-only |
28
+ | 저장 경유 | python 엔트리포인트 `okstra recap record`(skill 이 JSON 을 손으로 쓰지 않음) |
29
+ | CLI 노출 | `okstra recap <assemble\|record>` — Node 래퍼(`src/recap.mjs` + cli-registry) → python. 다른 read facet(`error-report`/`context-cost`)과 동일하게 skill 은 항상 `okstra <verb>` 를 호출하고 PYTHONPATH 를 직접 다루지 않음 |
30
+
31
+ **비목표**: `okstra.sh` 인터랙티브 wizard 에 `recap` 노출(읽기 facet 이라 불필요) · 기존 산출물 변형(recap 은 자기 로그만 append) · clarification carry-in 플로우 변경.
32
+
33
+ ## 3. 설계
34
+
35
+ ### 3.1 경로 SSOT 확장
36
+
37
+ `compute_run_paths` 가 레이아웃 단일 출처이므로 여기에 recap 경로를 추가한다 ([paths.py:87](../../../scripts/okstra_ctl/paths.py)).
38
+
39
+ ```
40
+ <task-root>/recap/
41
+ recap-log.jsonl # append-only Q&A·요약 로그
42
+ ```
43
+
44
+ - task-manifest 에 포인터가 필요하면 `recapLogPath` 를 `render_task_manifest` payload 에 추가 ([render.py:968](../../../scripts/okstra_ctl/render.py)). 1차 구현에서는 경로가 결정론적(task-root 상대)이므로 포인터는 선택.
45
+
46
+ ### 3.2 전/후 요약 (run 단위 변화 추적)
47
+
48
+ facet 진입 시 기본 출력. 맥락 소스는 전부 기존 산출물이며 **신규 측정 없음**:
49
+
50
+ 1. `history/timeline.json` 의 `runs[]` 를 시간순으로 읽어, **인접 두 run 의 `workflowSnapshot` 을 비교** → "이 run 전/후로 `currentPhase` / `lastCompletedPhase` / `nextRecommendedPhase` 가 어떻게 바뀌었나"를 run 단위로 재구성.
51
+ 2. 각 run 의 `reportPath`(final-report) 핵심 섹션과 `task-manifest.workflow.phaseStates` 로 "무엇을 했나"를 보강.
52
+ 3. 출력: run 별 전→후 phase 전이 + 한 줄 요약 타임라인.
53
+
54
+ 별도의 "before/after" 전용 레코드는 없으므로 인접 snapshot 비교가 1차 소스다. `team-state.phaseTimeline`(lead operating-phase 타임스탬프)은 존재하면 보조로만 쓰고 의존하지 않는다(collector 버전·Phase 7 도달 여부에 따라 부재 가능).
55
+
56
+ ### 3.3 자유 Q&A 루프
57
+
58
+ 요약 출력 뒤 사용자의 임의 질문을 받아, §3.2 의 산출물 위에서 답한다.
59
+
60
+ - 모든 사실 주장은 `.okstra` 파일 `path:line` 인용을 동반(검증 가능).
61
+ - 후속 질문 반복. 각 질문·답변은 §3.5 로 기록.
62
+
63
+ ### 3.4 2-모드 (맥락 범위)
64
+
65
+ - **기본 모드** — `.okstra/` 서브트리만 읽음. artifact-home 규칙 준수, 모든 인용이 `.okstra` 파일로 귀결.
66
+ - **코드 모드** — 사용자가 명시적으로 "코드 변경까지 봐줘" 류로 요청할 때만, 해당 task 의 worktree/브랜치 `git diff`·`git log` 를 추가로 읽어 코드 레벨로 답. **진입 시 자동 확장 금지** — 사용자의 verbatim 요청이 있을 때만.
67
+
68
+ ### 3.5 결과 저장 (`recap-log.jsonl` append-only)
69
+
70
+ - 형식: 줄당 한 엔트리
71
+ ```json
72
+ {"ts": "<ISO8601>", "kind": "summary|qa", "mode": "artifact|code",
73
+ "question": "<질문 또는 빈 문자열>", "answerSummary": "<요약>", "citations": ["path:line", ...]}
74
+ ```
75
+ summary 엔트리처럼 질문이 없으면 `question` 은 `null` 이 아니라 빈 문자열 `""` 로 저장한다(타입 일관성).
76
+ - **append-only** 이므로 중복 제거 로직 불필요.
77
+ - 쓰기는 **python 엔트리포인트 `okstra recap record` 서브커맨드** 경유 — skill 이 JSON 을 직접 작성하지 않음(단일 참조점 규칙; errors facet 이 .md 리포트를 산출하는 선례와 동일 결).
78
+ - recap 은 `recap-log.jsonl` 만 append 하고 **다른 산출물은 불변**. status facet 외 "read-only" 철학을 깨는 유일한 지점이므로 facet 문서에 이 경계를 명시.
79
+
80
+ ## 4. 영향 범위 (수정 파일)
81
+
82
+ | 파일 | 변경 |
83
+ |---|---|
84
+ | [scripts/okstra_ctl/paths.py](../../../scripts/okstra_ctl/paths.py) | `compute_run_paths` 에 `recap/` + `recap-log.jsonl` 경로 추가 |
85
+ | `scripts/okstra_ctl/recap.py` (신규) | `assemble`(run 간 전이 조립) + `record`(append 1줄) 서브커맨드 |
86
+ | `src/recap.mjs` (신규) + [src/cli-registry.mjs](../../../src/cli-registry.mjs) | `okstra recap` Node 래퍼 + introspection 카테고리 등록 |
87
+ | [skills/okstra-inspect/SKILL.md](../../../skills/okstra-inspect/SKILL.md) | facet 테이블에 `recap` 등록 + recap 섹션(요약/Q&A/2-모드/저장 경계) |
88
+ | [docs/kr/architecture.md](../../../docs/kr/architecture.md) | task-root 트리에 `recap/` 추가 |
89
+ | 빌드 | `npm run build` 로 runtime 동기화 |
90
+
91
+ ## 5. 검증
92
+
93
+ - `paths.py` recap 경로: 기존 `compute_task_paths` 테스트 패턴에 recap 경로 assert 추가(`tests/`).
94
+ - `recap record` append: 빈 로그 → 1줄 → 2줄 append 단위 테스트.
95
+ - facet 동작(요약·Q&A·2-모드)은 skill-markdown 주도라 자동 단위 테스트 대상이 아님 → e2e 또는 수동 스모크(`okstra recap <task-id>`)로 확인.
96
+ - 정형 검증 불가한 "요약 품질"은 spec 의 범위 밖(휴리스틱).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.95.0",
3
+ "version": "0.96.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.95.0",
3
- "builtAt": "2026-06-18T19:01:30.042Z",
2
+ "package": "0.96.0",
3
+ "builtAt": "2026-06-19T14:40:32.132Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -167,20 +167,6 @@
167
167
  "__free_input__": "직접 입력"
168
168
  }
169
169
  },
170
- "branch_confirm": {
171
- "label": "{summary}",
172
- "labels": {
173
- "new": "새 브랜치 `{branch}` 를 base-ref `{base_ref}` 에서 분기해 `{task_key}` 디렉터리(`{path}`)에 체크아웃합니다 — 진행할까요?",
174
- "reuse": "기존 `{task_key}` 디렉터리(`{path}`, 브랜치 `{branch}`)에서 이어서 진행합니다 — 진행할까요?",
175
- "in_worktree": "현재 worktree(`{path}`)에서 그대로 진행합니다(이미 non-main worktree) — 진행할까요?",
176
- "not_git": "git 저장소가 아니므로 `{path}` 에서 직접 진행합니다 — 진행할까요?",
177
- "impl_stage_new": "implementation 은 stage 격리로 동작합니다 — stage {stage} worktree 를 `{path}` 에 브랜치 `{branch}` 로 새로 만들고, base 커밋은 의존 stage 의 done 커밋 기준으로 run 준비 시점에 해소됩니다 — 진행할까요?",
178
- "impl_stage_reuse": "기존 stage {stage} worktree(`{path}`, 브랜치 `{branch}`)에서 이어서 진행합니다 — 진행할까요?",
179
- "impl_stage_auto": "implementation 은 stage 격리로 동작합니다 — stage 번호는 run 준비 시점에 자동 선택되고, `{task_key}` 디렉터리(`{path}`) 아래 `stage-<N>/` worktree 가 새로 만들어지거나 재사용됩니다 — 진행할까요?"
180
- },
181
- "options": { "proceed": "진행", "edit": "base-ref 다시 고르기", "abort": "중단" },
182
- "echo_template": "branch-confirm: {value}"
183
- },
184
170
  "fix_cycle_confirm": {
185
171
  "label": "이 task 는 release-handoff 까지 완료된 task 입니다. 이번 재진입을 기존 산출물에 대한 버그 픽스 사이클로 기록할까요?",
186
172
  "options": {
@@ -429,7 +415,8 @@
429
415
  "echo_template": "confirm: {value}",
430
416
  "options": {
431
417
  "proceed": "Proceed",
432
- "edit": "Edit"
418
+ "edit": "Edit",
419
+ "abort": "중단"
433
420
  }
434
421
  },
435
422
  "edit_target": {
@@ -442,6 +429,13 @@
442
429
  "workers_implementation_default": " workers : (프로필 기본 — executor + verifier 2 + report-writer)",
443
430
  "base_ref_stage_isolated": " base-ref : (stage 격리 — 의존 stage 기준으로 run 준비 시점에 자동 해소)",
444
431
  "base_ref_reuse_task_dir": " base-ref : (기존 `{task_key}` 디렉터리 재사용 — 최초 base 유지)",
432
+ "worktree_new": " worktree : 새 브랜치 `{branch}` (base-ref `{base_ref}`) → `{path}`",
433
+ "worktree_reuse": " worktree : 기존 디렉터리 재사용 — `{path}` (브랜치 `{branch}`)",
434
+ "worktree_in_worktree": " worktree : 현재 worktree 그대로 — `{path}`",
435
+ "worktree_not_git": " worktree : git 저장소 아님 — `{path}` 에서 직접 진행",
436
+ "worktree_impl_new": " worktree : stage {stage} 새 worktree `{path}` (브랜치 `{branch}`, base 는 run 준비 시 해소)",
437
+ "worktree_impl_reuse": " worktree : 기존 stage {stage} worktree `{path}` (브랜치 `{branch}`)",
438
+ "worktree_impl_auto": " worktree : stage 자동 선택 — `{path}` 아래 stage-<N>/ worktree 생성/재사용",
445
439
  "stage_whole_task": "전체 task",
446
440
  "handoff_scope_whole_task": "전체 task (whole-task 검증 기반)",
447
441
  "handoff_scope_stage_group": "stage-group ({stages})"
@@ -128,6 +128,8 @@ def compute_run_paths(
128
128
  runs_dir = task_root / "runs"
129
129
  history_dir = task_root / "history"
130
130
  timeline_file = history_dir / "timeline.json"
131
+ recap_dir = task_root / "recap"
132
+ recap_log = recap_dir / "recap-log.jsonl"
131
133
 
132
134
  run_dir = runs_dir / task_type_segment
133
135
  # Stage isolation: each stage's run artifacts live in a dedicated
@@ -247,6 +249,8 @@ def compute_run_paths(
247
249
  "RUNS_DIR": str(runs_dir),
248
250
  "HISTORY_DIR": str(history_dir),
249
251
  "TIMELINE_PATH": str(timeline_file),
252
+ "RECAP_DIR": str(recap_dir),
253
+ "RECAP_LOG_PATH": str(recap_log),
250
254
  "RUN_DIR": str(run_dir),
251
255
  "RUN_STAGE": "" if stage is None else str(int(stage)),
252
256
  "RUN_MANIFESTS_DIR": str(run_manifests),
@@ -312,6 +316,8 @@ def compute_run_paths(
312
316
  ("RUNS_RELATIVE_PATH", runs_dir),
313
317
  ("HISTORY_RELATIVE_PATH", history_dir),
314
318
  ("TIMELINE_RELATIVE_PATH", timeline_file),
319
+ ("RECAP_DIR_RELATIVE_PATH", recap_dir),
320
+ ("RECAP_LOG_RELATIVE_PATH", recap_log),
315
321
  ("RUN_DIR_RELATIVE_PATH", run_dir),
316
322
  ("RUN_MANIFESTS_RELATIVE_PATH", run_manifests),
317
323
  ("RUN_STATE_RELATIVE_PATH", run_state),
@@ -0,0 +1,126 @@
1
+ """Read-side recap for a task: assemble run-to-run phase transitions and
2
+ append a recap Q&A log. Mutates only <task-root>/recap/recap-log.jsonl;
3
+ never touches task-manifest / catalog / timeline.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import datetime as dt
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from okstra_ctl.task_target import resolve_task_root, project_rel
14
+
15
+
16
+ def _load_timeline(task_root: Path) -> list[dict]:
17
+ path = task_root / "history" / "timeline.json"
18
+ if not path.is_file():
19
+ return []
20
+ try:
21
+ data = json.loads(path.read_text(encoding="utf-8"))
22
+ except Exception:
23
+ return []
24
+ runs = data.get("runs", [])
25
+ return runs if isinstance(runs, list) else []
26
+
27
+
28
+ def _task_key(task_root: Path) -> str:
29
+ path = task_root / "task-manifest.json"
30
+ if not path.is_file():
31
+ return ""
32
+ try:
33
+ return json.loads(path.read_text(encoding="utf-8")).get("taskKey", "")
34
+ except Exception:
35
+ return ""
36
+
37
+
38
+ def assemble_recap(task_root: Path, project_root: Path) -> dict:
39
+ runs = _load_timeline(task_root)
40
+ transitions = []
41
+ prev_phase = ""
42
+ latest_states: dict = {}
43
+ for idx, run in enumerate(runs):
44
+ snap = run.get("workflowSnapshot", {}) if isinstance(run, dict) else {}
45
+ cur = snap.get("currentPhase", "")
46
+ transitions.append({
47
+ "index": idx,
48
+ "runTimestamp": run.get("runTimestamp", ""),
49
+ "taskType": run.get("taskType", ""),
50
+ "status": run.get("status", ""),
51
+ "fromPhase": prev_phase,
52
+ "toPhase": cur,
53
+ "lastCompletedPhase": snap.get("lastCompletedPhase", ""),
54
+ "nextRecommendedPhase": snap.get("nextRecommendedPhase", ""),
55
+ "reportPath": run.get("reportPath", ""),
56
+ })
57
+ prev_phase = cur
58
+ latest_states = snap.get("phaseStates", {}) or latest_states
59
+ return {
60
+ "taskKey": _task_key(task_root),
61
+ "runCount": len(runs),
62
+ "transitions": transitions,
63
+ "latestPhaseStates": latest_states,
64
+ }
65
+
66
+
67
+ def append_recap_entry(task_root, project_root, *, kind, mode, question,
68
+ answer_summary, citations, now) -> dict:
69
+ recap_dir = task_root / "recap"
70
+ recap_dir.mkdir(parents=True, exist_ok=True)
71
+ log_path = recap_dir / "recap-log.jsonl"
72
+ entry = {
73
+ "ts": now.isoformat(),
74
+ "kind": kind,
75
+ "mode": mode,
76
+ "question": question,
77
+ "answerSummary": answer_summary,
78
+ "citations": list(citations),
79
+ }
80
+ with log_path.open("a", encoding="utf-8") as fh:
81
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
82
+ return {"logPath": project_rel(log_path, project_root), "entry": entry}
83
+
84
+
85
+ def _add_common(sp) -> None:
86
+ # skill 이 쓰는 `<verb> <target> --project-root <root>` 형태를 받으려면
87
+ # 공통 옵션을 부모가 아닌 각 서브파서에 등록해야 한다.
88
+ sp.add_argument("--project-root", default="")
89
+ sp.add_argument("--cwd", default=".")
90
+
91
+
92
+ def main(argv: list[str] | None = None) -> int:
93
+ parser = argparse.ArgumentParser(prog="okstra recap")
94
+ sub = parser.add_subparsers(dest="command", required=True)
95
+
96
+ p_asm = sub.add_parser("assemble", help="assemble run-to-run phase transitions")
97
+ p_asm.add_argument("target", help="task root path or task-key")
98
+ _add_common(p_asm)
99
+
100
+ p_rec = sub.add_parser("record", help="append a recap Q&A/summary log entry")
101
+ p_rec.add_argument("target", help="task root path or task-key")
102
+ _add_common(p_rec)
103
+ p_rec.add_argument("--kind", choices=["summary", "qa"], required=True)
104
+ p_rec.add_argument("--mode", choices=["artifact", "code"], required=True)
105
+ p_rec.add_argument("--question", default="")
106
+ p_rec.add_argument("--answer", required=True)
107
+ p_rec.add_argument("--citation", action="append", default=[])
108
+
109
+ args = parser.parse_args(argv)
110
+ task_root, project_root = resolve_task_root(
111
+ args.target, args.project_root, args.cwd)
112
+
113
+ if args.command == "assemble":
114
+ result = assemble_recap(task_root, project_root)
115
+ else:
116
+ result = append_recap_entry(
117
+ task_root, project_root, kind=args.kind, mode=args.mode,
118
+ question=args.question, answer_summary=args.answer,
119
+ citations=args.citation,
120
+ now=dt.datetime.now(dt.timezone.utc))
121
+ print(json.dumps(result, ensure_ascii=False, indent=2))
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main(sys.argv[1:]))
@@ -7,7 +7,12 @@ from __future__ import annotations
7
7
 
8
8
  from pathlib import Path
9
9
 
10
- from okstra_project import ResolverError, find_task_root, resolve_project_root
10
+ from okstra_project import (
11
+ ResolverError,
12
+ StateError,
13
+ find_task_root,
14
+ resolve_project_root,
15
+ )
11
16
 
12
17
 
13
18
  def resolve_task_root(target: str, project_root: str, cwd: str) -> tuple[Path, Path]:
@@ -23,7 +28,13 @@ def resolve_task_root(target: str, project_root: str, cwd: str) -> tuple[Path, P
23
28
  except ResolverError as exc:
24
29
  raise SystemExit(f"project root resolution failed: {exc}") from exc
25
30
 
26
- task_root = find_task_root(resolved_project, target)
31
+ try:
32
+ task_root = find_task_root(resolved_project, target)
33
+ except StateError as exc:
34
+ raise SystemExit(
35
+ f"target is neither an existing path nor a valid task-key: "
36
+ f"{target!r} ({exc})"
37
+ ) from exc
27
38
  if task_root is None:
28
39
  raise SystemExit(f"no task root for task key: {target}")
29
40
  return task_root.resolve(), resolved_project
@@ -287,7 +287,6 @@ S_PR_TEMPLATE_PICK = "pr_template_pick"
287
287
  S_PR_TEMPLATE = "pr_template"
288
288
  S_PR_TEMPLATE_SCOPE = "pr_template_scope"
289
289
  S_FIX_CYCLE_CONFIRM = "fix_cycle_confirm"
290
- S_BRANCH_CONFIRM = "branch_confirm"
291
290
  S_CONFIRM = "confirm"
292
291
  S_EDIT_TARGET = "edit_target"
293
292
  S_DONE = "done"
@@ -402,7 +401,6 @@ class WizardState:
402
401
  # confirm / edit
403
402
  # "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
404
403
  fix_cycle: str = ""
405
- branch_confirmed: Optional[bool] = None
406
404
  confirmed: Optional[bool] = None
407
405
  edit_target: str = ""
408
406
  # terminal: user picked 중단 — no further prompt ever applies
@@ -905,7 +903,9 @@ def _base_ref_ready(state: WizardState) -> bool:
905
903
  return not _base_ref_required(state) or S_BASE_REF_PICK in state.answered
906
904
 
907
905
 
908
- def _branch_confirm_required(state: WizardState) -> bool:
906
+ def _worktree_preview_required(state: WizardState) -> bool:
907
+ """final-verification 은 stage worktree 를 읽기 전용으로 재사용하므로
908
+ 별도 worktree 미리보기 줄을 confirmation 블록에 넣지 않는다."""
909
909
  return state.task_type != "final-verification"
910
910
 
911
911
 
@@ -2674,93 +2674,54 @@ def _submit_fix_cycle_confirm(state: WizardState, value: str) -> Optional[str]:
2674
2674
  return f"fix-cycle: {v}"
2675
2675
 
2676
2676
 
2677
- def _build_branch_confirm(state: WizardState) -> Prompt:
2677
+ def _worktree_preview_line(state: WizardState) -> Optional[str]:
2678
+ """confirm 직전 요약 블록에 넣을 worktree 미리보기 한 줄.
2679
+ final-verification 은 stage worktree 를 읽기 전용 재사용하므로 None."""
2680
+ if not _worktree_preview_required(state):
2681
+ return None
2678
2682
  if state.task_type == "implementation":
2679
- return _build_branch_confirm_impl_stage(state)
2683
+ return _worktree_preview_line_impl(state)
2680
2684
  decision = preview_worktree_decision(
2681
2685
  project_root=Path(state.project_root),
2682
2686
  project_id=state.project_id,
2683
2687
  task_group_segment=state.task_group,
2684
2688
  task_id_segment=state.task_id,
2685
2689
  # okstra-run 경로는 --work-category 를 넘기지 않으므로 provision 도 ""(→ "task-" prefix)로
2686
- # 브랜치를 만든다. 이 단계에서 미리보기 브랜치명은 실제 생성 브랜치와 정확히 일치한다.
2690
+ # 브랜치를 만든다. 이 미리보기 브랜치명은 실제 생성 브랜치와 정확히 일치한다.
2687
2691
  work_category="",
2688
2692
  base_ref=state.base_ref,
2689
2693
  )
2690
2694
  key = {
2691
- "new": "new", "reused": "reuse",
2692
- "skipped-in-worktree": "in_worktree", "skipped-not-git": "not_git",
2695
+ "new": "worktree_new", "reused": "worktree_reuse",
2696
+ "skipped-in-worktree": "worktree_in_worktree",
2697
+ "skipped-not-git": "worktree_not_git",
2693
2698
  }[decision.status]
2694
- # Fetch raw step data first so we can compute the label before calling _p.
2695
- raw = _load_wizard_root(state.workspace_root)["steps"]["branch_confirm"]
2696
- label = raw["labels"][key].format(
2697
- branch=decision.branch or "(none)",
2698
- base_ref=decision.base_ref or "(HEAD)",
2699
- path=decision.path,
2700
- task_key=f"{state.task_group}/{state.task_id}",
2701
- )
2702
- # Pass the computed label as `summary` so _p's placeholder interpolation works.
2703
- t = _p(state.workspace_root, "branch_confirm", summary=label)
2704
- opts = t["options"]
2705
- options = [_opt("proceed", opts["proceed"])]
2706
- if decision.status == "new":
2707
- options.append(_opt("edit", opts["edit"]))
2708
- options.append(_opt("abort", opts["abort"]))
2709
- return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
2710
- options=options, echo_template=t["echo_template"])
2699
+ return _msg(state.workspace_root, "confirmation", key,
2700
+ branch=decision.branch or "(none)",
2701
+ base_ref=decision.base_ref or "(HEAD)",
2702
+ path=str(decision.path))
2711
2703
 
2712
2704
 
2713
- def _build_branch_confirm_impl_stage(state: WizardState) -> Prompt:
2705
+ def _worktree_preview_line_impl(state: WizardState) -> str:
2714
2706
  """implementation 은 stage 격리로 동작하므로 task-key 디렉터리가 아니라
2715
2707
  이번 run 이 실제로 사용할 stage worktree 관점으로 미리보기를 보여준다."""
2716
- raw = _load_wizard_root(state.workspace_root)["steps"]["branch_confirm"]
2717
- task_key = f"{state.task_group}/{state.task_id}"
2718
2708
  stage = (state.selected_stage or "auto").strip()
2719
2709
  if stage == "auto":
2720
2710
  parent_dir = compute_worktree_path(
2721
2711
  project_id=state.project_id, task_group_segment=state.task_group,
2722
2712
  task_id_segment=state.task_id,
2723
2713
  )
2724
- label = raw["labels"]["impl_stage_auto"].format(
2725
- task_key=task_key, path=str(parent_dir),
2726
- )
2727
- decision_status = "new"
2728
- else:
2729
- decision = preview_stage_worktree_decision(
2730
- project_id=state.project_id, task_group_segment=state.task_group,
2731
- task_id_segment=state.task_id, work_category="",
2732
- stage_number=int(stage),
2733
- )
2734
- key = "impl_stage_reuse" if decision.status == "reused" else "impl_stage_new"
2735
- label = raw["labels"][key].format(
2736
- stage=stage, path=decision.path, branch=decision.branch,
2737
- task_key=task_key,
2738
- )
2739
- decision_status = decision.status
2740
- t = _p(state.workspace_root, "branch_confirm", summary=label)
2741
- opts = t["options"]
2742
- options = [_opt("proceed", opts["proceed"])]
2743
- if decision_status == "new" and _base_ref_required(state):
2744
- options.append(_opt("edit", opts["edit"]))
2745
- options.append(_opt("abort", opts["abort"]))
2746
- return Prompt(step=S_BRANCH_CONFIRM, kind="pick", label=label,
2747
- options=options, echo_template=t["echo_template"])
2748
-
2749
-
2750
- def _submit_branch_confirm(state: WizardState, value: str) -> Optional[str]:
2751
- if value == "edit":
2752
- _reset_from(state, S_BASE_REF_PICK)
2753
- state.branch_confirmed = None
2754
- return "branch-confirm: edit"
2755
- if value == "abort":
2756
- state.aborted = True
2757
- return "branch-confirm: abort"
2758
- if value != "proceed":
2759
- raise WizardError(
2760
- f"expected 'proceed' / 'edit' / 'abort', got: {value!r}"
2761
- )
2762
- state.branch_confirmed = True
2763
- return "branch-confirm: proceed"
2714
+ return _msg(state.workspace_root, "confirmation", "worktree_impl_auto",
2715
+ path=str(parent_dir))
2716
+ decision = preview_stage_worktree_decision(
2717
+ project_id=state.project_id, task_group_segment=state.task_group,
2718
+ task_id_segment=state.task_id, work_category="",
2719
+ stage_number=int(stage),
2720
+ )
2721
+ key = ("worktree_impl_reuse" if decision.status == "reused"
2722
+ else "worktree_impl_new")
2723
+ return _msg(state.workspace_root, "confirmation", key,
2724
+ stage=stage, path=str(decision.path), branch=decision.branch)
2764
2725
 
2765
2726
 
2766
2727
  def _build_confirm(state: WizardState) -> Prompt:
@@ -2774,8 +2735,13 @@ def _build_confirm(state: WizardState) -> Prompt:
2774
2735
 
2775
2736
 
2776
2737
  def _submit_confirm(state: WizardState, value: str) -> Optional[str]:
2738
+ if value == "abort":
2739
+ state.aborted = True
2740
+ return "confirm: abort"
2777
2741
  if value not in ("proceed", "edit"):
2778
- raise WizardError(f"expected 'proceed' or 'edit', got: {value!r}")
2742
+ raise WizardError(
2743
+ f"expected 'proceed' / 'edit' / 'abort', got: {value!r}"
2744
+ )
2779
2745
  state.confirmed = value == "proceed"
2780
2746
  return f"confirm: {value}"
2781
2747
 
@@ -2785,7 +2751,7 @@ def _build_edit_target(state: WizardState) -> Prompt:
2785
2751
  # offer every step that has been answered.
2786
2752
  options: list[Option] = []
2787
2753
  for sid in state.answered:
2788
- if sid in (S_BRANCH_CONFIRM, S_CONFIRM, S_EDIT_TARGET):
2754
+ if sid in (S_CONFIRM, S_EDIT_TARGET):
2789
2755
  continue
2790
2756
  options.append(_opt(sid, sid))
2791
2757
  return Prompt(
@@ -3110,20 +3076,10 @@ STEPS: list[Step] = [
3110
3076
  and not s.fix_cycle),
3111
3077
  build=_build_fix_cycle_confirm, submit=_submit_fix_cycle_confirm,
3112
3078
  owns=("fix_cycle",)),
3113
- Step(S_BRANCH_CONFIRM,
3114
- applies=lambda s: (_ready_for_confirm(s)
3115
- and (not _fix_cycle_confirm_required(s)
3116
- or bool(s.fix_cycle))
3117
- and _branch_confirm_required(s)
3118
- and s.branch_confirmed is None),
3119
- build=_build_branch_confirm, submit=_submit_branch_confirm,
3120
- owns=("branch_confirmed",)),
3121
3079
  Step(S_CONFIRM,
3122
3080
  applies=lambda s: (_ready_for_confirm(s)
3123
3081
  and (not _fix_cycle_confirm_required(s)
3124
3082
  or bool(s.fix_cycle))
3125
- and (not _branch_confirm_required(s)
3126
- or s.branch_confirmed is True)
3127
3083
  and s.confirmed is None),
3128
3084
  build=_build_confirm, submit=_submit_confirm,
3129
3085
  owns=("confirmed", "edit_target")),
@@ -3222,7 +3178,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
3222
3178
  "pr_template_path": "", "pr_template_pending_text": False,
3223
3179
  "pr_template_scope": "",
3224
3180
  "fix_cycle": "",
3225
- "branch_confirmed": None, "confirmed": None, "edit_target": "",
3181
+ "confirmed": None, "edit_target": "",
3226
3182
  }
3227
3183
 
3228
3184
 
@@ -3493,6 +3449,9 @@ def confirmation_block(state: WizardState) -> str:
3493
3449
  task_key=f"{state.task_group}/{state.task_id}"))
3494
3450
  else:
3495
3451
  lines.append(f" base-ref : {state.base_ref}")
3452
+ worktree_line = _worktree_preview_line(state)
3453
+ if worktree_line is not None:
3454
+ lines.append(worktree_line)
3496
3455
  if state.task_type == "implementation":
3497
3456
  lines.append(f" executor : {state.executor or '(default)'}")
3498
3457
  lines.append(
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: okstra-inspect
3
3
  description: |
4
- Use for any read-side okstra inspection or status mutation. Single skill dispatches by sub-command to seven facets — status, history, report, time, logs, cost, errors. Trigger words include "okstra status", "task status", "current phase", "next phase", "okstra status set", "okstra mark", "<task-id> done|in-progress|진행중|완료", "okstra history", "past runs", "re-run", "resume", "list tasks", "find report", "show report for", "read the okstra report", "continue from report", "작업 시간", "소요 시간", "time summary", "duration", "elapsed", "얼마나 걸렸", "시간 분석", "okstra logs", "로그 현황", "로그 파일", "log size", "log status", "로그 정리", "log cleanup", "okstra context-cost", "context cost", "context-cost", "컨텍스트 비용", "읽기 비용", "산출물 비용", "okstra errors", "error report", "에러 리포트", "에러 보고", "에러 모아줘", "실패 로그 정리".
4
+ Use for any read-side okstra inspection or status mutation. Single skill dispatches by sub-command to eight facets — status, history, report, time, logs, cost, errors, recap. Trigger words include "okstra status", "task status", "current phase", "next phase", "okstra status set", "okstra mark", "<task-id> done|in-progress|진행중|완료", "okstra history", "past runs", "re-run", "resume", "list tasks", "find report", "show report for", "read the okstra report", "continue from report", "작업 시간", "소요 시간", "time summary", "duration", "elapsed", "얼마나 걸렸", "시간 분석", "okstra logs", "로그 현황", "로그 파일", "log size", "log status", "로그 정리", "log cleanup", "okstra context-cost", "context cost", "context-cost", "컨텍스트 비용", "읽기 비용", "산출물 비용", "okstra errors", "error report", "에러 리포트", "에러 보고", "에러 모아줘", "okstra recap", "recap", "작업 요약", "이 task 요약", "전후 요약", "이 작업 설명해줘", "task 질문", "실패 로그 정리".
5
5
  ---
6
6
 
7
7
  # OKSTRA Inspect
@@ -17,6 +17,7 @@ Single read-side entry point for okstra runtime inspection plus the one status m
17
17
  | `logs` | Inventory codex/antigravity wrapper `.log` sidecars; emit cleanup commands. |
18
18
  | `cost` | Estimate file/read context cost for a task bundle. |
19
19
  | `errors` | Aggregate okstra-run error logs for a task into a timestamped markdown report; print a summary. |
20
+ | `recap` | Summarize a task's run-to-run phase transitions, then answer free-form questions over its `.okstra` artifacts. Appends each summary/Q&A to `recap/recap-log.jsonl`. |
20
21
 
21
22
  ## Step 0: Verify okstra runtime + project setup (shared)
22
23
 
@@ -758,6 +759,64 @@ Parse the stdout JSON and report:
758
759
 
759
760
  ---
760
761
 
762
+ ## recap
763
+
764
+ Trigger phrases: "okstra recap", "recap", "작업 요약", "이 task 요약", "전후 요약", "이 작업 설명해줘", "task 질문".
765
+
766
+ task-id 하나에 쌓인 `.okstra` 산출물 위에서 (a) 처리 전/후 요약을 내고, (b) 그 작업에 대한 자유 질문에 답한다. 기본은 `.okstra/` 서브트리만 읽는다(artifact 모드). 사용자가 명시적으로 코드 변경까지 봐 달라고 요청할 때만 code 모드로 확장한다. 이 sub-command 는 `recap/recap-log.jsonl` 만 append 하고 `task-manifest.json` / catalog / timeline 은 절대 mutate 하지 않는다.
767
+
768
+ ### recap.1 — Resolve target
769
+
770
+ `cost` / `errors` 와 동일한 3형식: ① full task-key, ② task-id 만(→ `.okstra/discovery/task-catalog.json` 의 `taskId` 대소문자 무시 매칭; 복수면 나열·질문, 없으면 not-found), ③ task-root path.
771
+
772
+ ### recap.2 — Assemble the before/after summary
773
+
774
+ CLI 출력을 source of truth 로 쓴다:
775
+
776
+ ```bash
777
+ okstra recap assemble <resolved-target> --project-root <projectRoot>
778
+ ```
779
+
780
+ stdout JSON(`{taskKey, runCount, transitions[], latestPhaseStates}`)을 파싱해 run 단위 전/후 전이를 서술한다. 각 `transitions[]` 항목의 `fromPhase → toPhase`(직전 run currentPhase → 이번 run currentPhase), `status`, `lastCompletedPhase`, `nextRecommendedPhase`, `reportPath` 를 시간순으로 보여준다. `runCount == 0` 이면 "이 task 에는 기록된 run 이 없습니다." 라고만 답하고 파일을 읽었다고 주장하지 않는다.
781
+
782
+ `reportPath` 가 있는 run 은 사용자가 더 깊은 요약을 원할 때만 해당 final-report 를 읽어 보강한다(자동으로 모두 읽지 않는다).
783
+
784
+ ### recap.3 — Free-form Q&A loop
785
+
786
+ 요약 출력 뒤 사용자의 임의 질문을 받는다.
787
+
788
+ - **artifact 모드(기본)**: `.okstra/` 산출물(timeline, task-manifest, 해당 run 의 final-report)만 근거로 답한다. 모든 사실 주장은 `.okstra` 파일 `path:line` 인용을 동반한다.
789
+ - **code 모드(opt-in)**: 사용자가 "코드 변경까지 / diff 까지 봐줘" 류로 **명시적으로 요청**할 때만, 해당 task 의 worktree/브랜치에서 `git diff` · `git log` 를 추가로 읽어 코드 레벨로 답한다. recap 진입만으로는 절대 code 모드로 넘어가지 않는다.
790
+
791
+ ### recap.4 — Persist each turn
792
+
793
+ 요약 1건과 각 Q&A 답변을 로그에 남긴다(append-only):
794
+
795
+ ```bash
796
+ okstra recap record <resolved-target> --project-root <projectRoot> \
797
+ --kind <summary|qa> --mode <artifact|code> \
798
+ --question "<질문 또는 생략>" --answer "<한두 줄 요약>" \
799
+ --citation "<path:line>" --citation "<path:line>"
800
+ ```
801
+
802
+ `--answer` 에는 전체 답변 전사가 아니라 한두 줄 요약을 넣는다. 인용이 여러 개면 `--citation` 을 반복한다. 기록 실패(비정상 종료)는 조용히 넘기지 말고 사용자에게 알린다.
803
+
804
+ ### recap — Output template
805
+
806
+ ```markdown
807
+ ## okstra Recap — <task-key>
808
+
809
+ 전/후 요약 (runs: <N>):
810
+
811
+ | # | when | task-type | from → to | status | next |
812
+ |---|---|---|---|---|---|
813
+ | 1 | <ts> | requirements-discovery | (시작) → requirements-discovery | done | error-analysis |
814
+
815
+ <이후 자유 질문을 받습니다. 코드 변경까지 보려면 "diff 까지 봐줘" 라고 요청하세요.>
816
+ ```
817
+
818
+ ---
819
+
761
820
  ## Output Rules (shared)
762
821
 
763
822
  - Responses should be concise and written in Korean unless the user requests otherwise.