okstra 0.43.0 → 0.44.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/README.kr.md CHANGED
@@ -43,7 +43,7 @@ okstra/ npm 패키지 = repo 루트
43
43
  ├── tools/build.mjs runtime/ 동기화 스크립트 (prepack 에서 호출)
44
44
  ├── runtime/ gitignored 설치 payload; ~/.okstra 로 복사
45
45
  ├── scripts/ python + bash 런타임 소스
46
- ├── skills/ Claude Code 스킬 마크다운 소스 (스킬 13종)
46
+ ├── skills/ Claude Code 스킬 마크다운 소스 (스킬 10종)
47
47
  ├── agents/ lead SKILL.md + workers/
48
48
  ├── prompts/, schemas/, templates/, validators/
49
49
  ├── docs/kr/ 한국어 상세 매뉴얼 (architecture.md, cli.md)
@@ -66,6 +66,7 @@ okstra/ npm 패키지 = repo 루트
66
66
  ├── installed-skills.json 설치된 스킬 매니페스트 (uninstall 이 사용)
67
67
  ├── installed-agents.json 설치된 워커 에이전트 매니페스트 (uninstall 이 사용)
68
68
  ├── recent.jsonl, active.jsonl run 인덱스
69
+ ├── memory-book/ 전역 대화 메모리 (`okstra memory`)
69
70
  ├── projects/ 프로젝트별 메타데이터 미러
70
71
  ├── worktrees/ task-key 당 하나의 격리 git worktree
71
72
  │ (모든 phase가 공유; run 종료 후 자동 삭제하지 않음)
@@ -73,7 +74,7 @@ okstra/ npm 패키지 = repo 루트
73
74
  └── .locks/ central/task mutex 파일
74
75
 
75
76
  ~/.claude/skills/ Claude Code 가 자동 인식
76
- └── okstra-*/SKILL.md 스킬 13종 (§3.3 참조)
77
+ └── okstra-*/SKILL.md 스킬 10종 (§3.3 참조)
77
78
 
78
79
  ~/.claude/agents/ Claude Code 가 자동 인식 (subagent 디스커버리 경로)
79
80
  └── {claude,codex,gemini,report-writer}-worker.md worker subagent 정의
@@ -106,7 +107,7 @@ okstra/ npm 패키지 = repo 루트
106
107
  npx -y okstra@latest install
107
108
  ```
108
109
 
109
- `~/.okstra/{lib/python, bin, templates, version}`, `~/.claude/skills/` 아래 스킬 마크다운 13개, `~/.claude/agents/` 아래 worker agent 4개, `~/.okstra/` 의 설치 asset manifest 를 생성합니다. 재실행은 idempotent — 파일별 hash 를 비교하고 바뀐 파일만 갱신합니다.
110
+ `~/.okstra/{lib/python, bin, templates, version}`, `~/.claude/skills/` 아래 스킬 마크다운 10개, `~/.claude/agents/` 아래 worker agent 4개, `~/.okstra/` 의 설치 asset manifest 를 생성합니다. 재실행은 idempotent — 파일별 hash 를 비교하고 바뀐 파일만 갱신합니다.
110
111
 
111
112
  검증:
112
113
 
@@ -155,6 +156,7 @@ Claude Code 세션 안에서 사용하는 슬래시 커맨드:
155
156
  |---|---|
156
157
  | `/okstra-brief` | ticket, 요구사항 문서, 링크, 대화 내용을 `okstra-run`용 task brief로 변환 |
157
158
  | `/okstra-run` | 새 task 시작 (또는 기존 task 의 다음 phase 이어가기) |
159
+ | `/okstra-memory` | `~/.okstra/memory-book` 전역 대화 메모리 저장·검색·보관 |
158
160
  | `/okstra-inspect` | 통합 read-side 스킬. sub-command: `status` (phase / 상태, workStatus 설정), `history` (과거 task / re-run / resume), `report` (final-report 조회·읽기), `time` (소요 시간 breakdown), `logs` (wrapper log sidecar 조회·정리 제안) |
159
161
  | `/okstra-schedule` | task-group 전체에 대한 작업 계획표 생성 |
160
162
  | `/okstra-setup` | 프로젝트별 부트스트랩 (§3.2) |
@@ -206,6 +208,7 @@ Claude Code 세션 밖에서 task 를 시작하려면:
206
208
  | `npx -y okstra@latest setup --project-id <id>` | 현재 프로젝트를 등록 (`.okstra/project.json`) |
207
209
  | `npx -y okstra@latest check-project` | 현재 프로젝트가 `setup` 으로 등록됐는지 검증 |
208
210
  | `npx -y okstra@latest config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | okstra 설정 읽기/쓰기. 현재 지원 키: `pr-template-path` (project.json 또는 `~/.okstra/config.json` 의 `prTemplatePath` 갱신) |
211
+ | `npx -y okstra@latest memory <add\|list\|search\|show\|archive>` | `~/.okstra/memory-book` 전역 대화 메모리 저장·검색·보관 |
209
212
  | `npx -y okstra@latest render-views <final-report.md>` | final-report MD 한 본을 입력으로 슬림 MD + HTML 두 view 를 (재)생성 (Phase 7 step 1.5; 멱등) |
210
213
  | `npx -y okstra@latest token-usage ...` | run token usage 수집/치환. 설치된 Python token usage CLI를 감싼 Node wrapper |
211
214
  | `npx -y okstra@latest uninstall` | 런타임 + 스킬 제거; 사용자 데이터(`recent.jsonl`, `projects/`, …)는 보존 |
package/README.md CHANGED
@@ -43,7 +43,7 @@ okstra/ npm package = repo root
43
43
  ├── tools/build.mjs runtime/ sync script (invoked by prepack)
44
44
  ├── runtime/ gitignored install payload copied to ~/.okstra
45
45
  ├── scripts/ python + bash runtime sources
46
- ├── skills/ Claude Code skill markdown sources (13 skills)
46
+ ├── skills/ Claude Code skill markdown sources (14 skills)
47
47
  ├── agents/ lead SKILL.md + workers/
48
48
  ├── prompts/, schemas/, templates/, validators/
49
49
  ├── tests/, tests-e2e/
@@ -65,6 +65,7 @@ okstra/ npm package = repo root
65
65
  ├── installed-skills.json manifest of installed skills (used by uninstall)
66
66
  ├── installed-agents.json manifest of installed worker agents (used by uninstall)
67
67
  ├── recent.jsonl, active.jsonl run index
68
+ ├── memory-book/ global conversation memory (okstra memory)
68
69
  ├── projects/ per-project metadata mirror
69
70
  ├── worktrees/ one isolated git worktree per task-key
70
71
  │ (shared by all phases; not auto-removed)
@@ -72,7 +73,7 @@ okstra/ npm package = repo root
72
73
  └── .locks/ central/task mutex files
73
74
 
74
75
  ~/.claude/skills/ discovered automatically by Claude Code
75
- └── okstra-*/SKILL.md 13 skills total (see §3.3)
76
+ └── okstra-*/SKILL.md 14 skills total (see §3.3)
76
77
 
77
78
  ~/.claude/agents/ discovered automatically by Claude Code
78
79
  └── {claude,codex,gemini,report-writer}-worker.md subagent definitions
@@ -105,7 +106,7 @@ okstra/ npm package = repo root
105
106
  npx -y okstra@latest install
106
107
  ```
107
108
 
108
- Provisions `~/.okstra/{lib/python, bin, templates, version}`, the 13 skill markdown files under `~/.claude/skills/`, the 4 worker agents under `~/.claude/agents/`, and the installed-asset manifests in `~/.okstra/`. Re-running is idempotent — per-file hashes are compared and only changed files are touched.
109
+ Provisions `~/.okstra/{lib/python, bin, templates, version}`, the 14 skill markdown files under `~/.claude/skills/`, the 4 worker agents under `~/.claude/agents/`, and the installed-asset manifests in `~/.okstra/`. Re-running is idempotent — per-file hashes are compared and only changed files are touched.
109
110
 
110
111
  Verify:
111
112
 
@@ -154,6 +155,7 @@ User-facing slash commands inside a Claude Code session:
154
155
  |---|---|
155
156
  | `/okstra-brief` | Turn a ticket, requirements doc, link, or conversation into an `okstra-run` task brief |
156
157
  | `/okstra-run` | Start a new task (or resume the next phase of an existing one) |
158
+ | `/okstra-memory` | Store/search/archive global conversation memory in `~/.okstra/memory-book` |
157
159
  | `/okstra-inspect` | Unified read-side. Sub-commands: `status` (phase / state, workStatus update), `history` (past runs, re-run, resume), `report` (find/read final-report), `time` (elapsed-time breakdown), `logs` (wrapper log sidecar inventory + cleanup) |
158
160
  | `/okstra-schedule` | Generate a work schedule for an entire task-group |
159
161
  | `/okstra-setup` | Per-project bootstrap (§3.2) |
@@ -205,6 +207,7 @@ Recent workflow additions (post-0.8.0, on `main`):
205
207
  | `npx -y okstra@latest setup --project-id <id>` | Register the current project (`.okstra/project.json`) |
206
208
  | `npx -y okstra@latest check-project` | Verify the current project has been registered with `setup` |
207
209
  | `npx -y okstra@latest config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | Read / write okstra settings; initial key `pr-template-path` (writes `prTemplatePath` to project.json or `~/.okstra/config.json`) |
