okstra 0.82.1 → 0.83.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 -6
- package/README.md +6 -6
- package/docs/kr/architecture.md +8 -7
- package/docs/kr/cli.md +2 -2
- package/docs/kr/performance-improvement-plan-v2.md +14 -14
- package/docs/project-structure-overview.md +7 -8
- package/docs/superpowers/plans/2026-06-15-coding-preflight-pack-dispatch-path.md +504 -0
- package/docs/superpowers/plans/2026-06-15-internal-skill-migration-final-fixups.md +342 -0
- package/docs/superpowers/plans/2026-06-15-internal-skill-migration-fixups.md +258 -0
- package/docs/superpowers/plans/2026-06-15-internal-skill-migration-remaining-fixups.md +387 -0
- package/docs/superpowers/plans/2026-06-15-internal-skill-resource-migration.md +749 -0
- package/docs/superpowers/plans/2026-06-15-worker-prompt-anchor-final-fixups.md +828 -0
- package/docs/superpowers/plans/2026-06-15-worker-prompt-header-error-contract.md +490 -0
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/error-analysis.md +1 -1
- package/docs/task-process/final-verification.md +1 -1
- package/docs/task-process/implementation-planning.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/docs/task-process/release-handoff.md +1 -1
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/TODO.md +2 -0
- package/runtime/agents/workers/claude-worker.md +8 -8
- package/runtime/agents/workers/codex-worker.md +8 -8
- package/runtime/agents/workers/gemini-worker.md +8 -8
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- package/runtime/bin/lib/okstra/globals.sh +0 -1
- package/runtime/bin/okstra-wrapper-status.py +1 -1
- package/runtime/{skills/okstra-coding-preflight → prompts/coding-preflight}/languages/python.md +2 -2
- package/runtime/{skills/okstra-coding-preflight → prompts/coding-preflight}/languages/rust.md +1 -1
- package/runtime/{skills/okstra-coding-preflight/SKILL.md → prompts/coding-preflight/overview.md} +27 -38
- package/runtime/prompts/launch.template.md +5 -3
- package/runtime/{skills/okstra-context-loader/SKILL.md → prompts/lead/context-loader.md} +7 -14
- package/runtime/{skills/okstra-convergence/SKILL.md → prompts/lead/convergence.md} +12 -19
- package/runtime/{agents/SKILL.md → prompts/lead/okstra-lead-contract.md} +53 -59
- package/runtime/{skills/okstra-report-writer/SKILL.md → prompts/lead/report-writer.md} +12 -19
- package/runtime/{skills/okstra-team-contract/SKILL.md → prompts/lead/team-contract.md} +13 -19
- package/runtime/prompts/profiles/_coding-conventions-preflight.md +2 -2
- package/runtime/prompts/profiles/_common-contract.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +2 -2
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +4 -4
- package/runtime/prompts/profiles/requirements-discovery.md +2 -2
- package/runtime/python/okstra_ctl/codex_dispatch.py +12 -61
- package/runtime/python/okstra_ctl/context_cost.py +14 -11
- package/runtime/python/okstra_ctl/dispatch_core.py +36 -13
- package/runtime/python/okstra_ctl/paths.py +27 -1
- package/runtime/python/okstra_ctl/render.py +62 -8
- package/runtime/python/okstra_ctl/run.py +5 -5
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +126 -0
- package/runtime/python/okstra_token_usage/claude.py +1 -1
- package/runtime/python/okstra_token_usage/collect.py +1 -1
- package/runtime/templates/reports/task-brief.template.md +2 -2
- package/runtime/templates/worker-prompt-preamble.md +2 -2
- package/runtime/validators/lib/validate-assets.sh +12 -4
- package/runtime/validators/validate-run.py +3 -3
- package/runtime/validators/validate_session_conformance.py +11 -11
- package/src/install.mjs +95 -81
- package/src/skill-catalog.mjs +35 -0
- package/src/uninstall.mjs +5 -0
- /package/runtime/{skills/okstra-coding-preflight/architecture → prompts/coding-preflight/architectures}/hexagonal.md +0 -0
- /package/runtime/{skills/okstra-coding-preflight → prompts/coding-preflight}/clean-code.md +0 -0
- /package/runtime/{skills/okstra-coding-preflight/languages/nodejs.md → prompts/coding-preflight/frameworks/node-server.md} +0 -0
- /package/runtime/{skills/okstra-coding-preflight → prompts/coding-preflight}/languages/java.md +0 -0
- /package/runtime/{skills/okstra-coding-preflight → prompts/coding-preflight}/languages/javascript-typescript.md +0 -0
- /package/runtime/{skills/okstra-coding-preflight → prompts/coding-preflight}/languages/kotlin.md +0 -0
- /package/runtime/{skills/okstra-coding-preflight → prompts/coding-preflight}/languages/sql.md +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Shared worker prompt anchor header rendering."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
from .paths import okstra_home
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WorkerPromptHeaderError(Exception):
|
|
11
|
+
"""Raised when worker prompt anchor headers cannot be rendered."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def worker_prompt_headers(
|
|
15
|
+
*,
|
|
16
|
+
project_root: Path,
|
|
17
|
+
prompt_rel: str,
|
|
18
|
+
result_rel: str,
|
|
19
|
+
worker_id: str,
|
|
20
|
+
manifest: Mapping[str, Any],
|
|
21
|
+
active_context: Mapping[str, Any],
|
|
22
|
+
) -> list[str]:
|
|
23
|
+
"""Render the team-contract worker prompt anchor headers."""
|
|
24
|
+
prompt_path = _resolve_project_path(project_root, prompt_rel)
|
|
25
|
+
return [
|
|
26
|
+
f"**Project Root:** {project_root}",
|
|
27
|
+
f"**Prompt History Path:** {prompt_rel}",
|
|
28
|
+
f"**Result Path:** {result_rel}",
|
|
29
|
+
f"Assigned worker prompt history path: {prompt_path}",
|
|
30
|
+
f"**Worker Preamble Path:** {_worker_preamble_path()}",
|
|
31
|
+
f"**Coding preflight pack:** {_coding_preflight_pack_path(active_context)}",
|
|
32
|
+
f"**Errors log path:** {_errors_log_path(project_root, manifest, active_context)}",
|
|
33
|
+
(
|
|
34
|
+
f"**Errors sidecar path:** "
|
|
35
|
+
f"{_worker_errors_sidecar_path(project_root, manifest, active_context, worker_id)}"
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _worker_preamble_path() -> Path:
|
|
41
|
+
return okstra_home() / "templates" / "worker-prompt-preamble.md"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _coding_preflight_pack_path(active_context: Mapping[str, Any]) -> Path:
|
|
45
|
+
runtime_resources = active_context.get("runtimeResources")
|
|
46
|
+
if isinstance(runtime_resources, Mapping):
|
|
47
|
+
value = _string_value(runtime_resources.get("codingPreflightDir"))
|
|
48
|
+
if value:
|
|
49
|
+
return Path(value)
|
|
50
|
+
return okstra_home() / "prompts" / "coding-preflight"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _errors_log_path(
|
|
54
|
+
project_root: Path,
|
|
55
|
+
manifest: Mapping[str, Any],
|
|
56
|
+
active_context: Mapping[str, Any],
|
|
57
|
+
) -> Path:
|
|
58
|
+
error_logs = active_context.get("errorLogs")
|
|
59
|
+
if isinstance(error_logs, Mapping):
|
|
60
|
+
value = _string_value(error_logs.get("runErrorsLogPath"))
|
|
61
|
+
if value:
|
|
62
|
+
return _resolve_project_path(project_root, value)
|
|
63
|
+
run_dir = _run_directory_path(project_root, manifest)
|
|
64
|
+
task_type = _require_string(manifest, "taskType")
|
|
65
|
+
seq = _sequence(manifest, "state")
|
|
66
|
+
return run_dir / "logs" / f"errors-{task_type}-{seq}.jsonl"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _worker_errors_sidecar_path(
|
|
70
|
+
project_root: Path,
|
|
71
|
+
manifest: Mapping[str, Any],
|
|
72
|
+
active_context: Mapping[str, Any],
|
|
73
|
+
worker_id: str,
|
|
74
|
+
) -> Path:
|
|
75
|
+
error_logs = active_context.get("errorLogs")
|
|
76
|
+
if isinstance(error_logs, Mapping):
|
|
77
|
+
sidecars = error_logs.get("sidecarsByWorkerId")
|
|
78
|
+
if isinstance(sidecars, Mapping):
|
|
79
|
+
value = _string_value(sidecars.get(worker_id))
|
|
80
|
+
if value:
|
|
81
|
+
return _resolve_project_path(project_root, value)
|
|
82
|
+
run_dir = _run_directory_path(project_root, manifest)
|
|
83
|
+
task_type = _require_string(manifest, "taskType")
|
|
84
|
+
seq = _sequence(manifest, "workerResults")
|
|
85
|
+
return run_dir / "worker-results" / f"{worker_id}-worker-errors-{task_type}-{seq}.json"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _run_directory_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
|
|
89
|
+
value = _string_value(manifest.get("runDirectoryPath"))
|
|
90
|
+
if value:
|
|
91
|
+
return _resolve_project_path(project_root, value)
|
|
92
|
+
team_state_path = _resolve_project_path(
|
|
93
|
+
project_root,
|
|
94
|
+
_require_string(manifest, "teamStatePath"),
|
|
95
|
+
)
|
|
96
|
+
return team_state_path.parent.parent
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _sequence(manifest: Mapping[str, Any], key: str) -> str:
|
|
100
|
+
seqs = manifest.get("runSequencesByCategory")
|
|
101
|
+
if isinstance(seqs, Mapping):
|
|
102
|
+
return _string_value(seqs.get(key)) or _run_seq(manifest)
|
|
103
|
+
return _run_seq(manifest)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _run_seq(manifest: Mapping[str, Any]) -> str:
|
|
107
|
+
seqs = manifest.get("runSequencesByCategory")
|
|
108
|
+
if not isinstance(seqs, Mapping):
|
|
109
|
+
raise WorkerPromptHeaderError("run manifest has no runSequencesByCategory object")
|
|
110
|
+
return _require_string(seqs, "manifests")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _resolve_project_path(project_root: Path, value: str) -> Path:
|
|
114
|
+
path = Path(value)
|
|
115
|
+
return path if path.is_absolute() else project_root / path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _require_string(payload: Mapping[str, Any], key: str) -> str:
|
|
119
|
+
value = payload.get(key)
|
|
120
|
+
if not isinstance(value, str) or not value.strip():
|
|
121
|
+
raise WorkerPromptHeaderError(f"missing required string field: {key}")
|
|
122
|
+
return value
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _string_value(value: Any) -> str:
|
|
126
|
+
return value.strip() if isinstance(value, str) else ""
|
|
@@ -9,7 +9,7 @@ from pathlib import Path
|
|
|
9
9
|
from .cursor import MAX_NEEDLES, fresh_cache, load_cache, save_cache
|
|
10
10
|
from .paths import claude_project_dir, ts_in_window
|
|
11
11
|
|
|
12
|
-
# lead 의 단계 체크포인트 라인(
|
|
12
|
+
# lead 의 단계 체크포인트 라인(prompts/lead/okstra-lead-contract.md "Progress reporting") — phase id 가
|
|
13
13
|
# `phase-<digit>` 로 시작하는 라인만 마커로 인정해 일반 대화의 오탐을 줄인다.
|
|
14
14
|
_PROGRESS_RE = re.compile(r"^PROGRESS:\s+(phase-\d\S*(?:[ \t].*)?)$", re.MULTILINE)
|
|
15
15
|
_PROGRESS_MARKER_MAX_CHARS = 160
|
|
@@ -159,7 +159,7 @@ def _wall_ms(start_iso: str | None, end_iso: str | None) -> int | None:
|
|
|
159
159
|
|
|
160
160
|
|
|
161
161
|
def phase_timeline(markers: list[dict]) -> dict:
|
|
162
|
-
"""lead 의 PROGRESS 체크포인트(
|
|
162
|
+
"""lead 의 PROGRESS 체크포인트(prompts/lead/okstra-lead-contract.md "Progress reporting")로
|
|
163
163
|
run 내부 단계 경계 wall-clock 을 복원한다 (perf plan v2 P0 계측).
|
|
164
164
|
|
|
165
165
|
phase id = marker 의 첫 토큰(`phase-5.5-convergence` 등). 같은 phase 의
|
|
@@ -112,7 +112,7 @@ taskType: "{{FM_TASK_TYPE}}"
|
|
|
112
112
|
|
|
113
113
|
### Standing Ticket-Tagging Rule (always applied by every worker)
|
|
114
114
|
|
|
115
|
-
- 모든 항목 및 표 행에 ticket을 명시한다. 표 형식은 `Ticket ID` 컬럼, bullet/번호목록·섹션 헤더는 `[TICKETID: <id>]` 태그를 사용한다. 채움 우선순위·폴백·다중 ticket 규칙은 `
|
|
115
|
+
- 모든 항목 및 표 행에 ticket을 명시한다. 표 형식은 `Ticket ID` 컬럼, bullet/번호목록·섹션 헤더는 `[TICKETID: <id>]` 태그를 사용한다. 채움 우선순위·폴백·다중 ticket 규칙은 `team-contract` SKILL의 Ticket Tagging 절을 따른다.
|
|
116
116
|
|
|
117
117
|
### Standing Scope-Discipline Questions (always answered by every worker)
|
|
118
118
|
|
|
@@ -138,7 +138,7 @@ taskType: "{{FM_TASK_TYPE}}"
|
|
|
138
138
|
## Phase Boundary
|
|
139
139
|
|
|
140
140
|
- This okstra run executes the lifecycle phase named in `Task Type` and stops there. Outputs that belong to any other phase MUST NOT be produced inside this run.
|
|
141
|
-
- Allowed and forbidden actions for each task type are listed in `Lifecycle Phase Boundaries` of the okstra skill (`
|
|
141
|
+
- Allowed and forbidden actions for each task type are listed in `Lifecycle Phase Boundaries` of the okstra skill (`prompts/lead/okstra-lead-contract.md`). The lead and every worker stay inside that boundary.
|
|
142
142
|
- "다음 단계 진행해" or any equivalent user phrase is interpreted as "complete the remaining outputs of the current phase," never as "start the next lifecycle phase." The next phase begins only via a fresh okstra invocation with the new `--task-type`.
|
|
143
143
|
- For `implementation-planning` specifically: produce a plan document with the sections listed in `okstra-implementation-planning-input.template.md` `## Required Plan Deliverable`. Do not edit project source code, run builds/migrations/deployments, or write artifacts outside the run's own directories.
|
|
144
144
|
- For `implementation` specifically: edits are bounded by the approved plan's file list (the `--approved-plan` reference). The run MUST refuse to start if the approved plan path is missing or its frontmatter `approved` field is not `true`. `git push`, publish, deploy, real migrations, and any third-party write API call remain forbidden; only local `git add`/`git commit` are allowed. Verifier roles stay read-only — they record fix recommendations rather than applying edits — and acceptance verdicts belong to `final-verification`, not this phase.
|
|
@@ -18,7 +18,7 @@ Different recipients need different files. Do NOT include `final-report-template
|
|
|
18
18
|
|---|---|
|
|
19
19
|
| Claude / Codex / Gemini analysis workers | analysis-packet.md as the primary compact input; task-brief, analysis-profile, analysis-material, reference-expectations, and clarification-response remain source/fallback paths, not automatic first-read files |
|
|
20
20
|
| Report writer worker (Phase 6) | all of the above **plus** the instruction-set-local `final-report-template.md` (phase-stripped) and `final-report-schema.json` (per-task-type excerpt) — NOT the full `templates/reports/...` / `schemas/...` sources |
|
|
21
|
-
| Reverify dispatches (Phase 5.5, lightweight mode) | **do NOT inject `[Required reading]` at all** — see [
|
|
21
|
+
| Reverify dispatches (Phase 5.5, lightweight mode) | **do NOT inject `[Required reading]` at all** — see [convergence](../prompts/lead/convergence.md) "Reverify prompt: required-reading suppression". |
|
|
22
22
|
|
|
23
23
|
### Reading rules
|
|
24
24
|
|
|
@@ -112,7 +112,7 @@ Every item in sections 1–5 (and any section 6) MUST carry a worker-internal it
|
|
|
112
112
|
|
|
113
113
|
For task types `requirements-discovery`, `error-analysis`, `implementation-planning`, or `implementation`, every item in sections 1–5 MUST carry a ticket identifier. Use the `Ticket ID` column in table-form items and the `[TICKETID: <id>]` prefix in bullet/numbered items.
|
|
114
114
|
|
|
115
|
-
See `
|
|
115
|
+
See `prompts/lead/team-contract.md` "Worker Output Contract" for the full frontmatter schema and section ordering rules. This preamble is consistent with that contract; the team-contract document is authoritative if the two ever diverge.
|
|
116
116
|
|
|
117
117
|
## Return message to the lead (all workers)
|
|
118
118
|
|
|
@@ -57,10 +57,18 @@ else:
|
|
|
57
57
|
continue
|
|
58
58
|
check(source_path, workers_target / source_path.name)
|
|
59
59
|
|
|
60
|
-
# 2. Lead
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
# 2. Lead contract + internal lead resources: prompts/lead/* + prompts/coding-preflight/*
|
|
61
|
+
# -> runtime/prompts/*. These ship as runtime resources (okstra install copies
|
|
62
|
+
# runtime/prompts -> ~/.okstra/prompts), no longer as agent skills.
|
|
63
|
+
prompts_source_root = agents_source_root.parent / "prompts"
|
|
64
|
+
for sub in ("lead", "coding-preflight"):
|
|
65
|
+
sub_root = prompts_source_root / sub
|
|
66
|
+
if not sub_root.is_dir():
|
|
67
|
+
errors.append(f"missing prompts source directory: {sub_root}")
|
|
68
|
+
continue
|
|
69
|
+
for source_path in sorted(sub_root.rglob("*.md")):
|
|
70
|
+
relative = source_path.relative_to(prompts_source_root)
|
|
71
|
+
check(source_path, runtime_root / "prompts" / relative)
|
|
64
72
|
|
|
65
73
|
# 3. Skill packages: skills/<name>/SKILL.md -> runtime/skills/<name>/SKILL.md
|
|
66
74
|
if not skills_source_root.is_dir():
|
|
@@ -436,7 +436,7 @@ def validate_team_state(
|
|
|
436
436
|
"team-state.teamCreate.attempted must be true once any worker has "
|
|
437
437
|
"been dispatched (status in completed/timeout/error/in-progress). "
|
|
438
438
|
"Phase 3 (TeamCreate) was skipped — workers ran in-process without "
|
|
439
|
-
"the Teams split-pane surface. See
|
|
439
|
+
"the Teams split-pane surface. See prompts/lead/okstra-lead-contract.md Phase 3. "
|
|
440
440
|
"(The no-team concurrent-run path is legal ONLY when the "
|
|
441
441
|
"run-manifest carries prepare-recorded `concurrentRun.detected: "
|
|
442
442
|
'true` AND team-state records `teamCreate: { attempted: false, '
|
|
@@ -1373,7 +1373,7 @@ def _validate_verdict_card_consistency(content: str, failures: list[str]) -> Non
|
|
|
1373
1373
|
"""Verdict Card is a non-authoritative index of §7. If both blocks
|
|
1374
1374
|
carry a Verdict Token row, the values MUST byte-match (modulo case
|
|
1375
1375
|
and surrounding whitespace) — divergence is a contract violation per
|
|
1376
|
-
`
|
|
1376
|
+
`report-writer` SKILL.md "Authoring Contract".
|
|
1377
1377
|
"""
|
|
1378
1378
|
card_value = _extract_verdict_card_token(content)
|
|
1379
1379
|
final_value = _extract_final_verdict_token(content)
|
|
@@ -1828,7 +1828,7 @@ def _validate_session_conformance(
|
|
|
1828
1828
|
claude_projects_dir: str | None,
|
|
1829
1829
|
failures: list[str],
|
|
1830
1830
|
) -> None:
|
|
1831
|
-
"""
|
|
1831
|
+
"""prompts/lead/okstra-lead-contract.md BLOCKING 계약 3종(PROGRESS 체크포인트 / claude-worker
|
|
1832
1832
|
heartbeat / implementation entry guard)의 post-hoc 검사를 위임하고 실패를
|
|
1833
1833
|
``session-conformance: `` 접두로 folding 한다. 설계:
|
|
1834
1834
|
docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""prompts/lead/okstra-lead-contract.md BLOCKING 계약 3종의 post-hoc conformance 검사.
|
|
2
2
|
|
|
3
3
|
설계: docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-design.md
|
|
4
4
|
|
|
5
5
|
| 검사 | 선언 위치 | 증거 |
|
|
6
6
|
|------|----------|------|
|
|
7
|
-
| 1. lead PROGRESS 체크포인트 라인 |
|
|
7
|
+
| 1. lead PROGRESS 체크포인트 라인 | prompts/lead/okstra-lead-contract.md "Progress reporting (BLOCKING)" | lead 세션 jsonl 의 assistant text 블록 |
|
|
8
8
|
| 2. claude-worker 5분 heartbeat | agents/workers/claude-worker.md "Heartbeat" | audit 사이드카의 `- PROGRESS: <stage> <ISO>` 라인 |
|
|
9
|
-
| 3. implementation sidecar entry guard |
|
|
9
|
+
| 3. implementation sidecar entry guard | prompts/lead/okstra-lead-contract.md "Entry guard (BLOCKING)" | lead 세션 jsonl 의 Read tool_use |
|
|
10
10
|
|
|
11
11
|
스캔 규칙 (false-pass 방지가 핵심):
|
|
12
12
|
- `type == "assistant"` 레코드만 본다 — Skill 호출 시 SKILL.md 본문(체크포인트
|
|
@@ -336,7 +336,7 @@ def _check_worker_checkpoint_lines(
|
|
|
336
336
|
errors.append(
|
|
337
337
|
f"PROGRESS checkpoint missing: no `phase-4-dispatch worker=<role>` "
|
|
338
338
|
f"line names worker `{role}` — one line per dispatched worker, "
|
|
339
|
-
"
|
|
339
|
+
"prompts/lead/okstra-lead-contract.md 'Progress reporting (BLOCKING)'."
|
|
340
340
|
)
|
|
341
341
|
if status == "completed" and not _phase_mentions_worker(
|
|
342
342
|
by_phase.get("phase-5-collect", []), needles
|
|
@@ -344,7 +344,7 @@ def _check_worker_checkpoint_lines(
|
|
|
344
344
|
errors.append(
|
|
345
345
|
f"PROGRESS checkpoint missing: no `phase-5-collect worker=<role>` "
|
|
346
346
|
f"line names completed worker `{role}` — one line per collected "
|
|
347
|
-
"result,
|
|
347
|
+
"result, prompts/lead/okstra-lead-contract.md 'Progress reporting (BLOCKING)'."
|
|
348
348
|
)
|
|
349
349
|
|
|
350
350
|
|
|
@@ -363,19 +363,19 @@ def _check_progress_checkpoints(
|
|
|
363
363
|
if condition and phase not in by_phase:
|
|
364
364
|
errors.append(
|
|
365
365
|
f"PROGRESS checkpoint missing: `{phase}` ({detail}) — "
|
|
366
|
-
"
|
|
366
|
+
"prompts/lead/okstra-lead-contract.md 'Progress reporting (BLOCKING)'."
|
|
367
367
|
)
|
|
368
368
|
|
|
369
369
|
intake = by_phase.get("phase-1-intake", [])
|
|
370
370
|
if not any("complete" not in line.lower() for _ts, line in intake):
|
|
371
371
|
errors.append(
|
|
372
372
|
"PROGRESS checkpoint missing: `phase-1-intake reading task bundle` "
|
|
373
|
-
"(start-of-Phase-1 line) —
|
|
373
|
+
"(start-of-Phase-1 line) — prompts/lead/okstra-lead-contract.md 'Progress reporting (BLOCKING)'."
|
|
374
374
|
)
|
|
375
375
|
if not any("complete" in line.lower() for _ts, line in intake):
|
|
376
376
|
errors.append(
|
|
377
377
|
"PROGRESS checkpoint missing: `phase-1-intake complete` "
|
|
378
|
-
"(after all intake reads) —
|
|
378
|
+
"(after all intake reads) — prompts/lead/okstra-lead-contract.md 'Progress reporting (BLOCKING)'."
|
|
379
379
|
)
|
|
380
380
|
|
|
381
381
|
workers = [w for w in (team_state.get("workers") or []) if isinstance(w, dict)]
|
|
@@ -427,7 +427,7 @@ def _check_heartbeat_sidecar(path: Path, errors: list[str]) -> None:
|
|
|
427
427
|
f"`{rel}` has no `- PROGRESS: <stage> <ISO-8601-UTC>` heartbeat lines — "
|
|
428
428
|
"the claude-worker MUST write `started` immediately and append one "
|
|
429
429
|
"line per stage at <= 5-minute cadence (agents/workers/claude-worker.md "
|
|
430
|
-
"'Heartbeat',
|
|
430
|
+
"'Heartbeat', prompts/lead/okstra-lead-contract.md Common Mistakes)."
|
|
431
431
|
)
|
|
432
432
|
return
|
|
433
433
|
if entries[0][0] != "started":
|
|
@@ -498,7 +498,7 @@ def _check_implementation_sidecar_reads(evidence: _LeadEvidence, errors: list[st
|
|
|
498
498
|
f"implementation entry guard: no `Read` of `{basename}` found in "
|
|
499
499
|
f"the lead session jsonl within this run's window — the sidecar "
|
|
500
500
|
f"MUST be read fresh at {read_at} every implementation run "
|
|
501
|
-
"(
|
|
501
|
+
"(prompts/lead/okstra-lead-contract.md 'Entry guard (BLOCKING)')."
|
|
502
502
|
)
|
|
503
503
|
continue
|
|
504
504
|
anchor_ts = anchors.get(anchor_phase)
|
|
@@ -507,7 +507,7 @@ def _check_implementation_sidecar_reads(evidence: _LeadEvidence, errors: list[st
|
|
|
507
507
|
f"implementation entry guard: `{basename}` was first Read at "
|
|
508
508
|
f"{min(ts_list)}, not before the first `PROGRESS: {anchor_phase}` "
|
|
509
509
|
f"line ({anchor_ts}) — it must be loaded at {read_at}, before "
|
|
510
|
-
"that checkpoint (
|
|
510
|
+
"that checkpoint (prompts/lead/okstra-lead-contract.md 'Entry guard (BLOCKING)')."
|
|
511
511
|
)
|
|
512
512
|
|
|
513
513
|
|
package/src/install.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { resolvePaths } from "./paths.mjs";
|
|
|
7
7
|
import { fileExists } from "./_proc.mjs";
|
|
8
8
|
import { buildRuntimeManifest, RUNTIMES_MANIFEST_REL } from "./runtime-manifest.mjs";
|
|
9
9
|
import { normalizeRuntimeRequest, resolveRuntime } from "./runtime-resolver.mjs";
|
|
10
|
+
import { OBSOLETE_INTERNAL_SKILL_NAMES, USER_SKILL_NAMES } from "./skill-catalog.mjs";
|
|
10
11
|
|
|
11
12
|
const SKILLS_MANIFEST_REL = "installed-skills.json";
|
|
12
13
|
const AGENTS_MANIFEST_REL = "installed-agents.json";
|
|
@@ -41,6 +42,26 @@ const BIN_ENTRYPOINTS = [
|
|
|
41
42
|
"okstra-spawn-followups.py",
|
|
42
43
|
];
|
|
43
44
|
|
|
45
|
+
// Runtime prompt resources the launch prompt and routed coding-preflight pack
|
|
46
|
+
// depend on. Intentionally an exact allowlist, not a directory wildcard.
|
|
47
|
+
const REQUIRED_PROMPT_RESOURCE_FILES = Object.freeze([
|
|
48
|
+
["prompts", "lead", "okstra-lead-contract.md"],
|
|
49
|
+
["prompts", "lead", "context-loader.md"],
|
|
50
|
+
["prompts", "lead", "team-contract.md"],
|
|
51
|
+
["prompts", "lead", "convergence.md"],
|
|
52
|
+
["prompts", "lead", "report-writer.md"],
|
|
53
|
+
["prompts", "coding-preflight", "overview.md"],
|
|
54
|
+
["prompts", "coding-preflight", "clean-code.md"],
|
|
55
|
+
["prompts", "coding-preflight", "languages", "javascript-typescript.md"],
|
|
56
|
+
["prompts", "coding-preflight", "languages", "python.md"],
|
|
57
|
+
["prompts", "coding-preflight", "languages", "rust.md"],
|
|
58
|
+
["prompts", "coding-preflight", "languages", "java.md"],
|
|
59
|
+
["prompts", "coding-preflight", "languages", "kotlin.md"],
|
|
60
|
+
["prompts", "coding-preflight", "languages", "sql.md"],
|
|
61
|
+
["prompts", "coding-preflight", "frameworks", "node-server.md"],
|
|
62
|
+
["prompts", "coding-preflight", "architectures", "hexagonal.md"],
|
|
63
|
+
]);
|
|
64
|
+
|
|
44
65
|
const INSTALL_USAGE = `okstra install — install runtime into ~/.okstra
|
|
45
66
|
|
|
46
67
|
Usage:
|
|
@@ -57,6 +78,7 @@ Effect (copy mode):
|
|
|
57
78
|
${"$HOME"}/.okstra/lib/validators <- runtime/validators (stage/report structure validators)
|
|
58
79
|
${"$HOME"}/.okstra/bin <- runtime/bin
|
|
59
80
|
${"$HOME"}/.okstra/templates <- runtime/templates (report.css / report.js / *.template.md)
|
|
81
|
+
${"$HOME"}/.okstra/prompts <- runtime/prompts (lead contracts + coding-preflight pack)
|
|
60
82
|
${"$HOME"}/.okstra/templates/settings.local.json <- runtime/templates/reports/settings.template.json
|
|
61
83
|
${"$HOME"}/.claude/skills/<name> <- skills when ${"$HOME"}/.claude exists
|
|
62
84
|
${"$HOME"}/.codex/skills/<name> <- skills when ${"$HOME"}/.codex exists
|
|
@@ -70,6 +92,7 @@ Effect (copy mode):
|
|
|
70
92
|
Effect (link mode):
|
|
71
93
|
${"$HOME"}/.okstra/lib/python/<pkg> -> <repo>/scripts/<pkg> (symlink)
|
|
72
94
|
${"$HOME"}/.okstra/bin/<name>.sh -> <repo>/scripts/<name>.sh
|
|
95
|
+
${"$HOME"}/.okstra/prompts -> <repo>/prompts
|
|
73
96
|
${"$HOME"}/.okstra/templates/settings.local.json -> <repo>/templates/reports/settings.template.json
|
|
74
97
|
${"$HOME"}/.claude/skills/<name> -> <repo>/skills/<name> when ${"$HOME"}/.claude exists
|
|
75
98
|
${"$HOME"}/.codex/skills/<name> -> <repo>/skills/<name> when ${"$HOME"}/.codex exists
|
|
@@ -167,6 +190,16 @@ async function dirExists(path) {
|
|
|
167
190
|
}
|
|
168
191
|
}
|
|
169
192
|
|
|
193
|
+
async function pathEntryExists(path) {
|
|
194
|
+
try {
|
|
195
|
+
await fs.lstat(path);
|
|
196
|
+
return true;
|
|
197
|
+
} catch (err) {
|
|
198
|
+
if (err.code === "ENOENT") return false;
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
170
203
|
async function* walkFiles(root) {
|
|
171
204
|
let entries;
|
|
172
205
|
try {
|
|
@@ -237,34 +270,6 @@ async function copyTreeIfChanged(srcRoot, dstRoot, opts) {
|
|
|
237
270
|
return { copied, skipped, missingSource };
|
|
238
271
|
}
|
|
239
272
|
|
|
240
|
-
// Single-file variant of copyTreeIfChanged, used for the lead `okstra` skill
|
|
241
|
-
// whose source is one file (agents/SKILL.md) rather than a skill directory.
|
|
242
|
-
async function copyFileIfChanged(src, dst, opts) {
|
|
243
|
-
const { refresh = false, dryRun = false, mode } = opts ?? {};
|
|
244
|
-
let srcBuf;
|
|
245
|
-
try {
|
|
246
|
-
srcBuf = await fs.readFile(src);
|
|
247
|
-
} catch {
|
|
248
|
-
return { copied: 0, skipped: 0, missingSource: true };
|
|
249
|
-
}
|
|
250
|
-
let needsCopy = refresh;
|
|
251
|
-
if (!needsCopy) {
|
|
252
|
-
try {
|
|
253
|
-
const [srcHash, dstHash] = await Promise.all([hashFile(src), hashFile(dst)]);
|
|
254
|
-
needsCopy = srcHash !== dstHash;
|
|
255
|
-
} catch {
|
|
256
|
-
needsCopy = true;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
if (!needsCopy) return { copied: 0, skipped: 1, missingSource: false };
|
|
260
|
-
if (dryRun) {
|
|
261
|
-
process.stdout.write(`[dry-run] copy ${src} -> ${dst}\n`);
|
|
262
|
-
return { copied: 1, skipped: 0, missingSource: false };
|
|
263
|
-
}
|
|
264
|
-
await writeFileAtomic(dst, srcBuf, mode);
|
|
265
|
-
return { copied: 1, skipped: 0, missingSource: false };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
273
|
async function ensureSymlink(target, linkPath, opts) {
|
|
269
274
|
const { dryRun = false } = opts ?? {};
|
|
270
275
|
try {
|
|
@@ -341,6 +346,15 @@ async function installLinkMode(repoPath, paths, opts) {
|
|
|
341
346
|
const action = await ensureSymlink(src, dst, { dryRun });
|
|
342
347
|
if (!quiet) process.stdout.write(` bin/${name}: ${action}\n`);
|
|
343
348
|
}
|
|
349
|
+
const promptsSrc = join(repoAbs, "prompts");
|
|
350
|
+
const promptsDst = join(paths.home, "prompts");
|
|
351
|
+
if (await dirExists(promptsSrc)) {
|
|
352
|
+
const action = await ensureSymlink(promptsSrc, promptsDst, { dryRun });
|
|
353
|
+
if (!quiet) process.stdout.write(` prompts: ${action}\n`);
|
|
354
|
+
} else if (!quiet) {
|
|
355
|
+
process.stdout.write(" prompts: missing in repo — skipped\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
344
358
|
|
|
345
359
|
const skillTargets = await detectSkillTargets();
|
|
346
360
|
const installedSkillTargets = await installSkillTargetsLink(repoAbs, skillTargets, { dryRun, quiet });
|
|
@@ -376,16 +390,7 @@ async function installLinkMode(repoPath, paths, opts) {
|
|
|
376
390
|
);
|
|
377
391
|
}
|
|
378
392
|
return 0;
|
|
379
|
-
}
|
|
380
393
|
|
|
381
|
-
async function listSkillDirs(skillsRoot) {
|
|
382
|
-
try {
|
|
383
|
-
const entries = await fs.readdir(skillsRoot, { withFileTypes: true });
|
|
384
|
-
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
385
|
-
} catch (err) {
|
|
386
|
-
if (err.code === "ENOENT") return [];
|
|
387
|
-
throw err;
|
|
388
|
-
}
|
|
389
394
|
}
|
|
390
395
|
|
|
391
396
|
async function detectSkillTargets() {
|
|
@@ -612,15 +617,12 @@ const SETTINGS_TEMPLATE_DESCRIPTOR = {
|
|
|
612
617
|
label: "settings template",
|
|
613
618
|
};
|
|
614
619
|
|
|
615
|
-
const LEAD_SKILL_NAME = "okstra";
|
|
616
|
-
|
|
617
620
|
async function installSkillsCopy(runtimeRoot, target, opts) {
|
|
618
621
|
const { refresh, dryRun, quiet } = opts;
|
|
619
622
|
const srcRoot = join(runtimeRoot, "skills");
|
|
620
|
-
const names = await listSkillDirs(srcRoot);
|
|
621
623
|
let copied = 0;
|
|
622
624
|
let skipped = 0;
|
|
623
|
-
for (const name of
|
|
625
|
+
for (const name of USER_SKILL_NAMES) {
|
|
624
626
|
const r = await copyTreeIfChanged(
|
|
625
627
|
join(srcRoot, name),
|
|
626
628
|
join(target.skillsDir, name),
|
|
@@ -629,65 +631,48 @@ async function installSkillsCopy(runtimeRoot, target, opts) {
|
|
|
629
631
|
copied += r.copied;
|
|
630
632
|
skipped += r.skipped;
|
|
631
633
|
}
|
|
632
|
-
const
|
|
634
|
+
const pruned = await pruneObsoleteInternalSkills(target, opts);
|
|
633
635
|
if (!quiet) {
|
|
634
|
-
const totalCopied = copied + lead.copied;
|
|
635
|
-
const totalSkipped = skipped + lead.skipped;
|
|
636
|
-
const totalSkills = names.length + lead.installed.length;
|
|
637
636
|
process.stdout.write(
|
|
638
|
-
` ${target.provider} skills: copied=${
|
|
637
|
+
` ${target.provider} skills: copied=${copied} skipped=${skipped} pruned=${pruned} -> ${target.skillsDir}/ (${USER_SKILL_NAMES.length} skills)\n`,
|
|
639
638
|
);
|
|
640
639
|
}
|
|
641
|
-
return { ...target, installed: [...
|
|
640
|
+
return { ...target, installed: [...USER_SKILL_NAMES] };
|
|
642
641
|
}
|
|
643
642
|
|
|
644
643
|
async function installSkillsLink(repoAbs, target, opts) {
|
|
645
644
|
const { dryRun, quiet } = opts;
|
|
646
645
|
const srcRoot = join(repoAbs, "skills");
|
|
647
|
-
const names = await listSkillDirs(srcRoot);
|
|
648
646
|
if (!dryRun) await fs.mkdir(target.skillsDir, { recursive: true });
|
|
649
|
-
for (const name of
|
|
647
|
+
for (const name of USER_SKILL_NAMES) {
|
|
650
648
|
const src = join(srcRoot, name);
|
|
651
649
|
const dst = join(target.skillsDir, name);
|
|
652
650
|
const action = await ensureSymlink(src, dst, { dryRun });
|
|
653
651
|
if (!quiet) process.stdout.write(` ${target.provider} skills/${name}: ${action}\n`);
|
|
654
652
|
}
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
async function installLeadSkillCopy(runtimeRoot, target, opts) {
|
|
660
|
-
const { refresh, dryRun, quiet } = opts;
|
|
661
|
-
const src = join(runtimeRoot, "agents", "SKILL.md");
|
|
662
|
-
const dst = join(target.skillsDir, LEAD_SKILL_NAME, "SKILL.md");
|
|
663
|
-
const r = await copyFileIfChanged(src, dst, { refresh, dryRun, mode: 0o644 });
|
|
664
|
-
if (r.missingSource) {
|
|
665
|
-
if (!quiet) {
|
|
666
|
-
process.stdout.write(
|
|
667
|
-
` ${target.provider} skills/okstra: runtime/agents/SKILL.md missing — skipped\n`,
|
|
668
|
-
);
|
|
669
|
-
}
|
|
670
|
-
return { installed: [], copied: 0, skipped: 0 };
|
|
653
|
+
const pruned = await pruneObsoleteInternalSkills(target, opts);
|
|
654
|
+
if (!quiet && pruned > 0) {
|
|
655
|
+
process.stdout.write(` ${target.provider} obsolete internal skills: pruned=${pruned}\n`);
|
|
671
656
|
}
|
|
672
|
-
return { installed: [
|
|
657
|
+
return { ...target, installed: [...USER_SKILL_NAMES] };
|
|
673
658
|
}
|
|
674
659
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
660
|
+
// Remove former internal skill directories left by older installs. Exact-name
|
|
661
|
+
// only (OBSOLETE_INTERNAL_SKILL_NAMES) — those contracts now ship as runtime
|
|
662
|
+
// resources under ~/.okstra/prompts/, not as agent skills.
|
|
663
|
+
async function pruneObsoleteInternalSkills(target, opts) {
|
|
664
|
+
let pruned = 0;
|
|
665
|
+
for (const name of OBSOLETE_INTERNAL_SKILL_NAMES) {
|
|
666
|
+
const path = join(target.skillsDir, name);
|
|
667
|
+
if (!(await pathEntryExists(path))) continue;
|
|
668
|
+
pruned += 1;
|
|
669
|
+
if (opts.dryRun) {
|
|
670
|
+
process.stdout.write(`[dry-run] rm ${path}\n`);
|
|
671
|
+
continue;
|
|
683
672
|
}
|
|
684
|
-
|
|
673
|
+
await fs.rm(path, { recursive: true, force: true });
|
|
685
674
|
}
|
|
686
|
-
|
|
687
|
-
if (!dryRun) await fs.mkdir(join(target.skillsDir, LEAD_SKILL_NAME), { recursive: true });
|
|
688
|
-
const action = await ensureSymlink(src, dst, { dryRun });
|
|
689
|
-
if (!quiet) process.stdout.write(` ${target.provider} skills/okstra/SKILL.md: ${action}\n`);
|
|
690
|
-
return [LEAD_SKILL_NAME];
|
|
675
|
+
return pruned;
|
|
691
676
|
}
|
|
692
677
|
|
|
693
678
|
async function installSkillTargetsCopy(runtimeRoot, targets, opts) {
|
|
@@ -828,6 +813,17 @@ export async function runInstall(args) {
|
|
|
828
813
|
join(paths.home, "lib", "validators"),
|
|
829
814
|
{ refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o755 },
|
|
830
815
|
);
|
|
816
|
+
// prompts/ tree — lead/internal operating contracts (prompts/lead/*.md) and
|
|
817
|
+
// the coding-preflight resource pack (prompts/coding-preflight/*) are read at
|
|
818
|
+
// runtime by the lead and executor via absolute ~/.okstra/prompts/ paths
|
|
819
|
+
// (paths.py OKSTRA_LEAD_CONTRACT_PATH / OKSTRA_CODING_PREFLIGHT_DIR). Run-prep
|
|
820
|
+
// reads prompts/profiles + launch.template from the package runtime dir, but
|
|
821
|
+
// the lead's runtime Read needs them under the home tree like templates.
|
|
822
|
+
const promptsResult = await copyTreeIfChanged(
|
|
823
|
+
join(runtimeRoot, "prompts"),
|
|
824
|
+
join(paths.home, "prompts"),
|
|
825
|
+
{ refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o644 },
|
|
826
|
+
);
|
|
831
827
|
|
|
832
828
|
if (!opts.quiet) {
|
|
833
829
|
summarise("python", pythonResult, paths.pythonpath);
|
|
@@ -835,6 +831,7 @@ export async function runInstall(args) {
|
|
|
835
831
|
summarise("templates", templatesResult, join(paths.home, "templates"));
|
|
836
832
|
summarise("schemas", schemasResult, join(paths.home, "schemas"));
|
|
837
833
|
summarise("validators", validatorsResult, join(paths.home, "lib", "validators"));
|
|
834
|
+
summarise("prompts", promptsResult, join(paths.home, "prompts"));
|
|
838
835
|
}
|
|
839
836
|
|
|
840
837
|
if (pythonResult.missingSource && binResult.missingSource) {
|
|
@@ -857,6 +854,11 @@ export async function runInstall(args) {
|
|
|
857
854
|
"warning: runtime/validators is empty. stage-structure / report validation will use stale or missing validators — re-run the build step.\n",
|
|
858
855
|
);
|
|
859
856
|
}
|
|
857
|
+
if (promptsResult.missingSource) {
|
|
858
|
+
process.stderr.write(
|
|
859
|
+
"warning: runtime/prompts is empty. lead contracts + coding-preflight resources will be missing — re-run the build step.\n",
|
|
860
|
+
);
|
|
861
|
+
}
|
|
860
862
|
|
|
861
863
|
const skillTargets = await detectSkillTargets();
|
|
862
864
|
const installedSkillTargets = await installSkillTargetsCopy(runtimeRoot, skillTargets, opts);
|
|
@@ -895,7 +897,7 @@ export async function runInstall(args) {
|
|
|
895
897
|
async function skillTargetDriftReasons(targets) {
|
|
896
898
|
const reasons = [];
|
|
897
899
|
for (const target of targets) {
|
|
898
|
-
for (const name of
|
|
900
|
+
for (const name of USER_SKILL_NAMES) {
|
|
899
901
|
const skillPath = join(target.skillsDir, name, "SKILL.md");
|
|
900
902
|
if (!(await fileExists(skillPath))) {
|
|
901
903
|
reasons.push(`missing ${skillPath}`);
|
|
@@ -905,6 +907,15 @@ async function skillTargetDriftReasons(targets) {
|
|
|
905
907
|
return reasons;
|
|
906
908
|
}
|
|
907
909
|
|
|
910
|
+
async function promptResourceDriftReasons(paths) {
|
|
911
|
+
const reasons = [];
|
|
912
|
+
for (const parts of REQUIRED_PROMPT_RESOURCE_FILES) {
|
|
913
|
+
const path = join(paths.home, ...parts);
|
|
914
|
+
if (!(await fileExists(path))) reasons.push(`missing ${path}`);
|
|
915
|
+
}
|
|
916
|
+
return reasons;
|
|
917
|
+
}
|
|
918
|
+
|
|
908
919
|
async function agentDriftReasons(paths) {
|
|
909
920
|
const reasons = [];
|
|
910
921
|
const workersDir = join(paths.agents, "workers");
|
|
@@ -987,6 +998,9 @@ export async function runEnsureInstalled(args) {
|
|
|
987
998
|
if (!(await fileExists(join(paths.home, "schemas", "final-report-v1.0.schema.json")))) {
|
|
988
999
|
reasons.push(`missing ${join(paths.home, "schemas", "final-report-v1.0.schema.json")}`);
|
|
989
1000
|
}
|
|
1001
|
+
for (const reason of await promptResourceDriftReasons(paths)) {
|
|
1002
|
+
reasons.push(reason);
|
|
1003
|
+
}
|
|
990
1004
|
const skillTargets = await detectSkillTargets();
|
|
991
1005
|
for (const reason of await skillTargetDriftReasons(skillTargets)) {
|
|
992
1006
|
reasons.push(reason);
|