okstra 0.22.0 → 0.23.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 +3 -0
- package/README.md +3 -0
- package/bin/okstra +2 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +3 -1
- package/runtime/agents/workers/codex-worker.md +3 -1
- package/runtime/agents/workers/gemini-worker.md +3 -1
- package/runtime/agents/workers/report-writer-worker.md +16 -1
- package/runtime/prompts/profiles/release-handoff.md +16 -0
- package/runtime/python/okstra_ctl/render.py +25 -2
- package/runtime/skills/okstra-run/SKILL.md +44 -27
- package/runtime/skills/okstra-setup/SKILL.md +37 -0
- package/runtime/skills/okstra-team-contract/SKILL.md +46 -1
- package/runtime/templates/prd/brief.template.md +1 -0
- package/runtime/templates/project-docs/task-index.template.md +1 -0
- package/runtime/templates/reports/error-analysis-input.template.md +1 -0
- package/runtime/templates/reports/final-report.template.md +1 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -0
- package/runtime/templates/reports/implementation-input.template.md +1 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +1 -0
- package/runtime/templates/reports/quick-input.template.md +1 -0
- package/runtime/templates/reports/release-handoff-input.template.md +1 -0
- package/runtime/templates/reports/schedule.template.md +1 -0
- package/runtime/templates/reports/task-brief.template.md +1 -0
- package/src/config.mjs +392 -0
package/README.kr.md
CHANGED
|
@@ -167,6 +167,8 @@ Claude Code 세션 밖에서 task 를 시작하려면:
|
|
|
167
167
|
|
|
168
168
|
- **`--task-type implementation` 격리 worktree 자동 provisioning** — prepare 단계에서 `okstra-ctl` 이 `git worktree add ~/.okstra/worktrees/<project-id>/<task-group-segment>/<task-id-segment>-<run-seq>` 를 수행해 격리된 working tree 와 브랜치 `<work-category-prefix>-<task-id-segment>-<run-seq>` (예: `feat-dev-9436-001`, `fix-dev-7311-002`) 를 만듭니다. base ref 는 사용자가 `--base-ref` 로 지정 (release-handoff PR base picker 와 동일한 메뉴: `main` / `dev` / `staging` / `preprod` / `prod` / 직접 입력). 첫 phase 에서는 필수이며, okstra-run skill 이 `AskUserQuestion` 으로 수집합니다 — 비대화형 호출자는 `--base-ref` 플래그를 직접 전달해야 prepare 가 통과합니다. Executor 와 verifier 모두 이 worktree 안에서 동작하므로 caller 의 작업 디렉터리는 깨끗하게 유지되고, worktree 는 PR 작성 · rollback 검증의 권위 artefact 로 남습니다. caller 가 이미 다른 worktree 안에 있거나 project_root 가 git repo 가 아니면 provisioning 은 skip 됩니다. 수동 cleanup: `git worktree remove <path>` → `git branch -D <branch>`. 상세: [`docs/kr/architecture.md`](docs/kr/architecture.md) *Task type* 섹션, [`docs/kr/cli.md#--executor`](docs/kr/cli.md#--executor).
|
|
169
169
|
- **`release-handoff` lifecycle phase** — `final-verification` 이 `verdict=accepted` 를 반환한 직후에 실행되는 신규 phase. lead 가 Claude worker (drafter) 를 통해 commit message · PR body 후보를 만들고, `AskUserQuestion` 으로 사용자에게 (1) action (`commit only` / `commit + PR` / `skip`), (2) PR base branch (`staging` / `preprod` / `prod` / `main` / `dev` / 직접 입력), (3) message handling (`use as-is` / `edit then proceed` / `cancel`) 세 가지를 순서대로 묻습니다. 사용자가 메뉴로 선택한 git / gh 명령만 실행되고, force-push, base 브랜치 직접 push, hook bypass (`--no-verify`), release publish (`gh release`, `npm publish`, ...) 는 금지됩니다. 이 phase 에서는 소스 코드를 수정하지 않습니다. profile: [`prompts/profiles/release-handoff.md`](prompts/profiles/release-handoff.md).
|
|
170
|
+
- **PR 본문 템플릿 설정** (release-handoff) — PR 본문은 마크다운 템플릿에서 채워집니다. 해석 우선순위: 1회성 override (`--pr-template-path` 또는 okstra-run Step 6 prompt) → `<project_root>/.project-docs/okstra/project.json` 의 `prTemplatePath` → `~/.okstra/config.json` 의 `prTemplatePath` → 스킬 디폴트 `~/.claude/skills/okstra-run/templates/pr-body.template.md`. 템플릿 등록 명령: `okstra config set pr-template-path <path> [--scope project|global]` (project 스코프는 project root 기준 상대경로 허용, global 스코프는 절대경로 또는 `~/` 시작 경로만 허용). 현재 설정 확인: `okstra config get pr-template-path --scope all` 은 각 스코프 값 + 실제로 우승하는 경로(effective) 까지 보여줍니다. 디폴트 템플릿은 `## Summary` / `## Changes` / `## Test plan` / `## Linked issues` 4 섹션 + HTML 주석으로 lead 작성 가이드를 포함하며, PR 생성 직전에 lead 가 주석을 제거합니다.
|
|
171
|
+
- **프로파일 워커 로스터 검증** — `--workers <csv>` 와 okstra-run Step 6 의 워커 prompt 는 해당 프로파일의 `Required workers:` 블록에 선언된 워커 ID 만 허용합니다. 프로파일에 없는 워커 (예: `release-handoff` 에서 `codex` / `gemini`) 를 요청하면 명확한 에러로 거절되고, 인터랙티브 prompt 도 프로파일이 실제로 받는 워커만 보여줍니다.
|
|
170
172
|
|
|
171
173
|
### 3.5 운영 명령
|
|
172
174
|
|
|
@@ -177,6 +179,7 @@ Claude Code 세션 밖에서 task 를 시작하려면:
|
|
|
177
179
|
| `npx -y okstra@latest ensure-installed` | Idempotent 체크, stale 이면 자동 재설치 (스킬이 내부적으로 호출) |
|
|
178
180
|
| `npx -y okstra@latest setup --project-id <id>` | 현재 프로젝트를 등록 (`.project-docs/okstra/project.json`) |
|
|
179
181
|
| `npx -y okstra@latest check-project` | 현재 프로젝트가 `setup` 으로 등록됐는지 검증 |
|
|
182
|
+
| `npx -y okstra@latest config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | okstra 설정 읽기/쓰기. 현재 지원 키: `pr-template-path` (project.json 또는 `~/.okstra/config.json` 의 `prTemplatePath` 갱신) |
|
|
180
183
|
| `npx -y okstra@latest uninstall` | 런타임 + 스킬 제거; 사용자 데이터(`recent.jsonl`, `projects/`, …)는 보존 |
|
|
181
184
|
| `npx -y okstra@latest uninstall --purge -y` | 사용자 데이터까지 모두 제거 |
|
|
182
185
|
|
package/README.md
CHANGED
|
@@ -166,6 +166,8 @@ Recent workflow additions (post-0.8.0, on `main`):
|
|
|
166
166
|
|
|
167
167
|
- **Isolated task worktree for every task-type** — prepare automatically runs `git worktree add ~/.okstra/worktrees/<project>/<group>/<task>/` on a fresh branch `<work-category-prefix>-<task-id-segment>` branched from the **user-chosen base ref** (`--base-ref`, mirroring the `release-handoff` PR-base picker: `main` / `dev` / `staging` / `preprod` / `prod` / any local ref) the first time a task-key is seen. `--base-ref` is required on first phase; the okstra-run skill collects it via `AskUserQuestion`, non-interactive callers must pass the flag explicitly. Every subsequent phase of the same task-key (`requirements-discovery` → `error-analysis` → `implementation-planning` → `implementation`) reuses the same path and branch, so phase N inherits the working-tree state phase N-1 left behind. A global registry at `~/.okstra/worktrees/registry.json` (flock-guarded) reserves task-keys and branches across concurrent runs; all path/branch segments are sanitised (`/`, `:`, etc. → `-`). The worktree is preserved after every run for follow-up phases, PR authoring, and rollback. Skip paths: when the caller is already inside another worktree or `project_root` is not a git repo, provisioning no-ops. Manual cleanup: `git worktree remove <path>` → `git branch -D <branch>` plus removing the task-key entry from the registry. Details: [`docs/kr/architecture.md`](docs/kr/architecture.md) (*Task type* section) and [`docs/kr/cli.md#--executor`](docs/kr/cli.md#--executor).
|
|
168
168
|
- **`release-handoff` lifecycle phase** — runs after `final-verification` returns `verdict=accepted`. The lead drafts a commit message and PR body via a Claude worker, then prompts the user with `AskUserQuestion` for three choices: action (`commit only` / `commit + PR` / `skip`), PR base branch (`staging` / `preprod` / `prod` / `main` / `dev` / free-form), and message handling (`use as-is` / `edit then proceed` / `cancel`). Only user-selected mutating git/gh commands run. Force-push, base-branch direct push, hook bypass (`--no-verify`), and release publishing (`gh release`, `npm publish`, ...) are forbidden. Source code is not edited in this phase. Profile: [`prompts/profiles/release-handoff.md`](prompts/profiles/release-handoff.md).
|
|
169
|
+
- **Configurable PR body template** (release-handoff) — the PR body is filled from a markdown template chosen in priority order: per-run override (`--pr-template-path` or the okstra-run Step 6 prompt) → `<project_root>/.project-docs/okstra/project.json` `prTemplatePath` → `~/.okstra/config.json` `prTemplatePath` → bundled skill default at `~/.claude/skills/okstra-run/templates/pr-body.template.md`. Register a template with `okstra config set pr-template-path <path> [--scope project|global]` (project scope accepts paths relative to the project root; global scope requires absolute or `~/`-prefixed). `okstra config get pr-template-path --scope all` reports every scope plus the effective winner. The bundled default ships `## Summary` / `## Changes` / `## Test plan` / `## Linked issues` with HTML comment guidance that the lead strips before opening the PR.
|
|
170
|
+
- **Profile-roster worker validation** — `--workers <csv>` (and the okstra-run Step 6 worker prompt) are now restricted to the worker IDs declared by the chosen profile's `Required workers:` block. Asking for `codex` / `gemini` on a profile that does not list them (e.g. `release-handoff`) is rejected with a clear error, and the interactive prompt only offers workers the profile actually accepts.
|
|
169
171
|
|
|
170
172
|
### 3.5 Ops commands
|
|
171
173
|
|
|
@@ -176,6 +178,7 @@ Recent workflow additions (post-0.8.0, on `main`):
|
|
|
176
178
|
| `npx -y okstra@latest ensure-installed` | Idempotent check, auto-reinstall if stale (skills call this internally) |
|
|
177
179
|
| `npx -y okstra@latest setup --project-id <id>` | Register the current project (`.project-docs/okstra/project.json`) |
|
|
178
180
|
| `npx -y okstra@latest check-project` | Verify the current project has been registered with `setup` |
|
|
181
|
+
| `npx -y okstra@latest config <get\|set\|unset\|show> [key] [value] [--scope project\|global\|all]` | Read / write okstra settings; initial key `pr-template-path` (writes `prTemplatePath` to project.json or `~/.okstra/config.json`) |
|
|
179
182
|
| `npx -y okstra@latest uninstall` | Remove runtime + skills; preserves user data (`recent.jsonl`, `projects/`, …) |
|
|
180
183
|
| `npx -y okstra@latest uninstall --purge -y` | Remove everything including user data |
|
|
181
184
|
|
package/bin/okstra
CHANGED
|
@@ -9,6 +9,7 @@ const COMMANDS = new Map([
|
|
|
9
9
|
["doctor", () => import("../src/doctor.mjs").then((m) => m.run)],
|
|
10
10
|
["setup", () => import("../src/setup.mjs").then((m) => m.run)],
|
|
11
11
|
["check-project", () => import("../src/check-project.mjs").then((m) => m.run)],
|
|
12
|
+
["config", () => import("../src/config.mjs").then((m) => m.run)],
|
|
12
13
|
["task-list", () => import("../src/task-list.mjs").then((m) => m.run)],
|
|
13
14
|
["task-show", () => import("../src/task-show.mjs").then((m) => m.run)],
|
|
14
15
|
["worktree-lookup", () => import("../src/worktree-lookup.mjs").then((m) => m.run)],
|
|
@@ -40,6 +41,7 @@ Admin commands:
|
|
|
40
41
|
setup Register the current project (.project-docs/okstra/project.json)
|
|
41
42
|
doctor Diagnostic check of the installed runtime
|
|
42
43
|
check-project Verify the current project has been registered with setup
|
|
44
|
+
config Read / write okstra settings (e.g. PR template path)
|
|
43
45
|
paths Print runtime paths (workspace/agents/pythonpath/bin/home/version)
|
|
44
46
|
|
|
45
47
|
Introspection commands (JSON output, used by skills to avoid python heredocs):
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -63,7 +63,9 @@ Before producing any output, you MUST read every input file enumerated in the `[
|
|
|
63
63
|
|
|
64
64
|
## Worker Output Structure
|
|
65
65
|
|
|
66
|
-
When returning results, organize into the following sections in this exact order
|
|
66
|
+
When returning results, start the file with a YAML frontmatter block, then organize the body into the following sections in this exact order.
|
|
67
|
+
|
|
68
|
+
**Frontmatter (mandatory)** — set `workerId: "claude"`. Copy `id`, `aliases`, `taskType`, `task-id`, `task-group`, `project-id`, `date` verbatim from the input files (`analysis-material.md` is canonical; if it lacks any field, record a `tool-failure` and stop). Full schema and a concrete example live in the `okstra-team-contract` skill's "Result Frontmatter" subsection.
|
|
67
69
|
|
|
68
70
|
0. **Reading Confirmation** - one short line per input file confirming end-to-end reading (e.g. `- Read task-brief.md end-to-end (147 lines).`). If any file was skipped, record a `tool-failure` and do NOT produce sections 1–5.
|
|
69
71
|
1. **Findings** - what you identified
|
|
@@ -130,7 +130,9 @@ Before producing any output, you MUST ensure the underlying Codex CLI run reads
|
|
|
130
130
|
|
|
131
131
|
## Worker Output Structure
|
|
132
132
|
|
|
133
|
-
When returning results, organize into the following sections in this exact order
|
|
133
|
+
When returning results, start the file with a YAML frontmatter block, then organize the body into the following sections in this exact order.
|
|
134
|
+
|
|
135
|
+
**Frontmatter (mandatory)** — set `workerId: "codex"`. Copy `id`, `aliases`, `taskType`, `task-id`, `task-group`, `project-id`, `date` verbatim from the input files (`analysis-material.md` is canonical; if it lacks any field, record a `tool-failure` and stop). Full schema and a concrete example live in the `okstra-team-contract` skill's "Result Frontmatter" subsection.
|
|
134
136
|
|
|
135
137
|
0. **Reading Confirmation** - one short line per input file confirming end-to-end reading (e.g. `- Read task-brief.md end-to-end (147 lines).`). If any file was skipped, record a `tool-failure` and do NOT produce sections 1–5.
|
|
136
138
|
1. **Findings** - what Codex identified
|
|
@@ -130,7 +130,9 @@ Before producing any output, you MUST ensure the underlying Gemini CLI run reads
|
|
|
130
130
|
|
|
131
131
|
## Worker Output Structure
|
|
132
132
|
|
|
133
|
-
When returning results, organize into the following sections in this exact order
|
|
133
|
+
When returning results, start the file with a YAML frontmatter block, then organize the body into the following sections in this exact order.
|
|
134
|
+
|
|
135
|
+
**Frontmatter (mandatory)** — set `workerId: "gemini"`. Copy `id`, `aliases`, `taskType`, `task-id`, `task-group`, `project-id`, `date` verbatim from the input files (`analysis-material.md` is canonical; if it lacks any field, record a `tool-failure` and stop). Full schema and a concrete example live in the `okstra-team-contract` skill's "Result Frontmatter" subsection.
|
|
134
136
|
|
|
135
137
|
0. **Reading Confirmation** - one short line per input file confirming end-to-end reading (e.g. `- Read task-brief.md end-to-end (147 lines).`). If any file was skipped, record a `tool-failure` and do NOT produce sections 1–5.
|
|
136
138
|
1. **Findings** - what Gemini identified
|
|
@@ -81,9 +81,22 @@ The validator (`validators/validate-run.py`) checks this file exists whenever th
|
|
|
81
81
|
|
|
82
82
|
The lead's prompt provides this path as a second result destination — extract it from the `**Worker Result Path:**` line (or, if absent, derive it as `runs/<task-type>/worker-results/report-writer-worker-<task-type>-<seq>.md` under `Project Root`).
|
|
83
83
|
|
|
84
|
-
The worker-results file
|
|
84
|
+
The worker-results file MUST begin with a YAML frontmatter block (set `workerId: "report-writer"`; copy `id`, `aliases`, `taskType`, `task-id`, `task-group`, `project-id`, `date` verbatim from `analysis-material.md` — full schema lives in the `okstra-team-contract` "Result Frontmatter" subsection), followed by the standard header:
|
|
85
85
|
|
|
86
86
|
```markdown
|
|
87
|
+
---
|
|
88
|
+
title: OKSTRA Report Writer Worker Result - <task-key>
|
|
89
|
+
id: "<task-key with ':' replaced by '-'>"
|
|
90
|
+
aliases: ["<id>-<task-type>"]
|
|
91
|
+
tags: ["obsidian", "okstra", "worker-result", "<task-type>"]
|
|
92
|
+
taskType: "<task-type>"
|
|
93
|
+
workerId: "report-writer"
|
|
94
|
+
task-id: "<task-id>"
|
|
95
|
+
task-group: "<task-group>"
|
|
96
|
+
project-id: "<project-id>"
|
|
97
|
+
date: <YYYY-MM-DD>
|
|
98
|
+
---
|
|
99
|
+
|
|
87
100
|
# Report Writer Worker Analysis — <task-key>
|
|
88
101
|
|
|
89
102
|
**Task:** <task-type>
|
|
@@ -92,6 +105,8 @@ The worker-results file must begin with the standard header from `okstra-team-co
|
|
|
92
105
|
**Model:** Report writer worker, opus
|
|
93
106
|
```
|
|
94
107
|
|
|
108
|
+
The same frontmatter (with `workerId: "report-writer"`) MUST also appear on the final-report file you assemble — the `final-report.template.md` already encodes it, so simply preserve the template's frontmatter block when filling sections.
|
|
109
|
+
|
|
95
110
|
Followed by a short body that:
|
|
96
111
|
1. Names the canonical final-report file path written by this worker (relative to project root).
|
|
97
112
|
2. Lists the input artifacts you reconciled (each analysis worker's result file path under `worker-results/`, plus the convergence-state file path if present).
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
- `main`
|
|
29
29
|
- `직접 입력` (free-form branch name; lead validates the name exists on origin via `git ls-remote --heads origin <name>` and re-asks on failure)
|
|
30
30
|
The chosen base MUST NOT equal the feature branch. If it does, re-ask.
|
|
31
|
+
2b. **Pre-merge conflict probe** (only when the user picked `push + PR`) — before the push/PR step, the lead MUST refresh the base ref and probe for merge conflicts against it:
|
|
32
|
+
- run `git fetch origin <chosen-base>` (read-only on the local working tree).
|
|
33
|
+
- run `git merge-tree --write-tree --merge-base origin/<chosen-base> HEAD origin/<chosen-base>`. A non-zero exit code OR conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) in the output indicate a merge conflict against the chosen base.
|
|
34
|
+
- If no conflict is detected, proceed silently to Q3 (do NOT add a confirmation prompt — keep the happy path frictionless).
|
|
35
|
+
- If a conflict IS detected, present the conflicting paths (parsed from the `merge-tree` output) and capture exactly one:
|
|
36
|
+
- `proceed anyway` — continue to Q3; the PR will be opened with conflicts and the final report MUST flag this in `Merge Conflict Probe`.
|
|
37
|
+
- `change base branch` — return to Q2 and re-ask the base selection.
|
|
38
|
+
- `cancel` — end the run without push or PR; record the cancellation in the final report.
|
|
39
|
+
- The probe is read-only. It MUST NOT run `git merge`, `git rebase`, `git pull`, or any command that mutates the working tree or local refs.
|
|
31
40
|
3. **PR title + PR body confirmation** — show the lead's inline draft verbatim and capture one of:
|
|
32
41
|
- `use as-is` — proceed with the drafted text.
|
|
33
42
|
- `edit then proceed` — accept inline edits from the user, then proceed with the edited text.
|
|
@@ -40,6 +49,7 @@
|
|
|
40
49
|
2. **PR body** — markdown filled from `PR_TEMPLATE_PATH`. The user-confirmation step's diff (Q3 `edit then proceed`) is computed against the filled template, not against the raw template file.
|
|
41
50
|
- Allowed actions during the run (Claude lead only):
|
|
42
51
|
- read-only inspection: `git status`, `git status --short`, `git diff`, `git log`, `git rev-parse`, `git ls-remote --heads origin <name>`, `gh pr list --head <branch>`, `gh pr view <url>`.
|
|
52
|
+
- merge-conflict probe (only when the user picked `push + PR`): `git fetch origin <chosen-base>` and `git merge-tree --write-tree --merge-base origin/<chosen-base> HEAD origin/<chosen-base>`. Both are non-mutating with respect to the working tree.
|
|
43
53
|
- feature-branch push (only when the user picked `push + PR`): `git push -u origin <current-branch>`. The pushed ref MUST be the feature branch — never the chosen base branch.
|
|
44
54
|
- PR creation (only when the user picked `push + PR` AND no PR with the same head already exists on origin): `gh pr create --base <chosen-base> --head <current-branch> --title "<title>" --body "<body>"`. The title and body are the user-confirmed PR draft.
|
|
45
55
|
- PR reuse: if `gh pr list --head <branch> --state open --json url --jq '.[0].url'` returns a URL, treat that PR as already existing — record the URL in the final report and SKIP `gh pr create`.
|
|
@@ -65,9 +75,14 @@
|
|
|
65
75
|
- **User Selections**: a block recording each prompt and the user's verbatim answer.
|
|
66
76
|
- Q1 action: `local only` | `push + PR` | `skip`.
|
|
67
77
|
- Q2 PR base (if applicable): the chosen branch and how it was selected (menu pick vs free-form input).
|
|
78
|
+
- Q2b merge-conflict probe (if applicable): `clean` (no conflict, no prompt shown) | `proceed anyway` | `change base branch` | `cancel`. When a conflict was detected, list the conflicting paths.
|
|
68
79
|
- Q3 title/body: `use as-is` | `edit then proceed` (with a diff between the lead's draft and the final text) | `cancel`.
|
|
69
80
|
- **Executed Commands**: every git / gh command the lead actually ran, with its exit code and a one-line stdout/stderr summary. Read-only inspection commands MAY be summarised; mutating commands MUST be listed verbatim.
|
|
70
81
|
- **Commit List**: each existing implementation commit in `git log <base>..HEAD`, with short/full SHA, subject line, and touched files. Release-handoff MUST NOT create new commits.
|
|
82
|
+
- **Merge Conflict Probe**: one of
|
|
83
|
+
- `- Not run (user picked local only or skip).`
|
|
84
|
+
- `- Clean — no conflicts against <base> at <origin/base SHA>.`
|
|
85
|
+
- `- Conflicts detected against <base> at <origin/base SHA>; user chose <proceed anyway | change base branch | cancel>. Conflicting paths: <list>.`
|
|
71
86
|
- **Pull Request Outcome**: one of
|
|
72
87
|
- `- No PR action requested.` (user picked `local only` or `skip`)
|
|
73
88
|
- `- PR created: <url>` with title and base branch
|
|
@@ -80,6 +95,7 @@
|
|
|
80
95
|
3. **Forbidden-action audit** — scan the run's session transcripts (`git`, `gh` invocations) for every entry in the Forbidden actions list above. Any occurrence means the run has crossed into unsafe territory and MUST be flagged as `contract-violated`.
|
|
81
96
|
4. **Push-target audit** — for every `git push` recorded, confirm the refspec resolves to the feature branch, not the base branch.
|
|
82
97
|
5. **Idempotency check** — if a PR with the same head already existed at run start, confirm the report records `PR reused` rather than a fresh `gh pr create` invocation.
|
|
98
|
+
6. **Merge-conflict probe audit** — for any `push + PR` run, confirm the report's `Merge Conflict Probe` section is present and either records `Clean` or records `Conflicts detected` with the user's verbatim choice. A missing or unparseable probe entry on a `push + PR` run is a contract violation.
|
|
83
99
|
- Non-goals:
|
|
84
100
|
- re-litigating the final-verification verdict — release-handoff trusts the cited `accepted` verdict and does not reopen acceptance checks.
|
|
85
101
|
- creating, amending, squashing, or rewriting commits. Commit production belongs to `implementation`.
|
|
@@ -96,6 +96,16 @@ def _doc_type_from_template_path(template_path: str) -> str:
|
|
|
96
96
|
return stem
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def _frontmatter_id_from_task_key(task_key: str) -> str:
|
|
100
|
+
"""task_key (`project_id:task_group:task_id`) 를 ID 형식으로 변환.
|
|
101
|
+
|
|
102
|
+
`:` 를 `-` 로 치환한 단일 문자열. 예시:
|
|
103
|
+
``fontsninja-classifier-v2:DEV-9388:DEV-9429``
|
|
104
|
+
-> ``fontsninja-classifier-v2-DEV-9388-DEV-9429``
|
|
105
|
+
"""
|
|
106
|
+
return (task_key or "").strip().replace(":", "-")
|
|
107
|
+
|
|
108
|
+
|
|
99
109
|
def _frontmatter_mapping(ctx: dict) -> dict:
|
|
100
110
|
task_id = (ctx.get("TASK_ID") or "").strip()
|
|
101
111
|
project_id = (ctx.get("PROJECT_ID") or "").strip()
|
|
@@ -103,15 +113,28 @@ def _frontmatter_mapping(ctx: dict) -> dict:
|
|
|
103
113
|
task_key = (ctx.get("TASK_KEY") or "").strip()
|
|
104
114
|
task_date = (ctx.get("TASK_DATE") or "").strip()
|
|
105
115
|
doc_type = (ctx.get("DOC_TYPE") or "").strip()
|
|
116
|
+
# task_type 은 ctx 키가 두 곳에 분포 — 직접 키(`TASK_TYPE`), 또는
|
|
117
|
+
# `ANALYSIS_TYPE` fallback (workflow 의 render mapping 과 동일 우선순위).
|
|
118
|
+
task_type = (ctx.get("TASK_TYPE") or ctx.get("ANALYSIS_TYPE") or "").strip()
|
|
119
|
+
|
|
120
|
+
fm_id = _frontmatter_id_from_task_key(task_key)
|
|
121
|
+
fm_id_scalar = f'"{fm_id}"' if fm_id else f'"{_FM_DEFAULT}"'
|
|
122
|
+
alias_value = f"{fm_id}-{task_type}" if (fm_id and task_type) else fm_id
|
|
106
123
|
return {
|
|
107
124
|
"{{TASK_KEY}}": _fm_scalar(task_key),
|
|
108
125
|
"{{TASK_ID}}": _fm_scalar(task_id),
|
|
109
126
|
"{{PROJECT_ID}}": _fm_scalar(project_id),
|
|
110
127
|
"{{TASK_GROUP}}": _fm_scalar(task_group),
|
|
111
128
|
"{{TASK_DATE}}": _fm_scalar(task_date),
|
|
112
|
-
|
|
113
|
-
"
|
|
129
|
+
# task_key 의 `:` 를 `-` 로 치환한 단일 스칼라.
|
|
130
|
+
# 예: "fontsninja-classifier-v2-DEV-9388-DEV-9429"
|
|
131
|
+
"{{FM_ID}}": fm_id_scalar,
|
|
132
|
+
# id 와 task-type 을 `-` 로 연결한 단일 alias 를 array 한 칸에 담는다
|
|
133
|
+
# (Obsidian aliases 컨벤션).
|
|
134
|
+
"{{FM_ALIASES}}": _fm_array([alias_value]) if alias_value else "[]",
|
|
114
135
|
"{{FM_TAGS}}": _fm_tags(doc_type),
|
|
136
|
+
# 신규: 모든 okstra 산출물의 frontmatter 가 task type 을 명시한다.
|
|
137
|
+
"{{FM_TASK_TYPE}}": _fm_scalar(task_type),
|
|
115
138
|
}
|
|
116
139
|
|
|
117
140
|
|
|
@@ -20,6 +20,16 @@ Launch an okstra task — gather inputs interactively, render the full task bund
|
|
|
20
20
|
- User wants status only — use `okstra-status`.
|
|
21
21
|
- User wants past runs — use `okstra-history`.
|
|
22
22
|
|
|
23
|
+
## Prompt convention (use the right tool for the right input shape)
|
|
24
|
+
|
|
25
|
+
`AskUserQuestion` always renders a picker UI with a forced auto-attached `Other` option. While the user types into `Other`, the picker re-renders and the experience feels out of sync. So:
|
|
26
|
+
|
|
27
|
+
- **Use `AskUserQuestion` ONLY when the answer is a fixed pick from a short option set** (2–4 distinct, mutually exclusive choices). Examples in this skill: task-type choice (Step 4), executor provider (claude/codex/gemini), model picker (default/opus/sonnet/haiku per provider), Use defaults vs Customize, Proceed vs Edit confirmation.
|
|
28
|
+
- **For pure free-text inputs** (file paths, task identifiers, CSV strings, free directives, branch names typed by the user) **do NOT use `AskUserQuestion`**. Instead, write a plain text message (e.g. `"Task group (예: backend-api, INV-1234)을 알려주세요. 빈 줄이면 취소."`) and consume the user's NEXT message as the answer. Then validate and re-prompt with another plain text message on failure.
|
|
29
|
+
- **For "menu + free-text" places** (base-ref pickers, PR base branch) — show the menu with `AskUserQuestion` listing only the canonical options + a literal option labeled `직접 입력`. When the user picks `직접 입력`, follow up with a **separate** plain text prompt and consume the next user message. Do NOT rely on `Other` auto-text inside the picker — its re-render is the root cause of the lag.
|
|
30
|
+
|
|
31
|
+
Echo each captured answer on one short line (e.g. `task-group: backend-api`) so the user sees what was registered, regardless of which prompt shape was used.
|
|
32
|
+
|
|
23
33
|
## Authority Files (disk-only — no env var caching for per-run identity)
|
|
24
34
|
|
|
25
35
|
Every step reads disk afresh. The `OKSTRA_*` env vars below identify the
|
|
@@ -82,7 +92,7 @@ okstra check-project --cwd "$(pwd)"
|
|
|
82
92
|
```
|
|
83
93
|
|
|
84
94
|
- If `ok: true`: read `projectRoot` and `projectId` from the JSON.
|
|
85
|
-
- If `ok: false`: ask the user (`AskUserQuestion
|
|
95
|
+
- If `ok: false`: ask the user with a **plain text prompt** (not `AskUserQuestion` — this is pure free text per the convention above) for an absolute project-root path; rerun with `okstra check-project --cwd <their input>`. Re-prompt with another plain text message on failure.
|
|
86
96
|
|
|
87
97
|
## Step 2: Choose task — existing vs new
|
|
88
98
|
|
|
@@ -103,12 +113,12 @@ For an existing pick, read its `task-manifest.json` to capture `taskType` and `w
|
|
|
103
113
|
|
|
104
114
|
Skip if continuing existing.
|
|
105
115
|
|
|
106
|
-
|
|
116
|
+
Use **plain text prompts** (one at a time — write the message and consume the user's next reply; do NOT use `AskUserQuestion` for these per the convention above):
|
|
107
117
|
|
|
108
|
-
1. `"Task group (
|
|
109
|
-
2. `"Task id (
|
|
118
|
+
1. `"Task group 을 알려주세요 (예: backend-api, INV-1234, refactor)"` → `task_group`
|
|
119
|
+
2. `"Task id 를 알려주세요 (예: login-error-analysis, dev-9043)"` → `task_id`
|
|
110
120
|
|
|
111
|
-
Validate that slugified `task_group` and `task_id` each contain at least one alphanumeric character.
|
|
121
|
+
Validate that slugified `task_group` and `task_id` each contain at least one alphanumeric character. On failure, re-prompt with another plain text message stating the validation failure.
|
|
112
122
|
|
|
113
123
|
## Step 4: Choose task-type
|
|
114
124
|
|
|
@@ -125,9 +135,9 @@ Validate that slugified `task_group` and `task_id` each contain at least one alp
|
|
|
125
135
|
|
|
126
136
|
For existing tasks, present `nextRecommendedPhase` as the first option (recommended default).
|
|
127
137
|
|
|
128
|
-
If `implementation` chosen, ask two more
|
|
129
|
-
-
|
|
130
|
-
-
|
|
138
|
+
If `implementation` chosen, ask two more questions in order:
|
|
139
|
+
- **Plain text prompt** (file path is pure free text): `"approved final-report.md 의 경로를 알려주세요 (APPROVED 마커가 있어야 합니다)"`. The underlying python `prepare_task_bundle` re-validates the marker, but you can pre-check with `grep`. Re-prompt with plain text on failure.
|
|
140
|
+
- **`AskUserQuestion`** with three options (`claude` / `codex` / `gemini`) — only this provider mutates project files; the other two run as read-only verifiers. Default `claude` (or `OKSTRA_DEFAULT_EXECUTOR` if set). Pass the answer through `PrepareInputs.executor`.
|
|
131
141
|
|
|
132
142
|
## Step 4.6: Base ref for the task worktree (first phase only)
|
|
133
143
|
|
|
@@ -149,16 +159,17 @@ non-null `entry` with `status: "active"` → **REUSE**.
|
|
|
149
159
|
question (the registered base is authoritative).
|
|
150
160
|
- `ASK` → this is the first phase for this task-key. Continue.
|
|
151
161
|
|
|
152
|
-
|
|
162
|
+
Use the **menu + free-text two-step pattern** (per the convention above):
|
|
153
163
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
164
|
+
1. `AskUserQuestion` with label `"이 task worktree 의 base branch?"` and exactly these single-select options (NO auto-Other typing — the literal `직접 입력` option is the typed-input escape hatch):
|
|
165
|
+
1. `main` (recommended)
|
|
166
|
+
2. `dev`
|
|
167
|
+
3. `staging`
|
|
168
|
+
4. `preprod`
|
|
169
|
+
5. `prod`
|
|
170
|
+
6. `직접 입력`
|
|
171
|
+
2. If the user picks `직접 입력`, follow up with a **plain text prompt**: `"base ref 를 입력해주세요 (branch, tag, 또는 short/full SHA)"`. Consume the user's next message as the chosen ref.
|
|
172
|
+
3. Otherwise the picked option label is the chosen ref directly.
|
|
162
173
|
|
|
163
174
|
Validate the chosen ref exists in the MAIN worktree before continuing:
|
|
164
175
|
|
|
@@ -168,14 +179,15 @@ git -C "$(git -C "$PROJECT_ROOT" rev-parse --path-format=absolute --git-common-d
|
|
|
168
179
|
|| { echo "ref not found locally: <chosen-ref>"; exit 1; }
|
|
169
180
|
```
|
|
170
181
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
182
|
+
On failure, re-prompt with a plain text message (or return to step 1's
|
|
183
|
+
menu if the user wants to pick a different canonical branch). Echo the
|
|
184
|
+
resolved short SHA back to the user (`base 확정: <ref> (<short-sha>)`)
|
|
185
|
+
and capture `base_ref=<chosen-ref>` for Step 7.
|
|
174
186
|
|
|
175
187
|
## Step 5: Brief path
|
|
176
188
|
|
|
177
|
-
- New task:
|
|
178
|
-
- Existing task: default to the manifest's `taskBriefPath`. Show it; ask
|
|
189
|
+
- New task: **plain text prompt** (file path is pure free text per the convention) `"task brief markdown 의 경로를 알려주세요 (project root 기준 상대경로 또는 절대경로)"`. Consume the user's next message; verify the file exists; on failure, re-prompt with another plain text message.
|
|
190
|
+
- Existing task: default to the manifest's `taskBriefPath`. Show it; ask `AskUserQuestion` `"기존 경로를 유지할까요?"` with options `유지` / `변경`. On `변경`, follow up with a plain text prompt for the new path.
|
|
179
191
|
|
|
180
192
|
## Step 6 (optional): Directive / workers / models / related / clarification
|
|
181
193
|
|
|
@@ -201,8 +213,8 @@ In this phase the roster is fixed by the profile (executor + two verifiers + rep
|
|
|
201
213
|
1. `AskUserQuestion` `"리더(Claude lead) 모델?"` (Claude options) → `lead_model`
|
|
202
214
|
2. `AskUserQuestion` `"실행자({executor-provider}) 모델?"` with options matching the executor's provider (Claude / Codex / Gemini list above) → maps to `claude_model` / `codex_model` / `gemini_model`. The other two provider model fields stay empty (verifiers use defaults).
|
|
203
215
|
3. `AskUserQuestion` `"리포트 작성자(report-writer) 모델?"` (Claude options) → `report_writer_model`
|
|
204
|
-
4.
|
|
205
|
-
5.
|
|
216
|
+
4. **Plain text prompt** (free text) `"추가 directive 가 있으면 적어주세요 (없으면 빈 줄)"` → `directive`. Consume the user's next message verbatim; an empty line means "no directive".
|
|
217
|
+
5. **Plain text prompt** (free text) `"관련 task id 목록을 쉼표로 구분해서 적어주세요 (없으면 빈 줄)"` → `related_tasks_raw`.
|
|
206
218
|
|
|
207
219
|
Do NOT ask for `workers_override` in implementation — the profile's required roster must be preserved (verifier slots are mandatory). Leave `workers_override=""`.
|
|
208
220
|
|
|
@@ -225,9 +237,9 @@ workers outside this list. Special cases:
|
|
|
225
237
|
- Otherwise, the worker question must enumerate **only** `profile_workers` —
|
|
226
238
|
do NOT show `claude, codex, gemini, report-writer` blindly.
|
|
227
239
|
|
|
228
|
-
Ask each in turn
|
|
240
|
+
Ask each in turn. **Model prompts use `AskUserQuestion`** with the fixed option lists above. **All other prompts use plain text messages** (do NOT wrap free-text inputs in `AskUserQuestion` — the auto-Other re-render lag is what we're avoiding). Skip any worker-model prompt whose worker is not in `profile_workers`.
|
|
229
241
|
|
|
230
|
-
1. (only when `profile_workers` is non-empty)
|
|
242
|
+
1. (only when `profile_workers` is non-empty) **Plain text prompt** `"참여 워커 목록을 쉼표로 구분해서 적어주세요. 빈 줄이면 프로필 기본값 <profile_workers_csv> 을 그대로 씁니다. 사용 가능한 워커: <profile_workers_csv>"` → `workers_override`. Validate the answer is a subset of `profile_workers`; on failure, re-prompt with another plain text message. (Backend also rejects violations with `WorkersError`.)
|
|
231
243
|
2. `AskUserQuestion` `"리더(Claude lead) 모델?"` (Claude options) → `lead_model`
|
|
232
244
|
3. (only if `claude` ∈ resolved workers) `AskUserQuestion` `"claude 워커 모델?"` (Claude options) → `claude_model`
|
|
233
245
|
4. (only if `codex` ∈ resolved workers) `AskUserQuestion` `"codex 워커 모델?"` (Codex options) → `codex_model`
|
|
@@ -236,7 +248,12 @@ Ask each in turn (model prompts use `AskUserQuestion` with the option lists abov
|
|
|
236
248
|
7. `AskUserQuestion` `"추가 directive (선택, 빈 칸 가능)"` (free text) → `directive`
|
|
237
249
|
8. `AskUserQuestion` `"관련 task id 목록, 쉼표 구분 (선택, 빈 칸 가능)"` (free text) → `related_tasks_raw`
|
|
238
250
|
9. `AskUserQuestion` `"clarification-response 파일 경로 (follow-up 시에만, 빈 칸 가능)"` (free text) → `clarification_response_path`
|
|
239
|
-
10. (only when `task_type == "release-handoff"`)
|
|
251
|
+
10. (only when `task_type == "release-handoff"`) **Plain text prompt** `"PR 본문 템플릿 경로 1회성 override (빈 줄이면 project.json → ~/.okstra/config.json → 스킬 디폴트 순으로 자동 해석)"` → `pr_template_path`. The backend (`okstra_ctl.pr_template.resolve_pr_template_path`) validates the file exists and surfaces `PrTemplateError` on failure.
|
|
252
|
+
- **Persist follow-up** (only when the user typed a non-empty path AND it differs from any currently-registered project/global value): ask `AskUserQuestion` `"방금 입력한 경로를 영구 저장할까요?"` with three options:
|
|
253
|
+
1. `이번 run 만 (1회성)` — proceed with the override; do NOT touch project.json or global config.
|
|
254
|
+
2. `프로젝트에 저장 (project scope)` — run `okstra config set pr-template-path "<path>" --scope project` and use the override for this run too.
|
|
255
|
+
3. `전역에 저장 (global scope)` — run `okstra config set pr-template-path "<path>" --scope global` (must be absolute or `~/`-prefixed; if not, re-ask with a plain text prompt for an absolute version) and use the override for this run too.
|
|
256
|
+
- Skip the persist follow-up entirely when the user left the override blank, or when the typed value matches the value already stored at the scope it would land in (avoid no-op confirmations).
|
|
240
257
|
|
|
241
258
|
For prompts whose target worker is NOT in the resolved workers list (after override), present a single confirmation line such as `gemini-model 생략 (workers에 gemini 없음)` so the user can see why the question was skipped.
|
|
242
259
|
|
|
@@ -209,6 +209,43 @@ To opt out (advanced): replace the symlink with a regular file. okstra
|
|
|
209
209
|
will detect that it is no longer a symlink on its next setup call and
|
|
210
210
|
back it up as `.bak.<timestamp>` rather than overwriting silently.
|
|
211
211
|
|
|
212
|
+
## Step 4.8 (optional): register a project PR body template
|
|
213
|
+
|
|
214
|
+
`release-handoff` fills the PR body from a template. By default it uses the
|
|
215
|
+
bundled skill template at
|
|
216
|
+
`~/.claude/skills/okstra-run/templates/pr-body.template.md`. Most projects
|
|
217
|
+
want their own — e.g. the repo's `.github/PULL_REQUEST_TEMPLATE.md` — to
|
|
218
|
+
keep PRs consistent with what the team already merges manually.
|
|
219
|
+
|
|
220
|
+
Ask the user with `AskUserQuestion` (fixed options, NOT free text — file
|
|
221
|
+
path entry happens in the follow-up plain text prompt per the
|
|
222
|
+
okstra-run prompt convention):
|
|
223
|
+
|
|
224
|
+
- **Question**: `"이 프로젝트에서 release-handoff 가 사용할 PR 본문 템플릿을 등록할까요?"`
|
|
225
|
+
- **Options**:
|
|
226
|
+
1. `이번 프로젝트만 (project scope)` — write to `<PROJECT_ROOT>/.project-docs/okstra/project.json` `prTemplatePath`.
|
|
227
|
+
2. `전역 (global scope)` — write to `~/.okstra/config.json` `prTemplatePath`.
|
|
228
|
+
3. `나중에` — skip.
|
|
229
|
+
|
|
230
|
+
If the user picks scope 1 or 2, follow up with a **plain text prompt**:
|
|
231
|
+
`"PR 본문 템플릿 파일 경로를 알려주세요. project 스코프는 project-root 기준 상대경로 또는 절대경로, global 스코프는 절대경로 또는 ~/ 시작 경로만 허용됩니다."` Consume the next user message, then run:
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
okstra config set pr-template-path "<typed-path>" --scope <project|global>
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
The command validates the value (global rejects relative paths) and
|
|
238
|
+
writes atomically. Surface its stdout JSON so the user sees which file
|
|
239
|
+
was updated.
|
|
240
|
+
|
|
241
|
+
If the user chose `나중에`, tell them they can register later with one of:
|
|
242
|
+
|
|
243
|
+
- `okstra config set pr-template-path <path> --scope project|global` from a
|
|
244
|
+
terminal, or
|
|
245
|
+
- the per-run 1회성 override prompt during the next release-handoff run
|
|
246
|
+
(the run can also persist that override to project/global scope on the
|
|
247
|
+
spot — see the okstra-run skill).
|
|
248
|
+
|
|
212
249
|
## Step 5: Verify
|
|
213
250
|
|
|
214
251
|
```bash
|
|
@@ -202,7 +202,52 @@ Terminal statuses that can be recorded for a worker:
|
|
|
202
202
|
|
|
203
203
|
**Authoritative source.** If other documents (SKILL.md, worker agent definitions) disagree with this section, this section wins.
|
|
204
204
|
|
|
205
|
-
|
|
205
|
+
### Result Frontmatter (mandatory, precedes Section 0)
|
|
206
|
+
|
|
207
|
+
Every worker result file MUST begin with a YAML frontmatter block. The values are sourced from the corresponding fields of the input files' frontmatter (e.g. `analysis-material.md`, `task-brief.md`) — copy them verbatim; do NOT regenerate them. Only `workerId` and `title` are worker-specific.
|
|
208
|
+
|
|
209
|
+
```yaml
|
|
210
|
+
---
|
|
211
|
+
title: OKSTRA <Worker Role> Result - <task-key>
|
|
212
|
+
id: "<task-key with ':' replaced by '-'>"
|
|
213
|
+
aliases: ["<id>-<task-type>"]
|
|
214
|
+
tags: ["obsidian", "okstra", "worker-result", "<task-type>"]
|
|
215
|
+
taskType: "<task-type>"
|
|
216
|
+
workerId: "<claude|codex|gemini|report-writer>"
|
|
217
|
+
task-id: "<task-id>"
|
|
218
|
+
task-group: "<task-group>"
|
|
219
|
+
project-id: "<project-id>"
|
|
220
|
+
date: <YYYY-MM-DD>
|
|
221
|
+
---
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Concrete example for a `claude-worker` result on task-key `fontsninja-classifier-v2:DEV-9388:DEV-9429` and task-type `implementation`:
|
|
225
|
+
|
|
226
|
+
```yaml
|
|
227
|
+
---
|
|
228
|
+
title: OKSTRA Claude Worker Result - fontsninja-classifier-v2:DEV-9388:DEV-9429
|
|
229
|
+
id: "fontsninja-classifier-v2-DEV-9388-DEV-9429"
|
|
230
|
+
aliases: ["fontsninja-classifier-v2-DEV-9388-DEV-9429-implementation"]
|
|
231
|
+
tags: ["obsidian", "okstra", "worker-result", "implementation"]
|
|
232
|
+
taskType: "implementation"
|
|
233
|
+
workerId: "claude"
|
|
234
|
+
task-id: "DEV-9429"
|
|
235
|
+
task-group: "DEV-9388"
|
|
236
|
+
project-id: "fontsninja-classifier-v2"
|
|
237
|
+
date: 2026-05-15
|
|
238
|
+
---
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Rules:
|
|
242
|
+
- `id` is the run's `task-key` with `:` replaced by `-`. It is a scalar string, NOT an array.
|
|
243
|
+
- `aliases` is a YAML array containing a single value `"<id>-<task-type>"`.
|
|
244
|
+
- `taskType` mirrors the run's task type (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`).
|
|
245
|
+
- `workerId` identifies which worker produced the file. Required values: `claude` / `codex` / `gemini` / `report-writer`.
|
|
246
|
+
- Other fields (`task-id`, `task-group`, `project-id`, `date`) MUST match the input files' frontmatter exactly. If the input frontmatter is missing or unreadable, the worker MUST record a `tool-failure` and stop instead of guessing.
|
|
247
|
+
|
|
248
|
+
The same frontmatter contract applies to the `Report writer worker`'s final-report file — the report-writer copies these values from its inputs and only swaps `workerId` to `report-writer`.
|
|
249
|
+
|
|
250
|
+
A successful worker result must include the following sections in this exact order, beneath the frontmatter block:
|
|
206
251
|
|
|
207
252
|
0. **Reading Confirmation** — one short line per input file (`task-brief.md`, `analysis-profile.md`, `analysis-material.md` if present, `reference-expectations.md`, `clarification-response.md` if a carry-in was provided, `final-report-template.md`) stating that the worker read it end-to-end. Each line takes the form `- Read <file-name> end-to-end (<line-count> lines).`. If a file was skipped or only partially read, the worker MUST NOT produce sections 1–5; instead it records a `tool-failure` in the errors sidecar and stops. This section exists specifically to counteract the common failure mode where workers skim long inputs because they share structure with the file the run will eventually write into.
|
|
208
253
|
1. Findings
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve, isAbsolute } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { resolvePaths } from "./paths.mjs";
|
|
6
|
+
|
|
7
|
+
const USAGE = `okstra config — read / write okstra settings (project + global scopes)
|
|
8
|
+
|
|
9
|
+
Settings are persisted as JSON keys in one of two files:
|
|
10
|
+
project: <project-root>/.project-docs/okstra/project.json
|
|
11
|
+
global: ~/.okstra/config.json
|
|
12
|
+
|
|
13
|
+
Supported keys (CLI alias -> JSON field):
|
|
14
|
+
pr-template-path -> prTemplatePath
|
|
15
|
+
PR body template used by the release-handoff phase.
|
|
16
|
+
Path may be absolute, ~/-prefixed, or (project scope
|
|
17
|
+
only) relative to <project-root>.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
okstra config get <key> [--scope project|global|all]
|
|
21
|
+
Print the value at the requested scope.
|
|
22
|
+
'all' prints every scope, plus the effective resolved value where
|
|
23
|
+
available (for pr-template-path the resolver may fall back to the
|
|
24
|
+
bundled skill default).
|
|
25
|
+
|
|
26
|
+
okstra config set <key> <value> [--scope project|global]
|
|
27
|
+
Write the value. Scope is required unless cwd is inside an okstra
|
|
28
|
+
project (then defaults to 'project'). Global scope only accepts
|
|
29
|
+
absolute or ~/-prefixed paths.
|
|
30
|
+
|
|
31
|
+
okstra config unset <key> [--scope project|global]
|
|
32
|
+
Remove the key from the chosen scope.
|
|
33
|
+
|
|
34
|
+
okstra config show [--scope project|global|all]
|
|
35
|
+
Dump every okstra-managed key from the chosen scope as JSON.
|
|
36
|
+
|
|
37
|
+
Common options:
|
|
38
|
+
--cwd <dir> Search starting point for project root (default: pwd)
|
|
39
|
+
--json Force JSON output for get/show (default for show)
|
|
40
|
+
--quiet Suppress informational stdout on set/unset; exit
|
|
41
|
+
code alone reports success
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
// CLI-key -> { jsonField, scopes, validate }
|
|
45
|
+
// validate(value, ctx) returns null on success or an error string.
|
|
46
|
+
const KEYS = {
|
|
47
|
+
"pr-template-path": {
|
|
48
|
+
jsonField: "prTemplatePath",
|
|
49
|
+
scopes: ["project", "global"],
|
|
50
|
+
validate(value, ctx) {
|
|
51
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
52
|
+
return "value must be a non-empty path";
|
|
53
|
+
}
|
|
54
|
+
const v = value.trim();
|
|
55
|
+
if (ctx.scope === "global") {
|
|
56
|
+
if (!v.startsWith("~/") && !v.startsWith("~\\") && !isAbsolute(v)) {
|
|
57
|
+
return (
|
|
58
|
+
"global scope requires an absolute path or '~/'-prefixed path " +
|
|
59
|
+
`(got ${JSON.stringify(v)}). Use --scope project for project-root-relative paths.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
function parseArgs(args) {
|
|
69
|
+
const opts = {
|
|
70
|
+
op: null,
|
|
71
|
+
key: null,
|
|
72
|
+
value: null,
|
|
73
|
+
scope: null,
|
|
74
|
+
cwd: process.cwd(),
|
|
75
|
+
json: false,
|
|
76
|
+
quiet: false,
|
|
77
|
+
};
|
|
78
|
+
const positional = [];
|
|
79
|
+
for (let i = 0; i < args.length; i++) {
|
|
80
|
+
const a = args[i];
|
|
81
|
+
if (a === "--scope") {
|
|
82
|
+
const next = args[i + 1];
|
|
83
|
+
if (!next) throw new Error("--scope requires a value (project|global|all)");
|
|
84
|
+
opts.scope = next;
|
|
85
|
+
i++;
|
|
86
|
+
} else if (a === "--cwd") {
|
|
87
|
+
const next = args[i + 1];
|
|
88
|
+
if (!next) throw new Error("--cwd requires a path");
|
|
89
|
+
opts.cwd = next;
|
|
90
|
+
i++;
|
|
91
|
+
} else if (a === "--json") {
|
|
92
|
+
opts.json = true;
|
|
93
|
+
} else if (a === "--quiet" || a === "-q") {
|
|
94
|
+
opts.quiet = true;
|
|
95
|
+
} else if (a.startsWith("--")) {
|
|
96
|
+
throw new Error(`unknown flag ${a}`);
|
|
97
|
+
} else {
|
|
98
|
+
positional.push(a);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
opts.op = positional[0] ?? null;
|
|
102
|
+
opts.key = positional[1] ?? null;
|
|
103
|
+
opts.value = positional[2] ?? null;
|
|
104
|
+
return opts;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function okstraHome() {
|
|
108
|
+
const override = (process.env.OKSTRA_HOME || "").trim();
|
|
109
|
+
return override !== "" ? override : join(homedir(), ".okstra");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function globalConfigPath() {
|
|
113
|
+
return join(okstraHome(), "config.json");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function projectConfigPath(projectRoot) {
|
|
117
|
+
return join(projectRoot, ".project-docs", "okstra", "project.json");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function fileExists(p) {
|
|
121
|
+
try {
|
|
122
|
+
await fs.access(p);
|
|
123
|
+
return true;
|
|
124
|
+
} catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function readJson(p) {
|
|
130
|
+
if (!(await fileExists(p))) return null;
|
|
131
|
+
const text = await fs.readFile(p, "utf8");
|
|
132
|
+
try {
|
|
133
|
+
const data = JSON.parse(text);
|
|
134
|
+
return typeof data === "object" && data !== null ? data : {};
|
|
135
|
+
} catch (err) {
|
|
136
|
+
throw new Error(`failed to parse ${p}: ${err.message}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function writeJsonAtomic(p, data) {
|
|
141
|
+
await fs.mkdir(dirname(p), { recursive: true });
|
|
142
|
+
const tmp = `${p}.tmp.${process.pid}`;
|
|
143
|
+
await fs.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
144
|
+
await fs.rename(tmp, p);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function spawnCapture(cmd, args, env) {
|
|
148
|
+
return new Promise((resolveProc) => {
|
|
149
|
+
const child = spawn(cmd, args, {
|
|
150
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
151
|
+
env: { ...process.env, ...env },
|
|
152
|
+
});
|
|
153
|
+
let stdout = "";
|
|
154
|
+
let stderr = "";
|
|
155
|
+
child.stdout.on("data", (b) => (stdout += b.toString()));
|
|
156
|
+
child.stderr.on("data", (b) => (stderr += b.toString()));
|
|
157
|
+
child.on("error", (err) => resolveProc({ code: -1, stdout, stderr: err.message }));
|
|
158
|
+
child.on("close", (code) => resolveProc({ code, stdout, stderr }));
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function resolveProjectRoot(cwd) {
|
|
163
|
+
const paths = await resolvePaths();
|
|
164
|
+
const result = await spawnCapture(
|
|
165
|
+
"python3",
|
|
166
|
+
[
|
|
167
|
+
"-c",
|
|
168
|
+
[
|
|
169
|
+
"import sys",
|
|
170
|
+
"from okstra_project import resolve_project_root, ResolverError",
|
|
171
|
+
"try:",
|
|
172
|
+
" print(resolve_project_root(explicit_root='', cwd=sys.argv[1]))",
|
|
173
|
+
"except ResolverError as e:",
|
|
174
|
+
" sys.stderr.write(str(e)+'\\n'); sys.exit(2)",
|
|
175
|
+
].join("\n"),
|
|
176
|
+
cwd,
|
|
177
|
+
],
|
|
178
|
+
{ PYTHONPATH: paths.pythonpath },
|
|
179
|
+
);
|
|
180
|
+
if (result.code === 0) return result.stdout.trim();
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function emit(opts, payload) {
|
|
185
|
+
if (opts.quiet) return;
|
|
186
|
+
if (opts.json || typeof payload !== "string") {
|
|
187
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
188
|
+
} else {
|
|
189
|
+
process.stdout.write(`${payload}\n`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function effectivePrTemplatePath(projectRoot) {
|
|
194
|
+
// Mirror the python resolver order without depending on it for the simple
|
|
195
|
+
// case where we just want to surface the chain in 'config get --scope all'.
|
|
196
|
+
// Python remains the source of truth at run time; this is informational.
|
|
197
|
+
const paths = await resolvePaths();
|
|
198
|
+
const result = await spawnCapture(
|
|
199
|
+
"python3",
|
|
200
|
+
[
|
|
201
|
+
"-c",
|
|
202
|
+
[
|
|
203
|
+
"import json, sys",
|
|
204
|
+
"from pathlib import Path",
|
|
205
|
+
"from okstra_ctl.pr_template import resolve_pr_template_path, PrTemplateError",
|
|
206
|
+
"try:",
|
|
207
|
+
" r = resolve_pr_template_path(Path(sys.argv[1]))",
|
|
208
|
+
" print(json.dumps({'ok': True, 'path': str(r.path), 'source': r.source}))",
|
|
209
|
+
"except PrTemplateError as e:",
|
|
210
|
+
" print(json.dumps({'ok': False, 'reason': str(e)}))",
|
|
211
|
+
].join("\n"),
|
|
212
|
+
projectRoot ?? process.cwd(),
|
|
213
|
+
],
|
|
214
|
+
{ PYTHONPATH: paths.pythonpath },
|
|
215
|
+
);
|
|
216
|
+
if (result.code !== 0) return null;
|
|
217
|
+
try {
|
|
218
|
+
return JSON.parse(result.stdout.trim());
|
|
219
|
+
} catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async function opGet(opts) {
|
|
225
|
+
const spec = KEYS[opts.key];
|
|
226
|
+
const scope = opts.scope ?? "all";
|
|
227
|
+
const projectRoot = await resolveProjectRoot(opts.cwd);
|
|
228
|
+
|
|
229
|
+
const out = {};
|
|
230
|
+
if (scope === "project" || scope === "all") {
|
|
231
|
+
if (!projectRoot) {
|
|
232
|
+
out.project = { available: false, reason: "cwd not inside an okstra project" };
|
|
233
|
+
} else {
|
|
234
|
+
const cfg = (await readJson(projectConfigPath(projectRoot))) ?? {};
|
|
235
|
+
out.project = {
|
|
236
|
+
available: true,
|
|
237
|
+
path: projectConfigPath(projectRoot),
|
|
238
|
+
value: cfg[spec.jsonField] ?? null,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (scope === "global" || scope === "all") {
|
|
243
|
+
const cfg = (await readJson(globalConfigPath())) ?? {};
|
|
244
|
+
out.global = {
|
|
245
|
+
available: true,
|
|
246
|
+
path: globalConfigPath(),
|
|
247
|
+
value: cfg[spec.jsonField] ?? null,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (scope === "all" && opts.key === "pr-template-path") {
|
|
252
|
+
const effective = await effectivePrTemplatePath(projectRoot);
|
|
253
|
+
if (effective) out.effective = effective;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (scope !== "all" && !opts.json) {
|
|
257
|
+
const entry = out[scope];
|
|
258
|
+
if (entry?.available && entry.value !== null) {
|
|
259
|
+
emit(opts, entry.value);
|
|
260
|
+
} else {
|
|
261
|
+
emit(opts, "");
|
|
262
|
+
}
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
|
265
|
+
emit(opts, out);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function opSet(opts) {
|
|
270
|
+
const spec = KEYS[opts.key];
|
|
271
|
+
if (opts.value === null) throw new Error(`'config set ${opts.key}' requires a value argument`);
|
|
272
|
+
let scope = opts.scope;
|
|
273
|
+
const projectRoot = await resolveProjectRoot(opts.cwd);
|
|
274
|
+
if (!scope) {
|
|
275
|
+
scope = projectRoot ? "project" : null;
|
|
276
|
+
if (!scope) {
|
|
277
|
+
throw new Error(
|
|
278
|
+
"--scope is required when cwd is not inside an okstra project. " +
|
|
279
|
+
"Pass --scope project or --scope global.",
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
if (!spec.scopes.includes(scope)) {
|
|
284
|
+
throw new Error(`key '${opts.key}' does not support scope '${scope}'`);
|
|
285
|
+
}
|
|
286
|
+
const ctx = { scope, projectRoot };
|
|
287
|
+
const err = spec.validate(opts.value, ctx);
|
|
288
|
+
if (err) throw new Error(err);
|
|
289
|
+
|
|
290
|
+
const targetPath =
|
|
291
|
+
scope === "project" ? projectConfigPath(projectRoot) : globalConfigPath();
|
|
292
|
+
if (scope === "project" && !projectRoot) {
|
|
293
|
+
throw new Error("cwd not inside an okstra project — cannot write project scope");
|
|
294
|
+
}
|
|
295
|
+
const existing = (await readJson(targetPath)) ?? {};
|
|
296
|
+
existing[spec.jsonField] = opts.value;
|
|
297
|
+
await writeJsonAtomic(targetPath, existing);
|
|
298
|
+
emit(opts, { ok: true, scope, path: targetPath, key: opts.key, value: opts.value });
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function opUnset(opts) {
|
|
303
|
+
const spec = KEYS[opts.key];
|
|
304
|
+
let scope = opts.scope;
|
|
305
|
+
const projectRoot = await resolveProjectRoot(opts.cwd);
|
|
306
|
+
if (!scope) {
|
|
307
|
+
scope = projectRoot ? "project" : null;
|
|
308
|
+
if (!scope) throw new Error("--scope is required (project|global)");
|
|
309
|
+
}
|
|
310
|
+
const targetPath =
|
|
311
|
+
scope === "project" ? projectConfigPath(projectRoot) : globalConfigPath();
|
|
312
|
+
if (scope === "project" && !projectRoot) {
|
|
313
|
+
throw new Error("cwd not inside an okstra project — cannot unset project scope");
|
|
314
|
+
}
|
|
315
|
+
const existing = (await readJson(targetPath)) ?? {};
|
|
316
|
+
if (!(spec.jsonField in existing)) {
|
|
317
|
+
emit(opts, { ok: true, scope, path: targetPath, key: opts.key, removed: false });
|
|
318
|
+
return 0;
|
|
319
|
+
}
|
|
320
|
+
delete existing[spec.jsonField];
|
|
321
|
+
await writeJsonAtomic(targetPath, existing);
|
|
322
|
+
emit(opts, { ok: true, scope, path: targetPath, key: opts.key, removed: true });
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function opShow(opts) {
|
|
327
|
+
const scope = opts.scope ?? "all";
|
|
328
|
+
const projectRoot = await resolveProjectRoot(opts.cwd);
|
|
329
|
+
const out = {};
|
|
330
|
+
if (scope === "project" || scope === "all") {
|
|
331
|
+
out.project = projectRoot
|
|
332
|
+
? {
|
|
333
|
+
path: projectConfigPath(projectRoot),
|
|
334
|
+
data: (await readJson(projectConfigPath(projectRoot))) ?? {},
|
|
335
|
+
}
|
|
336
|
+
: { available: false, reason: "cwd not inside an okstra project" };
|
|
337
|
+
}
|
|
338
|
+
if (scope === "global" || scope === "all") {
|
|
339
|
+
out.global = {
|
|
340
|
+
path: globalConfigPath(),
|
|
341
|
+
data: (await readJson(globalConfigPath())) ?? {},
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
emit({ ...opts, json: true }, out);
|
|
345
|
+
return 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function run(args) {
|
|
349
|
+
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
|
350
|
+
process.stdout.write(USAGE);
|
|
351
|
+
return args.length === 0 ? 2 : 0;
|
|
352
|
+
}
|
|
353
|
+
const opts = parseArgs(args);
|
|
354
|
+
if (!opts.op) {
|
|
355
|
+
process.stderr.write("missing subcommand (get|set|unset|show)\n");
|
|
356
|
+
return 2;
|
|
357
|
+
}
|
|
358
|
+
if (opts.scope !== null && !["project", "global", "all"].includes(opts.scope)) {
|
|
359
|
+
process.stderr.write(`invalid --scope: ${opts.scope}\n`);
|
|
360
|
+
return 2;
|
|
361
|
+
}
|
|
362
|
+
if (["get", "set", "unset"].includes(opts.op)) {
|
|
363
|
+
if (!opts.key) {
|
|
364
|
+
process.stderr.write(`'config ${opts.op}' requires a key argument\n`);
|
|
365
|
+
return 2;
|
|
366
|
+
}
|
|
367
|
+
if (!KEYS[opts.key]) {
|
|
368
|
+
process.stderr.write(
|
|
369
|
+
`unknown key '${opts.key}'. Supported: ${Object.keys(KEYS).join(", ")}\n`,
|
|
370
|
+
);
|
|
371
|
+
return 2;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
switch (opts.op) {
|
|
376
|
+
case "get":
|
|
377
|
+
return await opGet(opts);
|
|
378
|
+
case "set":
|
|
379
|
+
return await opSet(opts);
|
|
380
|
+
case "unset":
|
|
381
|
+
return await opUnset(opts);
|
|
382
|
+
case "show":
|
|
383
|
+
return await opShow(opts);
|
|
384
|
+
default:
|
|
385
|
+
process.stderr.write(`unknown subcommand '${opts.op}'\n`);
|
|
386
|
+
return 2;
|
|
387
|
+
}
|
|
388
|
+
} catch (err) {
|
|
389
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
390
|
+
return 1;
|
|
391
|
+
}
|
|
392
|
+
}
|