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.
- package/README.md +1281 -18
- package/package.json +5 -7
- package/runtime/BUILD.json +3 -3
- package/runtime/skills/okstra-context-loader/SKILL.md +140 -0
- package/runtime/skills/okstra-convergence/SKILL.md +289 -0
- package/runtime/skills/okstra-history/SKILL.md +118 -0
- package/runtime/skills/okstra-report-finder/SKILL.md +68 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +256 -0
- package/runtime/skills/okstra-run/SKILL.md +223 -0
- package/runtime/skills/okstra-schedule/SKILL.md +605 -0
- package/runtime/skills/okstra-status/SKILL.md +208 -0
- package/runtime/skills/okstra-team-contract/SKILL.md +402 -0
- package/runtime/skills/okstra-time-summary/SKILL.md +119 -0
- package/runtime/skills/setup-okstra/SKILL.md +138 -0
- package/src/doctor.mjs +15 -0
- package/src/install.mjs +91 -2
- package/src/paths.mjs +2 -2
- package/src/uninstall.mjs +59 -6
|
@@ -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 <-
|
|
29
|
-
${"$HOME"}/.okstra/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
|
|
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
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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(
|
|
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
|
+
}
|