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 +6 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/okstra.sh +3 -0
- package/runtime/prompts/profiles/implementation-planning.md +2 -2
- package/runtime/prompts/profiles/implementation.md +5 -1
- package/runtime/python/lib/okstra/cli.sh +12 -1
- 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 +12 -3
- package/runtime/python/okstra_ctl/run.py +105 -8
- package/runtime/python/okstra_ctl/workflow.py +4 -1
- package/runtime/skills/okstra-status/SKILL.md +11 -2
- package/runtime/templates/reports/final-report.template.md +15 -3
- package/runtime/validators/validate-run.py +130 -3
- package/src/install.mjs +133 -2
- package/src/uninstall.mjs +46 -9
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
package/runtime/BUILD.json
CHANGED
package/runtime/bin/okstra.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 --
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
'
|
|
112
|
-
'
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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":
|
|
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,
|
|
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
|
-
-
|
|
94
|
-
- 승인
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
+
}
|