okstra 0.43.1 → 0.45.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 +6 -3
- package/README.md +6 -3
- package/bin/okstra +3 -0
- package/docs/kr/architecture.md +5 -5
- package/docs/kr/cli.md +1 -0
- package/docs/project-structure-overview.md +4 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/python/okstra_ctl/report_views.py +170 -0
- package/runtime/skills/okstra-memory/SKILL.md +86 -0
- package/runtime/templates/reports/report.css +32 -0
- package/src/memory.mjs +461 -0
- package/src/uninstall.mjs +1 -0
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 스킬 마크다운 소스 (스킬
|
|
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 스킬
|
|
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/` 아래 스킬 마크다운
|
|
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 (
|
|
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
|
|
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
|
|
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
|
package/docs/kr/architecture.md
CHANGED
|
@@ -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}`), 스킬
|
|
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,
|
|
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-
|
|
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`
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
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
package/runtime/BUILD.json
CHANGED
|
@@ -300,6 +300,10 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
300
300
|
rows.append(_split_pipe_row(lines[i]))
|
|
301
301
|
i += 1
|
|
302
302
|
|
|
303
|
+
grouped_spec = _grouped_table_spec(header_cells, section_path)
|
|
304
|
+
if grouped_spec is not None:
|
|
305
|
+
return _emit_grouped_table(header_cells, rows, grouped_spec), i - start
|
|
306
|
+
|
|
303
307
|
is_clarification_table = (
|
|
304
308
|
not _section_forbids_form(section_path)
|
|
305
309
|
and any("Clarification Items" in h for h in section_path)
|
|
@@ -383,6 +387,172 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
|
|
|
383
387
|
return f"<table>{head}{body}</table>", i - start
|
|
384
388
|
|
|
385
389
|
|
|
390
|
+
@dataclass(frozen=True)
|
|
391
|
+
class _GroupedSpec:
|
|
392
|
+
"""Plan for rendering a wide table as a compact grouped layout: the
|
|
393
|
+
short columns (``group_cols``) collapse into one stacked ``key:
|
|
394
|
+
value`` metadata cell led by ``headline_col``; the long columns
|
|
395
|
+
(``wide_cols``) each keep their own min-width column.
|
|
396
|
+
|
|
397
|
+
``kind == "clarification"`` additionally re-attaches the §5 form
|
|
398
|
+
widget to the ``user_input_col`` cell and the ``data-*`` row attrs."""
|
|
399
|
+
headline_col: int
|
|
400
|
+
group_cols: tuple[int, ...]
|
|
401
|
+
wide_cols: tuple[int, ...]
|
|
402
|
+
kind: str # "plain" | "clarification"
|
|
403
|
+
id_col: int = -1
|
|
404
|
+
kind_col: int = -1
|
|
405
|
+
status_col: int = -1
|
|
406
|
+
statement_col: int = -1
|
|
407
|
+
user_input_col: int = -1
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
_FOLLOWUP_WIDE_PREFIXES: tuple[str, ...] = ("title", "scope", "reason")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _grouped_table_spec(
|
|
414
|
+
header_cells: list[str], section_path: list[str]
|
|
415
|
+
) -> Optional[_GroupedSpec]:
|
|
416
|
+
"""Return a ``_GroupedSpec`` for the three wide final-report tables
|
|
417
|
+
that benefit from the compact layout — Execution Status, §5
|
|
418
|
+
Clarification Items, §7 Follow-up Tasks — or ``None`` for every other
|
|
419
|
+
table (which keeps the default per-cell ``td-narrow`` rendering).
|
|
420
|
+
|
|
421
|
+
Each table is identified by stable header tokens (the i18n token/cost
|
|
422
|
+
columns are never used as anchors). ``wide_cols`` lists the long-prose
|
|
423
|
+
columns that must keep a guaranteed min-width; everything else short
|
|
424
|
+
collapses into the leading metadata cell."""
|
|
425
|
+
norm = [h.strip() for h in header_cells]
|
|
426
|
+
|
|
427
|
+
def _spec(headline: int, wide: tuple[int, ...], **kw) -> _GroupedSpec:
|
|
428
|
+
wide_set = set(wide)
|
|
429
|
+
group = tuple(c for c in range(len(norm)) if c != headline and c not in wide_set)
|
|
430
|
+
return _GroupedSpec(headline_col=headline, group_cols=group, wide_cols=wide, **kw)
|
|
431
|
+
|
|
432
|
+
# Execution Status by Agent — Agent … Summary of Key Findings.
|
|
433
|
+
if len(norm) >= 3 and norm[0] == "Agent" and norm[-1] == "Summary of Key Findings":
|
|
434
|
+
return _spec(0, (len(norm) - 1,), kind="plain")
|
|
435
|
+
|
|
436
|
+
# §5 Clarification Items — keep the interactive form, but collapse the
|
|
437
|
+
# short ID/Kind/Status/… columns and widen Statement + User input.
|
|
438
|
+
if (
|
|
439
|
+
any("Clarification Items" in h for h in section_path)
|
|
440
|
+
and not _section_forbids_form(section_path)
|
|
441
|
+
and "ID" in norm
|
|
442
|
+
and "User input" in norm
|
|
443
|
+
and any(h.startswith("Statement") for h in norm)
|
|
444
|
+
):
|
|
445
|
+
statement_col = next(i for i, h in enumerate(norm) if h.startswith("Statement"))
|
|
446
|
+
user_input_col = norm.index("User input")
|
|
447
|
+
return _spec(
|
|
448
|
+
norm.index("ID"),
|
|
449
|
+
(statement_col, user_input_col),
|
|
450
|
+
kind="clarification",
|
|
451
|
+
id_col=norm.index("ID"),
|
|
452
|
+
kind_col=norm.index("Kind") if "Kind" in norm else -1,
|
|
453
|
+
status_col=norm.index("Status") if "Status" in norm else -1,
|
|
454
|
+
statement_col=statement_col,
|
|
455
|
+
user_input_col=user_input_col,
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
# §7 Follow-up Tasks — widen Title / Scope / Reason, collapse the rest.
|
|
459
|
+
if any("Follow-up Tasks" in h for h in section_path) and "ID" in norm:
|
|
460
|
+
wide = tuple(
|
|
461
|
+
i
|
|
462
|
+
for i, h in enumerate(norm)
|
|
463
|
+
if any(h.lower().startswith(p) for p in _FOLLOWUP_WIDE_PREFIXES)
|
|
464
|
+
)
|
|
465
|
+
if wide:
|
|
466
|
+
return _spec(norm.index("ID"), wide, kind="plain")
|
|
467
|
+
|
|
468
|
+
return None
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _grouped_meta_cell(
|
|
472
|
+
header_cells: list[str], row: list[str], spec: _GroupedSpec
|
|
473
|
+
) -> str:
|
|
474
|
+
"""The leading metadata ``<td>``: a bold headline (``headline_col``)
|
|
475
|
+
above one ``key: value`` line per collapsed short column."""
|
|
476
|
+
headline = row[spec.headline_col] if spec.headline_col < len(row) else ""
|
|
477
|
+
fields = "".join(
|
|
478
|
+
'<div class="grp-field">'
|
|
479
|
+
f'<span class="grp-key">{_inline(header_cells[col])}</span>'
|
|
480
|
+
f'<span class="grp-val">{_inline(row[col] if col < len(row) else "")}</span>'
|
|
481
|
+
"</div>"
|
|
482
|
+
for col in spec.group_cols
|
|
483
|
+
)
|
|
484
|
+
return (
|
|
485
|
+
'<td class="grp-meta">'
|
|
486
|
+
f'<div class="grp-headline">{_inline(headline)}</div>'
|
|
487
|
+
f"{fields}</td>"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _grouped_clarification_row(
|
|
492
|
+
row: list[str], spec: _GroupedSpec
|
|
493
|
+
) -> tuple[str, str]:
|
|
494
|
+
"""Return ``(tr_attrs, wide_cells_html)`` for one §5 row, re-attaching
|
|
495
|
+
the form widget + ``data-*`` attrs to ``C-\\d+`` rows exactly as the
|
|
496
|
+
non-grouped path does."""
|
|
497
|
+
rid = row[spec.id_col] if 0 <= spec.id_col < len(row) else ""
|
|
498
|
+
is_form_row = bool(re.fullmatch(r"C-\d+", rid)) and spec.user_input_col >= 0
|
|
499
|
+
kind = row[spec.kind_col] if is_form_row and 0 <= spec.kind_col < len(row) else ""
|
|
500
|
+
status = row[spec.status_col] if is_form_row and 0 <= spec.status_col < len(row) else ""
|
|
501
|
+
statement = (
|
|
502
|
+
row[spec.statement_col]
|
|
503
|
+
if is_form_row and 0 <= spec.statement_col < len(row)
|
|
504
|
+
else ""
|
|
505
|
+
)
|
|
506
|
+
tr_attrs = (
|
|
507
|
+
f' data-response-id="{html.escape(rid)}" '
|
|
508
|
+
f'data-kind="{html.escape(kind)}" '
|
|
509
|
+
f'data-status="{html.escape(status)}"'
|
|
510
|
+
if is_form_row
|
|
511
|
+
else ""
|
|
512
|
+
)
|
|
513
|
+
cells: list[str] = []
|
|
514
|
+
for col in spec.wide_cols:
|
|
515
|
+
value = row[col] if col < len(row) else ""
|
|
516
|
+
if is_form_row and col == spec.user_input_col:
|
|
517
|
+
cells.append(
|
|
518
|
+
f'<td class="grp-wide grp-form">'
|
|
519
|
+
f"{_form_control(rid, kind, status, value, statement)}</td>"
|
|
520
|
+
)
|
|
521
|
+
else:
|
|
522
|
+
cells.append(f'<td class="grp-wide">{_inline(value)}</td>')
|
|
523
|
+
return tr_attrs, "".join(cells)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _emit_grouped_table(
|
|
527
|
+
header_cells: list[str], rows: list[list[str]], spec: _GroupedSpec
|
|
528
|
+
) -> str:
|
|
529
|
+
"""Render a wide table in the compact grouped layout described by
|
|
530
|
+
``spec`` — one metadata cell plus the min-width long columns."""
|
|
531
|
+
head = (
|
|
532
|
+
"<thead><tr>"
|
|
533
|
+
f"<th>{_inline(header_cells[spec.headline_col])}</th>"
|
|
534
|
+
+ "".join(
|
|
535
|
+
f'<th class="grp-wide">{_inline(header_cells[col])}</th>'
|
|
536
|
+
for col in spec.wide_cols
|
|
537
|
+
)
|
|
538
|
+
+ "</tr></thead>"
|
|
539
|
+
)
|
|
540
|
+
body_rows: list[str] = []
|
|
541
|
+
for row in rows:
|
|
542
|
+
meta = _grouped_meta_cell(header_cells, row, spec)
|
|
543
|
+
if spec.kind == "clarification":
|
|
544
|
+
tr_attrs, wide_cells = _grouped_clarification_row(row, spec)
|
|
545
|
+
else:
|
|
546
|
+
tr_attrs = ""
|
|
547
|
+
wide_cells = "".join(
|
|
548
|
+
f'<td class="grp-wide">{_inline(row[col] if col < len(row) else "")}</td>'
|
|
549
|
+
for col in spec.wide_cols
|
|
550
|
+
)
|
|
551
|
+
body_rows.append(f"<tr{tr_attrs}>{meta}{wide_cells}</tr>")
|
|
552
|
+
body = "<tbody>" + "".join(body_rows) + "</tbody>"
|
|
553
|
+
return f'<table class="grouped-table">{head}{body}</table>'
|
|
554
|
+
|
|
555
|
+
|
|
386
556
|
_ENUM_LETTERS = "abcde"
|
|
387
557
|
_ENUM_CUE_WORDS: tuple[str, ...] = (
|
|
388
558
|
"권장은", "권장 ", "추천은", "추천 ", "사유:", "사유 ", "근거:",
|
|
@@ -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.
|
|
@@ -132,6 +132,38 @@ td.td-narrow, th.td-narrow {
|
|
|
132
132
|
width: 5%;
|
|
133
133
|
white-space: nowrap;
|
|
134
134
|
}
|
|
135
|
+
/* Compact grouped layout for the wide final-report tables (Execution
|
|
136
|
+
* Status, §5 Clarification Items, §7 Follow-up Tasks): the short
|
|
137
|
+
* columns collapse into one stacked `key: value` metadata cell, while
|
|
138
|
+
* each long-prose column keeps its own guaranteed min-width so it never
|
|
139
|
+
* gets squashed into a one-character ladder. */
|
|
140
|
+
table.grouped-table td.grp-meta {
|
|
141
|
+
width: 24%;
|
|
142
|
+
}
|
|
143
|
+
table.grouped-table th.grp-wide,
|
|
144
|
+
table.grouped-table td.grp-wide {
|
|
145
|
+
min-width: 18ch;
|
|
146
|
+
}
|
|
147
|
+
.grp-meta .grp-headline {
|
|
148
|
+
font-weight: 600;
|
|
149
|
+
margin-bottom: 0.4em;
|
|
150
|
+
}
|
|
151
|
+
.grp-meta .grp-field {
|
|
152
|
+
display: flex;
|
|
153
|
+
gap: 0.45em;
|
|
154
|
+
font-size: 0.88rem;
|
|
155
|
+
line-height: 1.55;
|
|
156
|
+
}
|
|
157
|
+
.grp-meta .grp-key {
|
|
158
|
+
color: GrayText;
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
}
|
|
161
|
+
.grp-meta .grp-key::after {
|
|
162
|
+
content: ":";
|
|
163
|
+
}
|
|
164
|
+
.grp-meta .grp-val {
|
|
165
|
+
overflow-wrap: anywhere;
|
|
166
|
+
}
|
|
135
167
|
thead th {
|
|
136
168
|
position: sticky;
|
|
137
169
|
top: 3rem;
|
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
|
+
}
|