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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.22.0",
3
- "builtAt": "2026-05-14T15:54:01.726Z",
2
+ "package": "0.23.0",
3
+ "builtAt": "2026-05-15T01:56:19.363Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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 must begin with the standard header from `okstra-team-contract`:
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
- "{{FM_ID}}": _fm_array([task_id, project_id, task_group]),
113
- "{{FM_ALIASES}}": _fm_array([task_id, project_id, task_group]),
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`, free text) for an absolute project-root path; rerun with `okstra check-project --cwd <their input>`.
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
- `AskUserQuestion` (free text, one at a time):
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 (e.g. backend-api, INV-1234, refactor)"` → `task_group`
109
- 2. `"Task id (e.g. login-error-analysis, dev-9043)"` → `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. Re-ask if not.
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 `AskUserQuestion` in order:
129
- - `"Path to the approved final-report.md (must contain APPROVED marker)"` the underlying python `prepare_task_bundle` re-validates the marker, but you can pre-check with `grep`.
130
- - `"Executor provider for this run (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`.
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
- `AskUserQuestion` (mirrors the `release-handoff` PR-base picker):
162
+ Use the **menu + free-text two-step pattern** (per the convention above):
153
163
 
154
- - **Label**: `"이 task worktree 의 base branch?"`
155
- - **Options** (single-select):
156
- 1. `main` (recommended)
157
- 2. `dev`
158
- 3. `staging`
159
- 4. `preprod`
160
- 5. `prod`
161
- 6. Free text let the user type any local ref (branch, tag, or full/short SHA).
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
- Re-ask on failure. Echo the resolved short SHA back to the user
172
- (`base 확정: <ref> (<short-sha>)`) and capture `base_ref=<chosen-ref>` for
173
- Step 7.
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: `AskUserQuestion` (free text) `"Path to the task brief markdown (relative to project root)"`. Verify file exists; re-ask on failure.
178
- - Existing task: default to the manifest's `taskBriefPath`. Show it; ask whether to keep or change.
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. `AskUserQuestion` `"추가 directive (선택,칸 가능)"` (free text) → `directive`
205
- 5. `AskUserQuestion` `"관련 task id 목록, 쉼표 구분 (선택,칸 가능)"` (free text) → `related_tasks_raw`
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 (model prompts use `AskUserQuestion` with the option lists above; others are free text). Skip any worker-model prompt whose worker is not in `profile_workers`.
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) `AskUserQuestion` `"참여 워커 목록 (쉼표 구분, = 프로필 기본값 <profile_workers_csv>). 선택지: <profile_workers_csv>"` (free text) → `workers_override`. Validate the answer is a subset of `profile_workers`; re-ask on failure. (Backend will also reject violations with `WorkersError`.)
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"`) `AskUserQuestion` `"PR 본문 템플릿 경로 1회성 override (빈 = project.json → ~/.okstra/config.json → 스킬 디폴트 순으로 자동 해석)"` (free text) → `pr_template_path`. The backend (`okstra_ctl.pr_template.resolve_pr_template_path`) validates the file exists and surfaces `PrTemplateError` on failure. If the user wants to persist the choice instead of a one-shot override, tell them to set `prTemplatePath` in `<project_root>/.project-docs/okstra/project.json` (project scope) or `~/.okstra/config.json` (global scope).
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
- A successful worker result must include the following sections in this exact order:
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
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Task Brief
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Task Summary
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Error Analysis Input
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # {{TASK_KEY}} - Multi-Agent Cross Verification Final Report
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Final Verification Input
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Implementation Input
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Implementation Planning Input
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Quick Input
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Release Handoff Input
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # <Title> — Work Schedule
@@ -8,6 +8,7 @@ date: {{TASK_DATE}}
8
8
  task-id: "{{TASK_ID}}"
9
9
  task-group: "{{TASK_GROUP}}"
10
10
  project-id: "{{PROJECT_ID}}"
11
+ taskType: "{{FM_TASK_TYPE}}"
11
12
  ---
12
13
 
13
14
  # OKSTRA Task Brief
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
+ }