okstra 0.6.1 → 0.8.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 (35) hide show
  1. package/README.kr.md +7 -1
  2. package/README.md +7 -1
  3. package/docs/kr/architecture.md +4 -3
  4. package/docs/kr/cli.md +26 -3
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/SKILL.md +20 -4
  8. package/runtime/agents/TODO.md +15 -2
  9. package/runtime/agents/workers/claude-worker.md +2 -2
  10. package/runtime/agents/workers/report-writer-worker.md +2 -2
  11. package/runtime/bin/okstra.sh +5 -0
  12. package/runtime/prompts/launch.template.md +2 -2
  13. package/runtime/prompts/profiles/error-analysis.md +2 -2
  14. package/runtime/prompts/profiles/final-verification.md +20 -1
  15. package/runtime/prompts/profiles/implementation-planning.md +3 -3
  16. package/runtime/prompts/profiles/implementation.md +17 -7
  17. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  18. package/runtime/python/lib/okstra/cli.sh +17 -1
  19. package/runtime/python/lib/okstra/globals.sh +3 -0
  20. package/runtime/python/lib/okstra/usage.sh +19 -2
  21. package/runtime/python/okstra_ctl/render.py +77 -3
  22. package/runtime/python/okstra_ctl/run.py +141 -9
  23. package/runtime/python/okstra_ctl/workflow.py +4 -1
  24. package/runtime/skills/okstra-history/SKILL.md +1 -0
  25. package/runtime/skills/okstra-run/SKILL.md +3 -1
  26. package/runtime/skills/okstra-setup/SKILL.md +1 -1
  27. package/runtime/skills/okstra-status/SKILL.md +11 -2
  28. package/runtime/skills/okstra-team-contract/SKILL.md +1 -0
  29. package/runtime/templates/reports/final-report.template.md +15 -3
  30. package/runtime/templates/reports/settings.template.json +1 -13
  31. package/runtime/templates/reports/task-brief.template.md +3 -14
  32. package/runtime/validators/validate-run.py +275 -3
  33. package/src/install.mjs +133 -2
  34. package/src/setup.mjs +1 -1
  35. package/src/uninstall.mjs +46 -9
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import os
7
8
  import sys
8
9
  from datetime import datetime, timezone
9
10
  from pathlib import Path
@@ -35,11 +36,31 @@ def default_next_phase(task_type: str) -> str:
35
36
  "requirements-discovery": "pending-routing-decision",
36
37
  "error-analysis": "implementation-planning",
37
38
  "implementation-planning": "implementation",
39
+ "implementation": "final-verification",
38
40
  "final-verification": "done-or-follow-up",
39
41
  }
40
42
  return mapping.get(task_type, "unknown")
41
43
 
42
44
 
