okstra 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: okstra-time-summary
3
+ description: Use when the user asks how long an okstra task took, time spent per task type, per-worker elapsed time, or for a duration/runtime breakdown of a specific task-id. Trigger words include "작업 시간", "소요 시간", "time summary", "duration", "elapsed", "얼마나 걸렸", "시간 분석".
4
+ ---
5
+
6
+ # OKSTRA Time Summary
7
+
8
+ Aggregate elapsed work time for a given task, grouped by **task type** and broken down by **worker** (lead, intake, claude-worker, codex-worker, gemini-worker, report-writer).
9
+
10
+ ## When to Use
11
+
12
+ - The user provides a `task-id` (or `task-key`) and asks how long the task took.
13
+ - The user wants to see time spent per phase / task type for a single task.
14
+ - The user wants a per-worker time breakdown for a task's runs.
15
+
16
+ ## Data Sources
17
+
18
+ Two sources, both already collected by `okstra`:
19
+
20
+ 1. `.project-docs/okstra/tasks/<task-group>/<task-id>/history/timeline.json`
21
+ — lists every run with `runTimestamp`, `taskType`, `status`, `teamStatePath`.
22
+ 2. Each run's `.../runs/<task-type>/state/team-state-<suffix>.json`
23
+ — populated by `scripts/okstra-token-usage.py` at Phase 7. Contains:
24
+ - `leadUsage.{startedAt, endedAt, durationMs}`
25
+ - `workers[].{workerId, agent, usage.{startedAt, endedAt, durationMs}}`
26
+
27
+ If a run never reached Phase 7, its `team-state` will not have `durationMs` filled in. Mark such runs as `unavailable` rather than guessing.
28
+
29
+ ## Step 1: Resolve task-id → timeline path
30
+
31
+ 1. If the user gave a full `task-key` (`<project-id>:<task-group>:<task-id>`), use it directly.
32
+ 2. Otherwise read `.project-docs/okstra/discovery/task-catalog.json` and find the entry whose `taskId` matches.
33
+ 3. If multiple entries match, list candidates (`taskKey`, `taskType`, `updatedAt`) and ask the user to pick.
34
+ 4. From the chosen entry, read `historyTimelinePath`.
35
+
36
+ If `task-catalog.json` is missing, respond: "No okstra history found. Run `scripts/okstra.sh` first."
37
+
38
+ ## Step 2: Walk runs and collect durations
39
+
40
+ For each entry in `timeline.json`'s `runs` array:
41
+
42
+ 1. Read the `team-state` file at `teamStatePath` (relative to the project root).
43
+ 2. Extract:
44
+ - `taskType` from the timeline entry (authoritative).
45
+ - `leadUsage.durationMs` and `leadUsage.{startedAt,endedAt}`.
46
+ - For each `worker` in `workers[]`: `workerId`, `agent`, `usage.durationMs`.
47
+ 3. If the team-state file is missing, or all `durationMs` values are 0/absent, record the run under `unavailable` with its `runTimestamp` and `taskType`.
48
+
49
+ ## Step 3: Aggregate
50
+
51
+ Build two tables:
52
+
53
+ ### A. Per task-type summary
54
+
55
+ For each distinct `taskType` across runs:
56
+
57
+ | Column | Computation |
58
+ |--------|-------------|
59
+ | `Runs` | count of runs of that task type that contributed any duration |
60
+ | `Total` | sum of (lead + all workers) across those runs |
61
+ | `Lead` | sum of `leadUsage.durationMs` |
62
+ | `Workers` | sum of all `workers[].usage.durationMs` |
63
+
64
+ Add a final `Grand total` row.
65
+
66
+ ### B. Per worker breakdown (per task type)
67
+
68
+ For each task type, list one row per `workerId` actually present, plus `lead` and (if non-zero) `intake`. Aggregate `durationMs` across all runs of that task type.
69
+
70
+ | Worker | Runs | Total | Avg/run |
71
+ |--------|------|-------|---------|
72
+
73
+ Use the `workerId` from team-state (e.g. `claude`, `codex`, `gemini`, `report-writer`). When the same `workerId` ran with different `agent` values across runs, append the agent in parentheses (`claude (claude)`, `codex (codex)`).
74
+
75
+ ## Step 4: Format output
76
+
77
+ - Convert `durationMs` to `HH:MM:SS` (zero-pad). Example: `7384000ms` → `02:03:04`.
78
+ - Sort task types by their order of first appearance in the timeline (chronological, not alphabetical).
79
+ - If any runs were `unavailable`, append a final note listing them with reason (`team-state missing`, `Phase 7 not reached`, etc.).
80
+
81
+ ### Output template
82
+
83
+ ```markdown
84
+ ## Time summary — <task-key>
85
+
86
+ ### By task type
87
+
88
+ | Task type | Runs | Total | Lead | Intake | Workers |
89
+ |------------------------|------|-----------|----------|----------|----------|
90
+ | requirements-discovery | 2 | 00:34:12 | 00:12:08 | 00:01:00 | 00:21:04 |
91
+ | error-analysis | 1 | 00:18:45 | 00:08:11 | -- | 00:10:34 |
92
+ | implementation | 3 | 02:11:09 | 00:45:30 | -- | 01:25:39 |
93
+ | **Grand total** | 6 | **03:04:06** | 01:05:49 | 00:01:00 | 01:57:17 |
94
+
95
+ ### Per worker — requirements-discovery
96
+
97
+ | Worker | Runs | Total | Avg/run |
98
+ |----------------|------|----------|----------|
99
+ | lead | 2 | 00:12:08 | 00:06:04 |
100
+ | intake | 1 | 00:01:00 | 00:01:00 |
101
+ | claude | 2 | 00:09:12 | 00:04:36 |
102
+ | codex | 2 | 00:07:40 | 00:03:50 |
103
+ | gemini | 2 | 00:03:12 | 00:01:36 |
104
+ | report-writer | 2 | 00:01:00 | 00:00:30 |
105
+
106
+ ### Per worker — error-analysis
107
+ ...
108
+
109
+ > Unavailable: 1 run (implementation / 2026-04-30_03-03-48) — team-state has no durationMs (Phase 7 not reached)
110
+ ```
111
+
112
+ If the `Intake` column is all zero across every task type, omit that column entirely.
113
+
114
+ ## Output Rules
115
+
116
+ - Always render durations as `HH:MM:SS`; never raw milliseconds.
117
+ - Never invent or estimate `durationMs`. Missing → `--`.
118
+ - Never sum across `unavailable` runs into the totals — those are reported only in the trailing note.
119
+ - Show the resolved `<task-key>` in the heading so the user can confirm disambiguation.
@@ -0,0 +1,138 @@
1
+ ---
2
+ name: setup-okstra
3
+ description: One-time bootstrap for okstra in a new project or on a new machine — installs the okstra runtime via npx and creates the project's .project-docs/okstra/project.json. Trigger words include "setup okstra", "initialize okstra", "okstra init", "first time okstra setup", "configure okstra here".
4
+ ---
5
+
6
+ # setup-okstra
7
+
8
+ One-time bootstrap. Run when okstra is being used for the first time on this
9
+ machine, or when adopting okstra in a new project.
10
+
11
+ ## When to use
12
+
13
+ - `~/.okstra/version` is missing or stale → okstra runtime is not installed yet.
14
+ - The current project has no `.project-docs/okstra/project.json` yet.
15
+ - The user says "set up okstra here", "first time", "okstra init", etc.
16
+
17
+ ## When NOT to use
18
+
19
+ - A task is already in flight → use [`okstra-run`](../okstra-run/SKILL.md) or
20
+ [`okstra-status`](../okstra-status/SKILL.md).
21
+ - Day-to-day usage in a project that already has `project.json` — skip this skill.
22
+
23
+ ## Prerequisites
24
+
25
+ Inform the user up-front and confirm before continuing:
26
+
27
+ - **Node 18+** required (runs `npx`). If missing, point to `brew install node`
28
+ or `nvm`.
29
+ - **Python 3.10+** required (runs the okstra core).
30
+ - The current working directory must be inside the project that will host the
31
+ okstra metadata. If unsure, ask with `AskUserQuestion` for an absolute project
32
+ root before proceeding.
33
+
34
+ ## Step 1: Install okstra
35
+
36
+ ```bash
37
+ npx -y okstra@latest install
38
+ ```
39
+
40
+ This single command populates everything the user needs:
41
+
42
+ - `~/.okstra/{lib/python, bin, version}` — python + bash runtime
43
+ - `~/.claude/skills/<name>/SKILL.md` — all 11 okstra skills (incl. this one)
44
+ - `~/.okstra/installed-skills.json` — manifest for safe uninstall
45
+
46
+ The skill should run this even if `~/.okstra/version` already exists —
47
+ `install` is idempotent (per-file hash skip), so re-running is cheap and
48
+ ensures the install matches the package version currently on disk.
49
+
50
+ Show the final summary line back to the user (`version stamp: x.y.z`).
51
+
52
+ If install fails, surface the stderr verbatim. Do NOT try to "fix" it by
53
+ running the legacy `okstra-install.sh` — that path is dev-only.
54
+
55
+ ## Step 2: Load runtime paths
56
+
57
+ ```bash
58
+ eval "$(npx -y okstra@latest paths --shell)"
59
+ export PYTHONPATH="$OKSTRA_PYTHONPATH"
60
+ ```
61
+
62
+ After this, `$OKSTRA_WORKSPACE`, `$OKSTRA_AGENTS_DIR`, `$OKSTRA_PYTHONPATH`,
63
+ `$OKSTRA_BIN`, `$OKSTRA_HOME` are all set. Do not hardcode any of these — read
64
+ them from the env vars.
65
+
66
+ ## Step 3: Resolve PROJECT_ROOT
67
+
68
+ ```bash
69
+ python3 - <<'PY'
70
+ from okstra_project import resolve_project_root, ResolverError
71
+ try:
72
+ pr = resolve_project_root(explicit_root="", cwd=".")
73
+ print(f"OK\t{pr}")
74
+ except ResolverError as e:
75
+ print(f"FAIL\t{e}")
76
+ PY
77
+ ```
78
+
79
+ - `OK` line → use that as `PROJECT_ROOT`.
80
+ - `FAIL` line → ask the user (`AskUserQuestion`, free text) for an absolute
81
+ project root. Re-run with `explicit_root=<their answer>`.
82
+
83
+ ## Step 4: Inspect or create `project.json`
84
+
85
+ ```bash
86
+ PROJECT_JSON="$PROJECT_ROOT/.project-docs/okstra/project.json"
87
+ if [ -f "$PROJECT_JSON" ]; then
88
+ cat "$PROJECT_JSON"
89
+ fi
90
+ ```
91
+
92
+ If the file exists, print its `projectId`/`projectRoot` and ask whether to
93
+ keep or overwrite. Default is to keep — okstra refuses to change `projectId`
94
+ on an existing project (see `okstra_project.resolver.upsert_project_json`),
95
+ so overwriting requires manually deleting the file first.
96
+
97
+ If the file does NOT exist, ask via `AskUserQuestion`:
98
+
99
+ - **Question**: `"Project id for okstra (e.g. INV-1234, fontsninja, okstra)"`
100
+ - **Validate**: slugified must contain at least one alphanumeric character.
101
+
102
+ Then create the file:
103
+
104
+ ```bash
105
+ python3 - <<PY
106
+ from pathlib import Path
107
+ from okstra_project import upsert_project_json
108
+ result = upsert_project_json(Path("$PROJECT_ROOT"), "$PROJECT_ID")
109
+ print(result)
110
+ PY
111
+ ```
112
+
113
+ ## Step 5: Verify
114
+
115
+ ```bash
116
+ npx -y okstra@latest doctor
117
+ ```
118
+
119
+ If all checks return `OK`, the setup is complete. If any check fails, surface
120
+ the output and let the user decide whether to re-run install or skip.
121
+
122
+ ## Step 6: Hand-off
123
+
124
+ Inform the user with a short summary:
125
+
126
+ > okstra is ready. Runtime: `~/.okstra` (version stamp). Project metadata:
127
+ > `<PROJECT_ROOT>/.project-docs/okstra/project.json` (`projectId`). Run
128
+ > `/okstra-run` to start your first task.
129
+
130
+ ## Failure modes
131
+
132
+ | Symptom | Cause | Fix |
133
+ |---|---|---|
134
+ | `command not found: npx` | Node missing | Install node 18+. |
135
+ | `okstra ensure-installed` keeps reinstalling | `~/.okstra/version` write fails (permissions) | Check `~/.okstra` ownership and writability. |
136
+ | `ResolverError: project_id required` | empty answer to Step 4 prompt | Re-ask Step 4. |
137
+ | `projectId 불일치` | `project.json` already exists with a different id | Decide which id is canonical; manually edit the file or pick the existing id. |
138
+ | `npx okstra@latest install` succeeds but `doctor` shows FAIL | runtime/{python,bin,skills} sync not yet performed (pre-release package) | Use dev install: clone the repo and run `node bin/okstra install --link <repo>`. |
package/src/doctor.mjs CHANGED
@@ -1,8 +1,12 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
+ import { homedir } from "node:os";
3
4
  import { join } from "node:path";
