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.
- package/README.kr.md +20 -3
- package/README.md +20 -3
- package/docs/kr/architecture.md +8 -3
- package/docs/kr/cli.md +55 -1
- package/docs/superpowers/plans/2026-05-12-ticket-id-in-reports.md +638 -0
- package/docs/superpowers/specs/2026-05-12-ticket-id-in-reports-design.md +131 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +13 -0
- package/runtime/agents/workers/claude-worker.md +2 -0
- package/runtime/agents/workers/codex-worker.md +2 -0
- package/runtime/agents/workers/gemini-worker.md +2 -0
- package/runtime/agents/workers/report-writer-worker.md +1 -0
- package/runtime/bin/okstra.sh +3 -0
- package/runtime/prompts/launch.template.md +11 -0
- package/runtime/prompts/profiles/implementation-planning.md +2 -2
- package/runtime/prompts/profiles/implementation.md +15 -1
- package/runtime/prompts/profiles/release-handoff.md +97 -0
- package/runtime/python/lib/okstra/cli.sh +13 -2
- package/runtime/python/lib/okstra/globals.sh +2 -0
- package/runtime/python/lib/okstra/usage.sh +11 -0
- package/runtime/python/okstra_ctl/render.py +21 -5
- package/runtime/python/okstra_ctl/run.py +135 -8
- package/runtime/python/okstra_ctl/workflow.py +34 -3
- package/runtime/python/okstra_ctl/worktree.py +235 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +1 -1
- package/runtime/skills/okstra-convergence/SKILL.md +11 -5
- package/runtime/skills/okstra-report-finder/SKILL.md +1 -0
- package/runtime/skills/okstra-report-writer/SKILL.md +6 -0
- package/runtime/skills/okstra-run/SKILL.md +2 -1
- package/runtime/skills/okstra-status/SKILL.md +14 -3
- package/runtime/skills/okstra-team-contract/SKILL.md +19 -0
- package/runtime/skills/okstra-time-summary/SKILL.md +1 -0
- package/runtime/templates/reports/error-analysis-input.template.md +1 -0
- package/runtime/templates/reports/final-report.template.md +144 -21
- package/runtime/templates/reports/implementation-input.template.md +1 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +1 -0
- package/runtime/templates/reports/quick-input.template.md +1 -0
- package/runtime/templates/reports/release-handoff-input.template.md +73 -0
- package/runtime/templates/reports/task-brief.template.md +5 -0
- package/runtime/validators/validate-run.py +136 -4
- package/src/install.mjs +133 -2
- 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
|
-
"
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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/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
|
+
}
|