45
+ def advance_next_phase(
46
+ current_phase: str,
47
+ phase_sequence: list,
48
+ ) -> str:
49
+ """Compute the next recommended phase after `current_phase` completes.
50
+
51
+ Walk `phase_sequence` and return the entry following `current_phase`.
52
+ Falls back to the static `default_next_phase` mapping when the current
53
+ phase is the terminal entry or is absent from the sequence (e.g. custom
54
+ task types that aren't part of the standard 5-phase lifecycle).
55
+ """
56
+ if isinstance(phase_sequence, list) and current_phase in phase_sequence:
57
+ idx = phase_sequence.index(current_phase)
58
+ if idx + 1 < len(phase_sequence):
59
+ return phase_sequence[idx + 1]
60
+ return "done-or-follow-up"
61
+ return default_next_phase(current_phase)
62
+
63
+
43
64
  def update_workflow_metadata(
44
65
  run_manifest: dict,
45
66
  task_manifest: dict,
@@ -76,9 +97,10 @@ def update_workflow_metadata(
76
97
  if current_phase:
77
98
  phase_states[current_phase] = current_phase_state
78
99
  last_completed_phase = current_phase or workflow.get("lastCompletedPhase", "")
79
- next_recommended_phase = (
80
- workflow.get("nextRecommendedPhase") or default_next_phase(current_phase)
81
- )
100
+ # Validation just passed → actively advance to the next phase in
101
+ # the sequence rather than preserving a stale value that may equal
102
+ # current_phase (which would cause the lifecycle pointer to stall).
103
+ next_recommended_phase = advance_next_phase(current_phase, phase_sequence)
82
104
  else:
83
105
  current_phase_state = "blocked"
84
106
  if current_phase:
@@ -97,6 +119,12 @@ def update_workflow_metadata(
97
119
  awaiting_approval = workflow.get("awaitingApproval")
98
120
  if not isinstance(awaiting_approval, bool):
99
121
  awaiting_approval = False
122
+ # 승인 게이트(`User Approval Request`)는 implementation 진입 직전에 한 번만 의미를 가진다.
123
+ # implementation run 이 검증을 통과했다는 것은 `_validate_approved_plan` 이 이미 사용자
124
+ # 마커를 소비했다는 뜻이므로, 이 시점에 awaitingApproval 플래그를 명시적으로 내려
125
+ # 다음 phase 의 status 뷰에서 stale 상태로 남지 않게 한다.
126
+ if validation_status == "passed" and current_phase == "implementation":
127
+ awaiting_approval = False
100
128
 
101
129
  last_safe_checkpoint = workflow.get("lastSafeCheckpoint", {})
102
130
  if not isinstance(last_safe_checkpoint, dict):
@@ -439,6 +467,28 @@ def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
439
467
  "Run `python3 scripts/okstra-token-usage.py <team-state> --write --summary "
440
468
  "--substitute-final-report <final-report>`."
441
469
  )
470
+ return
471
+ # Reject zero-valued usage when the collector flagged any source as
472
+ # `unavailable`. This catches the silent-failure mode where the
473
+ # collector ran but couldn't locate session jsonls (e.g. empty
474
+ # claudeSession.sessionId, missing subagent jsonl).
475
+ grand_total = summary.get("grandTotalTokens", 0)
476
+ if isinstance(grand_total, (int, float)) and grand_total == 0:
477
+ lead = team_state.get("leadUsage") or {}
478
+ if lead.get("source") == "unavailable":
479
+ failures.append(
480
+ "team-state.usageSummary.grandTotalTokens is 0 and leadUsage.source is "
481
+ f"`unavailable` — {lead.get('note', 'reason unknown')}. Re-collect once "
482
+ "the lead session jsonl is locatable."
483
+ )
484
+ for worker in team_state.get("workers") or []:
485
+ role = (worker or {}).get("role") or (worker or {}).get("workerId") or "<worker>"
486
+ usage = (worker or {}).get("usage") or {}
487
+ if usage.get("source") == "unavailable":
488
+ failures.append(
489
+ f"team-state.workers[{role}].usage.source is `unavailable` while "
490
+ f"grandTotalTokens is 0 — {usage.get('note', 'reason unknown')}."
491
+ )
442
492
 
443
493
 
444
494
  PLANNING_REQUIRED_SECTIONS = (
@@ -478,6 +528,203 @@ def validate_phase_boundary(
478
528
  )
479
529
 
480
530
 
531
+ def _refresh_task_catalog(project_root: Path, task_manifest: dict) -> tuple[bool, str]:
532
+ """Regenerate `discovery/task-catalog.json` so it stops trailing the
533
+ authoritative `task-manifest.json` after validation.
534
+
535
+ Resolves the catalog output path from the manifest, scans every
536
+ `task-manifest.json` under the tasks root, and rewrites the catalog
537
+ via `render_task_catalog_discovery`. Returns (ok, message); failure
538
+ is non-fatal — the validator logs a warning instead of breaking the
539
+ overall validation result.
540
+ """
541
+ catalog_relative = (task_manifest.get("taskCatalogPath") or "").strip()
542
+ if not catalog_relative:
543
+ return False, "taskCatalogPath missing from task-manifest — skip catalog refresh"
544
+
545
+ here = Path(__file__).resolve().parent
546
+ candidates = [
547
+ here.parent / "scripts",
548
+ here.parent / "python",
549
+ ]
550
+ env_pp = os.environ.get("OKSTRA_PYTHONPATH", "").strip()
551
+ if env_pp:
552
+ candidates.append(Path(env_pp))
553
+ for candidate in candidates:
554
+ if candidate.is_dir() and (candidate / "okstra_ctl").is_dir():
555
+ if str(candidate) not in sys.path:
556
+ sys.path.insert(0, str(candidate))
557
+ break
558
+
559
+ try:
560
+ from okstra_ctl.render import render_task_catalog_discovery # noqa: E402
561
+ except Exception as exc: # noqa: BLE001
562
+ return False, f"okstra_ctl import failed: {exc}"
563
+
564
+ tasks_root = (project_root / ".project-docs" / "okstra" / "tasks").resolve()
565
+ catalog_path = (project_root / catalog_relative).resolve()
566
+ ctx = {
567
+ "PROJECT_ROOT": str(project_root),
568
+ "OKSTRA_TASKS_ROOT": str(tasks_root),
569
+ "PROJECT_ID": task_manifest.get("projectId", ""),
570
+ "RUN_TIMESTAMP_ISO": utc_now(),
571
+ "TASK_KEY": task_manifest.get("taskKey", ""),
572
+ "OKSTRA_LATEST_TASK_RELATIVE_PATH": "",
573
+ }
574
+ try:
575
+ render_task_catalog_discovery(str(catalog_path), ctx)
576
+ except Exception as exc: # noqa: BLE001
577
+ return False, f"render_task_catalog_discovery raised: {exc}"
578
+ return True, f"task-catalog refreshed at {catalog_relative}"
579
+
580
+
581
+ def _import_token_usage():
582
+ """Resolve and import the okstra_token_usage package across layouts.
583
+
584
+ Source tree: <repo>/scripts/okstra_token_usage
585
+ Built runtime: <runtime>/python/okstra_token_usage (next to validators/)
586
+ Installed: $OKSTRA_PYTHONPATH/okstra_token_usage (~/.okstra/lib/python)
587
+ """
588
+ here = Path(__file__).resolve().parent
589
+ candidates = [
590
+ here.parent / "scripts",
591
+ here.parent / "python",
592
+ ]
593
+ env_pp = os.environ.get("OKSTRA_PYTHONPATH", "").strip()
594
+ if env_pp:
595
+ candidates.append(Path(env_pp))
596
+ for candidate in candidates:
597
+ if candidate.is_dir() and (candidate / "okstra_token_usage").is_dir():
598
+ if str(candidate) not in sys.path:
599
+ sys.path.insert(0, str(candidate))
600
+ break
601
+ from okstra_token_usage.collect import collect # noqa: E402
602
+ from okstra_token_usage.report import substitute_final_report # noqa: E402
603
+ return collect, substitute_final_report
604
+
605
+
606
+ def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
607
+ summary = team_state.get("usageSummary") or {}
608
+ if not summary or not summary.get("collectedAt"):
609
+ return True
610
+ if report_path.is_file():
611
+ content = report_path.read_text()
612
+ if any(p in content for p in TOKEN_PLACEHOLDERS):
613
+ return True
614
+ # Even if the collector already ran (collectedAt is set), trigger when
615
+ # every recorded usage is zero AND at least one source is "unavailable".
616
+ # That combination means the previous collection silently failed to
617
+ # locate session jsonls — we must surface accuracy failures rather than
618
+ # let zeroed data ship as the final answer.
619
+ grand_total = summary.get("grandTotalTokens", 0)
620
+ if isinstance(grand_total, (int, float)) and grand_total == 0:
621
+ lead_unavailable = (
622
+ (team_state.get("leadUsage") or {}).get("source") == "unavailable"
623
+ )
624
+ workers_unavailable = any(
625
+ (w or {}).get("usage", {}).get("source") == "unavailable"
626
+ for w in (team_state.get("workers") or [])
627
+ )
628
+ if lead_unavailable or workers_unavailable:
629
+ return True
630
+ return False
631
+
632
+
633
+ def _accuracy_failures(updated: dict) -> list[str]:
634
+ """Return human-readable reasons the collected usage is incomplete.
635
+
636
+ Goal: never let zero-valued usage be silently written or substituted into
637
+ the final report. If a session jsonl is missing, the operator must know
638
+ which one and why so they can re-collect — recording accurate token usage
639
+ is the contract this autofix preserves.
640
+ """
641
+ reasons: list[str] = []
642
+ lead_usage = updated.get("leadUsage") or {}
643
+ if lead_usage.get("source") == "unavailable":
644
+ reasons.append(
645
+ "lead Claude session jsonl was not found — "
646
+ f"{lead_usage.get('note', 'reason unknown')}. "
647
+ "Token usage cannot be recorded accurately until the lead session is locatable."
648
+ )
649
+ for worker in updated.get("workers") or []:
650
+ role = worker.get("role") or worker.get("workerId") or "<unknown worker>"
651
+ status = worker.get("status")
652
+ usage = worker.get("usage") or {}
653
+ if status == "completed" and usage.get("source") == "unavailable":
654
+ reasons.append(
655
+ f"worker `{role}` (status=completed) has no usage data — "
656
+ f"{usage.get('note', 'reason unknown')}."
657
+ )
658
+ if worker.get("agent") in ("codex", "gemini") and usage.get("source") != "unavailable":
659
+ if "cliTotalTokens" not in usage:
660
+ reasons.append(
661
+ f"worker `{role}` ({worker.get('agent')}) wrapper jsonl was located "
662
+ f"but its underlying CLI session usage was not — "
663
+ f"{usage.get('cliNote', 'reason unknown')}."
664
+ )
665
+ return reasons
666
+
667
+
668
+ def attempt_token_usage_autofix(
669
+ team_state: dict,
670
+ team_state_path: Path,
671
+ report_path: Path,
672
+ project_root: Path,
673
+ ) -> tuple[str, list[str]]:
674
+ """Run the Phase 7 token-usage collector in-process when artifacts indicate
675
+ Phase 7 was skipped.
676
+
677
+ Returns ``(state, messages)`` where ``state`` is one of:
678
+
679
+ - ``"skipped"`` — opt-out or autofix not needed; messages is empty.
680
+ - ``"recovered"`` — collector ran AND every session that should have a
681
+ jsonl was found; team-state is rewritten and the final report's token
682
+ placeholders are substituted with real values. messages carries a
683
+ single info line.
684
+ - ``"accuracy-failed"`` — collector ran but at least one expected
685
+ session is missing. Nothing is written to disk; messages contains the
686
+ contract violations the validator must surface so the operator can
687
+ re-collect accurately rather than ship a report containing zeros.
688
+ - ``"import-failed"`` / ``"collector-error"`` — autofix could not run;
689
+ caller falls back to the original contract failures.
690
+ """
691
+ if os.environ.get("OKSTRA_VALIDATE_NO_AUTOFIX") == "1":
692
+ return "skipped", []
693
+ if not _needs_token_autofix(team_state, report_path):
694
+ return "skipped", []
695
+ try:
696
+ collect, substitute_final_report = _import_token_usage()
697
+ except Exception as exc: # noqa: BLE001
698
+ return "import-failed", [f"okstra_token_usage import failed: {exc}"]
699
+ try:
700
+ updated = collect(team_state_path, project_root)
701
+ except Exception as exc: # noqa: BLE001
702
+ return "collector-error", [f"token-usage collector raised: {exc}"]
703
+
704
+ accuracy_problems = _accuracy_failures(updated)
705
+ if accuracy_problems:
706
+ # Refuse to persist zeroed usage. Surface specific reasons so the
707
+ # operator can locate the missing session(s) instead of silently
708
+ # shipping a report with `0` token counts.
709
+ return "accuracy-failed", [
710
+ f"Phase 7 token-usage auto-recovery refused to write incomplete data: {reason}"
711
+ for reason in accuracy_problems
712
+ ]
713
+
714
+ team_state_path.write_text(
715
+ json.dumps(updated, indent=2, ensure_ascii=False) + "\n"
716
+ )
717
+ replaced = substitute_final_report(report_path, updated)
718
+ detail = (
719
+ f"replaced {replaced} placeholder(s)"
720
+ if replaced > 0
721
+ else "no placeholders to replace"
722
+ if replaced == 0
723
+ else "report file missing"
724
+ )
725
+ return "recovered", [f"usageSummary repopulated; {detail}"]
726
+
727
+
481
728
  def main() -> int:
482
729
  parser = argparse.ArgumentParser(
483
730
  description="Validate okstra run contract artifacts."
@@ -527,7 +774,20 @@ def main() -> int:
527
774
  report_path = resolve_input(args.report)
528
775
  team_state = load_json(team_state_path)
529
776
 
777
+ autofix_state, autofix_messages = attempt_token_usage_autofix(
778
+ team_state, team_state_path, report_path, project_root
779
+ )
780
+ if autofix_state == "recovered":
781
+ team_state = load_json(team_state_path)
782
+ for msg in autofix_messages:
783
+ print(f"validate-run: Phase 7 auto-recovery — {msg}", file=sys.stderr)
784
+ elif autofix_state in ("import-failed", "collector-error"):
785
+ for msg in autofix_messages:
786
+ print(f"validate-run: Phase 7 auto-recovery skipped — {msg}", file=sys.stderr)
787
+
530
788
  failures: list[str] = []
789
+ if autofix_state == "accuracy-failed":
790
+ failures.extend(autofix_messages)
531
791
  contract = extract_contract(run_manifest, task_manifest, failures)
532
792
  validate_team_state(team_state, project_root, contract, failures)
533
793
  validate_report(report_path, contract["required_agent_status_entries"], failures)
@@ -545,6 +805,18 @@ def main() -> int:
545
805
  write_json(run_manifest_path, run_manifest)
546
806
  write_json(task_manifest_path, task_manifest)
547
807
 
808
+ # Best-effort: regenerate discovery/task-catalog.json so downstream
809
+ # tools (okstra-schedule, FleetView listings, etc.) don't read a stale
810
+ # snapshot frozen at instruction-set generation time.
811
+ catalog_ok, catalog_msg = _refresh_task_catalog(project_root, task_manifest)
812
+ if catalog_ok:
813
+ print(f"validate-run: {catalog_msg}", file=sys.stderr)
814
+ else:
815
+ print(
816
+ f"validate-run: task-catalog refresh skipped — {catalog_msg}",
817
+ file=sys.stderr,
818
+ )
819
+
548
820
  if args.final_status:
549
821
  final_status_path = resolve_input(args.final_status)
550
822
  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/setup.mjs CHANGED
@@ -265,7 +265,7 @@ export async function run(args) {
265
265
  return 1;
266
266
  }
267
267
  process.stderr.write(`PROJECT_ROOT: ${projectRoot}\n`);
268
- const answer = await prompt("project-id (e.g. INV-1234, fontsninja): ");
268
+ const answer = await prompt("project-id (e.g. INV-1234, my-app): ");
269
269
  projectId = answer;
270
270
  }
271
271
 
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
+ }