okstra 0.7.0 → 0.9.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.
Files changed (43) hide show
  1. package/README.kr.md +20 -3
  2. package/README.md +20 -3
  3. package/docs/kr/architecture.md +8 -3
  4. package/docs/kr/cli.md +55 -1
  5. package/docs/superpowers/plans/2026-05-12-ticket-id-in-reports.md +638 -0
  6. package/docs/superpowers/specs/2026-05-12-ticket-id-in-reports-design.md +131 -0
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/agents/SKILL.md +13 -0
  10. package/runtime/agents/workers/claude-worker.md +2 -0
  11. package/runtime/agents/workers/codex-worker.md +2 -0
  12. package/runtime/agents/workers/gemini-worker.md +2 -0
  13. package/runtime/agents/workers/report-writer-worker.md +1 -0
  14. package/runtime/bin/okstra.sh +3 -0
  15. package/runtime/prompts/launch.template.md +11 -0
  16. package/runtime/prompts/profiles/implementation-planning.md +2 -2
  17. package/runtime/prompts/profiles/implementation.md +15 -1
  18. package/runtime/prompts/profiles/release-handoff.md +97 -0
  19. package/runtime/python/lib/okstra/cli.sh +13 -2
  20. package/runtime/python/lib/okstra/globals.sh +2 -0
  21. package/runtime/python/lib/okstra/usage.sh +11 -0
  22. package/runtime/python/okstra_ctl/render.py +21 -5
  23. package/runtime/python/okstra_ctl/run.py +135 -8
  24. package/runtime/python/okstra_ctl/workflow.py +34 -3
  25. package/runtime/python/okstra_ctl/worktree.py +235 -0
  26. package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
  27. package/runtime/skills/okstra-convergence/SKILL.md +11 -5
  28. package/runtime/skills/okstra-report-finder/SKILL.md +1 -0
  29. package/runtime/skills/okstra-report-writer/SKILL.md +6 -0
  30. package/runtime/skills/okstra-run/SKILL.md +2 -1
  31. package/runtime/skills/okstra-status/SKILL.md +14 -3
  32. package/runtime/skills/okstra-team-contract/SKILL.md +19 -0
  33. package/runtime/skills/okstra-time-summary/SKILL.md +1 -0
  34. package/runtime/templates/reports/error-analysis-input.template.md +1 -0
  35. package/runtime/templates/reports/final-report.template.md +144 -21
  36. package/runtime/templates/reports/implementation-input.template.md +1 -0
  37. package/runtime/templates/reports/implementation-planning-input.template.md +1 -0
  38. package/runtime/templates/reports/quick-input.template.md +1 -0
  39. package/runtime/templates/reports/release-handoff-input.template.md +73 -0
  40. package/runtime/templates/reports/task-brief.template.md +5 -0
  41. package/runtime/validators/validate-run.py +136 -4
  42. package/src/install.mjs +133 -2
  43. package/src/uninstall.mjs +46 -9
@@ -36,11 +36,35 @@ def default_next_phase(task_type: str) -> str:
36
36
  "requirements-discovery": "pending-routing-decision",
37
37
  "error-analysis": "implementation-planning",
38
38
  "implementation-planning": "implementation",
39
- "final-verification": "done-or-follow-up",
39
+ "implementation": "final-verification",
40
+ # final-verification 의 다음 phase 는 verdict 에 따라 갈리므로
41
+ # 정적 매핑은 `pending-release-handoff` 로 두고, 실제 진입은
42
+ # release-handoff profile 의 entry gate (`accepted` 확인) 에서 강제한다.
43
+ "final-verification": "pending-release-handoff",
44
+ "release-handoff": "done-or-follow-up",
40
45
  }
41
46
  return mapping.get(task_type, "unknown")
42
47
 
43
48
 
