okstra 0.7.0 → 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 CHANGED
@@ -48,6 +48,7 @@ okstra/ npm 패키지 = repo 루트
48
48
  ├── lib/python/ okstra_project/, okstra_ctl/, okstra_token_usage/, lib/
49
49
  ├── bin/ okstra.sh, codex-exec, gemini-exec, ...
50
50
  ├── installed-skills.json 설치된 스킬 매니페스트 (uninstall 이 사용)
51
+ ├── installed-agents.json 설치된 워커 에이전트 매니페스트 (uninstall 이 사용)
51
52
  ├── recent.jsonl, active.jsonl run 인덱스
52
53
  ├── projects/ 프로젝트별 메타데이터 미러
53
54
  └── archive/ 완료된 run
@@ -55,6 +56,10 @@ okstra/ npm 패키지 = repo 루트
55
56
  ~/.claude/skills/ Claude Code 가 자동 인식
56
57
  └── okstra-*/SKILL.md 스킬 11종 (§3.3 참조)
57
58
 
59
+ ~/.claude/agents/ Claude Code 가 자동 인식 (subagent 디스커버리 경로)
60
+ └── {claude,codex,gemini,report-writer}-worker.md worker subagent 정의
61
+ (multi-agent dispatch 필수)
62
+
58
63
  <프로젝트 루트>/.project-docs/okstra/
59
64
  ├── project.json {projectId, projectRoot, ...} (`/okstra-setup` 이 작성)
60
65
  ├── discovery/{task-catalog,latest-task}.json
@@ -68,6 +73,7 @@ okstra/ npm 패키지 = repo 루트
68
73
  | 런타임 코드 (python + bash) | `~/.okstra/{lib/python, bin}` | `okstra install` |
69
74
  | agents/prompts/templates/validators | npm 패키지의 `runtime/` | `okstra` 패키지 자체 (`okstra paths` 로 해석) |
70
75
  | 스킬 마크다운 | `~/.claude/skills/<name>/SKILL.md` | `okstra install` (`installed-skills.json` 에 트래킹) |
76
+ | 워커 에이전트 마크다운 | `~/.claude/agents/<worker>.md` | `okstra install` (`installed-agents.json` 에 트래킹) |
71
77
  | 프로젝트 메타데이터 | `<project>/.project-docs/okstra/` | `/okstra-setup` + 프로젝트 자체 |
72
78
  | Run 인덱스 | `~/.okstra/{recent,active}.jsonl` | `prepare_task_bundle` |
73
79
 
package/README.md CHANGED
@@ -47,6 +47,7 @@ okstra/ npm package = repo root
47
47
  ├── lib/python/ okstra_project/, okstra_ctl/, okstra_token_usage/, lib/
48
48
  ├── bin/ okstra.sh, codex-exec, gemini-exec, ...
49
49
  ├── installed-skills.json manifest of installed skills (used by uninstall)
50
+ ├── installed-agents.json manifest of installed worker agents (used by uninstall)
50
51
  ├── recent.jsonl, active.jsonl run index
51
52
  ├── projects/ per-project metadata mirror
52
53
  └── archive/ completed runs
@@ -54,6 +55,10 @@ okstra/ npm package = repo root
54
55
  ~/.claude/skills/ discovered automatically by Claude Code
55
56
  └── okstra-*/SKILL.md 11 skills total (see §3.3)
56
57
 
58
+ ~/.claude/agents/ discovered automatically by Claude Code
59
+ └── {claude,codex,gemini,report-writer}-worker.md subagent definitions
60
+ (required for multi-agent dispatch)
61
+
57
62
  <project-root>/.project-docs/okstra/
58
63
  ├── project.json {projectId, projectRoot, ...} (written by /okstra-setup)
59
64
  ├── discovery/{task-catalog,latest-task}.json
@@ -67,6 +72,7 @@ okstra/ npm package = repo root
67
72
  | Runtime code (python + bash) | `~/.okstra/{lib/python, bin}` | `okstra install` |
68
73
  | agents/prompts/templates/validators | npm package's `runtime/` | the `okstra` package (resolved via `okstra paths`) |
69
74
  | Skill markdown | `~/.claude/skills/<name>/SKILL.md` | `okstra install` (tracked in `installed-skills.json`) |
75
+ | Worker agent markdown | `~/.claude/agents/<worker>.md` | `okstra install` (tracked in `installed-agents.json`) |
70
76
  | Project metadata | `<project>/.project-docs/okstra/` | `/okstra-setup` + the project itself |
71
77
  | Run index | `~/.okstra/{recent,active}.jsonl` | `prepare_task_bundle` |
72
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.7.0",
3
- "builtAt": "2026-05-12T08:22:42.014Z",
2
+ "package": "0.8.0",
3
+ "builtAt": "2026-05-12T09:54:25.514Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -79,6 +79,7 @@ okstra execution summary:
79
79
  workers override: ${WORKERS_OVERRIDE:-None}
80
80
  executor (implementation only): ${EXECUTOR_OVERRIDE:-default(claude)}
81
81
  approved plan: ${APPROVED_PLAN_PATH:-None}
82
+ approve ack (CLI 승인 의사): ${APPROVE_PLAN_ACK}
82
83
  related tasks: ${RELATED_TASKS_RAW:-None}
83
84
  CONFIRM_EOF
84
85
  printf 'Continue? [y/yes]: ' >&2
