okstra 0.83.0 → 0.85.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.83.0",
3
+ "version": "0.85.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.83.0",
3
- "builtAt": "2026-06-15T16:01:38.767Z",
2
+ "package": "0.85.0",
3
+ "builtAt": "2026-06-15T19:37:36.878Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -30,7 +30,7 @@ You are a Codex worker agent. Your job is to execute the OpenAI Codex CLI and re
30
30
  $HOME/.okstra/bin/okstra-codex-exec.sh "<absolute-project-root>" "<assigned-model-execution-value>" "<absolute-prompt-history-path>" [<absolute-worktree-path>] [<role>]
31
31
  ```
32
32
 
33
- The fifth argument `<role>` is folded into both the caller (worker) pane title `codex-<role>-<pid>` and the sibling trace-pane title `codex-<role>-<pid>-tail` (`<pid>` = the wrapper's PID, present so concurrent dispatches of the same role can be told apart). Pass the value of the dispatch prompt's `**Pane role:**` line verbatim — Lead injects `**Pane role:** executor` on an `implementation` Executor dispatch and `**Pane role:** verifier` on an `implementation` / `final-verification` verifier dispatch, so the trace pane names the actual job (`codex-executor-<pid>` / `codex-verifier-<pid>`). When the prompt carries no `**Pane role:**` line (analysis phases), pass the literal `worker`. The wrapper defaults to `worker` when the argument is omitted, but pass it explicitly so the pane title is self-describing.
33
+ The fifth argument `<role>` is folded into both the caller (worker) pane title `codex-<role>` and the sibling trace-pane title `codex-<role>-tail`. `<role>` carries the dispatched Agent `name` minus the `codex-` prefix, so the pane title equals the FleetView teammate name (`codex-worker-reverify-r1`, `codex-executor`, ) instead of a generic `worker`. Pass the value of the dispatch prompt's `**Pane role:**` line verbatim — Lead injects it on every CLI dispatch: `**Pane role:** worker-reverify-r1` on a convergence reverify, `**Pane role:** worker-critic` on a critic pass, `**Pane role:** executor` on an `implementation` Executor dispatch, `**Pane role:** verifier` on an `implementation` / `final-verification` verifier dispatch, and `**Pane role:** worker` on a plain analysis dispatch. The wrapper defaults to `worker` when the argument is omitted, but pass it explicitly so the pane title is self-describing.
34
34
 
35
35
  The fourth argument is **mandatory for implementation phase** and optional otherwise. It must be the literal `EXECUTOR_WORKTREE_PATH` recorded in the run context; the wrapper forwards it to codex as `--add-dir`, which grants the codex sandbox write access to the worktree (where all implementation-phase mutations occur). Without it, codex's `workspace-write` sandbox is anchored only at `<project-root>` and rejects every Edit/Write that targets the worktree (EPERM), which is the failure pattern that originally motivated this argument.
36
36
 
@@ -30,7 +30,7 @@ You are a Gemini worker agent. Your job is to execute the Google Gemini CLI and
30
30
  $HOME/.okstra/bin/okstra-gemini-exec.sh "<absolute-project-root>" "<assigned-model-execution-value>" "<absolute-prompt-history-path>" [<absolute-worktree-path>] [<role>]
31
31
  ```
32
32
 
