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,647 @@
1
+ # okstra-inspect `recap` facet 구현 계획
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** okstra-inspect 에 task-id 단위 전/후 요약 + 자유 Q&A 를 제공하는 8번째 facet `recap` 을 추가한다.
6
+
7
+ **Architecture:** 결정론적 부분(timeline run 간 phase 전이 계산 = `recap assemble`, recap-log append = `recap record`)은 python `okstra_ctl/recap.py` 에 두고 단위 테스트로 검증한다. 요약 서술과 자유 Q&A 는 skill-markdown(okstra-inspect)이 그 산출물 위에서 수행한다. 경로는 SSOT 인 `compute_run_paths` 에만 추가한다.
8
+
9
+ **Tech Stack:** Python 3 (argparse, 4-space indent, snake_case) · Node ES modules (`src/*.mjs`, 2-space indent) · skill markdown · pytest.
10
+
11
+ ## Global Constraints
12
+
13
+ - `runtime/` 는 빌드 산출물 — 절대 손으로 수정하지 않는다. 소스는 `scripts/`, `skills/`, `src/` 등에서만 고치고 `npm run build` 로 동기화한다.
14
+ - 모든 okstra 산출물은 `<PROJECT_ROOT>/.okstra/` 안에만 쓴다. recap 은 `<task-root>/recap/recap-log.jsonl` 만 append 하고 다른 task 산출물(`task-manifest.json`, catalog, timeline)은 mutate 하지 않는다.
15
+ - read-side python 모듈은 task-key/ task-root 입력 계약을 `okstra_ctl.task_target.resolve_task_root(target, project_root, cwd)` 로 공유한다 (context-cost / error-report 와 동일).
16
+ - 사용자-노출 문서·주석 산문은 한국어, 코드 식별자/경로/CLI 인자는 영어.
17
+ - 새 함수는 유효 코드 50줄 이하로 유지한다.
18
+ - 타임스탬프는 `datetime.datetime.now(datetime.timezone.utc).isoformat()` (error_report 와 동일 패턴).
19
+
20
+ ---
21
+
22
+ ### Task 1: `compute_run_paths` 에 recap 경로 추가
23
+
24
+ **Files:**
25
+ - Modify: `scripts/okstra_ctl/paths.py` (task-root 레벨 경로 블록 `paths.py:122-130`, `abs_paths` 딕셔너리 `paths.py:206-302`, `rel_pairs` 리스트 `paths.py:303-350`)
26
+ - Test: `tests/test_okstra_ctl_paths.py`
27
+
28
+ **Interfaces:**
29
+ - Consumes: 기존 `compute_run_paths(...)` 시그니처와 `task_root` 지역변수.
30
+ - Produces: 컨텍스트 딕셔너리에 새 키 `RECAP_DIR`, `RECAP_LOG_PATH`(절대), `RECAP_DIR_RELATIVE_PATH`, `RECAP_LOG_RELATIVE_PATH`(project-root 상대). 경로 규약: `<task-root>/recap/` 디렉터리와 `<task-root>/recap/recap-log.jsonl` 파일.
31
+
32
+ - [ ] **Step 1: Write the failing test**
33
+
34
+ `tests/test_okstra_ctl_paths.py` 끝에 추가:
35
+
36
+ ```python
37
+ def test_recap_paths_present():
38
+ ctx = compute_run_paths(**_common())
39
+ assert ctx["RECAP_DIR"].endswith("/.okstra/tasks/g/t/recap")
40
+ assert ctx["RECAP_LOG_PATH"].endswith("/.okstra/tasks/g/t/recap/recap-log.jsonl")
41
+ assert ctx["RECAP_DIR_RELATIVE_PATH"] == ".okstra/tasks/g/t/recap"
42
+ assert ctx["RECAP_LOG_RELATIVE_PATH"] == ".okstra/tasks/g/t/recap/recap-log.jsonl"
43
+ ```
44
+
45
+ - [ ] **Step 2: Run test to verify it fails**
46
+
47
+ Run: `python3 -m pytest tests/test_okstra_ctl_paths.py::test_recap_paths_present -v`
48
+ Expected: FAIL with `KeyError: 'RECAP_DIR'`.
49
+
50
+ - [ ] **Step 3: Add the path locals**
51
+
52
+ `scripts/okstra_ctl/paths.py` 의 `timeline_file = history_dir / "timeline.json"` (`paths.py:130`) 바로 다음 줄에 추가:
53
+
54
+ ```python
55
+ recap_dir = task_root / "recap"
56
+ recap_log = recap_dir / "recap-log.jsonl"
57
+ ```
58
+
59
+ - [ ] **Step 4: Register absolute keys**
60
+
61
+ `abs_paths` 딕셔너리에서 `"TIMELINE_PATH": str(timeline_file),` (`paths.py:249`) 다음 줄에 추가:
62
+
63
+ ```python
64
+ "RECAP_DIR": str(recap_dir),
65
+ "RECAP_LOG_PATH": str(recap_log),
66
+ ```
67
+
68
+ - [ ] **Step 5: Register relative keys**
69
+
70
+ `rel_pairs` 리스트에서 `("TIMELINE_RELATIVE_PATH", timeline_file),` (`paths.py:314`) 다음 줄에 추가:
71
+
72
+ ```python
73
+ ("RECAP_DIR_RELATIVE_PATH", recap_dir),
74
+ ("RECAP_LOG_RELATIVE_PATH", recap_log),
75
+ ```
76
+
77
+ - [ ] **Step 6: Run test to verify it passes**
78
+
79
+ Run: `python3 -m pytest tests/test_okstra_ctl_paths.py -v`
80
+ Expected: PASS (신규 + 기존 테스트 모두).
81
+
82
+ - [ ] **Step 7: Commit**
83
+
84
+ ```bash
85
+ git add scripts/okstra_ctl/paths.py tests/test_okstra_ctl_paths.py
86
+ git commit -m "feat(paths): compute_run_paths 에 recap 디렉터리/로그 경로 추가"
87
+ ```
88
+
89
+ ---
90
+
91
+ ### Task 2: `recap assemble` — timeline run 간 phase 전이 조립
92
+
93
+ **Files:**
94
+ - Create: `scripts/okstra_ctl/recap.py`
95
+ - Test: `tests/test_okstra_recap.py`
96
+
97
+ **Interfaces:**
98
+ - Consumes: `okstra_ctl.task_target.resolve_task_root`, `project_rel`. task-root 의 `history/timeline.json`(`{"runs": [ {runTimestamp, taskType, status, reportPath, workflowSnapshot:{currentPhase, lastCompletedPhase, nextRecommendedPhase, phaseStates}} ... ]}`).
99
+ - Produces: `assemble_recap(task_root: Path, project_root: Path) -> dict` 반환:
100
+ ```
101
+ {
102
+ "taskKey": str,
103
+ "runCount": int,
104
+ "transitions": [ {"index": int, "runTimestamp": str, "taskType": str,
105
+ "status": str, "fromPhase": str, "toPhase": str,
106
+ "lastCompletedPhase": str, "nextRecommendedPhase": str,
107
+ "reportPath": str} ... ],
108
+ "latestPhaseStates": dict
109
+ }
110
+ ```
111
+ `fromPhase` = 직전 run 의 `currentPhase`(첫 run 은 `""`), `toPhase` = 해당 run 의 `currentPhase`.
112
+
113
+ - [ ] **Step 1: Write the failing test**
114
+
115
+ `tests/test_okstra_recap.py` 생성:
116
+
117
+ ```python
118
+ import json
119
+ import sys
120
+ from pathlib import Path
121
+
122
+ LIB_DIR = Path(__file__).resolve().parents[1] / "scripts"
123
+ if str(LIB_DIR) not in sys.path:
124
+ sys.path.insert(0, str(LIB_DIR))
125
+
126
+ from okstra_ctl import recap
127
+
128
+
129
+ def _run(ts, current, last="", nxt="", task_type="implementation",
130
+ status="done", report=""):
131
+ return {
132
+ "runTimestamp": ts, "taskType": task_type, "status": status,
133
+ "reportPath": report,
134
+ "workflowSnapshot": {
135
+ "currentPhase": current, "lastCompletedPhase": last,
136
+ "nextRecommendedPhase": nxt, "phaseStates": {current: "done"},
137
+ },
138
+ }
139
+
140
+
141
+ def _write_timeline(task_root: Path, runs: list[dict]) -> None:
142
+ hist = task_root / "history"
143
+ hist.mkdir(parents=True, exist_ok=True)
144
+ (hist / "timeline.json").write_text(
145
+ json.dumps({"runs": runs}, ensure_ascii=False), encoding="utf-8")
146
+ (task_root / "task-manifest.json").write_text(
147
+ json.dumps({"taskKey": "p:g:DEV-1"}, ensure_ascii=False), encoding="utf-8")
148
+
149
+
150
+ def test_assemble_computes_adjacent_transitions(tmp_path):
151
+ _write_timeline(tmp_path, [
152
+ _run("2026-06-01T00:00:00+00:00", "requirements-discovery",
153
+ nxt="error-analysis"),
154
+ _run("2026-06-02T00:00:00+00:00", "error-analysis",
155
+ last="requirements-discovery", nxt="implementation-planning"),
156
+ ])
157
+ result = recap.assemble_recap(tmp_path, tmp_path)
158
+ assert result["taskKey"] == "p:g:DEV-1"
159
+ assert result["runCount"] == 2
160
+ assert result["transitions"][0]["fromPhase"] == ""
161
+ assert result["transitions"][0]["toPhase"] == "requirements-discovery"
162
+ assert result["transitions"][1]["fromPhase"] == "requirements-discovery"
163
+ assert result["transitions"][1]["toPhase"] == "error-analysis"
164
+ assert result["latestPhaseStates"] == {"error-analysis": "done"}
165
+
166
+
167
+ def test_assemble_empty_timeline(tmp_path):
168
+ (tmp_path / "history").mkdir(parents=True)
169
+ (tmp_path / "history" / "timeline.json").write_text(
170
+ json.dumps({"runs": []}), encoding="utf-8")
171
+ (tmp_path / "task-manifest.json").write_text(
172
+ json.dumps({"taskKey": "p:g:DEV-1"}), encoding="utf-8")
173
+ result = recap.assemble_recap(tmp_path, tmp_path)
174
+ assert result["runCount"] == 0
175
+ assert result["transitions"] == []
176
+ assert result["latestPhaseStates"] == {}
177
+ ```
178
+
179
+ - [ ] **Step 2: Run test to verify it fails**
180
+
181
+ Run: `python3 -m pytest tests/test_okstra_recap.py -v`
182
+ Expected: FAIL with `ModuleNotFoundError: No module named 'okstra_ctl.recap'`.
183
+
184
+ - [ ] **Step 3: Write the module with assemble**
185
+
186
+ `scripts/okstra_ctl/recap.py` 생성:
187
+
188
+ ```python
189
+ """Read-side recap for a task: assemble run-to-run phase transitions and
190
+ append a recap Q&A log. Mutates only <task-root>/recap/recap-log.jsonl;
191
+ never touches task-manifest / catalog / timeline.
192
+ """
193
+ from __future__ import annotations
194
+
195
+ import argparse
196
+ import datetime as dt
197
+ import json
198
+ import sys
199
+ from pathlib import Path
200
+
201
+ from okstra_ctl.task_target import resolve_task_root, project_rel
202
+
203
+
204
+ def _load_timeline(task_root: Path) -> list[dict]:
205
+ path = task_root / "history" / "timeline.json"
206
+ if not path.is_file():
207
+ return []
208
+ try:
209
+ data = json.loads(path.read_text(encoding="utf-8"))
210
+ except Exception:
211
+ return []
212
+ runs = data.get("runs", [])
213
+ return runs if isinstance(runs, list) else []
214
+
215
+
216
+ def _task_key(task_root: Path) -> str:
217
+ path = task_root / "task-manifest.json"
218
+ if not path.is_file():
219
+ return ""
220
+ try:
221
+ return json.loads(path.read_text(encoding="utf-8")).get("taskKey", "")
222
+ except Exception:
223
+ return ""
224
+
225
+
226
+ def assemble_recap(task_root: Path, project_root: Path) -> dict:
227
+ runs = _load_timeline(task_root)
228
+ transitions = []
229
+ prev_phase = ""
230
+ latest_states: dict = {}
231
+ for idx, run in enumerate(runs):
232
+ snap = run.get("workflowSnapshot", {}) if isinstance(run, dict) else {}
233
+ cur = snap.get("currentPhase", "")
234
+ transitions.append({
235
+ "index": idx,
236
+ "runTimestamp": run.get("runTimestamp", ""),
237
+ "taskType": run.get("taskType", ""),
238
+ "status": run.get("status", ""),
239
+ "fromPhase": prev_phase,
240
+ "toPhase": cur,
241
+ "lastCompletedPhase": snap.get("lastCompletedPhase", ""),
242
+ "nextRecommendedPhase": snap.get("nextRecommendedPhase", ""),
243
+ "reportPath": run.get("reportPath", ""),
244
+ })
245
+ prev_phase = cur
246
+ latest_states = snap.get("phaseStates", {}) or latest_states
247
+ return {
248
+ "taskKey": _task_key(task_root),
249
+ "runCount": len(runs),
250
+ "transitions": transitions,
251
+ "latestPhaseStates": latest_states,
252
+ }
253
+ ```
254
+
255
+ - [ ] **Step 4: Run test to verify it passes**
256
+
257
+ Run: `python3 -m pytest tests/test_okstra_recap.py -v`
258
+ Expected: PASS (2 tests).
259
+
260
+ - [ ] **Step 5: Commit**
261
+
262
+ ```bash
263
+ git add scripts/okstra_ctl/recap.py tests/test_okstra_recap.py
264
+ git commit -m "feat(recap): timeline run 간 phase 전이 조립(assemble_recap)"
265
+ ```
266
+
267
+ ---
268
+
269
+ ### Task 3: `recap record` — recap-log.jsonl append + CLI 진입점
270
+
271
+ **Files:**
272
+ - Modify: `scripts/okstra_ctl/recap.py`
273
+ - Test: `tests/test_okstra_recap.py`
274
+
275
+ **Interfaces:**
276
+ - Consumes: Task 2 의 `assemble_recap`, `resolve_task_root`, `project_rel`.
277
+ - Produces:
278
+ - `append_recap_entry(task_root, project_root, *, kind, mode, question, answer_summary, citations, now) -> dict` — `<task-root>/recap/recap-log.jsonl` 에 한 줄 append, `{"logPath": <project-rel>, "entry": <written dict>}` 반환. entry 스키마: `{ts, kind, mode, question, answerSummary, citations}`.
279
+ - `main(argv)` — `okstra recap` 의 두 서브커맨드(`assemble`, `record`)를 argparse 로 디스패치하고 결과 JSON 을 stdout 에 출력.
280
+
281
+ - [ ] **Step 1: Write the failing test**
282
+
283
+ `tests/test_okstra_recap.py` 에 추가:
284
+
285
+ ```python
286
+ def test_append_recap_entry_appends_one_line(tmp_path):
287
+ (tmp_path / "task-manifest.json").write_text(
288
+ json.dumps({"taskKey": "p:g:DEV-1"}), encoding="utf-8")
289
+ now = dt.datetime(2026, 6, 19, tzinfo=dt.timezone.utc)
290
+ first = recap.append_recap_entry(
291
+ tmp_path, tmp_path, kind="qa", mode="artifact",
292
+ question="무엇을 했나?", answer_summary="phase 2 까지 진행",
293
+ citations=["history/timeline.json:1"], now=now)
294
+ second = recap.append_recap_entry(
295
+ tmp_path, tmp_path, kind="summary", mode="code",
296
+ question="", answer_summary="요약", citations=[], now=now)
297
+ log = tmp_path / "recap" / "recap-log.jsonl"
298
+ lines = [l for l in log.read_text(encoding="utf-8").splitlines() if l.strip()]
299
+ assert len(lines) == 2
300
+ rec0 = json.loads(lines[0])
301
+ assert rec0["kind"] == "qa"
302
+ assert rec0["mode"] == "artifact"
303
+ assert rec0["question"] == "무엇을 했나?"
304
+ assert rec0["answerSummary"] == "phase 2 까지 진행"
305
+ assert rec0["citations"] == ["history/timeline.json:1"]
306
+ assert rec0["ts"] == "2026-06-19T00:00:00+00:00"
307
+ assert first["logPath"].endswith("recap/recap-log.jsonl")
308
+ assert second["entry"]["kind"] == "summary"
309
+
310
+
311
+ def test_main_assemble_prints_json(tmp_path, capsys):
312
+ _write_timeline(tmp_path, [
313
+ _run("2026-06-01T00:00:00+00:00", "implementation"),
314
+ ])
315
+ rc = recap.main(["assemble", str(tmp_path)])
316
+ out = json.loads(capsys.readouterr().out)
317
+ assert rc == 0
318
+ assert out["runCount"] == 1
319
+
320
+
321
+ def test_main_record_appends_and_prints(tmp_path, capsys):
322
+ (tmp_path / "task-manifest.json").write_text(
323
+ json.dumps({"taskKey": "p:g:DEV-1"}), encoding="utf-8")
324
+ rc = recap.main([
325
+ "record", str(tmp_path), "--kind", "qa", "--mode", "artifact",
326
+ "--question", "q?", "--answer", "a.",
327
+ "--citation", "x:1", "--citation", "y:2",
328
+ ])
329
+ out = json.loads(capsys.readouterr().out)
330
+ assert rc == 0
331
+ assert out["entry"]["citations"] == ["x:1", "y:2"]
332
+ log = tmp_path / "recap" / "recap-log.jsonl"
333
+ assert len([l for l in log.read_text().splitlines() if l.strip()]) == 1
334
+ ```
335
+
336
+ - [ ] **Step 2: Run test to verify it fails**
337
+
338
+ Run: `python3 -m pytest tests/test_okstra_recap.py -v`
339
+ Expected: FAIL with `AttributeError: module 'okstra_ctl.recap' has no attribute 'append_recap_entry'`.
340
+
341
+ - [ ] **Step 3: Add append + main**
342
+
343
+ `scripts/okstra_ctl/recap.py` 끝(`assemble_recap` 다음)에 추가:
344
+
345
+ ```python
346
+ def append_recap_entry(task_root, project_root, *, kind, mode, question,
347
+ answer_summary, citations, now) -> dict:
348
+ recap_dir = task_root / "recap"
349
+ recap_dir.mkdir(parents=True, exist_ok=True)
350
+ log_path = recap_dir / "recap-log.jsonl"
351
+ entry = {
352
+ "ts": now.isoformat(),
353
+ "kind": kind,
354
+ "mode": mode,
355
+ "question": question,
356
+ "answerSummary": answer_summary,
357
+ "citations": list(citations),
358
+ }
359
+ with log_path.open("a", encoding="utf-8") as fh:
360
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
361
+ return {"logPath": project_rel(log_path, project_root), "entry": entry}
362
+
363
+
364
+ def main(argv: list[str] | None = None) -> int:
365
+ parser = argparse.ArgumentParser(prog="okstra recap")
366
+ parser.add_argument("--project-root", default="")
367
+ parser.add_argument("--cwd", default=".")
368
+ sub = parser.add_subparsers(dest="command", required=True)
369
+
370
+ p_asm = sub.add_parser("assemble", help="assemble run-to-run phase transitions")
371
+ p_asm.add_argument("target", help="task root path or task-key")
372
+
373
+ p_rec = sub.add_parser("record", help="append a recap Q&A/summary log entry")
374
+ p_rec.add_argument("target", help="task root path or task-key")
375
+ p_rec.add_argument("--kind", choices=["summary", "qa"], required=True)
376
+ p_rec.add_argument("--mode", choices=["artifact", "code"], required=True)
377
+ p_rec.add_argument("--question", default="")
378
+ p_rec.add_argument("--answer", required=True)
379
+ p_rec.add_argument("--citation", action="append", default=[])
380
+
381
+ args = parser.parse_args(argv)
382
+ task_root, project_root = resolve_task_root(
383
+ args.target, args.project_root, args.cwd)
384
+
385
+ if args.command == "assemble":
386
+ result = assemble_recap(task_root, project_root)
387
+ else:
388
+ result = append_recap_entry(
389
+ task_root, project_root, kind=args.kind, mode=args.mode,
390
+ question=args.question, answer_summary=args.answer,
391
+ citations=args.citation,
392
+ now=dt.datetime.now(dt.timezone.utc))
393
+ print(json.dumps(result, ensure_ascii=False, indent=2))
394
+ return 0
395
+
396
+
397
+ if __name__ == "__main__":
398
+ raise SystemExit(main(sys.argv[1:]))
399
+ ```
400
+
401
+ - [ ] **Step 4: Run test to verify it passes**
402
+
403
+ Run: `python3 -m pytest tests/test_okstra_recap.py -v`
404
+ Expected: PASS (5 tests).
405
+
406
+ - [ ] **Step 5: Commit**
407
+
408
+ ```bash
409
+ git add scripts/okstra_ctl/recap.py tests/test_okstra_recap.py
410
+ git commit -m "feat(recap): recap-log.jsonl append + assemble/record CLI 디스패치"
411
+ ```
412
+
413
+ ---
414
+
415
+ ### Task 4: Node CLI 래퍼 `okstra recap`
416
+
417
+ **Files:**
418
+ - Create: `src/recap.mjs`
419
+ - Modify: `src/cli-registry.mjs` (introspection 카테고리, `error-report` 항목 `cli-registry.mjs:106-112` 바로 다음)
420
+
421
+ **Interfaces:**
422
+ - Consumes: `./_python-helper.mjs` 의 `runPythonModule({module, args, stdio})` (error-report.mjs 와 동일).
423
+ - Produces: `okstra recap <assemble|record> <target> ...` 가 `okstra_ctl.recap` 파이썬 모듈로 인자를 그대로 전달하고 stdout 을 그대로 흘려보낸다.
424
+
425
+ - [ ] **Step 1: Create the wrapper**
426
+
427
+ `src/recap.mjs` 생성:
428
+
429
+ ```javascript
430
+ import { runPythonModule } from "./_python-helper.mjs";
431
+
432
+ const USAGE = `okstra recap — assemble a task's run-to-run phase recap, or append a recap log entry
433
+
434
+ Usage:
435
+ okstra recap assemble <task-root|task-key> [--project-root <dir>] [--cwd <dir>]
436
+ okstra recap record <task-root|task-key> --kind <summary|qa> --mode <artifact|code> \\
437
+ [--question <text>] --answer <text> [--citation <path:line> ...]
438
+
439
+ \`assemble\` is read-only and prints a JSON summary of phase transitions across runs.
440
+ \`record\` appends one line to <task-root>/recap/recap-log.jsonl; it never mutates
441
+ other task artifacts.
442
+ `;
443
+
444
+ export async function run(args) {
445
+ if (args.includes("--help") || args.includes("-h")) {
446
+ process.stdout.write(USAGE);
447
+ return 0;
448
+ }
449
+
450
+ const result = await runPythonModule({
451
+ module: "okstra_ctl.recap",
452
+ args,
453
+ stdio: "inherit-stdout",
454
+ });
455
+ return result.code ?? 0;
456
+ }
457
+ ```
458
+
459
+ - [ ] **Step 2: Register in cli-registry**
460
+
461
+ `src/cli-registry.mjs` 의 `error-report` 객체(`cli-registry.mjs:106-112`) 닫는 `},` 다음에 추가:
462
+
463
+ ```javascript
464
+ {
465
+ name: "recap",
466
+ module: "./recap.mjs",
467
+ export: "run",
468
+ category: "introspection",
469
+ summary: ["Assemble a task's run-to-run phase recap or append a recap log entry"],
470
+ },
471
+ ```
472
+
473
+ - [ ] **Step 3: Smoke-test the CLI wiring**
474
+
475
+ Run: `node bin/okstra recap --help`
476
+ Expected: USAGE 텍스트 출력, exit 0.
477
+
478
+ - [ ] **Step 4: Smoke-test the python bridge**
479
+
480
+ Run: `node bin/okstra recap assemble /nonexistent-task-root`
481
+ Expected: `no task root for task key:` 류의 메시지로 비정상 종료(파이썬 브리지가 연결됐다는 증거). 경로/스택 추적 없이 SystemExit 메시지면 정상.
482
+
483
+ - [ ] **Step 5: Commit**
484
+
485
+ ```bash
486
+ git add src/recap.mjs src/cli-registry.mjs
487
+ git commit -m "feat(cli): okstra recap Node 래퍼 + cli-registry 등록"
488
+ ```
489
+
490
+ ---
491
+
492
+ ### Task 5: okstra-inspect 에 `recap` facet 추가
493
+
494
+ **Files:**
495
+ - Modify: `skills/okstra-inspect/SKILL.md` (frontmatter description `SKILL.md:3-4`, facet 테이블 `SKILL.md:11-19`, facet 본문 — `errors` 섹션 `SKILL.md:695` 다음 끝에 추가)
496
+
497
+ **Interfaces:**
498
+ - Consumes: Task 4 의 `okstra recap assemble` / `okstra recap record` CLI. Step 0 의 공유 부트스트랩(`okstra ensure-installed`, `okstra check-project --json`)과 target 해석 규약(task-key / task-id catalog 매칭 / task-root path — `cost`·`errors` 와 동일).
499
+ - Produces: 사용자에게 노출되는 8번째 sub-command `recap`.
500
+
501
+ - [ ] **Step 1: Extend the frontmatter description**
502
+
503
+ `SKILL.md:3-4` 의 `description` 에서 `dispatches by sub-command to seven facets — status, history, report, time, logs, cost, errors.` 를 `dispatches by sub-command to eight facets — status, history, report, time, logs, cost, errors, recap.` 로 바꾸고, 트리거 문자열 목록 끝(`"실패 로그 정리".` 직전)에 다음을 추가:
504
+
505
+ ```
506
+ "okstra recap", "recap", "작업 요약", "이 task 요약", "전후 요약", "이 작업 설명해줘", "task 질문",
507
+ ```
508
+
509
+ - [ ] **Step 2: Add the facet table row**
510
+
511
+ `SKILL.md:19` 의 `errors` 행 바로 다음에 추가:
512
+
513
+ ```
514
+ | `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`. |
515
+ ```
516
+
517
+ 그리고 `SKILL.md:9` 문장의 "Sub-commands:" 앞에 있는 facet 나열 설명이 7개를 가정하면 8개로 맞춘다(해당 문장에 숫자가 박혀 있지 않으면 변경 불필요 — 확인 후 진행).
518
+
519
+ - [ ] **Step 3: Add the `recap` facet body**
520
+
521
+ `errors — Output template` 블록(`SKILL.md:757` 부근, 파일 끝) 다음에 추가:
522
+
523
+ ````markdown
524
+ ---
525
+
526
+ ## recap
527
+
528
+ Trigger phrases: "okstra recap", "recap", "작업 요약", "이 task 요약", "전후 요약", "이 작업 설명해줘", "task 질문".
529
+
530
+ task-id 하나에 쌓인 `.okstra` 산출물 위에서 (a) 처리 전/후 요약을 내고, (b) 그 작업에 대한 자유 질문에 답한다. 기본은 `.okstra/` 서브트리만 읽는다(artifact 모드). 사용자가 명시적으로 코드 변경까지 봐 달라고 요청할 때만 code 모드로 확장한다. 이 sub-command 는 `recap/recap-log.jsonl` 만 append 하고 `task-manifest.json` / catalog / timeline 은 절대 mutate 하지 않는다.
531
+
532
+ ### recap.1 — Resolve target
533
+
534
+ `cost` / `errors` 와 동일한 3형식: ① full task-key, ② task-id 만(→ `.okstra/discovery/task-catalog.json` 의 `taskId` 대소문자 무시 매칭; 복수면 나열·질문, 없으면 not-found), ③ task-root path.
535
+
536
+ ### recap.2 — Assemble the before/after summary
537
+
538
+ CLI 출력을 source of truth 로 쓴다:
539
+
540
+ ```bash
541
+ okstra recap assemble <resolved-target> --project-root <projectRoot>
542
+ ```
543
+
544
+ stdout JSON(`{taskKey, runCount, transitions[], latestPhaseStates}`)을 파싱해 run 단위 전/후 전이를 서술한다. 각 `transitions[]` 항목의 `fromPhase → toPhase`(직전 run currentPhase → 이번 run currentPhase), `status`, `lastCompletedPhase`, `nextRecommendedPhase`, `reportPath` 를 시간순으로 보여준다. `runCount == 0` 이면 "이 task 에는 기록된 run 이 없습니다." 라고만 답하고 파일을 읽었다고 주장하지 않는다.
545
+
546
+ `reportPath` 가 있는 run 은 사용자가 더 깊은 요약을 원할 때만 해당 final-report 를 읽어 보강한다(자동으로 모두 읽지 않는다).
547
+
548
+ ### recap.3 — Free-form Q&A loop
549
+
550
+ 요약 출력 뒤 사용자의 임의 질문을 받는다.
551
+
552
+ - **artifact 모드(기본)**: `.okstra/` 산출물(timeline, task-manifest, 해당 run 의 final-report)만 근거로 답한다. 모든 사실 주장은 `.okstra` 파일 `path:line` 인용을 동반한다.
553
+ - **code 모드(opt-in)**: 사용자가 "코드 변경까지 / diff 까지 봐줘" 류로 **명시적으로 요청**할 때만, 해당 task 의 worktree/브랜치에서 `git diff` · `git log` 를 추가로 읽어 코드 레벨로 답한다. recap 진입만으로는 절대 code 모드로 넘어가지 않는다.
554
+
555
+ ### recap.4 — Persist each turn
556
+
557
+ 요약 1건과 각 Q&A 답변을 로그에 남긴다(append-only):
558
+
559
+ ```bash
560
+ okstra recap record <resolved-target> --project-root <projectRoot> \
561
+ --kind <summary|qa> --mode <artifact|code> \
562
+ --question "<질문 또는 생략>" --answer "<한두 줄 요약>" \
563
+ --citation "<path:line>" --citation "<path:line>"
564
+ ```
565
+
566
+ `--answer` 에는 전체 답변 전사가 아니라 한두 줄 요약을 넣는다. 인용이 여러 개면 `--citation` 을 반복한다. 기록 실패(비정상 종료)는 조용히 넘기지 말고 사용자에게 알린다.
567
+
568
+ ### recap — Output template
569
+
570
+ ```markdown
571
+ ## okstra Recap — <task-key>
572
+
573
+ 전/후 요약 (runs: <N>):
574
+
575
+ | # | when | task-type | from → to | status | next |
576
+ |---|---|---|---|---|---|
577
+ | 1 | <ts> | requirements-discovery | (시작) → requirements-discovery | done | error-analysis |
578
+
579
+ <이후 자유 질문을 받습니다. 코드 변경까지 보려면 "diff 까지 봐줘" 라고 요청하세요.>
580
+ ```
581
+ ````
582
+
583
+ - [ ] **Step 4: Verify the facet count is consistent**
584
+
585
+ Run: `grep -n "seven facets\|eight facets\|seven\b" skills/okstra-inspect/SKILL.md`
586
+ Expected: `seven` 잔존 없음, `eight facets` 만 존재. 잔존하면 그 자리를 8개 표현으로 고친다.
587
+
588
+ - [ ] **Step 5: Commit**
589
+
590
+ ```bash
591
+ git add skills/okstra-inspect/SKILL.md
592
+ git commit -m "feat(inspect): recap facet — task-id 전/후 요약 + 자유 Q&A"
593
+ ```
594
+
595
+ ---
596
+
597
+ ### Task 6: 아키텍처 문서 갱신 + 빌드 동기화 + 전체 검증
598
+
599
+ **Files:**
600
+ - Modify: `docs/kr/architecture.md` (task-root 트리, `architecture.md:443-478` 부근)
601
+ - (빌드) `npm run build` → `runtime/` 동기화
602
+
603
+ **Interfaces:**
604
+ - Consumes: Task 1–5 의 결과. 새 정보 없음(문서·빌드만).
605
+
606
+ - [ ] **Step 1: Document the recap directory**
607
+
608
+ `docs/kr/architecture.md` 의 task-root 트리(`history/`·`timeline.json` 가 그려진 블록, `architecture.md:443-478`)에 `recap/` 노드를 추가하고, 한 줄 설명을 단다:
609
+
610
+ ```
611
+ └── recap/
612
+ └── recap-log.jsonl # recap facet 의 전/후 요약·Q&A append-only 로그 (다른 산출물 불변)
613
+ ```
614
+
615
+ - [ ] **Step 2: Sync runtime**
616
+
617
+ Run: `npm run build`
618
+ Expected: 에러 없이 완료. `recap.py` / `recap.mjs` / 갱신된 `SKILL.md` 가 `runtime/` 에 복사됨.
619
+
620
+ - [ ] **Step 3: Run the full python suite**
621
+
622
+ Run: `python3 -m pytest tests/ -q`
623
+ Expected: 전부 PASS (신규 `tests/test_okstra_recap.py` 포함, 회귀 없음).
624
+
625
+ - [ ] **Step 4: CLI smoke**
626
+
627
+ Run: `node bin/okstra recap --help`
628
+ Expected: USAGE 출력, exit 0.
629
+
630
+ Run: `node bin/okstra --help`
631
+ Expected: introspection 카테고리에 `recap` 이 나열됨.
632
+
633
+ - [ ] **Step 5: Commit**
634
+
635
+ ```bash
636
+ git add docs/kr/architecture.md runtime/
637
+ git commit -m "docs(architecture): task-root 트리에 recap/ 추가 + runtime 동기화"
638
+ ```
639
+
640
+ ---
641
+
642
+ ## 검증 (계획 전체)
643
+
644
+ - 결정론적 로직(`compute_run_paths` recap 경로 / `assemble_recap` 전이 계산 / `append_recap_entry` append)은 모두 단위 테스트로 커버(Task 1·2·3).
645
+ - facet 동작(요약 서술·Q&A·2-모드)은 skill-markdown 주도라 자동 단위 테스트 대상이 아님 → Task 4·6 의 CLI 스모크 + 수동 `okstra recap assemble <task-id>` 로 확인.
646
+ - "요약 품질"은 휴리스틱이라 자동 검증 범위 밖(spec §5 와 동일 입장).
647
+ - 단일 참조점: 경로는 `compute_run_paths` 한 곳, target 해석은 `task_target.resolve_task_root` 한 곳, Node↔python 브리지는 `runPythonModule` 한 곳으로만 흐른다.