@@ -115,7 +116,9 @@ PY_ARGS=(
115
116
  [[ -n "${EXECUTOR_OVERRIDE-}" ]] && PY_ARGS+=(--executor "$EXECUTOR_OVERRIDE")
116
117
  [[ -n "${RELATED_TASKS_RAW-}" ]] && PY_ARGS+=(--related-tasks "$RELATED_TASKS_RAW")
117
118
  [[ -n "${APPROVED_PLAN_PATH-}" ]] && PY_ARGS+=(--approved-plan "$APPROVED_PLAN_PATH")
119
+ [[ "$APPROVE_PLAN_ACK" == "true" ]] && PY_ARGS+=(--approve)
118
120
  [[ -n "${CLARIFICATION_RESPONSE_PATH-}" ]] && PY_ARGS+=(--clarification-response "$CLARIFICATION_RESPONSE_PATH")
121
+ [[ -n "${WORK_CATEGORY-}" ]] && PY_ARGS+=(--work-category "$WORK_CATEGORY")
119
122
  [[ "$RENDER_ONLY" == "true" ]] && PY_ARGS+=(--render-only)
120
123
  [[ "$REFRESH_OKSTRA_ASSETS" == "true" ]] && PY_ARGS+=(--refresh-assets)
121
124
 
@@ -40,7 +40,7 @@
40
40
  - Authority & permissions assumption (HARD RULE — applies to every okstra task-type):
41
41
  - **Assume the user (and their team) holds full authority and every permission required for the anticipated work.** Treat external approvals, third-party access grants, role/IAM permissions, organisational sign-off, legal/compliance review, vendor coordination, and "verify access exists" steps as already satisfied unless the user explicitly states otherwise in the task brief.
42
42
  - Do NOT add such items to option trade-offs, dependency/migration risk, validation checklists, rollout plans, `Open Questions`, or day/effort estimates. They are not legitimate sources of schedule extension; effort sizing must reflect engineering work only.
43
- - The `User Approval Request` block at the end of the plan is the only authorised approval gate — that gate is the user themselves and is expected to be cleared without external coordination.
43
+ - The `User Approval Request (사용자 승인 게이트)` block at the **top of the final report** (immediately under the metadata header, before `Section 0`) is the only authorised approval gate — that gate is the user themselves, who clears it either by (a) editing the single checkbox `- [ ] Approved` to `- [x] Approved` directly, or (b) invoking the next phase with `--approve` so the CLI invocation itself is treated as the approval signal and the runtime flips the checkbox on the user's behalf. No external coordination is expected.
44
44
  - Non-goals:
45
45
  - code-level micro-optimization unless it changes the implementation approach
46
46
  - **source code edits of any kind** — this run produces a plan document only; Edit/Write on project source files is forbidden until the plan is approved and a separate `implementation` run starts
@@ -66,7 +66,7 @@
66
66
  - dependency / migration risk assessment (ordering constraints, data backfills, feature-flag prerequisites, cross-team coordination)
67
67
  - validation checklist (pre / mid / post) — each item is an exact command or observable outcome
68
68
  - rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
69
- - explicit `User Approval Request` block the next `implementation` run must not begin until the user replies with explicit approval
69
+ - explicit `User Approval Request (사용자 승인 게이트)` block placed at the **top of the report** with a single canonical checkbox marker `- [ ] Approved` (user toggles to `- [x] Approved` to authorise the next `implementation` run). Section `4.5.8` is retained only as a back-pointer to this top block for validator/key-substring compatibility — it must NOT carry an independent marker.
70
70
  - `Open Questions` block listing every ambiguity flagged during pre-planning that the user must resolve before approval
71
71
  - No-placeholder rule (plan failures — reject any option or step that contains these):
72
72
  - "TBD", "TODO", "implement later", "fill in details", "add appropriate error handling", "handle edge cases", "write tests for the above" without actual test code
@@ -26,7 +26,11 @@
26
26
  - the read-only MCP servers declared in the task brief's `## Available MCP Servers` section may be queried by both executor and verifiers as a read-only cross-check (sanity-checking row counts after a migration script's dry-run, comparing observed schema against the plan's expectations, etc.); that section is the canonical source of which servers and tools exist for this run, and any MCP-derived evidence MUST cite server, table, and the SELECT used. MCP MUST NEVER be used as a write path — schema/data mutations go through repository migration files, never through this MCP.
27
27
  - Pre-implementation gate (mandatory — refuse to start if any item fails):
28
28
  - the run brief MUST cite `--approved-plan <path>` pointing to a `final-report.md` produced by a prior `implementation-planning` run located under `runs/implementation-planning/.../reports/final-report.md`
29
- - that file MUST contain a `User Approval Request` block AND a recorded user approval marker matching one of the following line-anchored, case-insensitive forms (the runtime regex in `okstra_ctl.run._validate_approved_plan` enforces this and rejects the run with `PrepareError` before any prompt is generated): `APPROVED` (alone, followed by `:`, or end-of-line), `[x] Approved`, or `User Approval: APPROVED|granted|yes`. Free-form approvals such as "lgtm", "go ahead", or paraphrased confirmations are intentionally NOT accepted; if the user's approval is informal, re-edit the plan file to add one of the exact markers above before invoking the implementation run.
29
+ - that file MUST contain a `User Approval Request` block (canonically placed at the **top of the report**, immediately under the metadata header) AND a recorded user approval marker. The canonical, recommended form is the single markdown checkbox line `- [x] Approved`. The runtime regex in `okstra_ctl.run._validate_approved_plan` also accepts (case-insensitive, line-anchored, optional leading `-`/`*`/`+` bullet): `APPROVED` (alone, followed by `:`, or end-of-line), `[x] Approved`, or `User Approval: APPROVED|granted|yes`. Free-form approvals such as "lgtm", "go ahead", or paraphrased confirmations are intentionally NOT accepted; if the user's approval is informal, re-edit the plan file to flip the top checkbox to `- [x] Approved` before invoking the implementation run.
30
+ - Two equally-valid approval paths exist (both end up satisfying the same regex gate):
31
+ - **Manual edit** — the user opens the report, flips `- [ ] Approved` to `- [x] Approved`, saves, then runs `okstra ... --task-type implementation --approved-plan <path>`.
32
+ - **CLI ack** — the user runs `okstra ... --task-type implementation --approved-plan <path> --approve`. The CLI invocation itself is modelled as the user's act of approval; the runtime (`okstra_ctl.run._apply_cli_approval`) flips the checkbox in the report file and appends an audit line `- 승인 일시 (CLI ack): <ISO8601> — recorded by \`okstra --approve\`` before the standard regex validation runs. Use this when running unattended or when you want a single command to both approve and launch the next phase.
33
+ - The `--approve` flag is **only meaningful with `--task-type implementation` and `--approved-plan <path>`**. Passing it with any other task-type causes `PrepareError` (the runtime refuses to silently ignore approval signals). It is also a no-op if the file already carries a valid approval marker (idempotent — only an audit line is appended, the marker is not re-toggled).
30
34
  - the file's `Recommended option` and its bite-sized step list become the authoritative scope for this run; any deviation must be justified in the final report and routed back to a new `implementation-planning` run instead of being silently expanded.
31
35
  - Pre-implementation context exploration (executor before first edit):
32
36
  - re-read the approved plan end-to-end and extract: file list, step order, validation commands, rollback path
@@ -140,6 +140,10 @@ while [[ $# -gt 0 ]]; do
140
140
  RELATED_TASKS_RAW="$(require_option_value --related-tasks "${2-}")"
141
141
  shift 2
142
142
  ;;
143
+ --work-category)
144
+ WORK_CATEGORY="$(require_option_value --work-category "${2-}")"
145
+ shift 2
146
+ ;;
143
147
  --task-type)
144
148
  ANALYSIS_TYPE="$(require_option_value --task-type "${2-}")"
145
149
  shift 2
@@ -180,6 +184,13 @@ while [[ $# -gt 0 ]]; do
180
184
  APPROVED_PLAN_PATH="$(require_option_value --approved-plan "${2-}")"
181
185
  shift 2
182
186
  ;;
187
+ --approve)
188
+ # 사용자가 CLI 명령을 입력한 행위 자체를 plan 승인 의사로 간주한다.
189
+ # 런타임에서 approved-plan 파일의 승인 체크박스를 [x] 로 갱신하고
190
+ # 승인 시각/방식을 함께 기록한다 (scripts/okstra_ctl/run.py 참조).
191
+ APPROVE_PLAN_ACK="true"
192
+ shift
193
+ ;;
183
194
  -h|--help)
184
195
  usage
185
196
  exit 0
@@ -209,7 +220,7 @@ while [[ $# -gt 0 ]]; do
209
220
  printf ' hint: did you mean --task-id?\n' >&2
210
221
  ;;
211
222
  esac
212
- printf ' valid options: --render-only --resume-clarification --yes --refresh-assets --workers --lead-model --claude-model --codex-model --gemini-model --report-writer-model --executor --related-tasks --task-type --project-id --project-root --task-group --task-id --task-brief --directive --clarification-response --approved-plan -h|--help\n' >&2
223
+ printf ' valid options: --render-only --resume-clarification --yes --refresh-assets --workers --lead-model --claude-model --codex-model --gemini-model --report-writer-model --related-tasks --task-type --project-id --project-root --task-group --task-id --task-brief --directive --clarification-response --approved-plan --approve -h|--help\n' >&2
213
224
  usage
214
225
  exit 1
215
226
  ;;
@@ -25,6 +25,7 @@ CODEX_MODEL_OVERRIDE=""
25
25
  GEMINI_MODEL_OVERRIDE=""
26
26
  REPORT_WRITER_MODEL_OVERRIDE=""
27
27
  EXECUTOR_OVERRIDE=""
28
+ WORK_CATEGORY=""
28
29
  RELATED_TASKS_RAW=""
29
30
  ANALYSIS_TYPE=""
30
31
  BRIEF_PATH=""
@@ -37,6 +38,7 @@ REVIEW_PROFILE=""
37
38
  DIRECTIVE=""
38
39
  CLARIFICATION_RESPONSE_PATH=""
39
40
  APPROVED_PLAN_PATH=""
41
+ APPROVE_PLAN_ACK="false"
40
42
  CLARIFICATION_RESPONSE_FILE=""
41
43
  CLARIFICATION_RESPONSE_RELATIVE_PATH=""
42
44
  PROJECT_ROOT=""
@@ -39,6 +39,12 @@ optional arguments:
39
39
  should prefer --resume-clarification, which wraps this flag.
40
40
  --approved-plan Path to the approved final-report.md from a prior implementation-planning run.
41
41
  Required when --task-type=implementation; the file MUST contain a recorded user approval marker.
42
+ --approve Treat the user's CLI invocation itself as the plan-approval signal. Only meaningful
43
+ together with --approved-plan and --task-type=implementation. The runtime updates the
44
+ top "User Approval Request" block of the approved-plan file: flips
45
+ \`- [ ] Approved\` to \`- [x] Approved\` and appends an approval audit line
46
+ (timestamp + "CLI --approve"). Use this for scripted/CI flows or when you want a
47
+ single command to both approve and launch the next phase.
42
48
  --task-key <project-id:task-group:task-id>
43
49
  Shorthand for --project-id/--task-group/--task-id. When the matching task-manifest.json
44
50
  exists, brief-path and task-type are auto-filled from it (taskBriefPath and
@@ -72,6 +78,11 @@ options:
72
78
  providers are dispatched as read-only verifiers regardless of this selection.
73
79
  Has no effect on other task types.
74
80
  --related-tasks Optional comma-separated related task identifiers. Example: auth-token-refresh,frontend-login-ui
81
+ --work-category Work-category classification for this task. One of:
82
+ bugfix | feature | refactor | ops | improvement | unknown.
83
+ Defaults to 'unknown' when omitted. Use this when the
84
+ lifecycle skipped --task-type=requirements-discovery
85
+ (where work-category would otherwise be inferred).
75
86
  --task-type Set the task purpose for this run and select the matching profile file.
76
87
  -h, --help Show this help.
77
88
 
@@ -438,10 +438,19 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
438
438
  if current_phase:
439
439
  phase_states[current_phase] = current_phase_state
440
440
  work_category = existing.get("workCategory") or ctx.get("WORKFLOW_WORK_CATEGORY", "unknown")
441
- next_recommended_phase = (
442
- workflow.get("nextRecommendedPhase")
443
- or default_next_phase.get(current_phase, ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", "unknown"))
441
+ # Compute the canonical next phase from current_phase deterministically.
442
+ # Only preserve `existing.workflow.nextRecommendedPhase` when it is a
443
+ # legitimate forward pointer — i.e. NOT equal to `current_phase` itself
444
+ # (which would mean the lifecycle pointer has stalled on the current
445
+ # phase and would loop forever).
446
+ canonical_next = default_next_phase.get(
447
+ current_phase, ctx.get("WORKFLOW_NEXT_RECOMMENDED_PHASE", "unknown")
444
448
  )
449
+ existing_next = workflow.get("nextRecommendedPhase") or ""
450
+ if existing_next and existing_next != current_phase:
451
+ next_recommended_phase = existing_next
452
+ else:
453
+ next_recommended_phase = canonical_next
445
454
  last_completed_phase = workflow.get("lastCompletedPhase") or ctx.get("WORKFLOW_LAST_COMPLETED_PHASE", "")
446
455
  routing_status = workflow.get("routingStatus") or ctx.get("WORKFLOW_ROUTING_STATUS", "not-applicable")
447
456
  awaiting_approval = workflow.get("awaitingApproval")
@@ -21,6 +21,7 @@ import re
21
21
  import shutil
22
22
  import subprocess
23
23
  from dataclasses import dataclass, field
24
+ from datetime import datetime, timezone
24
25
  from pathlib import Path
25
26
  from typing import Optional
26
27
 
@@ -55,7 +56,7 @@ from .workers import normalize_workers, resolve_profile_workers
55
56
  from .workflow import compute_workflow_state
56
57
 
57
58
  APPROVED_PLAN_PATTERN = re.compile(
58
- r"^[ \t]*(APPROVED([ \t]|:|$)|\[x\][ \t]*Approved|"
59
+ r"^[ \t]*(?:[-*+][ \t]+)?(APPROVED([ \t]|:|$)|\[x\][ \t]*Approved|"
59
60
  r"User[ \t]+Approval[ \t]*:[ \t]*(APPROVED|granted|yes))",
60
61
  re.IGNORECASE | re.MULTILINE,
61
62
  )
@@ -83,10 +84,12 @@ class PrepareInputs:
83
84
  report_writer_model: str = ""
84
85
  executor: str = ""
85
86
  related_tasks_raw: str = ""
87
+ work_category: str = ""
86
88
  approved_plan_path: str = ""
87
89
  clarification_response_path: str = "" # absolute or empty
88
90
  render_only: bool = False
89
91
  refresh_assets: bool = False
92
+ approve_plan_ack: bool = False
90
93
 
91
94
 
92
95
  @dataclass
@@ -108,11 +111,65 @@ def _validate_approved_plan(path: str) -> None:
108
111
  if not APPROVED_PLAN_PATTERN.search(p.read_text(encoding="utf-8", errors="replace")):
109
112
  raise PrepareError(
110
113
  f"approved plan has no recognised user-approval marker: {path}\n"
111
- ' expected one of (case-insensitive, line-anchored): "APPROVED", '
112
- '"[x] Approved", "User Approval: APPROVED|granted|yes"'
114
+ ' canonical form (single line, top-of-report block): "- [x] Approved"\n'
115
+ ' also accepted (case-insensitive, line-anchored, optional leading bullet): '
116
+ '"APPROVED", "[x] Approved", "User Approval: APPROVED|granted|yes"\n'
117
+ " shortcut: re-run okstra with --approve to have the CLI itself "
118
+ "record the approval marker on this file."
113
119
  )
114
120
 
115
121
 
122
+ # `- [ ] Approved` 라인을 정확히 한 번만 매치한다. 좌측 leading whitespace 와
123
+ # 옵션 bullet 은 보존된 채 체크박스 안쪽 공백만 `x` 로 갱신된다.
124
+ APPROVAL_UNCHECKED_PATTERN = re.compile(
125
+ r"^([ \t]*(?:[-*+][ \t]+)?)\[[ \t]\][ \t]*Approved[ \t]*$",
126
+ re.IGNORECASE | re.MULTILINE,
127
+ )
128
+
129
+
130
+ def _apply_cli_approval(path: str) -> str:
131
+ """`--approve` 가 지정된 경우 approved-plan 파일에 사용자 승인 마커를 새겨 넣는다.
132
+
133
+ Returns a short human-readable description of the action taken (used in the
134
+ runtime audit line). Idempotent: if the file already carries a valid
135
+ approval marker, no edits are written and `"already-approved"` is returned.
136
+ """
137
+ p = Path(path)
138
+ if not p.is_file():
139
+ raise PrepareError(f"approved plan file not found: {path}")
140
+ body = p.read_text(encoding="utf-8", errors="replace")
141
+
142
+ audit_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
143
+ audit_line = (
144
+ f"- 승인 일시 (CLI ack): {audit_iso} — recorded by `okstra --approve` "
145
+ "(user CLI invocation treated as approval signal)"
146
+ )
147
+
148
+ if APPROVED_PLAN_PATTERN.search(body):
149
+ # 이미 사용자(또는 이전 --approve 호출)가 마커를 남긴 상태. audit 라인이
150
+ # 없으면 보조적으로 한 줄만 추가하고 마커 자체는 건드리지 않는다.
151
+ if audit_line.split(" — ")[1] in body:
152
+ return "already-approved"
153
+ new_body = body.rstrip("\n") + "\n" + audit_line + "\n"
154
+ p.write_text(new_body, encoding="utf-8")
155
+ return "already-approved-audit-appended"
156
+
157
+ if APPROVAL_UNCHECKED_PATTERN.search(body):
158
+ new_body, count = APPROVAL_UNCHECKED_PATTERN.subn(
159
+ lambda m: f"{m.group(1)}[x] Approved", body, count=1,
160
+ )
161
+ new_body = new_body.rstrip("\n") + "\n" + audit_line + "\n"
162
+ p.write_text(new_body, encoding="utf-8")
163
+ return "checkbox-flipped"
164
+
165
+ raise PrepareError(
166
+ f"--approve was given but the approved-plan file has no `User Approval Request` "
167
+ f"checkbox to flip: {path}\n"
168
+ " expected a line of the form `- [ ] Approved` near the top of the report "
169
+ "(see templates/reports/final-report.template.md)."
170
+ )
171
+
172
+
116
173
  def _ensure_task_directories(ctx: dict) -> None:
117
174
  for key in (
118
175
  "TASK_ROOT", "INSTRUCTION_SET_DIR", "RUNS_DIR", "HISTORY_DIR",
@@ -268,6 +325,7 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
268
325
  ("--report-writer-model", inp.report_writer_model or ctx.get("REPORT_WRITER_MODEL_DISPLAY", "")),
269
326
  ("--executor", inp.executor or ctx.get("EXECUTOR_PROVIDER", "")),
270
327
  ("--related-tasks", inp.related_tasks_raw),
328
+ ("--work-category", inp.work_category),
271
329
  ]
272
330
  argv: list[str] = []
273
331
  for flag, val in pairs:
@@ -330,7 +388,19 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
330
388
  raise PrepareError(
331
389
  "task-type implementation requires --approved-plan <path-to-final-report.md>"
332
390
  )
391
+ if inp.approve_plan_ack:
392
+ # 사용자가 직접 `--approve` 를 입력한 행위 자체를 승인 의사로 모델링한다.
393
+ # 파일을 먼저 갱신한 뒤 동일한 검증 경로를 그대로 통과시킨다 — 검증
394
+ # 책임을 단일 지점(`_validate_approved_plan`)으로 유지한다.
395
+ _apply_cli_approval(inp.approved_plan_path)
333
396
  _validate_approved_plan(inp.approved_plan_path)
397
+ elif inp.approve_plan_ack:
398
+ # implementation 외 task-type 에서 `--approve` 는 의미가 없다. 사용자에게
399
+ # 정확한 시점을 알려주기 위해 조용히 무시하지 않고 즉시 거부한다.
400
+ raise PrepareError(
401
+ "--approve is only meaningful with --task-type implementation "
402
+ "and --approved-plan <path>"
403
+ )
334
404
  if inp.clarification_response_path and not Path(inp.clarification_response_path).is_file():
335
405
  raise PrepareError(
336
406
  f"clarification response file not found: {inp.clarification_response_path}"
@@ -487,11 +557,15 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
487
557
  cleanup_obsolete_generated_docs(
488
558
  project_root=project_root, instruction_set_dir=Path(ctx["INSTRUCTION_SET_DIR"]),
489
559
  )
490
- if not inp.render_only:
491
- write_claude_resume_command_file(
492
- resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_FILE"]),
493
- project_root=project_root, claude_session_id=claude_session_id,
494
- )
560
+ # Always materialise the resume command script. Even in --render-only
561
+ # preparation flows the user (or a later non-interactive runner) may
562
+ # invoke it manually; deferring its creation until interactive launch
563
+ # leaves runs/<phase>/sessions/ empty and the manifest pointing at a
564
+ # path that does not exist.
565
+ write_claude_resume_command_file(
566
+ resume_command_path=Path(ctx["CLAUDE_RESUME_COMMAND_FILE"]),
567
+ project_root=project_root, claude_session_id=claude_session_id,
568
+ )
495
569
 
496
570
  # ---- write instruction-set scaffolding ----
497
571
  instruction_set = Path(ctx["INSTRUCTION_SET_DIR"])
@@ -569,6 +643,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
569
643
  current_run_status=ctx["CURRENT_RUN_STATUS"],
570
644
  current_task_status=ctx["CURRENT_TASK_STATUS"],
571
645
  render_only=inp.render_only,
646
+ work_category=inp.work_category,
572
647
  ))
573
648
  render_team_state(ctx["TEAM_STATE_FILE"], ctx)
574
649
  render_task_manifest(ctx["TASK_MANIFEST_FILE"], ctx)
@@ -639,9 +714,29 @@ def main(argv: list[str]) -> int:
639
714
  p.add_argument("--executor", default="")
640
715
  p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
641
716
  p.add_argument("--approved-plan", default="", dest="approved_plan_path")
717
+ p.add_argument(
718
+ "--approve",
719
+ action="store_true",
720
+ dest="approve_plan_ack",
721
+ help=(
722
+ "Treat the CLI invocation itself as the plan approval signal. "
723
+ "Flips `- [ ] Approved` to `- [x] Approved` in the --approved-plan file "
724
+ "and appends an audit line."
725
+ ),
726
+ )
642
727
  p.add_argument("--clarification-response", default="", dest="clarification_response_path")
643
728
  p.add_argument("--render-only", action="store_true", dest="render_only")
644
729
  p.add_argument("--refresh-assets", action="store_true", dest="refresh_assets")
730
+ p.add_argument(
731
+ "--work-category",
732
+ default="",
733
+ dest="work_category",
734
+ help=(
735
+ "Work-category classification for this task "
736
+ "(bugfix / feature / refactor / ops / improvement). "
737
+ "Falls back to `unknown` when omitted."
738
+ ),
739
+ )
645
740
  args = p.parse_args(argv)
646
741
 
647
742
  project_root = Path(args.project_root).expanduser().resolve()
@@ -677,10 +772,12 @@ def main(argv: list[str]) -> int:
677
772
  report_writer_model=args.report_writer_model,
678
773
  executor=args.executor,
679
774
  related_tasks_raw=args.related_tasks_raw,
775
+ work_category=args.work_category,
680
776
  approved_plan_path=args.approved_plan_path,
681
777
  clarification_response_path=clarification_abs,
682
778
  render_only=args.render_only,
683
779
  refresh_assets=args.refresh_assets,
780
+ approve_plan_ack=args.approve_plan_ack,
684
781
  )
685
782
  try:
686
783
  out = prepare_task_bundle(inputs)
@@ -138,6 +138,7 @@ def compute_workflow_state(
138
138
  current_run_status: str,
139
139
  current_task_status: str,
140
140
  render_only: bool,
141
+ work_category: str = "",
141
142
  ) -> dict:
142
143
  """WORKFLOW_* + PHASE_* 값을 dict 로 돌려준다."""
143
144
  if current_run_status == "in-progress":
@@ -170,8 +171,10 @@ def compute_workflow_state(
170
171
  rules = PHASE_RULES.get(task_type, PHASE_RULES_UNKNOWN)
171
172
  last_completed = task_type if current_run_status == "completed" else ""
172
173
 
174
+ resolved_work_category = (work_category or "").strip() or "unknown"
175
+
173
176
  return {
174
- "WORKFLOW_WORK_CATEGORY": "unknown",
177
+ "WORKFLOW_WORK_CATEGORY": resolved_work_category,
175
178
  "WORKFLOW_CURRENT_PHASE": task_type,
176
179
  "WORKFLOW_CURRENT_PHASE_STATE": phase_state,
177
180
  "WORKFLOW_NEXT_RECOMMENDED_PHASE": default_next_phase_for(task_type),
@@ -43,7 +43,7 @@ Extract the following fields from each task.
43
43
  |------|------|
44
44
  | `taskKey` | `<project-id>:<task-group>:<task-id>` |
45
45
  | `taskType` | latest task type |
46
- | `workCategory` | bugfix / feature / improvement / refactor / ops-change / unknown |
46
+ | `workCategory` | bugfix / feature / improvement / refactor / ops-change / unknown. Display `unknown` as-is, but flag with `(unset)` annotation so the reader knows the requirements-discovery classification was skipped or no `--work-category` flag was passed. |
47
47
  | `currentStatus` | task-level status |
48
48
  | `currentPhase` | lifecycle current phase |
49
49
  | `currentPhaseState` | lifecycle phase state |
@@ -213,7 +213,16 @@ Accepted `<status>` values: `todo`, `in-progress`, `blocked`, `done`.
213
213
 
214
214
  ### Default Value Convention
215
215
 
216
- If `workStatus` is missing or empty in any manifest, treat it as `in-progress` for read purposes. Do not back-fill the field on read; only write it when the user explicitly issues an update.
216
+ If `workStatus` is missing or empty in any manifest, infer the display value from the lifecycle state DO NOT default to a static `in-progress`:
217
+
218
+ | Manifest state | Inferred display |
219
+ |---|---|
220
+ | `currentStatus == "completed"` AND `workflow.nextRecommendedPhase` in (`done-or-follow-up`, empty) | `done` (inferred) |
221
+ | `currentStatus == "completed"` AND `workflow.currentPhaseState == "completed"` | `phase-done` (inferred) |
222
+ | `currentStatus == "contract-violated"` OR `workflow.currentPhaseState == "blocked"` | `blocked` (inferred) |
223
+ | anything else | `in-progress` (default) |
224
+
225
+ Annotate inferred values with `(inferred)` or `(default)` in the rendered output so the reader can see which value is user-set vs. system-inferred. Do not back-fill the field on read; only write it when the user explicitly issues an update.
217
226
 
218
227
  ### Catalog Sync Note
219
228
 
@@ -6,6 +6,18 @@
6
6
  - Lead Model: `{{LEAD_MODEL}}`
7
7
  - Clarification Response Carried In: `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}`
8
8
 
9
+ ## User Approval Request (사용자 승인 게이트)
10
+
11
+ > **이 블록은 `task-type` = `implementation-planning` 결과에서만 의미를 가집니다.** 다른 task-type의 보고서에서는 이 섹션 전체를 삭제하세요.
12
+ >
13
+ > 다음 `implementation` run은 아래 체크박스가 `[x]`로 표시되어 있을 때에만 진입할 수 있습니다 (`okstra_ctl.run._validate_approved_plan` 가 이 마커를 line-anchored 정규식으로 검사하여 통과/거부합니다). 본문(`Sections 1`–`4.5`)을 끝까지 읽고, `4.5.9 Open Questions`가 비어 있거나 모두 해소된 뒤 승인해 주세요.
14
+
15
+ - 승인 여부 (사용자가 직접 편집): `- [ ] Approved` ← 승인하려면 `[ ]` 를 `[x]` 로 변경하여 저장하세요.
16
+ - 승인 후 다음 단계 명령어 (방법 A — 수동 편집): `okstra --task-key {{TASK_KEY}} --task-type implementation --approved-plan <이 보고서 경로>`
17
+ - 승인 + 실행 한 번에 (방법 B — CLI 자체를 승인 의사로): `okstra --task-key {{TASK_KEY}} --task-type implementation --approved-plan <이 보고서 경로> --approve`
18
+ - 방법 B 는 `--approve` 입력 행위 자체를 승인 의사로 모델링합니다. 런타임이 본 블록의 체크박스를 자동으로 `[x]` 로 바꾸고, 본 섹션 하단에 `승인 일시 (CLI ack): <ISO8601>` audit 라인을 한 줄 덧붙입니다.
19
+ - 승인을 보류하거나 거부하려면 체크박스는 `[ ]` 로 두고 `--approve` 도 사용하지 마세요. 필요한 변경 사항은 `4.5.9 Open Questions` 또는 `Section 5 Clarification Requests` 에 기록한 뒤 같은 phase 를 재실행해 주세요.
20
+
9
21
  ## 0. Clarification Response Carried In From Previous Run
10
22
  - Source file: `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}`
11
23
  - If the source path is empty, write `- No prior clarification response was provided for this run.` and skip the rest of this section.
@@ -89,9 +101,9 @@
89
101
  ### 4.5.7 Rollback Strategy (롤백 전략)
90
102
  - 정확한 revert 경로(commits, flags, migrations 등)와 롤백을 트리거하는 신호(에러율, latency, 사용자 보고 등)를 명시합니다.
91
103
 
92
- ### 4.5.8 User Approval Request (사용자 승인 요청)
93
- - 다음 `implementation` run을 시작해도 되는지에 대한 명시적 승인 요청 블록입니다. 사용자가 명시적으로 승인하기 전까지는 plan을 코드로 옮기지 않습니다.
94
- - 승인 사용할 명령어 줄을 함께 적습니다: `okstra --task-key {{TASK_KEY}} --task-type implementation --approved-plan <이 보고서 경로>`
104
+ ### 4.5.8 User Approval Request (사용자 승인 요청 — 본 보고서 상단 참조)
105
+ - 실제 승인 게이트는 본 보고서 **상단 `User Approval Request (사용자 승인 게이트)` 블록**에 있습니다. 하위 섹션은 validator가 요구하는 영문 키워드(`User Approval Request`)와 본문 구조 일관성을 위해 남겨 둡니다.
106
+ - 본 섹션에는 승인 결정에 영향을 주는 *플랜 보충 메모*만 적습니다(예: 위험을 줄이기 위한 사전 작업, 승인 사용자가 확인해 두어야 할 사항). 승인 마커는 본 섹션이 아니라 상단 블록의 체크박스로만 부여합니다.
95
107
 
96
108
  ### 4.5.9 Open Questions
97
109
  - pre-planning에서 발견된 모든 모호점을 항목으로 남겨, 사용자가 승인 전에 해소해야 할 질문 목록으로 사용합니다.
@@ -36,11 +36,31 @@ 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
+ "implementation": "final-verification",
39
40
  "final-verification": "done-or-follow-up",
40
41
  }
41
42
  return mapping.get(task_type, "unknown")
42
43
 
43
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
+
44
64
  def update_workflow_metadata(
45
65
  run_manifest: dict,
46
66
  task_manifest: dict,
@@ -77,9 +97,10 @@ def update_workflow_metadata(
77
97
  if current_phase:
78
98
  phase_states[current_phase] = current_phase_state
79
99
  last_completed_phase = current_phase or workflow.get("lastCompletedPhase", "")
80
- next_recommended_phase = (
81
- workflow.get("nextRecommendedPhase") or default_next_phase(current_phase)
82
- )
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)
83
104
  else:
84
105
  current_phase_state = "blocked"
85
106
  if current_phase:
@@ -98,6 +119,12 @@ def update_workflow_metadata(
98
119
  awaiting_approval = workflow.get("awaitingApproval")
99
120
  if not isinstance(awaiting_approval, bool):
100
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
101
128
 
102
129
  last_safe_checkpoint = workflow.get("lastSafeCheckpoint", {})
103
130
  if not isinstance(last_safe_checkpoint, dict):
@@ -440,6 +467,28 @@ def validate_team_state_usage(team_state: dict, failures: list[str]) -> None:
440
467
  "Run `python3 scripts/okstra-token-usage.py <team-state> --write --summary "
441
468
  "--substitute-final-report <final-report>`."
442
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
+ )
443
492
 
