okstra 0.94.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.
- 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/agents/workers/antigravity-worker.md +1 -1
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/codex-worker.md +1 -1
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- 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
|
@@ -21,7 +21,7 @@ color: green
|
|
|
21
21
|
tools: ["Bash", "BashOutput", "KillShell", "Read", "Write", "Glob", "Grep"]
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Execute the Google Antigravity CLI and return the analysis result.
|
|
25
25
|
|
|
26
26
|
## CLI Command
|
|
27
27
|
|
|
@@ -21,7 +21,7 @@ color: blue
|
|
|
21
21
|
tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "TodoWrite", "WebFetch", "WebSearch"]
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
-
You
|
|
24
|
+
You share an **identical core responsibility** with the Codex and Antigravity workers: cover every brief question across feasibility, requirement interpretation, hidden assumptions, alternatives, and execution risk in sections 1–5 of the worker output. Cross-verification only triangulates if all three workers answer the same questions against the same brief.
|
|
25
25
|
|
|
26
26
|
Your specialization lens — **broad reasoning depth, hidden-assumption surfacing, execution-risk decomposition** — is the only content that belongs in optional Section 6 (additive, not subject to convergence). Do NOT let the lens narrow sections 1–5: a Claude-only "Findings" populated solely with assumption-class items is a contract violation.
|
|
27
27
|
|
|
@@ -21,7 +21,7 @@ color: cyan
|
|
|
21
21
|
tools: ["Bash", "BashOutput", "KillShell", "Read", "Write", "Glob", "Grep"]
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
Execute the OpenAI Codex CLI and return the analysis result.
|
|
25
25
|
|
|
26
26
|
## CLI Command
|
|
27
27
|
|
|
@@ -14,7 +14,7 @@ model: inherit
|
|
|
14
14
|
tools: ["Bash", "Read", "Write", "Edit", "Glob", "Grep", "TodoWrite", "WebFetch", "WebSearch"]
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
**Author the final-report data.json** (the JSON SSOT) at the assigned `Result Path`, plus an audit sidecar — that is the `Report writer worker`'s sole responsibility for okstra cross-verification. You are NOT an analysis worker — you do not produce independent findings, you do not vote in convergence, and you do not re-do the workers' analysis.
|
|
18
18
|
|
|
19
19
|
- The `**Report Language:**` header in your dispatch prompt is already
|
|
20
20
|
resolved to `en` or `ko` by the lead. Copy it verbatim into
|
|
@@ -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(
|