210
+ | `npx -y okstra@latest memory <add\|list\|search\|show\|archive>` | Store and find global conversation memory in `~/.okstra/memory-book` |
208
211
  | `npx -y okstra@latest render-views <final-report.md>` | Regenerate the slim-MD + HTML sibling views from a final-report MD (Phase 7 step 1.5; idempotent) |
209
212
  | `npx -y okstra@latest token-usage ...` | Collect/substitute token usage for a run; wraps the installed Python token usage CLI |
210
213
  | `npx -y okstra@latest uninstall` | Remove runtime + skills; preserves user data (`recent.jsonl`, `projects/`, …) |
package/bin/okstra CHANGED
@@ -19,6 +19,7 @@ const COMMANDS = new Map([
19
19
  ["render-views", () => import("../src/render-views.mjs").then((m) => m.run)],
20
20
  ["wizard", () => import("../src/wizard.mjs").then((m) => m.run)],
21
21
  ["token-usage", () => import("../src/token-usage.mjs").then((m) => m.run)],
22
+ ["memory", () => import("../src/memory.mjs").then((m) => m.run)],
22
23
  ]);
23
24
 
24
25
  const USAGE = `okstra — multi-agent cross-verification orchestrator for Claude Code
@@ -61,6 +62,8 @@ Introspection commands (JSON output, used by skills to avoid python heredocs):
61
62
  token-usage Collect token usage for a run (wraps the installed
62
63
  okstra-token-usage.py so skills avoid emitting
63
64
  python3 "$HOME/..." invocations).
65
+ memory Store and find user-home conversation memory under
66
+ ~/.okstra/memory-book.
64
67
 
65
68
  Global options:
66
69
  --version Print okstra version and exit
@@ -18,7 +18,7 @@
18
18
  - **Single python authority**: 모든 prepare wiring(profile/workers/model 해소, path 계산, render, central record_start)이 [`okstra_ctl.run.prepare_task_bundle()`](../../scripts/okstra_ctl/run.py) 한 함수에 모여 있습니다. `okstra.sh` 와 `okstra-run` skill 은 같은 함수를 호출하는 thin caller 이며, 환경 변수로 상태를 전달하지 않습니다 — task 정체성·경로·workflow 상태는 모두 디스크 권위 파일에서 매번 계산됩니다.
19
19
  - **Claude handoff (두 모드)**: (a) `okstra.sh` 가 새 `claude` 프로세스를 띄우는 전통 방식, (b) `okstra-run` skill 이 현재 claude 세션 안에서 prepare 후 lead 역할을 그대로 인계받는 in-session 모드. 둘 다 `prepare_task_bundle` 의 산출물(instruction-set 등)을 그대로 사용합니다.
20
20
  - **Required team contract**: 각 phase profile의 `Required workers:` 블록이 roster의 권위입니다. 일반 분석 phase는 Claude/Codex analyser + report-writer를 기본으로 하고, Gemini는 profile과 `--workers`가 허용할 때만 포함됩니다. `release-handoff`처럼 lead-only에 가까운 phase는 별도 roster를 가집니다.
21
- - **User-home install + project-local task bundles**: `npx okstra@latest install` 한 명령이 런타임(`~/.okstra/{lib/python, bin, templates}`), 스킬 13종(`~/.claude/skills/<name>/SKILL.md`), worker agent 4종(`~/.claude/agents/*-worker.md`)을 설치합니다. 대상 프로젝트에는 task bundle 과 discovery metadata 가 `.okstra/` 아래 저장되고, **추가로 `<PROJECT_ROOT>/.claude/settings.local.json` 이 `~/.okstra/templates/settings.local.json` 을 가리키는 symlink 로 provisioning** 됩니다 (`okstra setup` 또는 `okstra-ctl` prepare 가 idempotent 하게 관리; 기존에 일반 파일이 있었다면 `.bak.<timestamp>` 로 백업 후 교체). 이 symlink 가 host Claude Code 세션에 자동 로드되어 codex/gemini worker wrapper 호출 권한을 부여하므로, 사용자의 글로벌 `~/.claude/settings.json` 은 건드리지 않습니다. 개발용 `okstra install --link <repo>` 는 설치 파일을 repo source로 symlink합니다.
21
+ - **User-home install + project-local task bundles**: `npx okstra@latest install` 한 명령이 런타임(`~/.okstra/{lib/python, bin, templates}`), 스킬 10종(`~/.claude/skills/<name>/SKILL.md`), worker agent 4종(`~/.claude/agents/*-worker.md`)을 설치합니다. 전역 대화 메모리는 프로젝트와 분리된 `~/.okstra/memory-book/` 에 저장됩니다. 대상 프로젝트에는 task bundle 과 discovery metadata 가 `.okstra/` 아래 저장되고, **추가로 `<PROJECT_ROOT>/.claude/settings.local.json` 이 `~/.okstra/templates/settings.local.json` 을 가리키는 symlink 로 provisioning** 됩니다 (`okstra setup` 또는 `okstra-ctl` prepare 가 idempotent 하게 관리; 기존에 일반 파일이 있었다면 `.bak.<timestamp>` 로 백업 후 교체). 이 symlink 가 host Claude Code 세션에 자동 로드되어 codex/gemini worker wrapper 호출 권한을 부여하므로, 사용자의 글로벌 `~/.claude/settings.json` 은 건드리지 않습니다. 개발용 `okstra install --link <repo>` 는 설치 파일을 repo source로 symlink합니다.
22
22
  - **Resume and clarification**: `--task-key`, `--resume-clarification`, `--clarification-response`로 같은 task 재개와 lead의 추가 질문 응답 흐름을 지원합니다.
23
23
  - **Derived views and telemetry**: final-report data.json → Markdown → slim MD / self-contained HTML view, worker error sidecar, wrapper log sidecar, token usage / cost accounting을 제공합니다.
24
24
 
@@ -152,7 +152,7 @@ okstra 의 prepare 책임은 단일 python 진입점 [`okstra_ctl.run.prepare_ta
152
152
  - [`agents/SKILL.md`](../../agents/SKILL.md) — main okstra lead contract.
153
153
  - [`skills/okstra-setup/SKILL.md`](../../skills/okstra-setup/SKILL.md) — **첫 실행 부트스트랩**. `okstra install` + `project.json` 생성.
154
154
  - [`skills/okstra-run/SKILL.md`](../../skills/okstra-run/SKILL.md) — **현재 claude 세션 안에서 okstra task 를 시작**하는 in-session 진입점. `prepare_task_bundle` 직접 호출.
155
- - `skills/okstra-{brief,status,history,convergence,schedule,context-loader,team-contract,report-finder,report-writer,time-summary,logs}/SKILL.md` — brief 작성, phase 진행, status/history, report/time/log 보조 skill. `okstra-logs` 는 codex/gemini wrapper 가 매 dispatch 마다 `runs/<task-type>/prompts/<worker>-prompt-<phase>-<seq>.log` 로 남기는 live-log sidecar 의 인벤토리·정리 안내 (read-only, find-delete cleanup 명령 제안만 함).
155
+ - `skills/okstra-{brief,run,memory,inspect,schedule,context-loader,team-contract,convergence,report-writer}/SKILL.md` — brief 작성, phase 진행, 전역 Memory Book 저장/검색, status/history/report/time/log read-side, schedule 보조 skill. `okstra-inspect logs` 는 codex/gemini wrapper 가 매 dispatch 마다 `runs/<task-type>/prompts/<worker>-prompt-<phase>-<seq>.log` 로 남기는 live-log sidecar 의 인벤토리·정리 안내 (read-only, find-delete cleanup 명령 제안만 함).
156
156
  - 플러그인 매니페스트: [`../../.claude-plugin/plugin.json`](../../.claude-plugin/plugin.json) — `npx skills@latest add Devonshin/okstra` 보조 채널이 참조. 일반 셋업에는 `npx okstra@latest install` 을 사용한다.
157
157
  - 설치 위치: `~/.claude/skills/<name>/SKILL.md` (`okstra-install.sh` dev 설치, 또는 위 npx 채널).
158
158
  - 릴리스 절차: [`../../RELEASING.md`](../../RELEASING.md) — npm publish 흐름과 release-please / manual fallback.
@@ -839,7 +839,7 @@ scripts/okstra.sh --task-key <project-id>:<task-group>:<task-id> --task-type fin
839
839
  프로젝트 전체 상태를 훑을 때는 `.okstra/discovery/task-catalog.json`을 사용합니다.
840
840
  특정 task의 최신 상태를 볼 때는 `.okstra/discovery/latest-task.json`, `task-manifest.json`, 최신 `run-manifest`, `history/timeline.json` 순서로 확인합니다.
841
841
 
842
- Claude에서는 seeded `okstra-status` skill 사용해 아래 질문을 직접 처리할 수 있습니다.
842
+ Claude에서는 seeded `okstra-inspect` skill `status` 하위 흐름을 사용해 아래 질문을 직접 처리할 수 있습니다.
843
843
 
844
844
  - 전체 okstra task status 보여줘
845
845
  - 특정 `task-key`의 current phase와 next phase 알려줘
@@ -914,7 +914,7 @@ Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 두 view 를 결정
914
914
  - **run-scoped 태깅으로 정리**: trace pane 의 `tail -F` 는 tmux 셸의 자식이라 Claude 가 종료돼도 살아남습니다. wrapper 는 spawn 한 pane 을 `tmux set-option -p @okstra_trace_run=<RUN_DIR>` 로 태깅하고, `okstra-trace-cleanup.sh` 는 `tmux list-panes -a` 에서 그 태그로 pane 을 server-wide 발견해 `tmux kill-pane` 합니다. tmux env 변수·pane-id registry 없이 동작하며, run-scoped 태그라 동시에 도는 다른 okstra run 의 trace pane 을 죽이지 않습니다. cleanup 은 두 진입 형태를 가집니다 — lead 가 `--run-dir <RUN_DIR>` 로 호출(해당 run 의 trace + worker-agent pane 정리)하거나, `templates/reports/settings.template.json` 의 `hooks.SessionEnd` 가 `--reap` 로 호출(`$CLAUDE_PROJECT_DIR/.okstra/` 하위 태그를 가진 trace pane 일괄 정리; 단일 run-dir 이 없는 종료 시점용). tmux 가 없거나 stale pane id 인 경우 silent degrade.
915
915
  - **phase 전환 시 자동 정리 + worker-agent pane 포함**: `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` 는 태깅된 trace pane 뿐 아니라 dispatch 된 서브에이전트가 점유하는 worker-agent pane(title `claude-worker` / `codex-worker` / `gemini-worker` / `report-writer-worker`)도 lead 세션(`tmux list-panes -s -t <lead-pane>`) 범위에서 title allowlist 로 식별해 닫습니다(worker-agent pane 은 harness 소유라 태깅 불가). 세션 scope 와 lead 자기 pane 제외는 `<RUN_DIR>/state/lead-pane.id` 로 결정되며, lead 자신의 pane 은 title 이 걸려도 절대 죽이지 않습니다. lead 는 새 phase 의 worker 를 dispatch 하기 직전(`PROGRESS: phase-5.5-convergence` / `phase-6-synthesis` 마커 직전) 이 스크립트를 `--run-dir` 로 호출해 이전 phase 의 pane 을 prompt 없이 정리합니다.
916
916
  - **Phase 종료 시 사용자 확인**: run 최종 종료 시점(마지막 단계)에 lead 가 `okstra-trace-cleanup.sh --list --run-dir <RUN_DIR>` 로 잔여 okstra pane(worker-agent + trace) 목록을 출력한 뒤 사용자에게 "모두 닫기 / 그대로 두기" 양자택일을 묻고 응답대로 처리합니다 (`prompts/profiles/_common-contract.md` 의 *Phase wrap-up* 항목). `<RUN_DIR>/state/lead-pane.id` 가 비어 있는(=tmux 밖) 환경에서는 단계 자체가 silent-skip. `--list` 모드는 pane 을 죽이지 않고 `<pane_id>\t<pane_title>` 만 출력하므로 사용자가 무엇이 닫힐지 시각적으로 확인할 수 있습니다.
917
- - 디스크 누적은 `okstra-logs` skill read-only 로 인벤토리 + cleanup 명령을 제안합니다 (실행은 사용자 copy-paste).
917
+ - 디스크 누적은 `okstra-inspect logs` 흐름이 read-only 로 인벤토리 + cleanup 명령을 제안합니다 (실행은 사용자 copy-paste).
918
918
 
919
919
  ### Linked-worktree `.git/` write 권한 (codex / gemini)
920
920
 
@@ -973,7 +973,7 @@ phase 산출물의 출고 가능 여부를 강제하는 진입점:
973
973
  - 토큰 사용 및 비용 집계는 `scripts/okstra-token-usage.py`와 Node wrapper `okstra token-usage`가 담당합니다.
974
974
  - `okstra.sh`는 worker CLI 호출 anchoring을 위해 절대 projectRoot를 강제합니다.
975
975
  - `okstra wizard step` 은 `--answer <val>` 을 **필수** 로 받습니다. 응답을 줄 차례가 아니라 다음 prompt 만 미리 보고 싶다면 `--no-submit` 으로 peek 합니다.
976
- - `/okstra-history`는 task manifest fallback / 페이지네이션 / 필터를 지원하고, `--base-ref`는 워크트리 registry에서 해석합니다.
976
+ - `/okstra-inspect history`는 task manifest fallback / 페이지네이션 / 필터를 지원하고, `--base-ref`는 워크트리 registry에서 해석합니다.
977
977
  - 사용자 프로젝트에 대한 모든 쓰기는 `<PROJECT_ROOT>/.okstra/` 안에만 발생합니다 (Artifact-home rule 참조).
978
978
 
979
979
  ## Related documents
package/docs/kr/cli.md CHANGED
@@ -577,6 +577,7 @@ chmod +x ~/.local/bin/okstra-ctl
577
577
  | `okstra setup --project-id <id>` | 현재 프로젝트의 `.okstra/project.json` 생성/갱신 |
578
578
  | `okstra check-project [--json]` | 현재 프로젝트가 등록되었는지 검증 |
579
579
  | `okstra config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | 영구 설정 관리 (예: `pr-template-path`). 원자적 JSON 쓰기 |
580
+ | `okstra memory <add\|list\|search\|show\|archive>` | `~/.okstra/memory-book` 전역 대화 메모리 관리. 프로젝트 `.okstra/` 와 분리된 user-home 저장소이며, `okstra 에 정리해서 보관해` 자연어 스킬의 CLI 기반 |
580
581
  | `okstra migrate [--apply] [--cwd <dir>] [--quiet]` | 한 번만 실행: 프로젝트 산출물 루트를 `.project-docs/okstra/` → `.okstra/` 로 이동. 기본 dry-run, `--apply` 로 실제 실행. `git mv` (git worktree) + 빈 `.project-docs/` 제거 + `<PROJECT>/CLAUDE.md` import 라인 + `.gitignore` + `~/.okstra/{recent,active}.jsonl` + `~/.okstra/worktrees/registry.json` 의 해당 프로젝트 row 동기화. 이미 `.okstra/` 가 존재하거나 레거시 디렉토리가 없으면 exit 1 로 거부. v0.x 말까지 유지 후 제거 예정 |
581
582
  | `okstra task-list [--project-root <path>]` | `list_project_tasks` + `read_latest_task` 결과를 합쳐 task 카탈로그 + 최근 task 를 JSON 으로 반환 |
582
583
  | `okstra task-show <task-key> [--project-root <path>]` | task-manifest.json 의 workflow / phase / status 요약 |
@@ -28,7 +28,7 @@
28
28
  - Node CLI entrypoint: `bin/okstra`
29
29
  - Python orchestration authority: `scripts/okstra_ctl/run.py::prepare_task_bundle`
30
30
  - lifecycle: `requirements-discovery → error-analysis → implementation-planning → implementation → final-verification → release-handoff`
31
- - installed skills: 13
31
+ - installed skills: 10
32
32
  - worker agents: `claude`, `codex`, `gemini`, `report-writer`
33
33
  - final report SSOT: `schemas/final-report-v1.0.schema.json` + `*.data.json`
34
34
 
@@ -54,7 +54,7 @@ okstra/
54
54
  │ ├── okstra_vendor/ vendored Jinja2 / MarkupSafe
55
55
  │ ├── lib/okstra/ Bash helpers for okstra.sh
56
56
  │ └── lib/okstra-ctl/ Bash control-center subcommands
57
- ├── skills/ Claude Code skills (13)
57
+ ├── skills/ Claude Code skills (10)
58
58
  ├── agents/ lead SKILL.md + worker agent specs
59
59
  ├── prompts/ launch template, phase profiles, wizard prompt JSON
60
60
  ├── schemas/ JSON schema for final-report data.json
@@ -244,12 +244,13 @@ Token/cost accounting:
244
244
 
245
245
  ### 4.10 `skills/`
246
246
 
247
- 13 installed skills:
247
+ 10 installed skills:
248
248
 
249
249
  | Skill | User-invocable | Role |
250
250
  |---|---:|---|
251
251
  | `okstra-brief` | yes | Produce task brief from ticket/doc/link/conversation |
252
252
  | `okstra-run` | yes | Start/resume okstra task in current Claude Code session |
253
+ | `okstra-memory` | yes | Store/search/archive global conversation memory under `~/.okstra/memory-book` |
253
254
  | `okstra-inspect` | yes | Unified read-side — sub-commands `status` (lifecycle + workStatus), `history` (past runs / re-run / resume), `report` (find final-report), `time` (elapsed-time breakdown), `logs` (wrapper log inventory + cleanup) |
254
255
  | `okstra-schedule` | yes | Generate task-group schedule |
255
256
  | `okstra-setup` | yes | Install/check runtime and register project |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.43.0",
3
+ "version": "0.44.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.43.0",
3
- "builtAt": "2026-06-04T04:59:06.499Z",
2
+ "package": "0.44.0",
3
+ "builtAt": "2026-06-04T05:25:22.876Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -216,6 +216,8 @@ Use agent and subagent names that map cleanly to the selected worker roles. Do n
216
216
 
217
217
  Spawn **analysis workers only** in the same turn (Phase 4 in Teams mode; Phase 5 with `run_in_background: true` and no `team_name` when Teams unavailable). Preserve exact roster, role labels, assigned models from the task bundle.
218
218
 
219
+ **Agent `name` on dispatch (BLOCKING — token-usage attribution depends on it).** Every analysis-worker `Agent(...)` call MUST set `name: "<workerId>-worker"` — `name: "claude-worker"` / `name: "codex-worker"` / `name: "gemini-worker"` — exactly as the report-writer dispatch sets `name: "report-writer"` ([okstra-report-writer](./skills/okstra-report-writer/SKILL.md)). The Agent harness records this `name` as `agentName` in the subagent session jsonl, and the Phase 7 token collector matches each worker's session by that `agentName` (`okstra_token_usage/collect.py`). A worker dispatched **without** `name` produces a session with no `agentName`; the collector cannot attribute it and records the worker as `source: "unavailable"` even though the session exists and is team-tagged (observed in `dev-9692` error-analysis: `claude`/`codex` workers dispatched without `name` → both `unavailable`, while the named `report-writer` collected normally). Convergence reverify dispatches keep the prefix (`<workerId>-worker-reverify-r<N>`); implementation executor/verifier variants keep `<workerId>-worker` / `<workerId>-executor`.
220
+
219
221
  The no-`team_name` fallback (Phase 5) is only legal when team-state's `teamCreate.status` is `"error"` for this run. If `teamCreate` is missing or `attempted: false`, the correct action when an Agent dispatch is rejected for a missing team is to GO BACK to Phase 3 and call `TeamCreate` — never to strip `team_name` and continue.
220
222
 
221
223
  ### Errors log path wiring (BLOCKING)
@@ -0,0 +1,325 @@
1
+ #!/usr/bin/env python3
2
+ """OKSTRA follow-up task spawner.
3
+
4
+ Reads the ``followUpTasks[]`` array from a final-report ``data.json``
5
+ (the JSON SSOT for the final-report markdown) and creates stub task
6
+ directories for rows whose ``autoSpawn`` is ``yes`` AND whose ``origin``
7
+ is not the same-task-key ``phase-continuation`` marker.
8
+
9
+ Idempotent: rows whose target directory already exists are reported as
10
+ ``existing`` and skipped. Existing directories are NEVER mutated.
11
+
12
+ Output: writes new directories under
13
+ ``<project_root>/.okstra/tasks/<task-group>/<new-task-id>/`` with:
14
+ - ``task-manifest.json`` — minimal manifest (schemaVersion 1.0,
15
+ currentStatus ``todo``, workflow.currentPhase = suggestedTaskType,
16
+ workflow.currentPhaseState ``not-started``, parentTaskKey /
17
+ spawnedFromReport recorded under ``relatedTasks``).
18
+ - ``instruction-set/task-brief.md`` — stub brief naming the parent and
19
+ copying the Reason / Scope cells from the data.json row.
20
+ - ``task-index.md`` — short human-readable summary.
21
+
22
+ The script DOES NOT call the okstra runtime; it produces just enough
23
+ on-disk state for the next user-driven entry — ``/okstra-run
24
+ task-key=<new-key> task-type=<suggested>`` inside a Claude Code session,
25
+ or ``scripts/okstra.sh --task-key <new-key> --task-type <suggested>``
26
+ in a separate terminal — to pick the follow-up up and re-render a fully
27
+ canonical manifest on first execution.
28
+
29
+ Usage:
30
+ python3 scripts/okstra-spawn-followups.py \\
31
+ <final-report-data.json> \\
32
+ --project-root <abs-path> \\
33
+ --task-group <group-slug> \\
34
+ --parent-task-key <parent-task-key> \\
35
+ [--dry-run]
36
+
37
+ Exit codes:
38
+ 0 — at least one follow-up evaluated (including ``skipped`` /
39
+ ``existing`` only)
40
+ 1 — invocation / parsing failure, or any row failed validation
41
+ """
42
+ from __future__ import annotations
43
+
44
+ import argparse
45
+ import datetime as dt
46
+ import json
47
+ import re
48
+ import sys
49
+ from pathlib import Path
50
+
51
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
52
+
53
+ from okstra_project.dirs import tasks_root # noqa: E402
54
+
55
+
56
+ SLUG_RE = re.compile(r"[^a-zA-Z0-9-]+")
57
+
58
+ ALLOWED_TASK_TYPES = {
59
+ "requirements-discovery",
60
+ "error-analysis",
61
+ "implementation-planning",
62
+ "implementation",
63
+ "final-verification",
64
+ "release-handoff",
65
+ }
66
+ ALLOWED_ORIGINS = {
67
+ "phase-continuation",
68
+ "out-of-plan",
69
+ "verifier-concern",
70
+ "scope-boundary",
71
+ "open-question",
72
+ "manual",
73
+ }
74
+ # Origins that point at the SAME task-key (next phase) and therefore
75
+ # must never spawn a new task directory — the user advances via
76
+ # /okstra-run.
77
+ NON_SPAWNING_ORIGINS = {"phase-continuation"}
78
+
79
+
80
+ def _slugify(value: str) -> str:
81
+ value = value.strip()
82
+ value = SLUG_RE.sub("-", value)
83
+ value = re.sub(r"-+", "-", value)
84
+ return value.strip("-").lower()
85
+
86
+
87
+ def _validate_row(row: dict) -> tuple[bool, str]:
88
+ origin = (row.get("origin") or "").strip()
89
+ if origin not in ALLOWED_ORIGINS:
90
+ return False, f"invalid origin: {origin!r}"
91
+ task_type = (row.get("suggestedTaskType") or "").strip()
92
+ if task_type not in ALLOWED_TASK_TYPES:
93
+ return False, f"invalid suggestedTaskType: {task_type!r}"
94
+ if not (row.get("title") or "").strip():
95
+ return False, "title is empty"
96
+ if not (row.get("reason") or "").strip():
97
+ return False, "reason is empty"
98
+ if not (row.get("newTaskId") or "").strip():
99
+ return False, "newTaskId is empty"
100
+ return True, ""
101
+
102
+
103
+ def _write_manifest(path: Path, payload: dict) -> None:
104
+ path.parent.mkdir(parents=True, exist_ok=True)
105
+ path.write_text(
106
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
107
+ encoding="utf-8",
108
+ )
109
+
110
+
111
+ def _data_to_report_path(data_path: Path) -> Path:
112
+ """Derive the markdown sibling path used in spawned manifests for
113
+ the `parentReportPath` field. Falls back to the data.json itself if
114
+ the suffix is not recognised.
115
+ """
116
+ name = data_path.name
117
+ if name.endswith(".data.json"):
118
+ return data_path.with_name(name[: -len(".data.json")] + ".md")
119
+ return data_path.with_suffix(".md")
120
+
121
+
122
+ def _spawn_one(
123
+ *,
124
+ project_root: Path,
125
+ task_group: str,
126
+ parent_task_key: str,
127
+ parent_report_relative: str,
128
+ row: dict,
129
+ dry_run: bool,
130
+ ) -> tuple[str, str]:
131
+ """Returns (status, target_relative_path).
132
+
133
+ status ∈ {created, existing, skipped, invalid}
134
+ """
135
+ ok, why = _validate_row(row)
136
+ if not ok:
137
+ return ("invalid", why)
138
+
139
+ new_task_id = _slugify(row["newTaskId"])
140
+ if not new_task_id:
141
+ return ("invalid", "newTaskId slug is empty after normalisation")
142
+
143
+ task_root = (
144
+ tasks_root(project_root)
145
+ / _slugify(task_group)
146
+ / new_task_id
147
+ )
148
+ rel = task_root.relative_to(project_root).as_posix()
149
+ if task_root.exists():
150
+ return ("existing", rel)
151
+ if dry_run:
152
+ return ("created", rel)
153
+
154
+ suggested = row["suggestedTaskType"].strip()
155
+ title = row["title"].strip()
156
+ scope = (row.get("scope") or "").strip()
157
+ reason = row["reason"].strip()
158
+ origin = row["origin"].strip()
159
+ priority = (row.get("priority") or "P1").strip()
160
+ ticket_id = (row.get("ticketId") or "").strip()
161
+ new_task_key = f"{task_group}/{new_task_id}"
162
+ now = dt.datetime.now(dt.timezone.utc).isoformat()
163
+
164
+ spawned_meta: dict = {
165
+ "parentTaskKey": parent_task_key,
166
+ "parentReportPath": parent_report_relative,
167
+ "origin": origin,
168
+ "rowId": row.get("id", ""),
169
+ "priority": priority,
170
+ "spawnedAt": now,
171
+ }
172
+ if ticket_id:
173
+ spawned_meta["ticketId"] = ticket_id
174
+
175
+ manifest_payload = {
176
+ "schemaVersion": "1.0",
177
+ "taskGroup": task_group,
178
+ "taskId": new_task_id,
179
+ "taskKey": new_task_key,
180
+ "taskGroupPathSegment": _slugify(task_group),
181
+ "taskIdPathSegment": new_task_id,
182
+ "taskType": suggested,
183
+ "workCategory": "unknown",
184
+ "currentStatus": "todo",
185
+ "spawnedFromFollowUp": spawned_meta,
186
+ "relatedTasks": [
187
+ {"taskKey": parent_task_key, "relation": "parent-followup-source"},
188
+ ],
189
+ "workflow": {
190
+ "currentPhase": suggested,
191
+ "currentPhaseState": "not-started",
192
+ "nextRecommendedPhase": suggested,
193
+ "phaseStates": {},
194
+ "awaitingApproval": False,
195
+ "routingStatus": "follow-up-spawned",
196
+ },
197
+ }
198
+ _write_manifest(task_root / "task-manifest.json", manifest_payload)
199
+
200
+ brief_path = task_root / "instruction-set" / "task-brief.md"
201
+ brief_path.parent.mkdir(parents=True, exist_ok=True)
202
+ ticket_line = f"- Ticket ID: `{ticket_id}`\n" if ticket_id else ""
203
+ brief_body = (
204
+ f"# Follow-up Task Brief — {new_task_key}\n\n"
205
+ f"- Spawned from: `{parent_task_key}`\n"
206
+ f"{ticket_line}"
207
+ f"- Origin: `{origin}`\n"
208
+ f"- Source report row: `{row.get('id', '')}` in `{parent_report_relative}`\n"
209
+ f"- Suggested task-type: `{suggested}`\n"
210
+ f"- Priority: `{priority}`\n"
211
+ f"- Spawned at: `{now}`\n\n"
212
+ f"## Title\n\n{title}\n\n"
213
+ f"## Scope (files / areas)\n\n{scope or '_(미지정)_'}\n\n"
214
+ f"## Reason / Why deferred from parent run\n\n{reason}\n\n"
215
+ f"## Next step\n\n"
216
+ f"이 stub은 사용자가 정식 진입할 때 자동 갱신됩니다. 다음 명령 중 하나로 시작하세요:\n\n"
217
+ f"- Claude Code 세션 안: `/okstra-run task-key={new_task_key} task-type={suggested}`\n"
218
+ f"- 별도 터미널: `scripts/okstra.sh --task-key {new_task_key} --task-type {suggested}`\n"
219
+ )
220
+ brief_path.write_text(brief_body, encoding="utf-8")
221
+
222
+ index_path = task_root / "task-index.md"
223
+ index_body = (
224
+ f"# {new_task_key} — Follow-up Task (todo)\n\n"
225
+ f"- Parent: `{parent_task_key}`\n"
226
+ f"{ticket_line}"
227
+ f"- Suggested task-type: `{suggested}`\n"
228
+ f"- Origin: `{origin}`\n"
229
+ f"- Priority: `{priority}`\n"
230
+ f"- Spawned from report: `{parent_report_relative}`\n"
231
+ f"- Stub brief: `instruction-set/task-brief.md`\n"
232
+ f"- Status: `todo`\n\n"
233
+ f"이 task는 자동 생성된 follow-up stub입니다. 정식 진입 시 manifest가 재렌더링됩니다.\n"
234
+ )
235
+ index_path.write_text(index_body, encoding="utf-8")
236
+
237
+ return ("created", rel)
238
+
239
+
240
+ def main(argv: list[str]) -> int:
241
+ parser = argparse.ArgumentParser(
242
+ description="Spawn follow-up task stubs from a final-report data.json.",
243
+ )
244
+ parser.add_argument(
245
+ "data_file",
246
+ type=Path,
247
+ help="Path to the final-report data.json (the JSON SSOT).",
248
+ )
249
+ parser.add_argument("--project-root", type=Path, required=True)
250
+ parser.add_argument(
251
+ "--task-group",
252
+ required=True,
253
+ help="Task-group slug of the parent task.",
254
+ )
255
+ parser.add_argument("--parent-task-key", required=True)
256
+ parser.add_argument(
257
+ "--dry-run",
258
+ action="store_true",
259
+ help="Parse and validate only; do not write files.",
260
+ )
261
+ args = parser.parse_args(argv)
262
+
263
+ if not args.data_file.exists():
264
+ print(f"data.json not found: {args.data_file}", file=sys.stderr)
265
+ return 1
266
+
267
+ try:
268
+ data = json.loads(args.data_file.read_text(encoding="utf-8"))
269
+ except json.JSONDecodeError as exc:
270
+ print(f"invalid JSON in {args.data_file}: {exc}", file=sys.stderr)
271
+ return 1
272
+
273
+ rows = data.get("followUpTasks") or []
274
+ if not rows:
275
+ print("followUpTasks is empty — nothing to do.")
276
+ return 0
277
+
278
+ # Manifests record the markdown sibling rather than data.json so the
279
+ # user-facing report (and not the SSOT) is the cite-able artifact.
280
+ parent_report = _data_to_report_path(args.data_file)
281
+ try:
282
+ parent_report_relative = (
283
+ parent_report.resolve().relative_to(args.project_root.resolve()).as_posix()
284
+ )
285
+ except ValueError:
286
+ parent_report_relative = str(parent_report)
287
+
288
+ results = []
289
+ for row in rows:
290
+ origin = (row.get("origin") or "").strip().lower()
291
+ if origin in NON_SPAWNING_ORIGINS:
292
+ results.append((
293
+ "skipped",
294
+ row.get("newTaskId", ""),
295
+ f"{origin} (advance via /okstra-run, no new task dir)",
296
+ ))
297
+ continue
298
+ if (row.get("autoSpawn") or "").strip().lower() != "yes":
299
+ results.append((
300
+ "skipped",
301
+ row.get("newTaskId", ""),
302
+ "autoSpawn != yes",
303
+ ))
304
+ continue
305
+ status, info = _spawn_one(
306
+ project_root=args.project_root,
307
+ task_group=args.task_group,
308
+ parent_task_key=args.parent_task_key,
309
+ parent_report_relative=parent_report_relative,
310
+ row=row,
311
+ dry_run=args.dry_run,
312
+ )
313
+ results.append((status, row.get("newTaskId", ""), info))
314
+
315
+ print(f"Follow-up spawn summary ({'dry-run' if args.dry_run else 'live'}):")
316
+ for status, task_id, info in results:
317
+ print(f" - [{status}] {task_id}: {info}")
318
+
319
+ if any(status == "invalid" for status, *_ in results):
320
+ return 1
321
+ return 0
322
+
323
+
324
+ if __name__ == "__main__":
325
+ raise SystemExit(main(sys.argv[1:]))
@@ -118,6 +118,14 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
118
118
  claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid)
119
119
  by_agent: dict[str, list[tuple[str, Path, dict]]] = {}
120
120
  lead_path: Path | None = None
121
+ # Team-tagged non-lead sessions that carry no agentName. These are almost
122
+ # always a worker dispatched without the Agent `name` arg (so the harness
123
+ # recorded no agentName) — the session exists and is team-tagged, but there
124
+ # is nothing to match it to a workerId by. Surfacing them in usageSummary
125
+ # gives the "unavailable" worker a visible cause instead of vanishing
126
+ # silently (observed in dev-9692 error-analysis: claude/codex workers
127
+ # dispatched without `name` → both unavailable, report-writer named → fine).
128
+ unattributed_sessions: list[str] = []
121
129
  for sid, path in claude_sessions.items():
122
130
  if sid == lead_sid:
123
131
  lead_path = path
@@ -126,6 +134,8 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
126
134
  agent = totals.get("agentName")
127
135
  if agent:
128
136
  by_agent.setdefault(agent, []).append((sid, path, totals))
137
+ else:
138
+ unattributed_sessions.append(sid)
129
139
 
130
140
  # Lead.
131
141
  if lead_path is not None:
@@ -242,6 +252,7 @@ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
242
252
  "teamName": team_name,
243
253
  "sessionsFound": len(claude_sessions),
244
254
  "unmatchedModels": sorted(set(unmatched_models)),
255
+ "unattributedTeamSessions": unattributed_sessions,
245
256
  "definitions": {
246
257
  "totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
247
258
  "billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation_5m x1.25, cache_creation_1h x2.0, cache_read x0.1, output x5). 5m vs 1h is split from usage.cache_creation when the API breakdown is present; otherwise all cache_creation falls into 5m.",
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: okstra-memory
3
+ description: Use when the user wants to preserve, remember, store, recall, search, or archive AI/human conversation notes in okstra's global Memory Book. Trigger words include "okstra 에 정리해서 보관해", "memory-book", "기억해둬", "대화 저장", "정리해서 저장", "remember this", "store this conversation", "save this decision".
4
+ ---
5
+
6
+ # okstra-memory
7
+
8
+ Manage the user-home Memory Book at `~/.okstra/memory-book/`.
9
+
10
+ Memory Book is **global user memory**, not a project-local task artifact.
11
+ It does not require `<PROJECT_ROOT>/.okstra/project.json`, and it must not
12
+ write into any project `.okstra/` directory unless a separate okstra task
13
+ explicitly cites a memory entry later.
14
+
15
+ ## When to use
16
+
17
+ - The user says "okstra 에 정리해서 보관해", "기억해둬", "대화 저장",
18
+ "remember this", or similar.
19
+ - The user asks to search, list, show, or archive stored conversation memory.
20
+ - The user wants decisions, preferences, requirements, people/context notes,
21
+ or follow-ups from the current conversation saved for future retrieval.
22
+
23
+ ## Safety rule
24
+
25
+ Only save when the user explicitly asks to save/remember/store. If the user is
26
+ only brainstorming, ask one concise confirmation question before writing.
27
+ Never store credentials, API keys, tokens, private personal data, or secrets.
28
+ If the conversation includes sensitive material, omit it and note the omission
29
+ in the stored summary. As a backstop, `okstra memory add` itself refuses content
30
+ matching high-confidence secret shapes (private-key blocks, AWS/GitHub/Slack/Google
31
+ tokens, JWTs) and exits non-zero — redact and retry if you hit it.
32
+
33
+ ## Step 0: Check CLI availability
34
+
35
+ Run as a separate Bash tool call with literal leading token:
36
+
37
+ ```bash
38
+ okstra memory --help
39
+ ```
40
+
41
+ If `okstra` is not on PATH, tell the user:
42
+
43
+ `okstra not installed — run npx okstra@latest install once, then retry this skill.`
44
+
45
+ Do not use `npx` from this skill.
46
+
47
+ ## Store current conversation
48
+
49
+ 1. Extract only durable memory from the conversation:
50
+ - decision
51
+ - preference
52
+ - requirement
53
+ - person
54
+ - project-hint
55
+ - follow-up
56
+ - context
57
+ 2. Write a concise Markdown summary, not a full transcript.
58
+ 3. Include provenance:
59
+ - why it is being stored
60
+ - source: `conversation`
61
+ - related project ids if clearly stated
62
+ - tags useful for search
63
+ 4. Store with `--yes` because the user's save request is already explicit.
64
+
65
+ Command shape:
66
+
67
+ ```bash
68
+ okstra memory add --content "<summary markdown>" --title "<short title>" --type <type> --tag <tag> --project <id> --source conversation --yes
69
+ ```
70
+
71
+ Use repeated `--tag` / `--project` flags when needed. Omit `--project` when no
72
+ project is clearly related.
73
+
74
+ ## Search / read / archive
75
+
76
+ Use:
77
+
78
+ ```bash
79
+ okstra memory search "<query>"
80
+ okstra memory list --tag "<tag>"
81
+ okstra memory show "<memory-id>"
82
+ okstra memory archive "<memory-id>"
83
+ ```
84
+
85
+ Prefer `--json` when you need to parse IDs, then present a short human summary
86
+ to the user.
@@ -366,7 +366,7 @@ okstra token-usage /abs/path/to/run/state/team-state-<task-type>-<seq>.json --wr
366
366
  `okstra token-usage` is a thin Node-side wrapper around the python helper installed at `~/.okstra/bin/okstra-token-usage.py`. Calling the python script directly with `python3 "$HOME/..."` is forbidden — the `$HOME` expansion breaks the literal-token permission match and forces a confirmation prompt every call.
367
367
 
368
368
  The script reads:
369
- - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`).
369
+ - `~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl` for the lead and every Claude-side worker (Claude worker, Report writer worker, plus the Claude wrappers around Codex/Gemini workers). Sessions are discovered by `teamName: okstra-<task-id>`, lead is identified by `lead.sessionId`, and other workers are identified by `agentName` (e.g. `claude-worker`, `codex-worker`, `gemini-worker`, `report-writer`). **For this `agentName` match to work, Lead MUST set the Agent `name` arg to `<workerId>-worker` on every dispatch** (see [agents SKILL.md Phase 4 — "Agent `name` on dispatch"](../../agents/SKILL.md)); a worker dispatched without `name` carries no `agentName`, so the collector cannot attribute its session and records it `unavailable` (now surfaced as a `usageSummary.unattributedTeamSessions` entry rather than dropped silently).
370
370
  - `~/.codex/sessions/Y/M/D/rollout-*.jsonl` for the underlying Codex CLI session (matched by `cwd` and timestamp window of the wrapper subagent). Last `event_msg.token_count.total_token_usage.total_tokens` is the session total.
371
371
  - `~/.gemini/tmp/<project>/chats/session-*.json` for the underlying Gemini CLI session. Sum of per-message `tokens.total`.
372
372
 
package/src/install.mjs CHANGED
@@ -28,6 +28,7 @@ const BIN_ENTRYPOINTS = [
28
28
  "okstra-render-report-views.py",
29
29
  "okstra-render-final-report.py",
30
30
  "okstra-wrapper-status.py",
31
+ "okstra-spawn-followups.py",
31
32
  ];
32
33
 
33
34
  const INSTALL_USAGE = `okstra install — install runtime into ~/.okstra
package/src/memory.mjs ADDED
@@ -0,0 +1,461 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { promises as fs } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, join, relative, resolve } from "node:path";
6
+
7
+ const MEMORY_TYPES = new Set([
8
+ "context",
9
+ "decision",
10
+ "preference",
11
+ "requirement",
12
+ "person",
13
+ "project-hint",
14
+ "follow-up",
15
+ ]);
16
+
17
+ // High-confidence secret shapes. The skill instructs the agent to omit secrets,
18
+ // but this is the CLI-side enforcement (declaration → enforcement) so a stray
19
+ // credential cannot be persisted. Patterns are intentionally narrow (known token
20
+ // prefixes / key blocks) to avoid false-positives on prose that merely mentions
21
+ // "password" or "token".
22
+ const SECRET_PATTERNS = [
23
+ [/-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/, "private key block"],
24
+ [/\bAKIA[0-9A-Z]{16}\b/, "AWS access key id"],
25
+ [/\bASIA[0-9A-Z]{16}\b/, "AWS temporary access key id"],
26
+ [/\bgh[posru]_[A-Za-z0-9]{36,}\b/, "GitHub token"],
27
+ [/\bgithub_pat_[A-Za-z0-9_]{60,}\b/, "GitHub fine-grained token"],
28
+ [/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/, "Slack token"],
29
+ [/\bAIza[0-9A-Za-z_-]{35}\b/, "Google API key"],
30
+ [/\bsk-[A-Za-z0-9]{20,}\b/, "OpenAI-style secret key"],
31
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/, "JWT"],
32
+ ];
33
+
34
+ function detectSecrets(content) {
35
+ const hits = [];
36
+ for (const [pattern, label] of SECRET_PATTERNS) {
37
+ if (pattern.test(content)) hits.push(label);
38
+ }
39
+ return [...new Set(hits)];
40
+ }
41
+
42
+ const USAGE = `okstra memory — store and find conversation memory
43
+
44
+ Memory Book stores global, user-home notes under ~/.okstra/memory-book.
45
+ It is separate from project-local .okstra task artifacts.
46
+
47
+ Usage:
48
+ okstra memory add [--content <text> | --file <path>] [options]
49
+ okstra memory list [--limit <n>] [--tag <tag>] [--type <type>] [--json]
50
+ okstra memory search <query> [--limit <n>] [--include-archived] [--json]
51
+ okstra memory show <id> [--json]
52
+ okstra memory archive <id> [--json]
53
+
54
+ Add options:
55
+ --title <title> Entry title. Defaults to the first non-empty line.
56
+ --type <type> context|decision|preference|requirement|person|
57
+ project-hint|follow-up. Default: context.
58
+ --scope <scope> Free-form scope label. Default: global.
59
+ --project <id> Related project id. Repeatable.
60
+ --tag <tag> Tag. Repeatable or comma-separated.
61
+ --source <source> Source label. Default: conversation.
62
+ --yes Skip interactive confirmation.
63
+ --json Emit JSON.
64
+ `;
65
+
66
+ function okstraHome() {
67
+ const override = (process.env.OKSTRA_HOME || "").trim();
68
+ return override !== "" ? override : join(homedir(), ".okstra");
69
+ }
70
+
71
+ function memoryRoot() {
72
+ return join(okstraHome(), "memory-book");
73
+ }
74
+
75
+ function indexPath() {
76
+ return join(memoryRoot(), "index.jsonl");
77
+ }
78
+
79
+ function parseGlobalFlags(args) {
80
+ return {
81
+ json: args.includes("--json"),
82
+ includeArchived: args.includes("--include-archived"),
83
+ };
84
+ }
85
+
86
+ function takeValue(args, index, flag) {
87
+ const value = args[index + 1];
88
+ if (!value || value.startsWith("--")) {
89
+ throw new Error(`${flag} requires a value`);
90
+ }
91
+ return value;
92
+ }
93
+
94
+ function parseAddArgs(args) {
95
+ const opts = {
96
+ content: null,
97
+ file: null,
98
+ title: null,
99
+ type: "context",
100
+ scope: "global",
101
+ source: "conversation",
102
+ tags: [],
103
+ projects: [],
104
+ yes: false,
105
+ json: false,
106
+ };
107
+ for (let i = 0; i < args.length; i++) {
108
+ const flag = args[i];
109
+ if (flag === "--content") opts.content = takeValue(args, i++, flag);
110
+ else if (flag === "--file") opts.file = takeValue(args, i++, flag);
111
+ else if (flag === "--title") opts.title = takeValue(args, i++, flag);
112
+ else if (flag === "--type") opts.type = takeValue(args, i++, flag);
113
+ else if (flag === "--scope") opts.scope = takeValue(args, i++, flag);
114
+ else if (flag === "--source") opts.source = takeValue(args, i++, flag);
115
+ else if (flag === "--project") opts.projects.push(takeValue(args, i++, flag));
116
+ else if (flag === "--tag") opts.tags.push(...splitCsv(takeValue(args, i++, flag)));
117
+ else if (flag === "--yes") opts.yes = true;
118
+ else if (flag === "--json") opts.json = true;
119
+ else throw new Error(`unknown flag ${flag}`);
120
+ }
121
+ if (!MEMORY_TYPES.has(opts.type)) {
122
+ throw new Error(`invalid --type: ${opts.type}`);
123
+ }
124
+ if (opts.content !== null && opts.file !== null) {
125
+ throw new Error("pass only one of --content or --file");
126
+ }
127
+ return opts;
128
+ }
129
+
130
+ function parseListArgs(args) {
131
+ const opts = { ...parseGlobalFlags(args), limit: 20, tag: null, type: null };
132
+ for (let i = 0; i < args.length; i++) {
133
+ const flag = args[i];
134
+ if (flag === "--json" || flag === "--include-archived") continue;
135
+ if (flag === "--limit") opts.limit = parseLimit(takeValue(args, i++, flag));
136
+ else if (flag === "--tag") opts.tag = takeValue(args, i++, flag);
137
+ else if (flag === "--type") opts.type = takeValue(args, i++, flag);
138
+ else throw new Error(`unknown flag ${flag}`);
139
+ }
140
+ return opts;
141
+ }
142
+
143
+ function parseQueryArgs(args) {
144
+ const opts = { ...parseGlobalFlags(args), limit: 20, query: [] };
145
+ for (let i = 0; i < args.length; i++) {
146
+ const flag = args[i];
147
+ if (flag === "--json" || flag === "--include-archived") continue;
148
+ if (flag === "--limit") opts.limit = parseLimit(takeValue(args, i++, flag));
149
+ else if (flag.startsWith("--")) throw new Error(`unknown flag ${flag}`);
150
+ else opts.query.push(flag);
151
+ }
152
+ if (opts.query.length === 0) throw new Error("search requires a query");
153
+ return { ...opts, query: opts.query.join(" ").trim() };
154
+ }
155
+
156
+ function parseLimit(raw) {
157
+ const value = Number.parseInt(raw, 10);
158
+ if (!Number.isInteger(value) || value < 1) {
159
+ throw new Error("--limit must be a positive integer");
160
+ }
161
+ return value;
162
+ }
163
+
164
+ function splitCsv(value) {
165
+ return value
166
+ .split(",")
167
+ .map((part) => part.trim())
168
+ .filter(Boolean);
169
+ }
170
+
171
+ async function readInputContent(opts) {
172
+ if (opts.content !== null) return opts.content;
173
+ if (opts.file !== null) return fs.readFile(resolve(opts.file), "utf8");
174
+ if (!process.stdin.isTTY) return readStdin();
175
+ throw new Error("memory add requires --content, --file, or stdin");
176
+ }
177
+
178
+ async function readStdin() {
179
+ const chunks = [];
180
+ for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
181
+ return Buffer.concat(chunks).toString("utf8");
182
+ }
183
+
184
+ function inferTitle(content, explicitTitle, filePath) {
185
+ if (explicitTitle?.trim()) return explicitTitle.trim();
186
+ const firstLine = content
187
+ .split(/\r?\n/)
188
+ .map((line) => line.trim())
189
+ .find(Boolean);
190
+ if (firstLine) return truncate(firstLine.replace(/^#+\s*/, ""), 80);
191
+ if (filePath) return basename(filePath);
192
+ return "Untitled memory";
193
+ }
194
+
195
+ function truncate(value, maxLength) {
196
+ return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
197
+ }
198
+
199
+ function slugify(value) {
200
+ const slug = value
201
+ .toLowerCase()
202
+ .replace(/[^a-z0-9가-힣]+/g, "-")
203
+ .replace(/^-+|-+$/g, "")
204
+ .slice(0, 48);
205
+ return slug || "memory";
206
+ }
207
+
208
+ function buildEntry(opts, content, now) {
209
+ const title = inferTitle(content, opts.title, opts.file);
210
+ const compact = now.toISOString().replace(/\D/g, "").slice(0, 17);
211
+ const slug = slugify(title);
212
+ const id = `mem_${compact}_${slug}`;
213
+ const relativePath = join(
214
+ "entries",
215
+ String(now.getUTCFullYear()),
216
+ String(now.getUTCMonth() + 1).padStart(2, "0"),
217
+ `${now.toISOString().replace(/[:.]/g, "")}-${slug}.md`,
218
+ );
219
+ return {
220
+ id,
221
+ title,
222
+ type: opts.type,
223
+ scope: opts.scope,
224
+ source: opts.source,
225
+ tags: [...new Set(opts.tags)],
226
+ relatedProjects: [...new Set(opts.projects)],
227
+ status: "active",
228
+ createdAt: now.toISOString(),
229
+ archivedAt: null,
230
+ path: relativePath,
231
+ };
232
+ }
233
+
234
+ function renderMarkdown(entry, content) {
235
+ return [
236
+ "---",
237
+ `id: ${entry.id}`,
238
+ `createdAt: ${entry.createdAt}`,
239
+ `source: ${entry.source}`,
240
+ `scope: ${entry.scope}`,
241
+ `type: ${entry.type}`,
242
+ `status: ${entry.status}`,
243
+ `relatedProjects: [${entry.relatedProjects.join(", ")}]`,
244
+ `tags: [${entry.tags.join(", ")}]`,
245
+ "---",
246
+ "",
247
+ `# ${entry.title}`,
248
+ "",
249
+ "## Summary",
250
+ "",
251
+ content.trim() || "(empty)",
252
+ "",
253
+ ].join("\n");
254
+ }
255
+
256
+ async function confirmSave(entry, content, opts) {
257
+ if (opts.yes || opts.json || !process.stdin.isTTY) return true;
258
+ process.stdout.write(`Memory Book entry:\n`);
259
+ process.stdout.write(` title: ${entry.title}\n`);
260
+ process.stdout.write(` type: ${entry.type}\n`);
261
+ process.stdout.write(` tags: ${entry.tags.join(", ") || "(none)"}\n`);
262
+ process.stdout.write(` text: ${truncate(content.trim().replace(/\s+/g, " "), 140)}\n`);
263
+ const rl = createInterface({ input, output });
264
+ const answer = await rl.question("Save to ~/.okstra/memory-book? [y/N] ");
265
+ rl.close();
266
+ return /^y(es)?$/i.test(answer.trim());
267
+ }
268
+
269
+ async function appendIndex(entry) {
270
+ await fs.mkdir(dirname(indexPath()), { recursive: true });
271
+ await fs.appendFile(indexPath(), JSON.stringify(entry) + "\n", "utf8");
272
+ }
273
+
274
+ async function readIndex() {
275
+ let text;
276
+ try {
277
+ text = await fs.readFile(indexPath(), "utf8");
278
+ } catch (err) {
279
+ if (err.code === "ENOENT") return [];
280
+ throw err;
281
+ }
282
+ return text
283
+ .split(/\r?\n/)
284
+ .filter(Boolean)
285
+ .map((line) => JSON.parse(line));
286
+ }
287
+
288
+ async function writeIndex(entries) {
289
+ await fs.mkdir(dirname(indexPath()), { recursive: true });
290
+ const data = entries.map((entry) => JSON.stringify(entry)).join("\n");
291
+ await fs.writeFile(indexPath(), data ? `${data}\n` : "", "utf8");
292
+ }
293
+
294
+ function visibleEntries(entries, opts) {
295
+ return entries.filter((entry) => opts.includeArchived || entry.status !== "archived");
296
+ }
297
+
298
+ function formatEntryLine(entry) {
299
+ const tags = entry.tags.length > 0 ? ` #${entry.tags.join(" #")}` : "";
300
+ return `${entry.id} ${entry.createdAt.slice(0, 10)} ${entry.type} ${entry.title}${tags}`;
301
+ }
302
+
303
+ function emitJson(payload) {
304
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
305
+ }
306
+
307
+ async function opAdd(args) {
308
+ const opts = parseAddArgs(args);
309
+ const content = await readInputContent(opts);
310
+ const secrets = detectSecrets(content);
311
+ if (secrets.length > 0) {
312
+ throw new Error(
313
+ `refusing to store likely secret(s): ${secrets.join(", ")}. ` +
314
+ "Memory Book must not hold credentials — redact the secret and store a note instead.",
315
+ );
316
+ }
317
+ const entry = buildEntry(opts, content, new Date());
318
+ if (!(await confirmSave(entry, content, opts))) {
319
+ process.stdout.write("cancelled\n");
320
+ return 0;
321
+ }
322
+ const absolutePath = join(memoryRoot(), entry.path);
323
+ await fs.mkdir(dirname(absolutePath), { recursive: true });
324
+ await fs.writeFile(absolutePath, renderMarkdown(entry, content), "utf8");
325
+ try {
326
+ await appendIndex(entry);
327
+ } catch (err) {
328
+ // Index append failed after the file was written — remove the orphan so
329
+ // `add` stays all-or-nothing (an entry with no index row is invisible to
330
+ // list/search anyway).
331
+ await fs.rm(absolutePath, { force: true });
332
+ throw err;
333
+ }
334
+ if (opts.json) emitJson({ ok: true, entry, path: absolutePath });
335
+ else process.stdout.write(`saved ${entry.id}\n${absolutePath}\n`);
336
+ return 0;
337
+ }
338
+
339
+ async function opList(args) {
340
+ const opts = parseListArgs(args);
341
+ const entries = visibleEntries(await readIndex(), opts)
342
+ .filter((entry) => !opts.tag || entry.tags.includes(opts.tag))
343
+ .filter((entry) => !opts.type || entry.type === opts.type)
344
+ .slice(0, opts.limit);
345
+ if (opts.json) emitJson(entries);
346
+ else process.stdout.write(entries.map(formatEntryLine).join("\n") + (entries.length ? "\n" : ""));
347
+ return 0;
348
+ }
349
+
350
+ async function opSearch(args) {
351
+ const opts = parseQueryArgs(args);
352
+ const needle = opts.query.toLowerCase();
353
+ const entries = visibleEntries(await readIndex(), opts);
354
+ const matches = [];
355
+ for (const entry of entries) {
356
+ if (await entryMatches(entry, needle)) matches.push(entry);
357
+ if (matches.length >= opts.limit) break;
358
+ }
359
+ if (opts.json) emitJson(matches);
360
+ else process.stdout.write(matches.map(formatEntryLine).join("\n") + (matches.length ? "\n" : ""));
361
+ return 0;
362
+ }
363
+
364
+ async function entryMatches(entry, needle) {
365
+ const haystack = [
366
+ entry.id,
367
+ entry.title,
368
+ entry.type,
369
+ entry.scope,
370
+ entry.source,
371
+ ...entry.tags,
372
+ ...entry.relatedProjects,
373
+ ]
374
+ .join(" ")
375
+ .toLowerCase();
376
+ if (haystack.includes(needle)) return true;
377
+ try {
378
+ const body = await fs.readFile(join(memoryRoot(), entry.path), "utf8");
379
+ return body.toLowerCase().includes(needle);
380
+ } catch {
381
+ return false;
382
+ }
383
+ }
384
+
385
+ async function findEntry(id) {
386
+ const entries = await readIndex();
387
+ const entry = entries.find((candidate) => candidate.id === id);
388
+ if (!entry) throw new Error(`memory entry not found: ${id}`);
389
+ return { entry, entries };
390
+ }
391
+
392
+ async function opShow(args) {
393
+ const json = args.includes("--json");
394
+ const ids = args.filter((arg) => arg !== "--json");
395
+ if (ids.length !== 1) throw new Error("show requires exactly one id");
396
+ const { entry } = await findEntry(ids[0]);
397
+ const absolutePath = join(memoryRoot(), entry.path);
398
+ const body = await fs.readFile(absolutePath, "utf8");
399
+ if (json) emitJson({ entry, path: absolutePath, body });
400
+ else process.stdout.write(body);
401
+ return 0;
402
+ }
403
+
404
+ async function opArchive(args) {
405
+ const json = args.includes("--json");
406
+ const ids = args.filter((arg) => arg !== "--json");
407
+ if (ids.length !== 1) throw new Error("archive requires exactly one id");
408
+ const { entry, entries } = await findEntry(ids[0]);
409
+ const archivedEntry = await moveToArchive(entry);
410
+ const updated = entries.map((candidate) =>
411
+ candidate.id === entry.id ? archivedEntry : candidate,
412
+ );
413
+ await writeIndex(updated);
414
+ if (json) emitJson({ ok: true, entry: archivedEntry });
415
+ else process.stdout.write(`archived ${entry.id}\n`);
416
+ return 0;
417
+ }
418
+
419
+ async function moveToArchive(entry) {
420
+ if (entry.status === "archived") return entry;
421
+ const sourcePath = join(memoryRoot(), entry.path);
422
+ const archiveDir = join("archive", entry.createdAt.slice(0, 4));
423
+ const nextPath = join(archiveDir, basename(entry.path));
424
+ const targetPath = join(memoryRoot(), nextPath);
425
+ await fs.mkdir(dirname(targetPath), { recursive: true });
426
+ await fs.rename(sourcePath, targetPath);
427
+ return {
428
+ ...entry,
429
+ status: "archived",
430
+ archivedAt: new Date().toISOString(),
431
+ path: relative(memoryRoot(), targetPath),
432
+ };
433
+ }
434
+
435
+ export async function run(args) {
436
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
437
+ process.stdout.write(USAGE);
438
+ return args.length === 0 ? 2 : 0;
439
+ }
440
+ const [op, ...rest] = args;
441
+ try {
442
+ switch (op) {
443
+ case "add":
444
+ return await opAdd(rest);
445
+ case "list":
446
+ return await opList(rest);
447
+ case "search":
448
+ return await opSearch(rest);
449
+ case "show":
450
+ return await opShow(rest);
451
+ case "archive":
452
+ return await opArchive(rest);
453
+ default:
454
+ process.stderr.write(`unknown memory subcommand: ${op}\n\n${USAGE}`);
455
+ return 2;
456
+ }
457
+ } catch (err) {
458
+ process.stderr.write(`error: ${err.message}\n`);
459
+ return 1;
460
+ }
461
+ }
package/src/uninstall.mjs CHANGED
@@ -20,6 +20,7 @@ const FALLBACK_SKILL_NAMES = [
20
20
  "okstra-setup",
21
21
  "okstra-brief",
22
22
  "okstra-run",
23
+ "okstra-memory",
23
24
  "okstra-inspect",
24
25
  "okstra-schedule",
25
26
  "okstra-context-loader",