4
5
  import { resolvePaths } from "./paths.mjs";
5
6
 
7
+ const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
8
+ const REQUIRED_SKILLS = ["setup-okstra", "okstra-run"];
9
+
6
10
  const USAGE = `okstra doctor — diagnose the installed runtime
7
11
 
8
12
  Usage:
@@ -15,6 +19,7 @@ Checks:
15
19
  - okstra_ctl module importable
16
20
  - bash entrypoints present and executable in $HOME/.okstra/bin
17
21
  - agents/ directory exists inside the okstra package
22
+ - canonical skills present at $HOME/.claude/skills/<name>/SKILL.md
18
23
  - version stamp matches package version
19
24
  `;
20
25
 
@@ -104,6 +109,16 @@ export async function run(args) {
104
109
  await check("bin: okstra-codex-exec.sh", () => checkBashEntry(paths.bin, "okstra-codex-exec.sh")),
105
110
  await check("bin: okstra-gemini-exec.sh", () => checkBashEntry(paths.bin, "okstra-gemini-exec.sh")),
106
111
  await check("bin: okstra-central.sh", () => checkBashEntry(paths.bin, "okstra-central.sh")),
112
+ ...(await Promise.all(
113
+ REQUIRED_SKILLS.map((name) =>
114
+ check(`skill: ${name}`, async () => {
115
+ const p = join(CLAUDE_SKILLS_DIR, name, "SKILL.md");
116
+ return (await pathExists(p))
117
+ ? { ok: true, detail: p }
118
+ : { ok: false, detail: `not found: ${p}` };
119
+ }),
120
+ ),
121
+ )),
107
122
  await check("version stamp", async () =>
108
123
  paths.version === paths.package
109
124
  ? { ok: true, detail: paths.version }
package/src/install.mjs CHANGED
@@ -1,9 +1,13 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
+ import { homedir } from "node:os";
3
4
  import { join, relative, resolve as resolveAbs } from "node:path";
4
5
  import { getPackageRoot } from "./version.mjs";
5
6
  import { resolvePaths } from "./paths.mjs";
6
7
 
8
+ const SKILLS_MANIFEST_REL = "installed-skills.json";
9
+ const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
10
+
7
11
  const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "lib"];
8
12
  const BIN_ENTRYPOINTS = [
9
13
  "okstra.sh",
@@ -25,13 +29,16 @@ Usage:
25
29
  okstra install --refresh Re-copy even files that match by hash
26
30
 
27
31
  Effect (copy mode):
28
- ${"$HOME"}/.okstra/lib/python <- packages/okstra/runtime/python
29
- ${"$HOME"}/.okstra/bin <- packages/okstra/runtime/bin
32
+ ${"$HOME"}/.okstra/lib/python <- runtime/python
33
+ ${"$HOME"}/.okstra/bin <- runtime/bin
34
+ ${"$HOME"}/.claude/skills/<name> <- runtime/skills/<name> (per skill)
35
+ ${"$HOME"}/.okstra/installed-skills.json <- manifest of installed skills
30
36
  ${"$HOME"}/.okstra/version <- installed package version stamp
31
37
 
32
38
  Effect (link mode):
33
39
  ${"$HOME"}/.okstra/lib/python/<pkg> -> <repo>/scripts/<pkg> (symlink)
34
40
  ${"$HOME"}/.okstra/bin/<name>.sh -> <repo>/scripts/<name>.sh
41
+ ${"$HOME"}/.claude/skills/<name> -> <repo>/skills/<name> (symlink dir)
35
42
  ${"$HOME"}/.okstra/dev-link <- <repo> path stamp
36
43
  ${"$HOME"}/.okstra/version <- installed package version stamp
37
44
 
@@ -208,6 +215,9 @@ async function installLinkMode(repoPath, paths, opts) {
208
215
  if (!quiet) process.stdout.write(` bin/${name}: ${action}\n`);
209
216
  }
210
217
 
218
+ const skillResult = await installSkillsLink(repoAbs, { dryRun, quiet });
219
+ await writeSkillsManifest(paths.home, skillResult.installed, { dryRun });
220
+
211
221
  if (!dryRun) {
212
222
  await writeFileAtomic(join(paths.home, "dev-link"), repoAbs + "\n", 0o644);
213
223
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
@@ -229,6 +239,79 @@ async function fileExists(p) {
229
239
  }
230
240
  }
231
241
 
242
+ async function listSkillDirs(skillsRoot) {
243
+ try {
244
+ const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
245
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
246
+ } catch (err) {
247
+ if (err.code === "ENOENT") return [];
248
+ throw err;
249
+ }
250
+ }
251
+
252
+ async function writeSkillsManifest(home, names, opts) {
253
+ const { dryRun = false } = opts ?? {};
254
+ const data = {
255
+ version: 1,
256
+ installedAt: new Date().toISOString(),
257
+ skills: Array.from(new Set(names)).sort(),
258
+ };
259
+ if (dryRun) {
260
+ process.stdout.write(`[dry-run] write skills manifest: ${data.skills.length} entries\n`);
261
+ return;
262
+ }
263
+ await writeFileAtomic(
264
+ join(home, SKILLS_MANIFEST_REL),
265
+ JSON.stringify(data, null, 2) + "\n",
266
+ 0o644,
267
+ );
268
+ }
269
+
270
+ async function installSkillsCopy(runtimeRoot, opts) {
271
+ const { refresh, dryRun, quiet } = opts;
272
+ const srcRoot = join(runtimeRoot, "skills");
273
+ const names = await listSkillDirs(srcRoot);
274
+ if (names.length === 0) {
275
+ if (!quiet) process.stdout.write(" skills: runtime/skills empty — skipped\n");
276
+ return { installed: [] };
277
+ }
278
+ let copied = 0;
279
+ let skipped = 0;
280
+ for (const name of names) {
281
+ const r = await copyTreeIfChanged(
282
+ join(srcRoot, name),
283
+ join(CLAUDE_SKILLS_DIR, name),
284
+ { refresh, dryRun, mode: 0o644 },
285
+ );
286
+ copied += r.copied;
287
+ skipped += r.skipped;
288
+ }
289
+ if (!quiet) {
290
+ process.stdout.write(
291
+ ` skills: copied=${copied} skipped=${skipped} -> ${CLAUDE_SKILLS_DIR}/ (${names.length} skills)\n`,
292
+ );
293
+ }
294
+ return { installed: names };
295
+ }
296
+
297
+ async function installSkillsLink(repoAbs, opts) {
298
+ const { dryRun, quiet } = opts;
299
+ const srcRoot = join(repoAbs, "skills");
300
+ const names = await listSkillDirs(srcRoot);
301
+ if (names.length === 0) {
302
+ if (!quiet) process.stdout.write(" skills: <repo>/skills missing — skipped\n");
303
+ return { installed: [] };
304
+ }
305
+ if (!dryRun) await fs.mkdir(CLAUDE_SKILLS_DIR, { recursive: true });
306
+ for (const name of names) {
307
+ const src = join(srcRoot, name);
308
+ const dst = join(CLAUDE_SKILLS_DIR, name);
309
+ const action = await ensureSymlink(src, dst, { dryRun });
310
+ if (!quiet) process.stdout.write(` skills/${name}: ${action}\n`);
311
+ }
312
+ return { installed: names };
313
+ }
314
+
232
315
  function parseInstallArgs(args) {
233
316
  const result = {
234
317
  dryRun: false,
@@ -309,6 +392,9 @@ export async function runInstall(args) {
309
392
  );
310
393
  }
311
394
 
395
+ const skillResult = await installSkillsCopy(runtimeRoot, opts);
396
+ await writeSkillsManifest(paths.home, skillResult.installed, { dryRun: opts.dryRun });
397
+
312
398
  if (!opts.dryRun) {
313
399
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
314
400
  }
@@ -344,6 +430,9 @@ export async function runEnsureInstalled(args) {
344
430
  }
345
431
  if (!(await dirExists(paths.pythonpath))) reasons.push(`missing ${paths.pythonpath}`);
346
432
  if (!(await dirExists(paths.agents))) reasons.push(`missing agents dir ${paths.agents}`);
433
+ if (!(await fileExists(join(CLAUDE_SKILLS_DIR, "setup-okstra", "SKILL.md")))) {
434
+ reasons.push(`missing ${CLAUDE_SKILLS_DIR}/setup-okstra/SKILL.md`);
435
+ }
347
436
 
348
437
  if (reasons.length === 0) {
349
438
  if (!quiet) process.stdout.write(`okstra runtime OK (package ${paths.package})\n`);
package/src/paths.mjs CHANGED
@@ -14,8 +14,8 @@ Usage:
14
14
  Fields:
15
15
  agents Absolute path to agents/. Default: <workspace>/agents.
16
16
  workspace Directory containing prompts/, templates/, validators/,
17
- agents/. Equals packages/okstra/runtime in copy mode,
18
- or the dev-link repo path in dev mode.
17
+ agents/. Equals the package's runtime/ directory in copy
18
+ mode, or the dev-link repo path in dev mode.
19
19
  pythonpath $HOME/.okstra/lib/python
20
20
  bin $HOME/.okstra/bin
21
21
  home $HOME/.okstra
package/src/uninstall.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import { resolvePaths } from "./paths.mjs";
4
5
 
@@ -11,13 +12,35 @@ const BIN_ENTRYPOINTS = [
11
12
  "okstra-error-log.py",
12
13
  ];
13
14
 
14
- const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra
15
+ const FALLBACK_SKILL_NAMES = [
16
+ "setup-okstra",
17
+ "okstra-run",
18
+ "okstra-status",
19
+ "okstra-history",
20
+ "okstra-schedule",
21
+ "okstra-context-loader",
22
+ "okstra-team-contract",
23
+ "okstra-convergence",
24
+ "okstra-report-writer",
25
+ "okstra-report-finder",
26
+ "okstra-time-summary",
27
+ ];
28
+
29
+ const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
30
+ const SKILLS_MANIFEST_REL = "installed-skills.json";
31
+
32
+ const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra and ~/.claude/skills
15
33
 
16
34
  Usage:
17
- okstra uninstall Remove lib/, bin/{known entries}, version, dev-link
18
- Preserves user data: recent.jsonl, active.jsonl,
19
- projects/, archive/, state.json, .locks/
20
- okstra uninstall --purge Remove the entire ~/.okstra directory (DESTRUCTIVE)
35
+ okstra uninstall Remove ~/.okstra/{lib, bin/<known>, version,
36
+ dev-link, installed-skills.json} AND
37
+ ~/.claude/skills/<name> for every entry in
38
+ the install manifest (fallback: hard-coded
39
+ okstra-* names). Preserves user data:
40
+ recent.jsonl, active.jsonl, projects/,
41
+ archive/, state.json, .locks/
42
+ okstra uninstall --purge Remove the entire ~/.okstra directory AND
43
+ ~/.claude/skills/<okstra-*> (DESTRUCTIVE).
21
44
  Requires -y or an interactive confirmation
22
45
  okstra uninstall --dry-run Print the plan without touching disk
23
46
  okstra uninstall -y Skip confirmation prompt for --purge
@@ -79,7 +102,9 @@ export async function runUninstall(args) {
79
102
 
80
103
  if (opts.purge) {
81
104
  if (!opts.yes && !opts.dryRun) {
82
- const ok = await promptConfirm(`purge entire ${paths.home}? user data will be lost.`);
105
+ const ok = await promptConfirm(
106
+ `purge entire ${paths.home} AND ~/.claude/skills/{okstra-*,setup-okstra}? user data will be lost.`,
107
+ );
83
108
  if (!ok) {
84
109
  process.stdout.write("aborted.\n");
85
110
  return 1;
@@ -87,6 +112,10 @@ export async function runUninstall(args) {
87
112
  }
88
113
  if (!opts.quiet) process.stdout.write(`purging ${paths.home}\n`);
89
114
  await removePath(paths.home, opts);
115
+ // Skills live outside ~/.okstra — purge those too with the fallback list.
116
+ for (const name of FALLBACK_SKILL_NAMES) {
117
+ await removePath(join(CLAUDE_SKILLS_DIR, name), opts);
118
+ }
90
119
  return 0;
91
120
  }
92
121
 
@@ -112,6 +141,19 @@ export async function runUninstall(args) {
112
141
  }
113
142
  }
114
143
  }
144
+
145
+ // Remove the skills we own. Manifest is authoritative; fall back to the
146
+ // hard-coded okstra-* names if it is missing (e.g. an install from an old
147
+ // version that did not write the manifest).
148
+ const skillNames = await readSkillsManifest(paths.home);
149
+ if (!opts.quiet) {
150
+ process.stdout.write(` skills: removing ${skillNames.length} entries from ${CLAUDE_SKILLS_DIR}\n`);
151
+ }
152
+ for (const name of skillNames) {
153
+ await removePath(join(CLAUDE_SKILLS_DIR, name), opts);
154
+ }
155
+ await removePath(join(paths.home, SKILLS_MANIFEST_REL), opts);
156
+
115
157
  await removePath(join(paths.home, "version"), opts);
116
158
  await removePath(join(paths.home, "dev-link"), opts);
117
159
 
@@ -120,3 +162,14 @@ export async function runUninstall(args) {
120
162
  }
121
163
  return 0;
122
164
  }
165
+
166
+ async function readSkillsManifest(home) {
167
+ try {
168
+ const raw = await fs.readFile(join(home, SKILLS_MANIFEST_REL), "utf8");
169
+ const data = JSON.parse(raw);
170
+ if (Array.isArray(data?.skills)) return data.skills;
171
+ } catch {
172
+ /* fall through */
173
+ }
174
+ return FALLBACK_SKILL_NAMES;
175
+ }