444
493
 
445
494
  PLANNING_REQUIRED_SECTIONS = (
@@ -479,6 +528,56 @@ def validate_phase_boundary(
479
528
  )
480
529
 
481
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
+
482
581
  def _import_token_usage():
483
582
  """Resolve and import the okstra_token_usage package across layouts.
484
583
 
@@ -512,6 +611,22 @@ def _needs_token_autofix(team_state: dict, report_path: Path) -> bool:
512
611
  content = report_path.read_text()
513
612
  if any(p in content for p in TOKEN_PLACEHOLDERS):
514
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
515
630
  return False
516
631
 
517
632
 
@@ -690,6 +805,18 @@ def main() -> int:
690
805
  write_json(run_manifest_path, run_manifest)
691
806
  write_json(task_manifest_path, task_manifest)
692
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
+
693
820
  if args.final_status:
694
821
  final_status_path = resolve_input(args.final_status)
695
822
  final_status_path.parent.mkdir(parents=True, exist_ok=True)
package/src/install.mjs CHANGED
@@ -6,7 +6,9 @@ import { getPackageRoot } from "./version.mjs";
6
6
  import { resolvePaths } from "./paths.mjs";
7
7
 
8
8
  const SKILLS_MANIFEST_REL = "installed-skills.json";
9
+ const AGENTS_MANIFEST_REL = "installed-agents.json";
9
10
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
11
+ const CLAUDE_AGENTS_DIR = join(homedir(), ".claude", "agents");
10
12
 
11
13
  const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "lib"];
12
14
  const BIN_ENTRYPOINTS = [
@@ -32,18 +34,23 @@ Effect (copy mode):
32
34
  ${"$HOME"}/.okstra/lib/python <- runtime/python
33
35
  ${"$HOME"}/.okstra/bin <- runtime/bin
34
36
  ${"$HOME"}/.claude/skills/<name> <- runtime/skills/<name> (per skill)
37
+ ${"$HOME"}/.claude/agents/<worker>.md <- runtime/agents/workers/<worker>.md
35
38
  ${"$HOME"}/.okstra/installed-skills.json <- manifest of installed skills
39
+ ${"$HOME"}/.okstra/installed-agents.json <- manifest of installed workers
36
40
  ${"$HOME"}/.okstra/version <- installed package version stamp
37
41
 
38
42
  Effect (link mode):
39
43
  ${"$HOME"}/.okstra/lib/python/<pkg> -> <repo>/scripts/<pkg> (symlink)
40
44
  ${"$HOME"}/.okstra/bin/<name>.sh -> <repo>/scripts/<name>.sh
41
45
  ${"$HOME"}/.claude/skills/<name> -> <repo>/skills/<name> (symlink dir)
46
+ ${"$HOME"}/.claude/agents/<worker>.md -> <repo>/agents/workers/<worker>.md
42
47
  ${"$HOME"}/.okstra/dev-link <- <repo> path stamp
43
48
  ${"$HOME"}/.okstra/version <- installed package version stamp
44
49
 
45
- agents/ is NOT copied it stays inside the package (copy mode) or is
46
- resolved to <repo>/agents (link mode) via 'okstra paths --field agents'.
50
+ Worker agent definitions are installed into ${"$HOME"}/.claude/agents/ so
51
+ that Claude Code's subagent discovery picks them up; they cannot live
52
+ inside the package alone because the harness only scans ~/.claude/agents/
53
+ and <project>/.claude/agents/.
47
54
  `;
48
55
 
49
56
  const ENSURE_USAGE = `okstra ensure-installed — idempotent install check
@@ -218,6 +225,9 @@ async function installLinkMode(repoPath, paths, opts) {
218
225
  const skillResult = await installSkillsLink(repoAbs, { dryRun, quiet });
219
226
  await writeSkillsManifest(paths.home, skillResult.installed, { dryRun });
220
227
 
228
+ const agentResult = await installAgentsLink(repoAbs, { dryRun, quiet });
229
+ await writeAgentsManifest(paths.home, agentResult.installed, { dryRun });
230
+
221
231
  if (!dryRun) {
222
232
  await writeFileAtomic(join(paths.home, "dev-link"), repoAbs + "\n", 0o644);
223
233
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
@@ -274,6 +284,98 @@ async function writeSkillsManifest(home, names, opts) {
274
284
  );
275
285
  }
276
286
 
287
+ async function writeAgentsManifest(home, names, opts) {
288
+ const { dryRun = false } = opts ?? {};
289
+ const data = {
290
+ version: 1,
291
+ installedAt: new Date().toISOString(),
292
+ agents: Array.from(new Set(names)).sort(),
293
+ };
294
+ if (dryRun) {
295
+ process.stdout.write(`[dry-run] write agents manifest: ${data.agents.length} entries\n`);
296
+ return;
297
+ }
298
+ await writeFileAtomic(
299
+ join(home, AGENTS_MANIFEST_REL),
300
+ JSON.stringify(data, null, 2) + "\n",
301
+ 0o644,
302
+ );
303
+ }
304
+
305
+ async function listWorkerFiles(workersRoot) {
306
+ try {
307
+ const entries = await fs.readdir(workersRoot, { withFileTypes: true });
308
+ return entries
309
+ .filter((e) => e.isFile() && e.name.endsWith(".md"))
310
+ .map((e) => e.name);
311
+ } catch (err) {
312
+ if (err.code === "ENOENT") return [];
313
+ throw err;
314
+ }
315
+ }
316
+
317
+ async function installAgentsCopy(runtimeRoot, opts) {
318
+ const { refresh, dryRun, quiet } = opts;
319
+ const srcRoot = join(runtimeRoot, "agents", "workers");
320
+ const names = await listWorkerFiles(srcRoot);
321
+ if (names.length === 0) {
322
+ if (!quiet) process.stdout.write(" agents: runtime/agents/workers empty — skipped\n");
323
+ return { installed: [] };
324
+ }
325
+ if (!dryRun) await fs.mkdir(CLAUDE_AGENTS_DIR, { recursive: true });
326
+ let copied = 0;
327
+ let skipped = 0;
328
+ for (const name of names) {
329
+ const src = join(srcRoot, name);
330
+ const dst = join(CLAUDE_AGENTS_DIR, name);
331
+ let needsCopy = refresh;
332
+ if (!needsCopy) {
333
+ try {
334
+ await fs.access(dst);
335
+ const [a, b] = await Promise.all([hashFile(src), hashFile(dst)]);
336
+ needsCopy = a !== b;
337
+ } catch {
338
+ needsCopy = true;
339
+ }
340
+ }
341
+ if (!needsCopy) {
342
+ skipped++;
343
+ continue;
344
+ }
345
+ if (dryRun) {
346
+ process.stdout.write(`[dry-run] copy ${src} -> ${dst}\n`);
347
+ } else {
348
+ const buf = await fs.readFile(src);
349
+ await writeFileAtomic(dst, buf, 0o644);
350
+ }
351
+ copied++;
352
+ }
353
+ if (!quiet) {
354
+ process.stdout.write(
355
+ ` agents: copied=${copied} skipped=${skipped} -> ${CLAUDE_AGENTS_DIR}/ (${names.length} workers)\n`,
356
+ );
357
+ }
358
+ return { installed: names };
359
+ }
360
+
361
+ async function installAgentsLink(repoAbs, opts) {
362
+ const { dryRun, quiet } = opts;
363
+ const srcRoot = join(repoAbs, "agents", "workers");
364
+ const names = await listWorkerFiles(srcRoot);
365
+ if (names.length === 0) {
366
+ if (!quiet) process.stdout.write(" agents: <repo>/agents/workers missing — skipped\n");
367
+ return { installed: [] };
368
+ }
369
+ if (!dryRun) await fs.mkdir(CLAUDE_AGENTS_DIR, { recursive: true });
370
+ for (const name of names) {
371
+ const src = join(srcRoot, name);
372
+ const dst = join(CLAUDE_AGENTS_DIR, name);
373
+ const action = await ensureSymlink(src, dst, { dryRun });
374
+ if (!quiet) process.stdout.write(` agents/${name}: ${action}\n`);
375
+ }
376
+ return { installed: names };
377
+ }
378
+
277
379
  async function installSkillsCopy(runtimeRoot, opts) {
278
380
  const { refresh, dryRun, quiet } = opts;
279
381
  const srcRoot = join(runtimeRoot, "skills");
@@ -402,6 +504,9 @@ export async function runInstall(args) {
402
504
  const skillResult = await installSkillsCopy(runtimeRoot, opts);
403
505
  await writeSkillsManifest(paths.home, skillResult.installed, { dryRun: opts.dryRun });
404
506
 
507
+ const agentResult = await installAgentsCopy(runtimeRoot, opts);
508
+ await writeAgentsManifest(paths.home, agentResult.installed, { dryRun: opts.dryRun });
509
+
405
510
  if (!opts.dryRun) {
406
511
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
407
512
  }
@@ -419,6 +524,29 @@ export async function runInstall(args) {
419
524
  return 0;
420
525
  }
421
526
 
527
+ async function agentDriftReasons(paths) {
528
+ const reasons = [];
529
+ const workersDir = join(paths.agents, "workers");
530
+ const expected = await listWorkerFiles(workersDir);
531
+ if (expected.length === 0) return reasons;
532
+ for (const name of expected) {
533
+ const target = join(CLAUDE_AGENTS_DIR, name);
534
+ try {
535
+ const lst = await fs.lstat(target);
536
+ if (lst.isSymbolicLink()) {
537
+ try {
538
+ await fs.stat(target);
539
+ } catch {
540
+ reasons.push(`dangling symlink ${target}`);
541
+ }
542
+ }
543
+ } catch {
544
+ reasons.push(`missing agent ${target}`);
545
+ }
546
+ }
547
+ return reasons;
548
+ }
549
+
422
550
  function summarise(label, result, target) {
423
551
  if (result.missingSource) {
424
552
  process.stdout.write(` ${label}: source directory missing — skipped\n`);
@@ -447,6 +575,9 @@ export async function runEnsureInstalled(args) {
447
575
  if (!(await fileExists(join(CLAUDE_SKILLS_DIR, "okstra-setup", "SKILL.md")))) {
448
576
  reasons.push(`missing ${CLAUDE_SKILLS_DIR}/okstra-setup/SKILL.md`);
449
577
  }
578
+ for (const reason of await agentDriftReasons(paths)) {
579
+ reasons.push(reason);
580
+ }
450
581
 
451
582
  if (reasons.length === 0) {
452
583
  if (!quiet) process.stdout.write(`okstra runtime OK (package ${paths.package})\n`);
package/src/uninstall.mjs CHANGED
@@ -26,21 +26,34 @@ const FALLBACK_SKILL_NAMES = [
26
26
  "okstra-time-summary",
27
27
  ];
28
28
 
29
+ const FALLBACK_AGENT_NAMES = [
30
+ "claude-worker.md",
31
+ "codex-worker.md",
32
+ "gemini-worker.md",
33
+ "report-writer-worker.md",
34
+ ];
35
+
29
36
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
37
+ const CLAUDE_AGENTS_DIR = join(homedir(), ".claude", "agents");
30
38
  const SKILLS_MANIFEST_REL = "installed-skills.json";
39
+ const AGENTS_MANIFEST_REL = "installed-agents.json";
31
40
 
32
- const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra and ~/.claude/skills
41
+ const USAGE = `okstra uninstall — remove installed runtime from ~/.okstra, ~/.claude/skills, ~/.claude/agents
33
42
 
34
43
  Usage:
35
44
  okstra uninstall Remove ~/.okstra/{lib, bin/<known>, version,
36
- dev-link, installed-skills.json} AND
37
- ~/.claude/skills/<name> for every entry in
38
- the install manifest (fallback: hard-coded
39
- okstra-* names). Preserves user data:
40
- recent.jsonl, active.jsonl, projects/,
41
- archive/, state.json, .locks/
45
+ dev-link, installed-skills.json,
46
+ installed-agents.json} AND
47
+ ~/.claude/skills/<name> AND
48
+ ~/.claude/agents/<worker>.md for every
49
+ entry in the install manifests (fallback:
50
+ hard-coded okstra-* / *-worker.md names).
51
+ Preserves user data: recent.jsonl,
52
+ active.jsonl, projects/, archive/,
53
+ state.json, .locks/
42
54
  okstra uninstall --purge Remove the entire ~/.okstra directory AND
43
- ~/.claude/skills/<okstra-*> (DESTRUCTIVE).
55
+ ~/.claude/skills/<okstra-*> AND
56
+ ~/.claude/agents/<*-worker.md> (DESTRUCTIVE).
44
57
  Requires -y or an interactive confirmation
45
58
  okstra uninstall --dry-run Print the plan without touching disk
46
59
  okstra uninstall -y Skip confirmation prompt for --purge
@@ -112,10 +125,13 @@ export async function runUninstall(args) {
112
125
  }
113
126
  if (!opts.quiet) process.stdout.write(`purging ${paths.home}\n`);
114
127
  await removePath(paths.home, opts);
115
- // Skills live outside ~/.okstra — purge those too with the fallback list.
128
+ // Skills and worker agents live outside ~/.okstra — purge those too.
116
129
  for (const name of FALLBACK_SKILL_NAMES) {
117
130
  await removePath(join(CLAUDE_SKILLS_DIR, name), opts);
118
131
  }
132
+ for (const name of FALLBACK_AGENT_NAMES) {
133
+ await removePath(join(CLAUDE_AGENTS_DIR, name), opts);
134
+ }
119
135
  return 0;
120
136
  }
121
137
 
@@ -154,6 +170,16 @@ export async function runUninstall(args) {
154
170
  }
155
171
  await removePath(join(paths.home, SKILLS_MANIFEST_REL), opts);
156
172
 
173
+ // Remove worker agents we own. Same authoritative-manifest pattern as skills.
174
+ const agentNames = await readAgentsManifest(paths.home);
175
+ if (!opts.quiet) {
176
+ process.stdout.write(` agents: removing ${agentNames.length} entries from ${CLAUDE_AGENTS_DIR}\n`);
177
+ }
178
+ for (const name of agentNames) {
179
+ await removePath(join(CLAUDE_AGENTS_DIR, name), opts);
180
+ }
181
+ await removePath(join(paths.home, AGENTS_MANIFEST_REL), opts);
182
+
157
183
  await removePath(join(paths.home, "version"), opts);
158
184
  await removePath(join(paths.home, "dev-link"), opts);
159
185
 
@@ -173,3 +199,14 @@ async function readSkillsManifest(home) {
173
199
  }
174
200
  return FALLBACK_SKILL_NAMES;
175
201
  }
202
+
203
+ async function readAgentsManifest(home) {
204
+ try {
205
+ const raw = await fs.readFile(join(home, AGENTS_MANIFEST_REL), "utf8");
206
+ const data = JSON.parse(raw);
207
+ if (Array.isArray(data?.agents)) return data.agents;
208
+ } catch {
209
+ /* fall through */
210
+ }
211
+ return FALLBACK_AGENT_NAMES;
212
+ }