33
- The fifth argument `<role>` is folded into both the caller (worker) pane title `gemini-<role>-<pid>` and the sibling trace-pane title `gemini-<role>-<pid>-tail` (`<pid>` = the wrapper's PID, present so concurrent dispatches of the same role can be told apart). Pass the value of the dispatch prompt's `**Pane role:**` line verbatim — Lead injects `**Pane role:** executor` on an `implementation` Executor dispatch and `**Pane role:** verifier` on an `implementation` / `final-verification` verifier dispatch, so the trace pane names the actual job (`gemini-executor-<pid>` / `gemini-verifier-<pid>`). When the prompt carries no `**Pane role:**` line (analysis phases), pass the literal `worker`. The wrapper defaults to `worker` when the argument is omitted, but pass it explicitly so the pane title is self-describing.
33
+ The fifth argument `<role>` is folded into both the caller (worker) pane title `gemini-<role>` and the sibling trace-pane title `gemini-<role>-tail`. `<role>` carries the dispatched Agent `name` minus the `gemini-` prefix, so the pane title equals the FleetView teammate name (`gemini-worker-reverify-r1`, `gemini-executor`, ) instead of a generic `worker`. Pass the value of the dispatch prompt's `**Pane role:**` line verbatim — Lead injects it on every CLI dispatch: `**Pane role:** worker-reverify-r1` on a convergence reverify, `**Pane role:** worker-critic` on a critic pass, `**Pane role:** executor` on an `implementation` Executor dispatch, `**Pane role:** verifier` on an `implementation` / `final-verification` verifier dispatch, and `**Pane role:** worker` on a plain analysis dispatch. The wrapper defaults to `worker` when the argument is omitted, but pass it explicitly so the pane title is self-describing.
34
34
 
35
35
  The fourth argument is **mandatory for implementation phase** and optional otherwise. It must be the literal `EXECUTOR_WORKTREE_PATH` recorded in the run context; the wrapper appends it to gemini's `--include-directories` list so the model can both read and operate on the worktree alongside project-root.
36
36
 
@@ -39,6 +39,25 @@ if ! command -v claude >/dev/null 2>&1; then
39
39
  exit 127
40
40
  fi
41
41
 
42
+ # Grant tool access to the implementation worktree (and the main repo's shared
43
+ # .git for linked-worktree commits) when one is supplied. The executor mutates
44
+ # a git worktree that lives outside project-root; without --add-dir Claude
45
+ # Code's Edit/Write tools cannot reach it. Mirrors okstra-codex-exec.sh.
46
+ extra_args=()
47
+ if [[ -n "$worktree_path" ]]; then
48
+ extra_args+=(--add-dir "$worktree_path")
49
+ if command -v git >/dev/null 2>&1; then
50
+ common_git_dir=$(git -C "$worktree_path" rev-parse --git-common-dir 2>/dev/null || true)
51
+ if [[ -n "$common_git_dir" ]]; then
52
+ [[ "$common_git_dir" != /* ]] && common_git_dir="$worktree_path/$common_git_dir"
53
+ if [[ -d "$common_git_dir" ]]; then
54
+ common_git_dir=$(cd "$common_git_dir" && pwd -P)
55
+ extra_args+=(--add-dir "$common_git_dir")
56
+ fi
57
+ fi
58
+ fi
59
+ fi
60
+
42
61
  log_path="${prompt_path%.md}.log"
43
62
  [[ "$log_path" == "$prompt_path" ]] && log_path="${prompt_path}.log"
44
63
  : > "$log_path"
@@ -62,10 +81,50 @@ trap _okstra_status_finish EXIT
62
81
 
63
82
  cd "$project_root"
64
83
  set +e
65
- claude -p --model "$model" --output-format=stream-json < "$prompt_path" \
84
+ claude -p --model "$model" ${extra_args[@]+"${extra_args[@]}"} --output-format=stream-json \
85
+ < "$prompt_path" \
66
86
  > >(tee -a "$log_path") \
67
- 2> >(tee -a "$log_path" >&2)
87
+ 2> >(tee -a "$log_path" >&2) &
88
+ claude_pid=$!
89
+
90
+ # Idle watchdog: if the live log sees no write for $idle_timeout_secs, SIGTERM
91
+ # claude (5s grace, then SIGKILL) and mark the status sidecar timed out, so a
92
+ # silent hang is bounded instead of stalling the lead. Pass 0 to disable.
93
+ watchdog_pid=""
94
+ if (( idle_timeout_secs > 0 )); then
95
+ poll_interval=$(( idle_timeout_secs / 20 ))
96
+ (( poll_interval < 5 )) && poll_interval=5
97
+ (( poll_interval > 30 )) && poll_interval=30
98
+ (
99
+ while kill -0 "$claude_pid" 2>/dev/null; do
100
+ sleep "$poll_interval"
101
+ kill -0 "$claude_pid" 2>/dev/null || exit 0
102
+ last_mtime=$(stat -f %m "$log_path" 2>/dev/null || stat -c %Y "$log_path" 2>/dev/null || printf '0')
103
+ now=$(date +%s)
104
+ idle=$(( now - last_mtime ))
105
+ if (( idle >= idle_timeout_secs )); then
106
+ printf '\n[okstra wrapper] idle-watchdog: %ds without stdout — terminating claude (pid=%d)\n' \
107
+ "$idle" "$claude_pid" >> "$log_path" 2>&1 || true
108
+ python3 "$script_dir/okstra-wrapper-status.py" \
109
+ timeout "$status_path" "$now" "$idle" >>"$log_path" 2>&1 || true
110
+ kill -TERM "$claude_pid" 2>/dev/null || true
111
+ sleep 5
112
+ kill -KILL "$claude_pid" 2>/dev/null || true
113
+ exit 0
114
+ fi
115
+ done
116
+ ) &
117
+ watchdog_pid=$!
118
+ fi
119
+
120
+ wait "$claude_pid"
68
121
  claude_exit=$?
69
122
  set -e
70
123
 
124
+ if [[ -n "$watchdog_pid" ]]; then
125
+ kill "$watchdog_pid" 2>/dev/null || true
126
+ wait "$watchdog_pid" 2>/dev/null || true
127
+ fi
128
+ wait 2>/dev/null || true
129
+
71
130
  exit "$claude_exit"
@@ -211,11 +211,12 @@ if type okstra_resolve_caller_pane >/dev/null 2>&1; then
211
211
  caller_pane="$(okstra_resolve_caller_pane)"
212
212
  fi
213
213
 
214
- # Pane titles: worker (caller) pane gets `codex-<role>-<pid>`; the sibling
215
- # trace pane is that same caller title with a `-tail` suffix, so the
216
- # operator can visually pair `<caller> ↔ <caller>-tail`. The wrapper PID in
217
- # the caller title disambiguates concurrent dispatches of the same role.
218
- pane_label="codex-${role}-$$"
214
+ # Pane titles: the caller (worker) pane gets `codex-<role>`; the sibling trace
215
+ # pane is that same caller title with a `-tail` suffix, so the operator can
216
+ # visually pair `<caller> ↔ <caller>-tail`. `role` carries the dispatched Agent
217
+ # name minus the `codex-` prefix (e.g. `worker-reverify-r1`, `executor`), so the
218
+ # pane title equals the FleetView teammate name instead of a generic `worker`.
219
+ pane_label="codex-${role}"
219
220
  trace_label="${pane_label}-tail"
220
221
 
221
222
  # Capture the caller pane's current title so the EXIT trap can restore it
@@ -251,13 +252,13 @@ fi
251
252
  # for the wrapper to exit. This fires in every phase the wrapper is invoked
252
253
  # from (analysis, error-analysis, implementation-planning, implementation,
253
254
  # …) — long-running codex dispatches are not implementation-specific. The
254
- # new pane carries the title `codex-<role>-<pid>-tail` so the operator can
255
- # pair it with its caller pane (`codex-<role>-<pid>`). The split is
255
+ # new pane carries the title `codex-<role>-tail` so the operator can
256
+ # pair it with its caller pane (`codex-<role>`). The split is
256
257
  # explicitly anchored to the caller pane (`-t "$caller_pane"`) to avoid
257
258
  # attaching to tmux's idle active pane. `role` is the optional 5th
258
259
  # positional arg (defaults to `worker`); callers that dispatch a different
259
- # role (e.g. `executor`) must pass it explicitly. The `<pid>` suffix is the
260
- # wrapper's PID and disambiguates concurrent dispatches of the same role.
260
+ # role (e.g. `executor`, `worker-reverify-r1`) must pass it explicitly so the
261
+ # pane title names the actual job.
261
262
  # The pane uses `tail -F` (follow-by-name) so it survives any truncation a
262
263
  # re-dispatch performs on the same log path. We gate on a resolved
263
264
  # `$caller_pane` (non-empty only when tmux is reachable) rather than the
@@ -153,11 +153,12 @@ if type okstra_resolve_caller_pane >/dev/null 2>&1; then
153
153
  caller_pane="$(okstra_resolve_caller_pane)"
154
154
  fi
155
155
 
156
- # Pane titles: worker (caller) pane gets `gemini-<role>-<pid>`; the sibling
157
- # trace pane is that same caller title with a `-tail` suffix, so the
158
- # operator can visually pair `<caller> ↔ <caller>-tail`. The wrapper PID in
159
- # the caller title disambiguates concurrent dispatches of the same role.
160
- pane_label="gemini-${role}-$$"
156
+ # Pane titles: the caller (worker) pane gets `gemini-<role>`; the sibling trace
157
+ # pane is that same caller title with a `-tail` suffix, so the operator can
158
+ # visually pair `<caller> ↔ <caller>-tail`. `role` carries the dispatched Agent
159
+ # name minus the `gemini-` prefix (e.g. `worker-reverify-r1`, `executor`), so the
160
+ # pane title equals the FleetView teammate name instead of a generic `worker`.
161
+ pane_label="gemini-${role}"
161
162
  trace_label="${pane_label}-tail"
162
163
 
163
164
  # Capture the caller pane's current title so the EXIT trap can restore it
@@ -191,13 +192,13 @@ fi
191
192
  # When a tmux session is reachable, split a sibling pane tailing the log so
192
193
  # the operator can watch progress live. This fires in every phase the
193
194
  # wrapper is invoked from — long-running gemini dispatches are not
194
- # implementation-specific. Title `gemini-<role>-<pid>-tail` so the operator
195
- # can pair it with its caller pane (`gemini-<role>-<pid>`). The split is
195
+ # implementation-specific. Title `gemini-<role>-tail` so the operator
196
+ # can pair it with its caller pane (`gemini-<role>`). The split is
196
197
  # explicitly anchored to the caller pane to avoid attaching to tmux's idle
197
198
  # active pane. `role` is the optional 5th positional arg (defaults to
198
- # `worker`); callers that dispatch a different role must pass it explicitly.
199
- # The `<pid>` suffix is the wrapper's PID and disambiguates concurrent
200
- # dispatches of the same role. We gate on a resolved `$caller_pane`
199
+ # `worker`); callers that dispatch a different role (e.g. `worker-reverify-r1`)
200
+ # must pass it explicitly so the pane title names the actual job.
201
+ # We gate on a resolved `$caller_pane`
201
202
  # (non-empty only when tmux is reachable) rather than the now-stripped
202
203
  # `$TMUX`. See the codex wrapper for the full design rationale and the
203
204
  # silent-degrade failure model.
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for coding-preflight.
3
+ #
4
+ # Inspects the target file path of a Write/Edit/MultiEdit/NotebookEdit
5
+ # call. If the extension matches a language covered by this skill,
6
+ # emits a `hookSpecificOutput.additionalContext` JSON payload that
7
+ # reminds the agent to invoke the skill before writing.
8
+ #
9
+ # Fires at most once per session per language (marker file).
10
+ # Exits 0 in every case — never blocks the tool call.
11
+
12
+ set -euo pipefail
13
+
14
+ input="$(cat)"
15
+
16
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
17
+ case "$tool_name" in
18
+ Write|Edit|MultiEdit|NotebookEdit) ;;
19
+ *) exit 0 ;;
20
+ esac
21
+
22
+ file_path="$(printf '%s' "$input" \
23
+ | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')"
24
+ [[ -z "$file_path" ]] && exit 0
25
+
26
+ lang=""
27
+ ref=""
28
+ extra_hint=""
29
+ case "$file_path" in
30
+ *.java)
31
+ lang="Java"
32
+ ref="languages/java.md"
33
+ ;;
34
+ *.kt|*.kts)
35
+ lang="Kotlin"
36
+ ref="languages/kotlin.md"
37
+ ;;
38
+ *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs)
39
+ lang="JavaScript-TypeScript"
40
+ ref="languages/javascript-typescript.md"
41
+ extra_hint=" If this file is part of a Node.js server (Express/Fastify/Nest/etc.), also read languages/nodejs.md."
42
+ ;;
43
+ *.py)
44
+ lang="Python"
45
+ ref="languages/python.md"
46
+ ;;
47
+ *.sql)
48
+ lang="SQL"
49
+ ref="languages/sql.md"
50
+ ;;
51
+ *.rs)
52
+ lang="Rust"
53
+ ref="languages/rust.md"
54
+ ;;
55
+ *)
56
+ exit 0
57
+ ;;
58
+ esac
59
+
60
+ session_id="$(printf '%s' "$input" | jq -r '.session_id // "no-session"')"
61
+ marker_dir="${TMPDIR:-/tmp}/mcbsc"
62
+ mkdir -p "$marker_dir"
63
+ marker="${marker_dir}/${session_id}_${lang}"
64
+
65
+ if [[ -f "$marker" ]]; then
66
+ exit 0
67
+ fi
68
+
69
+ : > "$marker"
70
+
71
+ skill_root="$HOME/.okstra/prompts/coding-preflight"
72
+ msg="[coding-preflight] About to edit a ${lang} file (${file_path}). Before writing, you MUST Read ${skill_root}/${ref} plus ${skill_root}/clean-code.md (the skill is user-invocable:false — read the files directly).${extra_hint} If the project uses ports-and-adapters (domain/ + ports/ + adapters/, *.port.* files), also Read ${skill_root}/architecture/hexagonal.md. (Fires once per session per language.)"
73
+
74
+ jq -nc \
75
+ --arg event "PreToolUse" \
76
+ --arg ctx "$msg" \
77
+ '{hookSpecificOutput: {hookEventName: $event, additionalContext: $ctx}}'
78
+
79
+ exit 0
@@ -266,8 +266,9 @@ Agent(
266
266
 
267
267
  - Agent Teams mode: Spawn within an existing team
268
268
  - Fallback mode: Spawn with `run_in_background: true` and no `team_name`
269
+ - CLI (Codex / Gemini) worker: inject `**Pane role:** worker-reverify-r<N>` (the Agent `name` minus the `<cli>-` prefix) into the reverify prompt body so the tmux trace pane reads `<cli>-worker-reverify-r<N>` per [okstra-lead-contract](./okstra-lead-contract.md) "Agent `name` on dispatch".
269
270
 
270
- For `tmux-pane` backend runs, do not use the Agent snippet. For each reverify round, write a jobs file at `runs/<task-type>/state/reverify-jobs-r<N>-<task-type>-<seq>.json` with `dispatchKind: "reverify-r<N>"` and worker entries containing `workerId`, `provider`, `role`, `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths`; then run `okstra team dispatch --project-root <dir> --run-manifest <path> --dispatch-kind reverify-r<N> --jobs-file <jobs-file>` followed by `okstra team await --project-root <dir> --run-manifest <path>`.
271
+ For `tmux-pane` backend runs, do not use the Agent snippet. For each reverify round, write a jobs file at `runs/<task-type>/state/reverify-jobs-r<N>-<task-type>-<seq>.json` with `dispatchKind: "reverify-r<N>"` and worker entries containing `workerId`, `provider`, `role` (set to `worker-reverify-r<N>` for the pane title), `modelExecutionValue`, `promptPath`, `resultPath`, `workerResultPath`, and `completionPaths`; then run `okstra team dispatch --project-root <dir> --run-manifest <path> --dispatch-kind reverify-r<N> --jobs-file <jobs-file>` followed by `okstra team await --project-root <dir> --run-manifest <path>`.
271
272
 
272
273
 
273
274
  **Completion detection per round (BLOCKING).** Each round dispatches a variable set (1..N) of reverify workers asynchronously; the `Agent(... team_name ...)` calls return `Spawned successfully` immediately, which is NOT completion. Lead MUST detect each round's completion via the self-scheduled polling protocol in [team-contract](./team-contract.md) "Worker-completion detection (self-scheduled polling)", with the pending set reconstructed from that round's dispatched workers' Result Paths — do NOT restate the algorithm here. Lead MUST NOT treat the spawn ack as completion and MUST NOT end its turn with a prose "waiting" statement.
@@ -523,7 +524,7 @@ The critic input is the Round 0 consolidated finding list. Reverify rounds only
523
524
  - **Gap verification + merge**: only after BOTH the finding-convergence loop has exited AND the critic result is collected, and BEFORE the Phase 6 report-writer dispatch. If the loop exited `aborted-non-result`, do NOT dispatch a gap-verification round — surface the gaps as unverified `clarification` items per §"Gap verification" and record that fact.
524
525
 
525
526
  ### Dispatch (reused worker)
526
- Dispatch ONE pass to the `config.critic.provider`'s existing subagent (`claude-worker` / `codex-worker` / `gemini-worker`) with `model = config.critic.modelExecutionValue` — no new agent type. If `config.critic.modelExecutionValue` is null/empty (model could not be resolved), skip the critic pass and record `critic-skipped: model-unresolved` in the convergence state rather than dispatching with no model. Result path: `runs/<task-type>/worker-results/<provider>-critic-<task-type>-<seq>.md`. The critic prompt seeds the consolidated findings and asks ONLY for coverage gaps:
527
+ Dispatch ONE pass to the `config.critic.provider`'s existing subagent (`claude-worker` / `codex-worker` / `gemini-worker`) with `model = config.critic.modelExecutionValue` — no new agent type, `name: "<provider>-worker-critic"`. If `config.critic.modelExecutionValue` is null/empty (model could not be resolved), skip the critic pass and record `critic-skipped: model-unresolved` in the convergence state rather than dispatching with no model. For a CLI (Codex / Gemini) critic dispatch, inject `**Pane role:** worker-critic` into the prompt body so the trace pane reads `<cli>-worker-critic` per [okstra-lead-contract](./okstra-lead-contract.md) "Agent `name` on dispatch". Result path: `runs/<task-type>/worker-results/<provider>-critic-<task-type>-<seq>.md`. The critic prompt seeds the consolidated findings and asks ONLY for coverage gaps:
527
528
 
528
529
  ```
529
530
  You are the coverage critic for <task-key>. Below are the consolidated findings
@@ -222,7 +222,7 @@ If the launch prompt contains `Tmux Worker Dispatch Gate`, Phase 3 is recorded a
222
222
 
223
223
  Spawn **analysis workers only** in the same turn (Phase 4 in Teams mode; Phase 5 with `run_in_background: true` and no `team_name` when Teams unavailable). Preserve exact roster, role labels, assigned models from the task bundle.
224
224
 
225
- **Agent `name` on dispatch (BLOCKING — token-usage attribution depends on it).** Every analysis-worker `Agent(...)` call MUST set `name: "<workerId>-worker"` — `name: "claude-worker"` / `name: "codex-worker"` / `name: "gemini-worker"` — exactly as the report-writer dispatch sets `name: "report-writer"` ([report-writer](./report-writer.md)). The Agent harness records this `name` as `agentName` in the subagent session jsonl, and the Phase 7 token collector matches each worker's session by that `agentName` (`okstra_token_usage/collect.py`). A worker dispatched **without** `name` produces a session with no `agentName`; the collector cannot attribute it and records the worker as `source: "unavailable"` even though the session exists and is team-tagged (observed in `dev-9692` error-analysis: `claude`/`codex` workers dispatched without `name` → both `unavailable`, while the named `report-writer` collected normally). Convergence reverify dispatches keep the prefix (`<workerId>-worker-reverify-r<N>`). For `implementation` and `final-verification` dispatches the `name` carries the **functional role** so the FleetView teammate pill and trace panes name the actual job instead of a generic `worker`: the Executor dispatch sets `name: "<workerId>-executor"` (e.g. `codex-executor`) and every verifier dispatch sets `name: "<workerId>-verifier"` (e.g. `claude-verifier`). These role suffixes still attribute correctly — the collector matches any `agentName` beginning `<workerId>-` (`okstra_token_usage/collect.py` `match_prefixes`). **For CLI (Codex / Gemini) executor/verifier dispatches, Lead MUST also inject a `**Pane role:** executor` (or `verifier`) line into the dispatched prompt body** — the wrapper subagent reads it and passes it as the wrapper's 5th `<role>` argument so the tmux trace pane title reads `<cli>-<role>-<pid>` instead of `<cli>-worker-<pid>` (see `agents/workers/_cli-wrapper-template.md`). Analysis-phase worker dispatches are unchanged: `name: "<workerId>-worker"`, no `**Pane role:**` line (the wrapper defaults to `worker`).
225
+ **Agent `name` on dispatch (BLOCKING — token-usage attribution depends on it).** Every analysis-worker `Agent(...)` call MUST set `name: "<workerId>-worker"` — `name: "claude-worker"` / `name: "codex-worker"` / `name: "gemini-worker"` — exactly as the report-writer dispatch sets `name: "report-writer"` ([report-writer](./report-writer.md)). The Agent harness records this `name` as `agentName` in the subagent session jsonl, and the Phase 7 token collector matches each worker's session by that `agentName` (`okstra_token_usage/collect.py`). A worker dispatched **without** `name` produces a session with no `agentName`; the collector cannot attribute it and records the worker as `source: "unavailable"` even though the session exists and is team-tagged (observed in `dev-9692` error-analysis: `claude`/`codex` workers dispatched without `name` → both `unavailable`, while the named `report-writer` collected normally). Convergence reverify dispatches keep the prefix (`<workerId>-worker-reverify-r<N>`). For `implementation` and `final-verification` dispatches the `name` carries the **functional role** so the FleetView teammate pill and trace panes name the actual job instead of a generic `worker`: the Executor dispatch sets `name: "<workerId>-executor"` (e.g. `codex-executor`) and every verifier dispatch sets `name: "<workerId>-verifier"` (e.g. `claude-verifier`). These role suffixes still attribute correctly — the collector matches any `agentName` beginning `<workerId>-` (`okstra_token_usage/collect.py` `match_prefixes`). **For every CLI (Codex / Gemini) dispatch, Lead MUST inject a `**Pane role:**` line into the dispatched prompt body whose value is the Agent `name` with the leading `<cli>-` prefix removed** — the wrapper subagent reads it and passes it as the wrapper's 5th `<role>` argument so the tmux trace pane title reads `<cli>-<role>` (which equals the FleetView teammate name) instead of a generic `<cli>-worker` (see `agents/workers/_cli-wrapper-template.md`). So `name: "codex-worker"` `**Pane role:** worker`, `name: "codex-worker-reverify-r1"` → `**Pane role:** worker-reverify-r1`, `name: "codex-executor"` → `**Pane role:** executor`, `name: "codex-verifier"` → `**Pane role:** verifier`. The wrapper defaults to `worker` when the line is absent, but always inject it so the pane names the actual job.
226
226
 
227
227
  **Agent `model:` on dispatch (BLOCKING — assignment is otherwise ignored).** The `Claude worker` `Agent(...)` call MUST set `model: "<family token of that role's modelExecutionValue>"` (`fable` / `opus` / `sonnet` / `haiku`), per [team-contract](./team-contract.md) "Model Assignment Rules" #3–#4. The claude-worker definition is `model: inherit`, so omitting this parameter makes the worker silently run on the lead's model instead of its manifest assignment — the assigned-vs-actual deviation. `Codex worker` / `Gemini worker` are exempt: their CLI model is applied via the wrapper's own `--model` argument, so leave their Agent `model:` at `inherit` (rule #5).
228
228
 
@@ -79,6 +79,7 @@ from .session import (
79
79
  from .pr_template import PrTemplateError, resolve_pr_template_path
80
80
  from .workers import (
81
81
  normalize_workers,
82
+ resolve_optional_workers,
82
83
  resolve_profile_workers,
83
84
  validate_workers_against_profile,
84
85
  )
@@ -1402,10 +1403,11 @@ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str],
1402
1403
  if inp.task_type == "release-handoff":
1403
1404
  return [], ""
1404
1405
  profile_workers = resolve_profile_workers(profile_file)
1406
+ optional_workers = resolve_optional_workers(profile_file)
1405
1407
  profile_workers_csv = ",".join(profile_workers)
1406
1408
  workers = normalize_workers(inp.workers_override or profile_workers_csv)
1407
1409
  if inp.workers_override.strip():
1408
- validate_workers_against_profile(workers, profile_workers)
1410
+ validate_workers_against_profile(workers, profile_workers, optional_workers)
1409
1411
  if not workers:
1410
1412
  raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
1411
1413
  return workers, ",".join(workers)
@@ -145,7 +145,7 @@ If the user picked `Reporter input`, proceed to Step 1.A's sub-flow below — th
145
145
  - **Label**: "Source of this brief?"
146
146
  - **Options** (single-select):
147
147
  1. `File` — path to an existing markdown / txt / issue-export file
148
- 2. `Issue tracker ticket` — Linear / Jira / GitHub Issue / Notion key or URL
148
+ 2. `Issue tracker ticket` — Linear URL/ Jira URL/ GitHub Issue URL/ Notion key or URL
149
149
  3. `Link URL` — general web page, blog, spec doc, design doc, etc.
150
150
  4. `User input` — synthesize from the current conversation context, or
151
151
  accept a short free-text from the user
@@ -678,7 +678,7 @@ Use the same template per brief file. In tracker mode producing a parent
678
678
  plus N children, you write **N+1 files** (each carries only its own Source
679
679
  Material).
680
680
 
681
- [`templates/reports/brief.template.md`](../../templates/reports/brief.template.md) is the byte-for-byte SSOT for the brief shape — frontmatter keys, top-header blockquote, section ordering, and inline `<!-- author guidance -->` HTML comments. Render that file with the per-brief values substituted; leave any section's body as `_(none)_` rather than fabricating content. Preserve the template's HTML comments verbatim (they are part of the contract) but do NOT promote them to body prose.
681
+ The installed template `~/.okstra/templates/reports/brief.template.md` is the byte-for-byte SSOT for the brief shape — frontmatter keys, top-header blockquote, section ordering, and inline `<!-- author guidance -->` HTML comments. Render that file with the per-brief values substituted; leave any section's body as `_(none)_` rather than fabricating content. Preserve the template's HTML comments verbatim (they are part of the contract) but do NOT promote them to body prose.
682
682
 
683
683
  **Variant note:** The template file is the canonical reporter-input shape. For the codebase-scan variant (`scope: codebase` in frontmatter), OMIT the `## Source Material` and `## Problem / Symptom` headings entirely — do NOT include them with `_(none)_` bodies. Instead, KEEP the `## Scan Scope` and `## Priority Lenses` sections (already present in the template file, marked with HTML comments). The remaining sections (Context / Desired Outcome / Constraints / Related Artifacts / Open Questions / Reporter Confirmations / Augmentation) apply to both variants.
684
684
 
@@ -710,6 +710,22 @@ Material).
710
710
  frontmatter.
711
711
  - `ticket-id` is populated only for tracker sources; leave it as an empty
712
712
  string otherwise.
713
+ - `source-type` records the input origin (`file | linear | jira | github |
714
+ notion | url | user-input`).
715
+ - `task-group` echoes the Step 2a value.
716
+ - `created` is the brief's creation date (`YYYY-MM-DD`).
717
+ - `generator: okstra-brief` is a fixed value.
718
+ - `reporter-confirmations` is set by Step 6.5 (`complete | partial | pending |
719
+ skipped`).
720
+ - `scope` is `codebase` for the codebase-scan variant; omit it (⇒
721
+ `reporter-input`) for the reporter-input variant.
722
+
723
+ The required-key set (`type`, `brief-id`, `parent-id`, `ticket-id`,
724
+ `source-type`, `task-group`, `depth`, `created`, `generator`,
725
+ `reporter-confirmations`) and every consistency rule above are enforced by
726
+ `~/.okstra/lib/validators/validate-brief.py` — run in
727
+ Step 6.6. The byte-for-byte field shape remains the job of
728
+ `~/.okstra/templates/reports/brief.template.md`.
713
729
 
714
730
  Echo the file path back on one line. Show the rendered brief to the user
715
731
  inline and ask:
@@ -750,8 +766,17 @@ cannot resolve:
750
766
  - `intent-check:` (auto-mirrored from `intent-inference` augmentations)
751
767
  - `conversion-block:` (translation failed in Step 3c)
752
768
 
753
- If the resulting list is empty, set `reporter-confirmations: complete` in
754
- the brief's frontmatter and skip the rest of this step.
769
+ **Re-run reseed (rebuild from disk).** A prior run may have left this brief at
770
+ `reporter-confirmations: partial` with some rows already answered. Before
771
+ building the pending list, exclude every `intent-check:` / `conversion-block:`
772
+ row that already carries a `[CONFIRMED <date> → RC-N]` marker — those were
773
+ resolved in an earlier run and their answers already live under `## Reporter
774
+ Confirmations`. Only the unmarked rows are pending this run. This mirrors the
775
+ visited-set rebuild rule in Step 1b: confirmation state lives on disk, not in
776
+ memory.
777
+
778
+ If the resulting pending list is empty, set `reporter-confirmations: complete`
779
+ in the brief's frontmatter and skip the rest of this step.
755
780
 
756
781
  ### 6.5b. Ask once: collect now or later
757
782
 
@@ -839,6 +864,37 @@ rows (`complete` / `partial` with remainder in the same file) or an
839
864
  explicit `skipped` signal that downstream phases consume per the
840
865
  "Brief consumption" addendum in `_common-contract.md`.
841
866
 
867
+ ## Step 6.6: Self-validate before hand-off
868
+
869
+ After every brief is written (Step 5) and its `reporter-confirmations` state
870
+ is set (Step 6.5), and **before** printing the Step 7 hand-off message, you
871
+ MUST validate every brief produced this run. The validator enforces the
872
+ labelled-handoff contract — required frontmatter keys, Augmentation labels,
873
+ `intent-inference ↔ intent-check:` auto-mirroring, `parent-id`/`depth`
874
+ consistency, `reporter-confirmations` markers, and the codebase-scan
875
+ `scope`/`Scan Scope`/`Priority Lenses` rules.
876
+
877
+ 1. Run the validator over the briefs directory:
878
+
879
+ ```bash
880
+ ~/.okstra/lib/validators/validate-brief.sh "<PROJECT_ROOT>/.okstra/briefs" --briefs-root "<PROJECT_ROOT>/.okstra/briefs"
881
+ ```
882
+
883
+ If you are working from a repo checkout instead of an install, the dev
884
+ copy is at `validators/validate-brief.sh`.
885
+
886
+ 2. If it exits non-zero, **fix the offending brief(s) and re-run**. Do NOT
887
+ print the Step 7 message until the validator passes.
888
+
889
+ 3. If neither validator copy is present, fall back to a manual checklist:
890
+ confirm each brief has all ten required frontmatter keys (`type`,
891
+ `brief-id`, `parent-id`, `ticket-id`, `source-type`, `task-group`,
892
+ `depth`, `created`, `generator`, `reporter-confirmations`); every
893
+ Augmentation entry carries one of the four labels; every `intent-inference`
894
+ has a paired `intent-check:` row; `parent-id` is `self` at depth 0 and a
895
+ real parent id below; and for `scope: codebase` briefs, `Scan Scope` is
896
+ non-empty and `Priority Lenses` lists 1–4 whitelist lenses.
897
+
842
898
  ## Step 7: Hand off
843
899
 
844
900
  Single brief:
@@ -866,13 +922,10 @@ started.
866
922
 
867
923
  ## Output Rules
868
924
 
869
- - All okstra-generated artifacts live under
870
- `<PROJECT_ROOT>/.okstra/`. This skill's brief files go
871
- under `.okstra/briefs/`; its glossary writes go to
872
- `.okstra/glossary.md` (Step 4.5 approval flow).
873
- - Paths outside `<PROJECT_ROOT>/.okstra/**` are read-only
874
- source material only when explicitly cited by the reporter. This skill
875
- never writes outside okstra's subtree.
925
+ - **Artifact-home** (see the "Artifact-home rule" section above): every write
926
+ stays under `<PROJECT_ROOT>/.okstra/` briefs in `.okstra/briefs/`, glossary
927
+ in `.okstra/glossary.md` (Step 4.5). Paths outside that subtree are read-only
928
+ source material, and only when the reporter cited them.
876
929
  - **Verbatim source**: never paraphrase, summarize, restructure, or reorder
877
930
  the Source Material section. Only format conversion (ADF → MD, HTML → MD)
878
931
  is allowed, and the conversion must be annotated in the `format:` meta.
@@ -910,3 +963,4 @@ started.
910
963
  | URL fetch fails or 401 / login wall | intranet, paywall, JS-rendered page | Ask the user to paste the core body. Never guess. |
911
964
  | Tracker body is in non-MD format (ADF / HTML) | Jira description, Notion blocks | Convert format only, preserve semantics. Annotate the conversion in the `format:` meta. |
912
965
  | Brief comes out skeletal after synthesis | source too thin AND user picked `User input` with little context | Switch to inline input mode and ask one or two targeted questions. |
966
+ | `validate-brief` exits non-zero in Step 6.6 | missing frontmatter key, unlabelled augmentation, broken auto-mirroring, or codebase-scan lens out of whitelist | Read the validator's per-row message, fix the cited brief, re-run. Never print the Step 7 hand-off until it passes. |
@@ -360,7 +360,7 @@ If a run never reached Phase 7, its `team-state` lacks `durationMs`. Mark such r
360
360
  3. Multiple matches → list candidates (`taskKey`, `taskType`, `updatedAt`) and ask the user to pick.
361
361
  4. Read `historyTimelinePath` from the chosen entry.
362
362
 
363
- If `task-catalog.json` is missing: `No okstra history found. Run scripts/okstra.sh first.`
363
+ If `task-catalog.json` is missing: `No okstra history found. Run /okstra-run first.`
364
364
 
365
365
  ### time.2 — Walk runs and collect durations
366
366
 
@@ -236,7 +236,7 @@ The validator rejects any of these patterns.
236
236
 
237
237
  ## Section Contract (required)
238
238
 
239
- The canonical template lives at `templates/reports/schedule.template.md`. **Read it before writing the schedule** and follow it byte-for-byte for headings.
239
+ The canonical template lives at `~/.okstra/templates/reports/schedule.template.md`. **Read it before writing the schedule** and follow it byte-for-byte for headings.
240
240
 
241
241
  ### MUST — exact ordered heading list
242
242
 
@@ -441,14 +441,14 @@ After writing the file and before printing the completion message in Step 7, you
441
441
  1. **Re-read the file** you just wrote.
442
442
  2. **Run the validator**:
443
443
  ```bash
444
- python3 validators/validate-schedule.py <output-path>
444
+ python3 ~/.okstra/lib/validators/validate-schedule.py <output-path>
445
445
  ```
446
446
  3. If the validator exits non-zero, **fix the file and re-validate**. Do not print the completion message until the validator passes.
447
- 4. If `validators/validate-schedule.py` is not present in the repo, fall back to a manual checklist: confirm each of the 11 mandatory headings from `### MUST — exact ordered heading list` (above) appears in the file with that exact spelling, the title ends with `— Work Schedule`, and the metadata `> Generated:` block is present.
447
+ 4. If `~/.okstra/lib/validators/validate-schedule.py` is not present, fall back to a manual checklist: confirm each of the 11 mandatory headings from `### MUST — exact ordered heading list` (above) appears in the file with that exact spelling, the title ends with `— Work Schedule`, and the metadata `> Generated:` block is present.
448
448
 
449
449
  ## Schedule Document Template
450
450
 
451
- [`templates/reports/schedule.template.md`](../../templates/reports/schedule.template.md) is the byte-for-byte SSOT for section ordering, heading spelling, table columns, and per-section HTML-comment guidance (Dependency Graph Shape A/B, Gantt rules, optional Glossary gate). Do not omit sections; if data is unavailable, render the section with `_없음_` rather than skipping it.
451
+ The installed template `~/.okstra/templates/reports/schedule.template.md` is the byte-for-byte SSOT for section ordering, heading spelling, table columns, and per-section HTML-comment guidance (Dependency Graph Shape A/B, Gantt rules, optional Glossary gate). Do not omit sections; if data is unavailable, render the section with `_없음_` rather than skipping it.
452
452
 
453
453
  The two rules below complement the template — they encode COMPUTATION that the template scaffold cannot carry inline.
454
454
 
@@ -250,8 +250,8 @@ back it up as `.bak.<timestamp>` rather than overwriting silently.
250
250
  ## Step 4.8 (optional, opt-in): register a project PR body template
251
251
 
252
252
  `release-handoff` fills the PR body from a template. By default it uses the
253
- bundled skill template at
254
- `~/.claude/skills/templates/prd/pr-body.template.md`. Most projects
253
+ installed template at
254
+ `~/.okstra/templates/prd/pr-body.template.md`. Most projects
255
255
  want their own — e.g. the repo's `.github/PULL_REQUEST_TEMPLATE.md` — to
256
256
  keep PRs consistent with what the team already merges manually.
257
257
 
@@ -14,140 +14,31 @@
14
14
  "WebFetch",
15
15
  "WebSearch",
16
16
  "TodoWrite",
17
-
18
- "Bash(pwd)",
19
- "Bash(cd)",
20
- "Bash(cd:*)",
21
-
22
- "Bash(ls)",
23
- "Bash(ls:*)",
24
- "Bash(cat:*)",
25
- "Bash(head:*)",
26
- "Bash(tail:*)",
27
- "Bash(wc:*)",
28
-
29
- "Bash(git)",
30
- "Bash(git:*)",
31
- "Bash(gh)",
32
- "Bash(gh:*)",
33
-
34
- "Bash(rg:*)",
35
- "Bash(grep:*)",
36
- "Bash(find:*)",
37
- "Bash(diff:*)",
38
-
39
- "Bash(jq:*)",
40
- "Bash(yq:*)",
41
- "Bash(awk:*)",
42
- "Bash(sed:*)",
43
- "Bash(tr:*)",
44
- "Bash(sort:*)",
45
- "Bash(uniq:*)",
46
- "Bash(xargs:*)",
47
-
48
- "Bash(mkdir:*)",
49
- "Bash(cp:*)",
50
- "Bash(mv:*)",
51
- "Bash(rm:*)",
52
- "Bash(chmod:*)",
53
-
54
- "Bash(printf:*)",
55
- "Bash(echo)",
56
- "Bash(echo:*)",
57
- "Bash(export:*)",
58
- "Bash(env)",
59
- "Bash(env:*)",
60
- "Bash(test:*)",
61
- "Bash(true)",
62
- "Bash(true:*)",
63
- "Bash(false)",
64
- "Bash(false:*)",
65
-
66
- "Bash(bash:*)",
67
- "Bash(sh:*)",
68
- "Bash(eval:*)",
69
-
70
- "Bash(python)",
71
- "Bash(python:*)",
72
- "Bash(python3)",
73
- "Bash(python3:*)",
74
- "Bash(pip)",
75
- "Bash(pip:*)",
76
- "Bash(pip3)",
77
- "Bash(pip3:*)",
78
- "Bash(uv:*)",
79
- "Bash(poetry:*)",
80
- "Bash(pytest:*)",
81
-
82
- "Bash(node)",
83
- "Bash(node:*)",
84
- "Bash(npm)",
85
- "Bash(npm:*)",
86
- "Bash(npx)",
87
- "Bash(npx:*)",
88
- "Bash(pnpm)",
89
- "Bash(pnpm:*)",
90
- "Bash(yarn)",
91
- "Bash(yarn:*)",
92
- "Bash(tsc)",
93
- "Bash(tsc:*)",
94
-
95
- "Bash(cargo)",
96
- "Bash(cargo:*)",
97
- "Bash(rustc)",
98
- "Bash(rustc:*)",
99
-
100
- "Bash(go)",
101
- "Bash(go:*)",
102
- "Bash(java)",
103
- "Bash(java:*)",
104
- "Bash(javac)",
105
- "Bash(javac:*)",
106
- "Bash(mvn)",
107
- "Bash(mvn:*)",
108
- "Bash(gradle)",
109
- "Bash(gradle:*)",
110
- "Bash(./gradlew)",
111
- "Bash(./gradlew:*)",
112
- "Bash(make)",
113
- "Bash(make:*)",
114
-
115
- "Bash(docker)",
116
- "Bash(docker:*)",
117
- "Bash(docker compose)",
118
- "Bash(docker compose:*)",
119
- "Bash(docker-compose)",
120
- "Bash(docker-compose:*)",
121
- "Bash(kubectl)",
122
- "Bash(kubectl:*)",
123
- "Bash(helm)",
124
- "Bash(helm:*)",
125
-
126
- "Bash(curl:*)",
127
- "Bash(wget:*)",
128
-
129
- "Bash(codex)",
130
- "Bash(codex:*)",
131
- "Bash(codex exec:*)",
132
17
  "Bash(okstra)",
133
18
  "Bash(okstra:*)",
134
19
  "Bash(npx okstra@latest:*)",
135
20
  "Bash(npx -y okstra@latest:*)",
136
21
  "Bash($HOME/.okstra/bin/:*)",
137
- "Bash(STATE_FILE=:*)",
138
- "Bash(ROOT=:*)",
139
-
22
+ "Bash(codex)",
23
+ "Bash(codex:*)",
140
24
  "Bash(gemini)",
141
25
  "Bash(gemini:*)",
142
-
143
26
  "Bash(claude)",
144
- "Bash(claude:*)",
145
-
146
- "mcp__test-context7__resolve-library-id",
147
- "mcp__test-context7__query-docs"
27
+ "Bash(claude:*)"
148
28
  ]
149
29
  },
150
30
  "hooks": {
31
+ "PreToolUse": [
32
+ {
33
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit",
34
+ "hooks": [
35
+ {
36
+ "type": "command",
37
+ "command": "bash \"$HOME/.okstra/prompts/coding-preflight/scripts/preedit-check.sh\""
38
+ }
39
+ ]
40
+ }
41
+ ],
151
42
  "SessionEnd": [
152
43
  {
153
44
  "hooks": [
@@ -25,7 +25,13 @@ Checks performed per brief file:
25
25
  own `brief-id`.
26
26
  9. `reporter-confirmations` consistency: when `complete`, every
27
27
  `intent-check:` and `conversion-block:` row in Open Questions MUST
28
- carry a `[CONFIRMED YYYY-MM-DD → RC-N]` marker.
28
+ carry a `[CONFIRMED YYYY-MM-DD → RC-N]` marker; when `partial`, at
29
+ least one such row MUST carry the marker (use `skipped` when nothing
30
+ was answered).
31
+ 10. `scope` is one of {reporter-input, codebase} (absent ⇒ reporter-input).
32
+ 11. codebase-scan variant (`scope: codebase`): the Scan Scope section is
33
+ non-empty and Priority Lenses lists 1–4 values from the lens whitelist
34
+ (`scripts/okstra_ctl/improvement_lenses.py` SSOT).
29
35
 
30
36
  Exit code 0 on PASS, 1 on FAIL.
31
37
  """
@@ -38,6 +44,19 @@ import sys
38
44
  from pathlib import Path
39
45
  from typing import Iterable
40
46
 
47
+ # scripts/ (repo) or python/ (installed under ~/.okstra/lib) is not a package;
48
+ # insert whichever exists so okstra_ctl is importable for the lens whitelist.
49
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
50
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
51
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
52
+ sys.path.insert(0, str(_ssot_dir))
53
+
54
+ from okstra_ctl.improvement_lenses import (
55
+ LENSES,
56
+ MAX_PRIORITY_LENSES,
57
+ MIN_PRIORITY_LENSES,
58
+ )
59
+
41
60
  REQUIRED_FRONTMATTER_KEYS = {
42
61
  "type",
43
62
  "brief-id",
@@ -68,6 +87,8 @@ AUGMENTATION_LABELS = {
68
87
 
69
88
  REPORTER_CONFIRMATION_VALUES = {"complete", "partial", "pending", "skipped"}
70
89
 
90
+ SCOPE_VALUES = {"reporter-input", "codebase"}
91
+
71
92
 
72
93
  def parse_frontmatter(text: str) -> tuple[dict[str, str], int]:
73
94
  """Return (frontmatter dict, line after closing `---`)."""
@@ -173,6 +194,68 @@ def parse_augmentation_label(entry: str) -> tuple[str | None, str]:
173
194
  return None, stripped
174
195
 
175
196
 
197
+ def meaningful_bullets(text: str, heading: str) -> list[str]:
198
+ """Real `- ` bullets under a heading, excluding placeholders/template scaffold."""
199
+ body = section_body(text, heading)
200
+ out: list[str] = []
201
+ for line in body.splitlines():
202
+ stripped = line.strip()
203
+ if not stripped.startswith("- "):
204
+ continue
205
+ content = stripped[2:].strip()
206
+ if is_placeholder(content) or is_template_example(content):
207
+ continue
208
+ out.append(content.strip("`"))
209
+ return out
210
+
211
+
212
+ def priority_lens_values(text: str) -> list[str]:
213
+ """Lens tokens from the `## Priority Lenses` bullets (`- <lens>: <rationale>`)."""
214
+ return [
215
+ bullet.split(":", 1)[0].strip()
216
+ for bullet in meaningful_bullets(text, "Priority Lenses")
217
+ ]
218
+
219
+
220
+ def check_codebase_scope(text: str, errors: list[str]) -> None:
221
+ """codebase-scan variant: Scan Scope non-empty, Priority Lenses ⊆ whitelist."""
222
+ if not meaningful_bullets(text, "Scan Scope"):
223
+ errors.append("scope is 'codebase' but the Scan Scope section has no entries")
224
+ lenses = priority_lens_values(text)
225
+ if not (MIN_PRIORITY_LENSES <= len(lenses) <= MAX_PRIORITY_LENSES):
226
+ errors.append(
227
+ f"Priority Lenses must list {MIN_PRIORITY_LENSES}–{MAX_PRIORITY_LENSES} "
228
+ f"lens(es), found {len(lenses)}"
229
+ )
230
+ unknown = [lens for lens in lenses if lens not in LENSES]
231
+ if unknown:
232
+ errors.append(
233
+ f"Priority Lenses contains values outside the lens whitelist: {unknown} "
234
+ f"(allowed: {sorted(LENSES)})"
235
+ )
236
+
237
+
238
+ def check_reporter_confirmations(
239
+ rc_status: str | None, reporter_rows: list[str], errors: list[str]
240
+ ) -> None:
241
+ """`complete` ⇒ every reporter row confirmed; `partial` ⇒ at least one."""
242
+ confirmed = [row for row in reporter_rows if "[CONFIRMED" in row]
243
+ if rc_status == "complete":
244
+ unconfirmed = [row for row in reporter_rows if "[CONFIRMED" not in row]
245
+ if unconfirmed:
246
+ errors.append(
247
+ f"reporter-confirmations is 'complete' but {len(unconfirmed)} "
248
+ f"intent-check:/conversion-block: row(s) lack a [CONFIRMED …] "
249
+ f"marker (e.g. {unconfirmed[0]!r})"
250
+ )
251
+ elif rc_status == "partial" and reporter_rows and not confirmed:
252
+ errors.append(
253
+ "reporter-confirmations is 'partial' but no intent-check:/"
254
+ "conversion-block: row carries a [CONFIRMED …] marker "
255
+ "(use 'skipped' when nothing was answered)"
256
+ )
257
+
258
+
176
259
  def validate_brief(path: Path, briefs_root: Path) -> list[str]:
177
260
  text = path.read_text(encoding="utf-8")
178
261
  errors: list[str] = []
@@ -202,6 +285,15 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
202
285
  f"{fm.get('reporter-confirmations')!r}"
203
286
  )
204
287
 
288
+ scope = fm.get("scope", "reporter-input")
289
+ if scope not in SCOPE_VALUES:
290
+ errors.append(
291
+ f"frontmatter scope must be one of {sorted(SCOPE_VALUES)} "
292
+ f"(or absent ⇒ reporter-input), got {scope!r}"
293
+ )
294
+ if scope == "codebase":
295
+ check_codebase_scope(text, errors)
296
+
205
297
  # 2. brief-id matches filename stem
206
298
  stem = path.stem
207
299
  if fm.get("brief-id") and fm["brief-id"] != stem:
@@ -306,21 +398,12 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
306
398
  f"({brief_id!r})"
307
399
  )
308
400
 
309
- # 9. reporter-confirmations consistency
310
- rc_status = fm.get("reporter-confirmations")
311
- if rc_status == "complete":
312
- unconfirmed = [
313
- row
314
- for row in (intent_check_rows + conversion_block_rows)
315
- if "[CONFIRMED" not in row
316
- ]
317
- if unconfirmed:
318
- sample = unconfirmed[0]
319
- errors.append(
320
- f"reporter-confirmations is 'complete' but {len(unconfirmed)} "
321
- f"intent-check:/conversion-block: row(s) lack a [CONFIRMED …] "
322
- f"marker (e.g. {sample!r})"
323
- )
401
+ # 9. reporter-confirmations consistency (complete / partial)
402
+ check_reporter_confirmations(
403
+ fm.get("reporter-confirmations"),
404
+ intent_check_rows + conversion_block_rows,
405
+ errors,
406
+ )
324
407
 
325
408
  return errors
326
409
 
package/src/doctor.mjs CHANGED
@@ -146,9 +146,14 @@ async function loadPhaseDiagnostics(phase, paths) {
146
146
  ")",
147
147
  "print(json.dumps(payload, ensure_ascii=False))",
148
148
  ].join("\n");
149
+ // `home` locates the agent install root (`<home>/.claude/agents`), which is
150
+ // the user home — NOT the okstra home (`paths.home` = ~/.okstra). Agents are
151
+ // installed under `homedir()/.claude/agents` (see install.mjs), so passing
152
+ // paths.home made every worker-agent phase check resolve to a path that
153
+ // never exists and report a false "missing agent".
149
154
  const result = await runPythonSnippet({
150
155
  script,
151
- args: [phase, process.cwd(), paths.workspace, paths.home],
156
+ args: [phase, process.cwd(), paths.workspace, homedir()],
152
157
  });
153
158
  if (result.code !== 0 && !result.stdout.trim()) {
154
159
  return {