49
+ def advance_next_phase(
50
+ current_phase: str,
51
+ phase_sequence: list,
52
+ ) -> str:
53
+ """Compute the next recommended phase after `current_phase` completes.
54
+
55
+ Walk `phase_sequence` and return the entry following `current_phase`.
56
+ Falls back to the static `default_next_phase` mapping when the current
57
+ phase is the terminal entry or is absent from the sequence (e.g. custom
58
+ task types that aren't part of the standard 5-phase lifecycle).
59
+ """
60
+ if isinstance(phase_sequence, list) and current_phase in phase_sequence:
61
+ idx = phase_sequence.index(current_phase)
62
+ if idx + 1 < len(phase_sequence):
63
+ return phase_sequence[idx + 1]
64
+ return "done-or-follow-up"
65
+ return default_next_phase(current_phase)
66
+
67
+
44
68
  def update_workflow_metadata(
45
69
  run_manifest: dict,
46
70
  task_manifest: dict,
@@ -64,6 +88,7 @@ def update_workflow_metadata(
64
88
  "implementation-planning",
65
89
  "implementation",
66
90
  "final-verification",
91
+ "release-handoff",
67
92
  ]
68
93
 
69
94
  phase_states = workflow.get("phaseStates", {})
@@ -77,9 +102,10 @@ def update_workflow_metadata(
77
102
  if current_phase:
78
103
  phase_states[current_phase] = current_phase_state
79
104
  last_completed_phase = current_phase or workflow.get("lastCompletedPhase", "")
80
- next_recommended_phase = (
81
- workflow.get("nextRecommendedPhase") or default_next_phase(current_phase)
82
- )
105
+ # Validation just passed → actively advance to the next phase in
106
+ # the sequence rather than preserving a stale value that may equal
107
+ # current_phase (which would cause the lifecycle pointer to stall).
108
+ next_recommended_phase = advance_next_phase(current_phase, phase_sequence)
83
109
  else:
84
110
  current_phase_state = "blocked"
85
111
  if current_phase:
@@ -98,6 +124,12 @@ def update_workflow_metadata(
98
124
  awaiting_approval = workflow.get("awaitingApproval")
99
125
  if not isinstance(awaiting_approval, bool):
100
126
  awaiting_approval = False
127
+ # 승인 게이트(`User Approval Request`)는 implementation 진입 직전에 한 번만 의미를 가진다.
128
+ # implementation run 이 검증을 통과했다는 것은 `_validate_approved_plan` 이 이미 사용자
129
+ # 마커를 소비했다는 뜻이므로, 이 시점에 awaitingApproval 플래그를 명시적으로 내려
130
+ # 다음 phase 의 status 뷰에서 stale 상태로 남지 않게 한다.
131
+ if validation_status == "passed" and current_phase == "implementation":
132
+ awaiting_approval = False
101
133
 
102
134
  last_safe_checkpoint = workflow.get("lastSafeCheckpoint", {})
103
135
  if not isinstance(last_safe_checkpoint, dict):
@@ -440,6 +472,28 @@ def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
440
472
  "Run `python3 scripts/okstra-token-usage.py <team-state> --write --summary "
441
473
  "--substitute-final-report <final-report>`."
442
474
  )
475
+ return
476
+ # Reject zero-valued usage when the collector flagged any source as
477
+ # `unavailable`. This catches the silent-failure mode where the
478
+ # collector ran but couldn't locate session jsonls (e.g. empty
479
+ # claudeSession.sessionId, missing subagent jsonl).
480
+ grand_total = summary.get("grandTotalTokens", 0)
481
+ if isinstance(grand_total, (int, float)) and grand_total == 0:
482
+ lead = team_state.get("leadUsage") or {}
483
+ if lead.get("source") == "unavailable":
484
+ failures.append(
485
+ "team-state.usageSummary.grandTotalTokens is 0 and leadUsage.source is "
486
+ f"`unavailable` — {lead.get('note', 'reason unknown')}. Re-collect once "
487
+ "the lead session jsonl is locatable."
488
+ )
489
+ for worker in team_state.get("workers") or []:
490
+ role = (worker or {}).get("role") or (worker or {}).get("workerId") or "<worker>"
491
+ usage = (worker or {}).get("usage") or {}
492
+ if usage.get("source") == "unavailable":
493
+ failures.append(
494
+ f"team-state.workers[{role}].usage.source is `unavailable` while "
495
+ f"grandTotalTokens is 0 — {usage.get('note', 'reason unknown')}."
496
+ )
443
497
 
444
498
 
445
499
  PLANNING_REQUIRED_SECTIONS = (
@@ -479,6 +533,56 @@ def validate_phase_boundary(
479
533
  )
480
534
 
481
535
 
536
+ def _refresh_task_catalog(project_root: Path, task_manifest: dict) -> tuple[bool, str]:
537
+ """Regenerate `discovery/task-catalog.json` so it stops trailing the
538
+ authoritative `task-manifest.json` after validation.
539
+
540
+ Resolves the catalog output path from the manifest, scans every
541
+ `task-manifest.json` under the tasks root, and rewrites the catalog
542
+ via `render_task_catalog_discovery`. Returns (ok, message); failure
543
+ is non-fatal — the validator logs a warning instead of breaking the
544
+ overall validation result.
545
+ """
546
+ catalog_relative = (task_manifest.get("taskCatalogPath") or "").strip()
547
+ if not catalog_relative:
548
+ return False, "taskCatalogPath missing from task-manifest — skip catalog refresh"
549
+
550
+ here = Path(__file__).resolve().parent
551
+ candidates = [
552
+ here.parent / "scripts",
553
+ here.parent / "python",
554
+ ]
555
+ env_pp = os.environ.get("OKSTRA_PYTHONPATH", "").strip()
556
+ if env_pp:
557
+ candidates.append(Path(env_pp))
558
+ for candidate in candidates:
559
+ if candidate.is_dir() and (candidate / "okstra_ctl").is_dir():
560
+ if str(candidate) not in sys.path:
561
+ sys.path.insert(0, str(candidate))
562
+ break
563
+
564
+ try:
565
+ from okstra_ctl.render import render_task_catalog_discovery # noqa: E402
566
+ except Exception as exc: # noqa: BLE001
567
+ return False, f"okstra_ctl import failed: {exc}"
568
+
569
+ tasks_root = (project_root / ".project-docs" / "okstra" / "tasks").resolve()
570
+ catalog_path = (project_root / catalog_relative).resolve()
571
+ ctx = {
572
+ "PROJECT_ROOT": str(project_root),
573
+ "OKSTRA_TASKS_ROOT": str(tasks_root),
574
+ "PROJECT_ID": task_manifest.get("projectId", ""),
575
+ "RUN_TIMESTAMP_ISO": utc_now(),
576
+ "TASK_KEY": task_manifest.get("taskKey", ""),
577
+ "OKSTRA_LATEST_TASK_RELATIVE_PATH": "",
578
+ }
579
+ try:
580
+ render_task_catalog_discovery(str(catalog_path), ctx)
581
+ except Exception as exc: # noqa: BLE001
582
+ return False, f"render_task_catalog_discovery raised: {exc}"
583
+ return True, f"task-catalog refreshed at {catalog_relative}"
584
+
585
+
482
586
  def _import_token_usage():
483
587
  """Resolve and import the okstra_token_usage package across layouts.
484
588
 
@@ -512,6 +616,22 @@ def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
512
616
  content = report_path.read_text()
513
617
  if any(p in content for p in TOKEN_PLACEHOLDERS):
514
618
  return True
619
+ # Even if the collector already ran (collectedAt is set), trigger when
620
+ # every recorded usage is zero AND at least one source is "unavailable".
621
+ # That combination means the previous collection silently failed to
622
+ # locate session jsonls — we must surface accuracy failures rather than
623
+ # let zeroed data ship as the final answer.
624
+ grand_total = summary.get("grandTotalTokens", 0)
625
+ if isinstance(grand_total, (int, float)) and grand_total == 0:
626
+ lead_unavailable = (
627
+ (team_state.get("leadUsage") or {}).get("source") == "unavailable"
628
+ )
629
+ workers_unavailable = any(
630
+ (w or {}).get("usage", {}).get("source") == "unavailable"
631
+ for w in (team_state.get("workers") or [])
632
+ )
633
+ if lead_unavailable or workers_unavailable:
634
+ return True
515
635
  return False
516
636
 
517
637
 
@@ -690,6 +810,18 @@ def main() -> int:
690
810
  write_json(run_manifest_path, run_manifest)
691
811
  write_json(task_manifest_path, task_manifest)
692
812
 
813
+ # Best-effort: regenerate discovery/task-catalog.json so downstream
814
+ # tools (okstra-schedule, FleetView listings, etc.) don't read a stale
815
+ # snapshot frozen at instruction-set generation time.
816
+ catalog_ok, catalog_msg = _refresh_task_catalog(project_root, task_manifest)
817
+ if catalog_ok:
818
+ print(f"validate-run: {catalog_msg}", file=sys.stderr)
819
+ else:
820
+ print(
821
+ f"validate-run: task-catalog refresh skipped — {catalog_msg}",
822
+ file=sys.stderr,
823
+ )
824
+
693
825
  if args.final_status:
694
826
  final_status_path = resolve_input(args.final_status)
695
827
  final_status_path.parent.mkdir(parents=True, exist_ok=True)
package/src/install.mjs CHANGED
@@ -6,7 +6,9 @@ import { getPackageRoot } from "./version.mjs";
6
6
  import { resolvePaths } from "./paths.mjs";
7
7
 
8
8
  const SKILLS_MANIFEST_REL = "installed-skills.json";
9
+ const AGENTS_MANIFEST_REL = "installed-agents.json";
9
10
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
11
+ const CLAUDE_AGENTS_DIR = join(homedir(), ".claude", "agents");
10
12
 
11
13
  const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "lib"];
12
14
  const BIN_ENTRYPOINTS = [
@@ -32,18 +34,23 @@ Effect (copy mode):
32
34
  ${"$HOME"}/.okstra/lib/python <- runtime/python
33
35
  ${"$HOME"}/.okstra/bin <- runtime/bin
34
36
  ${"$HOME"}/.claude/skills/<name> <- runtime/skills/<name> (per skill)
37
+ ${"$HOME"}/.claude/agents/<worker>.md <- runtime/agents/workers/<worker>.md
35
38
  ${"$HOME"}/.okstra/installed-skills.json <- manifest of installed skills
39
+ ${"$HOME"}/.okstra/installed-agents.json <- manifest of installed workers
36
40
  ${"$HOME"}/.okstra/version <- installed package version stamp
37
41
 
38
42
  Effect (link mode):
39
43
  ${"$HOME"}/.okstra/lib/python/<pkg> -> <repo>/scripts/<pkg> (symlink)
40
44
  ${"$HOME"}/.okstra/bin/<name>.sh -> <repo>/scripts/<name>.sh
41
45
  ${"$HOME"}/.claude/skills/<name> -> <repo>/skills/<name> (symlink dir)
46
+ ${"$HOME"}/.claude/agents/<worker>.md -> <repo>/agents/workers/<worker>.md
42
47
  ${"$HOME"}/.okstra/dev-link <- <repo> path stamp
43
48
  ${"$HOME"}/.okstra/version <- installed package version stamp
44
49
 
45
- agents/ is NOT copied it stays inside the package (copy mode) or is
46
- resolved to <repo>/agents (link mode) via 'okstra paths --field agents'.
50
+ Worker agent definitions are installed into ${"$HOME"}/.claude/agents/ so
51
+ that Claude Code's subagent discovery picks them up; they cannot live
52
+ inside the package alone because the harness only scans ~/.claude/agents/
53
+ and <project>/.claude/agents/.
47
54
  `;
48
55
 
49
56
  const ENSURE_USAGE = `okstra ensure-installed — idempotent install check
@@ -218,6 +225,9 @@ async function installLinkMode(repoPath, paths, opts) {
218
225
  const skillResult = await installSkillsLink(repoAbs, { dryRun, quiet });
219
226
  await writeSkillsManifest(paths.home, skillResult.installed, { dryRun });
220
227
 
228
+ const agentResult = await installAgentsLink(repoAbs, { dryRun, quiet });
229
+ await writeAgentsManifest(paths.home, agentResult.installed, { dryRun });
230
+
221
231
  if (!dryRun) {
222
232
  await writeFileAtomic(join(paths.home, "dev-link"), repoAbs + "\n", 0o644);
223
233
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
@@ -274,6 +284,98 @@ async function writeSkillsManifest(home, names, opts) {
274
284
  );
275
285
  }
276
286
 
287
+ async function writeAgentsManifest(home, names, opts) {
288
+ const { dryRun = false } = opts ?? {};
289
+ const data = {
290
+ version: 1,
291
+ installedAt: new Date().toISOString(),
292
+ agents: Array.from(new Set(names)).sort(),
293
+ };
294
+ if (dryRun) {
295
+ process.stdout.write(`[dry-run] write agents manifest: ${data.agents.length} entries\n`);
296
+ return;
297
+ }
298
+ await writeFileAtomic(
299
+ join(home, AGENTS_MANIFEST_REL),
300
+ JSON.stringify(data, null, 2) + "\n",
301
+ 0o644,
302
+ );
303
+ }
304
+
305
+ async function listWorkerFiles(workersRoot) {
306
+ try {
307
+ const entries = await fs.readdir(workersRoot, { withFileTypes: true });
308
+ return entries
309
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
310
+ .map((e) => e.name);
311
+ } catch (err) {
312
+ if (err.code === "ENOENT") return [];
313
+ throw err;
314
+ }
315
+ }
316
+
317
+ async function installAgentsCopy(runtimeRoot, opts) {
318
+ const { refresh, dryRun, quiet } = opts;
319
+ const srcRoot = join(runtimeRoot, "agents", "workers");
320
+ const names = await listWorkerFiles(srcRoot);
321
+ if (names.length === 0) {
322
+ if (!quiet) process.stdout.write(" agents: runtime/agents/workers empty — skipped\n");
323
+ return { installed: [] };
324
+ }
325
+ if (!dryRun) await fs.mkdir(CLAUDE_AGENTS_DIR, { recursive: true });
326
+ let copied = 0;
327
+ let skipped = 0;
328
+ for (const name of names) {
329
+ const src = join(srcRoot, name);
330
+ const dst = join(CLAUDE_AGENTS_DIR, name);
331
+ let needsCopy = refresh;
332
+ if (!needsCopy) {
333
+ try {
334
+ await fs.access(dst);
335
+ const [a, b] = await Promise.all([hashFile(src), hashFile(dst)]);
336
+ needsCopy = a !== b;
337
+ } catch {
338
+ needsCopy = true;
339
+ }
340
+ }
341
+ if (!needsCopy) {
342
+ skipped++;
343
+ continue;
344
+ }
345
+ if (dryRun) {
346
+ process.stdout.write(`[dry-run] copy ${src} -> ${dst}\n`);
347
+ } else {
348
+ const buf = await fs.readFile(src);
349
+ await writeFileAtomic(dst, buf, 0o644);
350
+ }
351
+ copied++;
352
+ }
353
+ if (!quiet) {
354
+ process.stdout.write(
355
+ ` agents: copied=${copied} skipped=${skipped} -> ${CLAUDE_AGENTS_DIR}/ (${names.length} workers)\n`,
356
+ );
357
+ }
358
+ return { installed: names };
359
+ }
360
+
361
+ async function installAgentsLink(repoAbs, opts) {
362
+ const { dryRun, quiet } = opts;
363
+ const srcRoot = join(repoAbs, "agents", "workers");
364
+ const names = await listWorkerFiles(srcRoot);
365
+ if (names.length === 0) {
366
+ if (!quiet) process.stdout.write(" agents: <repo>/agents/workers missing — skipped\n");
367
+ return { installed: [] };
368
+ }
369
+ if (!dryRun) await fs.mkdir(CLAUDE_AGENTS_DIR, { recursive: true });
370
+ for (const name of names) {
371
+ const src = join(srcRoot, name);
372
+ const dst = join(CLAUDE_AGENTS_DIR, name);
373
+ const action = await ensureSymlink(src, dst, { dryRun });
374
+ if (!quiet) process.stdout.write(` agents/${name}: ${action}\n`);
375
+ }
376
+ return { installed: names };
377
+ }
378
+
277
379
  async function installSkillsCopy(runtimeRoot, opts) {
278
380
  const { refresh, dryRun, quiet } = opts;
279
381
  const srcRoot = join(runtimeRoot, "skills");
@@ -402,6 +504,9 @@ export async function runInstall(args) {
402
504
  const skillResult = await installSkillsCopy(runtimeRoot, opts);
403
505
  await writeSkillsManifest(paths.home, skillResult.installed, { dryRun: opts.dryRun });
404
506
 
507
+ const agentResult = await installAgentsCopy(runtimeRoot, opts);
508
+ await writeAgentsManifest(paths.home, agentResult.installed, { dryRun: opts.dryRun });
509
+
405
510
  if (!opts.dryRun) {
406
511
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
407
512
  }
@@ -419,6 +524,29 @@ export async function runInstall(args) {
419
524
  return 0;
420
525
  }
421
526
 
527
+ async function agentDriftReasons(paths) {
528
+ const reasons = [];
529
+ const workersDir = join(paths.agents, "workers");
530
+ const expected = await listWorkerFiles(workersDir);
531
+ if (expected.length === 0) return reasons;
532
+ for (const name of expected) {
533
+ const target = join(CLAUDE_AGENTS_DIR, name);
534
+ try {
535
+ const lst = await fs.lstat(target);
536
+ if (lst.isSymbolicLink()) {
537
+ try {
538
+ await fs.stat(target);
539
+ } catch {
540
+ reasons.push(`dangling symlink ${target}`);
541
+ }
542
+ }
543
+ } catch {
544
+ reasons.push(`missing agent ${target}`);
545
+ }
546
+ }
547
+ return reasons;
548
+ }
549
+
422
550
  function summarise(label, result, target) {
423
551
  if (result.missingSource) {
424
552
  process.stdout.write(` ${label}: source directory missing — skipped\n`);
@@ -447,6 +575,9 @@ export async function runEnsureInstalled(args) {
447
575
  if (!(await fileExists(join(CLAUDE_SKILLS_DIR, "okstra-setup", "SKILL.md")))) {
448
576
  reasons.push(`missing ${CLAUDE_SKILLS_DIR}/okstra-setup/SKILL.md`);
449
577
  }
578
+ for (const reason of await agentDriftReasons(paths)) {
579
+ reasons.push(reason);
580
+ }
450
581
 
451
582
  if (reasons.length === 0) {
452
583
  if (!quiet) process.stdout.write(`okstra runtime OK (package ${paths.package})\n`);
package/src/uninstall.mjs CHANGED
@@ -26,21 +26,34 @@ const FALLBACK_SKILL_NAMES = [
26
26
  "okstra-time-summary",
27
27
  ];
28
28
 
29
+ const FALLBACK_AGENT_NAMES = [
30
+ "claude-worker.md",
31
+ "codex-worker.md",
32
+ "gemini-worker.md",
33
+ "report-writer-worker.md",
34
+ ];
35
+
29
36
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
37
+ const CLAUDE_AGENTS_DIR = join(homedir(), ".claude", "agents");
30
38
  const SKILLS_MANIFEST_REL = "installed-skills.json";
39
+ const AGENTS_MANIFEST_REL = "installed-agents.json";
31
40
 
32
- const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra and ~/.claude/skills
41
+ const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra, ~/.claude/skills, ~/.claude/agents
33
42
 
34
43
  Usage:
35
44
  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/
45
+ dev-link, installed-skills.json,
46
+ installed-agents.json} AND
47
+ ~/.claude/skills/<name> AND
48
+ ~/.claude/agents/<worker>.md for every
49
+ entry in the install manifests (fallback:
50
+ hard-coded okstra-* / *-worker.md names).
51
+ Preserves user data: recent.jsonl,
52
+ active.jsonl, projects/, archive/,
53
+ state.json, .locks/
42
54
  okstra uninstall --purge Remove the entire ~/.okstra directory AND
43
- ~/.claude/skills/<okstra-*> (DESTRUCTIVE).
55
+ ~/.claude/skills/<okstra-*> AND
56
+ ~/.claude/agents/<*-worker.md> (DESTRUCTIVE).
44
57
  Requires -y or an interactive confirmation
45
58
  okstra uninstall --dry-run Print the plan without touching disk
46
59
  okstra uninstall -y Skip confirmation prompt for --purge
@@ -112,10 +125,13 @@ export async function runUninstall(args) {
112
125
  }
113
126
  if (!opts.quiet) process.stdout.write(`purging ${paths.home}\n`);
114
127
  await removePath(paths.home, opts);
115
- // Skills live outside ~/.okstra — purge those too with the fallback list.
128
+ // Skills and worker agents live outside ~/.okstra — purge those too.
116
129
  for (const name of FALLBACK_SKILL_NAMES) {
117
130
  await removePath(join(CLAUDE_SKILLS_DIR, name), opts);
118
131
  }
132
+ for (const name of FALLBACK_AGENT_NAMES) {
133
+ await removePath(join(CLAUDE_AGENTS_DIR, name), opts);
134
+ }
119
135
  return 0;
120
136
  }
121
137
 
@@ -154,6 +170,16 @@ export async function runUninstall(args) {
154
170
  }
155
171
  await removePath(join(paths.home, SKILLS_MANIFEST_REL), opts);
156
172
 
173
+ // Remove worker agents we own. Same authoritative-manifest pattern as skills.
174
+ const agentNames = await readAgentsManifest(paths.home);
175
+ if (!opts.quiet) {
176
+ process.stdout.write(` agents: removing ${agentNames.length} entries from ${CLAUDE_AGENTS_DIR}\n`);
177
+ }
178
+ for (const name of agentNames) {
179
+ await removePath(join(CLAUDE_AGENTS_DIR, name), opts);
180
+ }
181
+ await removePath(join(paths.home, AGENTS_MANIFEST_REL), opts);
182
+
157
183
  await removePath(join(paths.home, "version"), opts);
158
184
  await removePath(join(paths.home, "dev-link"), opts);
159
185
 
@@ -173,3 +199,14 @@ async function readSkillsManifest(home) {
173
199
  }
174
200
  return FALLBACK_SKILL_NAMES;
175
201
  }
202
+
203
+ async function readAgentsManifest(home) {
204
+ try {
205
+ const raw = await fs.readFile(join(home, AGENTS_MANIFEST_REL), "utf8");
206
+ const data = JSON.parse(raw);
207
+ if (Array.isArray(data?.agents)) return data.agents;
208
+ } catch {
209
+ /* fall through */
210
+ }
211
+ return FALLBACK_AGENT_NAMES;
212
+ }