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.
- package/README.kr.md +7 -1
- package/README.md +7 -1
- package/docs/kr/architecture.md +4 -3
- package/docs/kr/cli.md +26 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +20 -4
- package/runtime/agents/TODO.md +15 -2
- package/runtime/agents/workers/claude-worker.md +2 -2
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/bin/okstra.sh +5 -0
- package/runtime/prompts/launch.template.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +2 -2
- package/runtime/prompts/profiles/final-verification.md +20 -1
- package/runtime/prompts/profiles/implementation-planning.md +3 -3
- package/runtime/prompts/profiles/implementation.md +17 -7
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/python/lib/okstra/cli.sh +17 -1
- package/runtime/python/lib/okstra/globals.sh +3 -0
- package/runtime/python/lib/okstra/usage.sh +19 -2
- package/runtime/python/okstra_ctl/render.py +77 -3
- package/runtime/python/okstra_ctl/run.py +141 -9
- package/runtime/python/okstra_ctl/workflow.py +4 -1
- package/runtime/skills/okstra-history/SKILL.md +1 -0
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-setup/SKILL.md +1 -1
- package/runtime/skills/okstra-status/SKILL.md +11 -2
- package/runtime/skills/okstra-team-contract/SKILL.md +1 -0
- package/runtime/templates/reports/final-report.template.md +15 -3
- package/runtime/templates/reports/settings.template.json +1 -13
- package/runtime/templates/reports/task-brief.template.md +3 -14
- package/runtime/validators/validate-run.py +275 -3
- package/src/install.mjs +133 -2
- package/src/setup.mjs +1 -1
- 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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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,
|
|
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
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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-*>
|
|
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
|
|
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
|
+
}
|