okstra 0.95.0 → 0.96.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/docs/kr/architecture.md +16 -11
- package/docs/project-structure-overview.md +3 -0
- package/docs/superpowers/plans/2026-06-19-okstra-inspect-recap-facet.md +647 -0
- package/docs/superpowers/specs/2026-06-19-okstra-inspect-recap-facet-design.md +96 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +9 -15
- package/runtime/python/okstra_ctl/paths.py +6 -0
- package/runtime/python/okstra_ctl/recap.py +126 -0
- package/runtime/python/okstra_ctl/task_target.py +13 -2
- package/runtime/python/okstra_ctl/wizard.py +40 -81
- package/runtime/skills/okstra-inspect/SKILL.md +60 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/src/cli-registry.mjs +7 -0
- package/src/recap.mjs +27 -0
|
@@ -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
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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": "
|
|
2692
|
-
"skipped-in-worktree": "
|
|
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
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
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
|
|
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
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
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(
|
|
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 (
|
|
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
|
-
"
|
|
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
|
|
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.
|