okstra 0.125.0 → 0.125.2

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.
@@ -298,7 +298,7 @@ The standard `okstra` workflow applies the following team contract consistently
298
298
  - The main Claude is always the `Claude lead` and operates in synthesis-only mode.
299
299
  - The default required worker roles are `Claude worker`, `Codex worker`, and `Report writer worker`. `Antigravity worker` is optional and is included as required only when explicitly named by `--workers` or the profile's `- Workers:` section.
300
300
  - `Report writer worker` focuses on report structure and evidence organization, but `Claude lead` remains the final synthesis owner.
301
- - The default model contract is computed from central defaults. Fallbacks are `Claude lead`=`opus`, `Claude worker`=`opus`, `Codex worker`=`gpt-5.6-sol`, and `Antigravity worker`=`auto` (when opted in). Without a separate override, `Report writer worker` follows the `Claude lead` model (therefore `opus` by default).
301
+ - The default model contract is computed from central defaults. Fallbacks are `Claude lead`=`opus`, `Claude worker`=`opus`, `Codex worker`=`gpt-5.6-sol`, `Report writer worker`=`sonnet`, and `Antigravity worker`=`auto` (when opted in). `Report writer worker` no longer inherits the `Claude lead` model it resolves from its own catalog `ROLE_DEFAULTS` entry because it does not vote in convergence; `--report-writer-model opus` or `OKSTRA_DEFAULT_REPORT_WRITER_MODEL` restores the previous behavior.
302
302
  - Because `Antigravity worker` is optional, it is attempted only in runs where it is explicitly included.
303
303
  - Before the final judgment, each required role in the current run's worker roster must have either a result or an explicit terminal status (`completed`, `timeout`, `error`, `not-run`).
304
304
  - Every attempted worker (`completed`, `timeout`, `error`) must have an assigned worker prompt history file under the current run's `prompts/` directory.
package/docs/cli.md CHANGED
@@ -383,7 +383,7 @@ The central-default environment variables are:
383
383
  Fallback defaults are:
384
384
 
385
385
  - `Claude lead`: `opus`
386
- - `Report writer worker`: `opus`
386
+ - `Report writer worker`: `sonnet`
387
387
  - `Claude worker`: `opus`
388
388
  - `Codex worker`: `gpt-5.6-sol`
389
389
  - `Antigravity worker`: `auto`
@@ -54,13 +54,14 @@ okstra/
54
54
  │ ├── okstra_vendor/ vendored Jinja2 / MarkupSafe + graphify / networkx (okstra-graphify skill)
55
55
  │ ├── lib/okstra/ Bash helpers for okstra.sh
56
56
  │ └── lib/okstra-ctl/ Bash control-center subcommands
57
- ├── skills/ Claude Code skills (11)
57
+ ├── skills/ Claude Code skills (13); `_fragments/` holds shared marker blocks
58
58
  ├── agents/ lead SKILL.md + worker agent specs
59
59
  ├── prompts/ launch template, phase profiles, wizard prompt JSON
60
60
  ├── schemas/ JSON schema for final-report data.json
61
61
  ├── templates/ report, setup, PR, project-doc templates/assets
62
62
  ├── validators/ run / brief / schedule / view validators
63
63
  ├── tools/build.mjs source → runtime sync
64
+ ├── tools/sync-skill-fragments.mjs expand shared fragment blocks into skills/*/SKILL.md
64
65
  ├── tools/docs_publication/ public-manual publication pipeline (build-time only; not shipped)
65
66
  ├── config/docs-publication.json publication inventory: every tracked Markdown path + classification
66
67
  ├── runtime/ generated install payload; do not edit directly
@@ -220,6 +221,7 @@ Important modules:
220
221
  | `run.py` | `prepare_task_bundle()` single authority and CLI parser |
221
222
  | `implementation_stage.py` | `implementation` single-stage run orchestration — read the Stage Lifecycle Snapshot → pick an available Stage Map entry → provision an isolated stage worktree → publish the selected stage as run context (extracted from `run.py`) |
222
223
  | `stage_targets.py` | Stage readiness/verification policy SSOT — from the Stage Lifecycle Snapshot (`consumers.jsonl` ledger + carry sidecar backfill + active registry reservation) it decides which stage is runnable, which commit it branches from, and what final-verification checks. `order_stage_closure` topologically sorts (Kahn) the dependency closure of the wizard's multi-selected stage set to produce the unattended `chain-stages` chaining order |
224
+ | `stage_fix_carry.py` | fix-run carry derivation for a re-run on an `implementation` stage whose latest final-report data.json carries verifier `FAIL` verdicts — collects the previous report path, previous run HEAD, failed verifiers, carried blocking findings, and a routing recommendation, which `run.py` renders into the analysis profile through the `{{FIX_RUN_CONTEXT}}` token. A first run, or a re-run after `PASS`, yields no carry and renders the token empty |
223
225
  | `stage_reconcile.py` | best-effort git reconciliation shared by the stage prepare flow (delegates to `git_reconcile.auto_reconcile`; advisory — failures are only reported to stderr, the dependency gate stays authoritative) |
224
226
  | `design_surfaces.py` | deterministic detection of an `implementation-planning` stage's design surface — matches the stage's file-path tokens/suffixes/patterns and action wording via `SurfaceRule` to derive which design input the stage needs among domain contract, DB/table schema, external interface, transaction/consistency, transformation mapping, lifecycle, rollout/observability, and manual user test, plus its evidence (`TriggerEvidence`). An unmappable structure raises `DesignSurfaceError` |
225
227
  | `design_prep.py` | fingerprint / materialize / resolve backend for design-preparation requests (CLI: `okstra design-prep <list\|show\|write>`) — computes an assessment fingerprint from the approved planning snapshot's `ASSESSMENT_FIELDS`, idempotently writes an Okstra-owned request under `design-prep-requests/`, and resolves the highest-revision append-only user response under `design-prep-inputs/` whose fingerprint matches as the effective response. Keeps the three authorities (report snapshot / Okstra request / user input) separate and never modifies the report or existing revisions. Sidecar I/O is protected by a directory-fd anchor + flock |
@@ -342,14 +344,16 @@ Optional (v1.0 backward-compatible) top-level keys:
342
344
 
343
345
  ### 4.10 `skills/`
344
346
 
345
- 13 user-facing skills (the only skills `okstra install` copies). The list SSOT is `USER_SKILL_NAMES` in `src/lib/skill-catalog.mjs`; the Claude plugin manifest and the installer both derive from it:
347
+ 13 user-facing skills (the only skills `okstra install` copies). The list SSOT is `USER_SKILL_NAMES` in `src/lib/skill-catalog.mjs`; the Claude plugin manifest and the installer both derive from it.
348
+
349
+ Boilerplate shared by several skills (bash invocation rule, outdated-CLI preflight, python bootstrap note) is kept canonical in `skills/_fragments/*.md` and expanded in place inside each `SKILL.md` between `<!-- BEGIN FRAGMENT: <name> -->` / `<!-- END FRAGMENT: <name> -->` markers by `tools/sync-skill-fragments.mjs` (`--check` fails on drift). Sources stay fully expanded, so `runtime/` and installed copies remain self-contained; the guards are `tests-js/skill-fragments.test.mjs` and `tests/contract/test_prompt_fragment_ownership.py`.
346
350
 
347
351
  | Skill | User-invocable | Role |
348
352
  |---|---:|---|
349
353
  | `okstra-brief-gen` | yes | Produce task brief from ticket/doc/link/conversation |
350
354
  | `okstra-run` | yes | Start/resume okstra task in current Claude Code session |
351
355
  | `okstra-memory` | yes | Store/search/archive global conversation memory under `~/.okstra/memory-book` |
352
- | `okstra-inspect` | yes | Unified read-side — sub-commands `status` (lifecycle + workStatus), `history` (past runs / re-run / resume), `report` (find final-report), `time` (elapsed-time breakdown), `logs` (wrapper log inventory + cleanup), `cost` (task bundle context/read cost) |
356
+ | `okstra-inspect` | yes | Unified read-side — sub-commands `status` (lifecycle + workStatus), `history` (past runs / re-run / resume), `report` (find final-report), `time` (elapsed-time breakdown), `logs` (wrapper log inventory + cleanup), `cost` (task bundle context/read cost), `errors` (error-log aggregation), `error-zip` (anonymized cross-project error bundle), `recap` (cross-run phase recap). `SKILL.md` is a thin core (preflight + dispatch table + shared rules) and each sub-command body lives in `skills/okstra-inspect/facets/<sub-command>.md`, lazily read only after dispatch resolves; the 1:1 match between dispatch rows and facet files is enforced by `tests/contract/test_okstra_inspect_facets.py` |
353
357
  | `okstra-rollup` | yes | Cross-task roll-up — aggregate runs/time/errors across a task-group (or whole project) and synthesize a digest from the report files |
354
358
  | `okstra-usage` | yes | Read-only project usage snapshot — aggregate recent run coverage, tokens, known cost, CPU, and wall-clock time by task type (default: 30 days) |
355
359
  | `okstra-schedule-gen` | yes | Generate task-group schedule |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.125.0",
3
+ "version": "0.125.2",
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.125.0",
3
- "builtAt": "2026-07-19T16:21:54.855Z",
2
+ "package": "0.125.2",
3
+ "builtAt": "2026-07-20T09:08:03.300Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -254,6 +254,16 @@ mkfifo "$stdout_fifo"
254
254
  tee -a "$log_path" < "$stdout_fifo" &
255
255
  stdout_tee_pid=$!
256
256
 
257
+ # Disable git's fsmonitor for every git command agy runs in this process tree —
258
+ # the main worktree's fsmonitor daemon leaks its IPC socket into task worktrees,
259
+ # so git status/commit here intermittently fail with `fsmonitor_ipc__send_query`
260
+ # errors. GIT_CONFIG_* scopes the override to this process tree without touching
261
+ # the user's repo config. See `okstra-codex-exec.sh` for the full rationale.
262
+ _gc_idx="${GIT_CONFIG_COUNT:-0}"
263
+ export "GIT_CONFIG_KEY_${_gc_idx}=core.fsmonitor"
264
+ export "GIT_CONFIG_VALUE_${_gc_idx}=false"
265
+ export GIT_CONFIG_COUNT="$(( _gc_idx + 1 ))"
266
+
257
267
  agy --print "$(cat "$prompt_path")" --model "$model" "${add_dir_args[@]}" \
258
268
  --print-timeout "$PRINT_TIMEOUT" --dangerously-skip-permissions \
259
269
  2>> "$log_path" \
@@ -328,6 +328,18 @@ mkfifo "$stdout_fifo"
328
328
  tee -a "$log_path" < "$stdout_fifo" &
329
329
  stdout_tee_pid=$!
330
330
 
331
+ # Disable git's fsmonitor for every git command codex runs in this process
332
+ # tree. The main worktree's fsmonitor daemon leaks its IPC socket into task
333
+ # worktrees, so git status/commit here intermittently fail with
334
+ # `fsmonitor_ipc__send_query` errors (workers otherwise recover by hand with
335
+ # `git -c core.fsmonitor=false`). Injecting it via GIT_CONFIG_* scopes the
336
+ # override to this process tree without touching the user's repo config, and
337
+ # appends after any GIT_CONFIG_* the caller already set.
338
+ _gc_idx="${GIT_CONFIG_COUNT:-0}"
339
+ export "GIT_CONFIG_KEY_${_gc_idx}=core.fsmonitor"
340
+ export "GIT_CONFIG_VALUE_${_gc_idx}=false"
341
+ export GIT_CONFIG_COUNT="$(( _gc_idx + 1 ))"
342
+
331
343
  codex exec -C "$project_root" ${extra_args[@]+"${extra_args[@]}"} --model "$model" --sandbox workspace-write -c approval_policy=never - \
332
344
  < "$prompt_path" \
333
345
  2>> "$log_path" \
@@ -42,6 +42,7 @@ Plan-body verification is configured under `convergence.planBodyVerification` in
42
42
  |---------|---------|-------------|
43
43
  | `enabled` | `true` | If `false`, the round is skipped and the top-of-report Approval marker is rendered unconditionally (legacy behaviour). |
44
44
  | `maxRounds` | `1` | Upper bound. Plan-body verification is consistency / completeness checking, not fact checking — additional rounds rarely help. Range 1–3. |
45
+ | `selfFixMaxRounds` | `3` | Upper bound on the report-writer self-fix loop (§"Round protocol" step 7). Range 1–5. The loop also stops early on no-progress, so this is a ceiling, not a target. |
45
46
  | `gating` | `true` | If `true` (default), `majority-disagree` blocks the Approval marker. If `false`, the round is advisory-only and the marker always renders. |
46
47
 
47
48
  Default values are emitted into the manifest by `scripts/okstra_ctl/render.py` (`_build_convergence_block`). The ctx knob `OKSTRA_PLAN_VERIFICATION=false` flips `planBodyVerification.enabled` to false.
@@ -126,27 +127,33 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
126
127
  - `partial-consensus` — majority `AGREE`, dissenting `DISAGREE` recorded.
127
128
  - `dissent-isolated` — only one worker `DISAGREE`s, others `AGREE` — treat as `partial-consensus` for gate purposes; record dissent. (Distinct from finding-convergence `worker-unique`, which means the *opposite*: only one worker AGREEs. Plan-body classifications use this dedicated label to avoid the collision.)
128
129
  - `majority-disagree` — a *majority* of analysers `DISAGREE` (majority needs ≥2 participating non-error votes), OR any single-vote-blocking kind fires: one `DISAGREE(a)` / `DISAGREE(d)` on any item, or one `DISAGREE(f)` on a `P-Req-*` item (see §"Single-vote-blocking kinds"). This classification **blocks the Approval marker**.
130
+ - `needs-reverify` — a single-vote-blocking kind fired but the item has **fewer than 2 participating non-error votes**, i.e. the lone dissent was never cross-verified because its peer returned `verification-error`. A single-vote-blocking kind means "one *confirmed* DISAGREE is enough"; an unconfirmed one is not. This does **not** block the Approval marker — blocking on it would make a worker failure produce a stricter gate than a healthy roster, the same paradox the ≥2-vote majority rule already rules out. The item is re-dispatched in the next round (step 7); if it survives the round budget it is promoted per step 8 with a Statement that says verification never completed. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` returns `needs-reverify` for this shape and `_recompute_plan_body_gate` folds it into `passed-with-dissent`.
129
131
  - `contested` only meaningful when `maxRounds > 1`; at default `maxRounds=1`, fold any unresolved item into `partial-consensus`.
130
132
  5. Gate result resolution:
131
133
  - any `majority-disagree` item present AND `gating=true` → `blocked-by-disagreement`
132
134
  - all dispatches non-result → `aborted-non-result`
133
- - any `partial-consensus` / `dissent-isolated` present, no `majority-disagree` → `passed-with-dissent`
135
+ - any `partial-consensus` / `dissent-isolated` / `needs-reverify` present, no `majority-disagree` → `passed-with-dissent`
134
136
  - all items `full-consensus` → `passed`
135
137
  6. Lead writes `runs/<task-type>/state/plan-body-verification-<task-type>-<seq>.json` (schema below) and populates `### 5.5.9 Plan Body Verification` in the final report's data.json (`implementationPlanning.planBodyVerification`, schema `schemas/final-report-v1.0.schema.json`; template at `templates/reports/final-report.template.md`). The §5.5.9 body is **grouped by plan item**: `planItems[]`, each carrying its `id`, its plain-language `subject` (rendered as the item heading), an optional `sourceSection`, an optional `clarificationId` (the `C-<N>` this item blocks on when `majority-disagree`), and a `verdicts[]` list (`worker / verdict / breakageKind / note`) — one verdict row per worker under that item. The renderer prints three fixed legends (gate values, verdict tokens, breakage kinds a–f) so the reader can decode every cell without opening this spec. The older flat `#### Verdict details` table (`Plan item / Worker / …`, one row per plan-item × worker pair) is superseded by the grouped layout — it hid *what* each vote was about behind a bare `P-*` ID; the subject heading is the fix. The validator's `Plan Body Verification` + `Gate result:` substring checks still gate this section.
136
- 7. **Self-fix round (at most once, targeting planner-fixable defects).** After aggregation, if at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`, lead attempts one self-fix **before** promoting it to the user:
138
+ 7. **Self-fix loop (up to `selfFixMaxRounds`, targeting planner-fixable defects).** After aggregation, while at least one `majority-disagree` item has a majority of its `DISAGREE` verdicts at `fixability == planner-fixable`, lead runs self-fix rounds **before** promoting anything to the user:
137
139
  - lead instructs report-writer to rewrite only those planner-fixable items for correction (NOT a full draft regeneration — patch only the plan section each `P-*` points to; procedure in [report-writer](./report-writer.md) §"Self-fix rewrite").
138
140
  - missing or weak `P-Prep-*` contracts are repaired by adding kind-specific inline detail or an AI-prepared PREP item with a concrete proposal. Facts that require user or external authority remain `blocked` and keep their request material; never invent those facts during self-fix.
139
- - lead re-runs plan-body verification once (focused on the corrected items + adjacent items the rewrite touched). After re-verification, overwrite `planItems[].verdicts` with the new verdicts.
141
+ - lead re-runs plan-body verification (focused on the corrected items + adjacent items the rewrite touched, plus any `needs-reverify` items whose peer failed to vote last round). After re-verification, overwrite `planItems[].verdicts` with the new verdicts.
140
142
  - for an item whose `majority-disagree` was resolved by self-fix, record `self-fixed in round <N>: <what was fixed>` in `planItems[].selfFixNote`. A resolved item does not create a clarification.
141
- - this round runs **at most once** (infinite-loop cap). If a self-fix round was attempted, record `planBodyVerification.selfFixRoundApplied = true`.
143
+ - **Loop termination.** Record the round count in `planBodyVerification.selfFixRoundsApplied` and why the loop stopped in `planBodyVerification.selfFixStopReason`:
144
+ - `all-resolved` — no planner-fixable `majority-disagree` item remains. Exit.
145
+ - `no-progress` — the round resolved **zero** planner-fixable items relative to the previous round. Exit even with budget left: the same rewrite would repeat. Newly *introduced* defects count against progress, so a rewrite that trades one defect for another stops the loop rather than churning.
146
+ - `max-rounds-reached` — `selfFixRoundsApplied == selfFixMaxRounds`. Exit.
147
+ - `not-attempted` — the loop never ran because no item qualified.
148
+ The `no-progress` and `max-rounds-reached` exits are what make the loop terminate; `selfFixMaxRounds` alone is the backstop.
142
149
  - a `majority-disagree` item with a majority of `needs-user-input` is NOT a self-fix target — it goes straight to the next step's clarification promotion.
143
- 8. For every `majority-disagree` item **that remains after the self-fix round** (items not resolved by self-fix, or with a `needs-user-input` majority from the start), lead adds a row to `## 1. Clarification Items` with:
150
+ 8. For every `majority-disagree` item **that remains after the self-fix loop** (items not resolved by self-fix, or with a `needs-user-input` majority from the start), lead adds a row to `## 1. Clarification Items` with:
144
151
  - new `C-<N>` ID (numbering continues from any existing rows)
145
152
  - `Statement` summarising the disagreement and the worker breakage `<kind>`
146
153
  - `Kind` chosen per the standard policy (usually `decision` for option-level conflicts, `data-point` for path/symbol mismatches)
147
154
  - `Blocks=approval`
148
155
  - the item's `planItems[].clarificationId` set to that `C-<N>` (1:1 link). `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item's `clarificationId` is missing, dangling, or points at a non-`approval` row.
149
- - a `planner-fixable` item that survives the self-fix round is still promoted, but its `Statement` MUST state "planner self-fix attempted but unresolved" (the cap prevents repeating the rewrite). `validators/validate-run.py` `_validate_self_fix_before_clarification` fails when `selfFixRoundApplied != true` yet a planner-fixable majority item is promoted.
156
+ - a `planner-fixable` item that survives the self-fix loop is still promoted, but its `Statement` MUST state "planner self-fix attempted but unresolved" and name the stop reason. `validators/validate-run.py` `_validate_self_fix_before_clarification` fails when a planner-fixable majority item is promoted while the budget is not exhausted — it requires `selfFixRoundsApplied >= 1` **and** `selfFixStopReason` in `{no-progress, max-rounds-reached}`, so neither `all-resolved` nor `not-attempted` can excuse a promotion.
150
157
  9. The top-of-report `- [ ] Approved` marker line is rendered if and only if the Gate result is `passed` or `passed-with-dissent`. `validators/validate-run.py` `validate_phase_boundary` enforces this correspondence; manually adding the marker line when the gate did not pass is a contract violation.
151
158
 
152
159
  ## `plan-body-verification-<task-type>-<seq>.json` schema
@@ -160,7 +167,8 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
160
167
  "gating": true,
161
168
  "verificationMode": "lightweight",
162
169
  "gateResult": "passed | passed-with-dissent | blocked-by-disagreement | aborted-non-result",
163
- "selfFixRoundApplied": false,
170
+ "selfFixRoundsApplied": 0,
171
+ "selfFixStopReason": "not-attempted | all-resolved | no-progress | max-rounds-reached",
164
172
  "planItems": [
165
173
  {
166
174
  "id": "P-Opt-1",
@@ -193,7 +201,7 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
193
201
 
194
202
  `planItems[].votes.<worker>` is the verbatim verdict token emitted by the worker — `AGREE | DISAGREE(<a|b|c|d|e|f>) | SUPPLEMENT` — or `verification-error` for terminal non-result dispatches. The `DISAGREE` token retains its `<kind>` suffix so the breakage class is recoverable from the state file alone.
195
203
 
196
- `planBodyVerification.selfFixRoundApplied` (bool, default false): whether a self-fix round was attempted at least once. `planItems[].verdicts[].fixability` is each `DISAGREE`'s `planner-fixable | needs-user-input` judgement, recorded in the final data.json (`§5.5.9`).
204
+ `planBodyVerification.selfFixRoundsApplied` (int, default 0): how many self-fix rounds actually ran. `planBodyVerification.selfFixStopReason` (enum, default `not-attempted`): why the loop exited — see §"Round protocol" step 7. `planItems[].verdicts[].fixability` is each `DISAGREE`'s `planner-fixable | needs-user-input` judgement, recorded in the final data.json (`§5.5.9`).
197
205
 
198
206
  ## Plan-body reverify prompt
199
207
 
@@ -235,7 +235,9 @@ When lead instructs a self-fix round (passing the target `P-*` list + the reason
235
235
  - requirement-coverage mapping error → remap to a real option/stage/step or XP row.
236
236
  - missing/weak design-prep contract → fix with kind-specific inline detail or an AI-prepared PREP item carrying a concrete `aiProposal`. Do NOT invent facts requiring user/external authority — keep them `blocked` + request material.
237
237
 
238
- After correcting, leave `self-fixed in round <N>: <what was fixed>` in that `planItems[].selfFixNote` and mark `planBodyVerification.selfFixRoundApplied = true`. `needs-user-input` items are NEVER a correction target — they are promoted to clarification as-is.
238
+ After correcting, leave `self-fixed in round <N>: <what was fixed>` in that `planItems[].selfFixNote` and set `planBodyVerification.selfFixRoundsApplied` to the number of rounds run so far. `needs-user-input` items are NEVER a correction target — they are promoted to clarification as-is.
239
+
240
+ Lead may instruct several rounds (bounded by `selfFixMaxRounds`, see [plan-body-verification](./plan-body-verification.md) §"Round protocol" step 7). Each round targets only the items still broken, so a round that fixes nothing new ends the loop — correcting an item by breaking a sibling counts as no progress, not progress.
239
241
 
240
242
  ### Final-verification verdict token contract (BLOCKING)
241
243
 
@@ -1160,6 +1160,8 @@ def _build_convergence_block(ctx: dict) -> dict:
1160
1160
  False otherwise
1161
1161
  - `planBodyVerification` is implementation-planning specific; the key is
1162
1162
  always emitted (dead-letter on other phases) so the schema stays stable.
1163
+ Its `selfFixMaxRounds` default 3 bounds the report-writer self-fix loop
1164
+ that runs before a planner-fixable defect is promoted to the user.
1163
1165
 
1164
1166
  ctx knobs honoured:
1165
1167
  - `OKSTRA_PLAN_VERIFICATION`: "true" | "false" | "" (empty → default True).
@@ -1199,6 +1201,7 @@ def _build_convergence_block(ctx: dict) -> dict:
1199
1201
  "planBodyVerification": {
1200
1202
  "enabled": plan_verify_enabled,
1201
1203
  "maxRounds": 1,
1204
+ "selfFixMaxRounds": 3,
1202
1205
  "gating": True,
1203
1206
  },
1204
1207
  }
@@ -1844,7 +1844,15 @@
1844
1844
  "aborted-non-result"
1845
1845
  ]
1846
1846
  },
1847
- "selfFixRoundApplied": { "type": "boolean" },
1847
+ "selfFixRoundsApplied": { "type": "integer", "minimum": 0 },
1848
+ "selfFixStopReason": {
1849
+ "enum": [
1850
+ "not-attempted",
1851
+ "all-resolved",
1852
+ "no-progress",
1853
+ "max-rounds-reached"
1854
+ ]
1855
+ },
1848
1856
  "planItems": {
1849
1857
  "type": "array",
1850
1858
  "items": {
@@ -262,10 +262,10 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
262
262
 
263
263
  ### Validation Checklist{% if t("sectionAside.validationChecklist") != "Validation Checklist" %} ({{ t("sectionAside.validationChecklist") }}){% endif %}
264
264
 
265
- | ID | Phase | Ticket ID | Check | Command / Observation | Expected outcome |
266
- |----|-------|-----------|-------|------------------------|-------------------|
265
+ | {{ t("columns.recordMeta") }} | Check | Command / Observation | Expected outcome |
266
+ |--------|-------|------------------------|-------------------|
267
267
  {% for row in implementationPlanning.validationChecklist -%}
268
- | {{ row.id | mdcell }} | {{ row.phase | mdcell }} | `{{ row.ticketId | mdcell }}` | {{ row.check | mdcell }} | `{{ row.commandOrObservation | mdcell }}` | {{ row.expectedOutcome | mdcell }} |
268
+ | **{{ row.id | mdcell }}**<br>Ticket: `{{ row.ticketId | mdcell }}`<br>Phase: {{ row.phase | mdcell }} | {{ row.check | mdcell }} | `{{ row.commandOrObservation | mdcell }}` | {{ row.expectedOutcome | mdcell }} |
269
269
  {% endfor %}
270
270
 
271
271
  ### Rollback Strategy{% if t("sectionAside.rollbackStrategy") != "Rollback Strategy" %} ({{ t("sectionAside.rollbackStrategy") }}){% endif %}
@@ -43,9 +43,17 @@ Different recipients need different files. Do NOT include `final-report-template
43
43
 
44
44
  ## Error reporting (all workers)
45
45
 
46
- If any tool call you make (Bash, Read, Edit, MCP, etc.) returns a non-zero exit code, raises an exception, or otherwise fails its intended effect, append a single entry to your worker errors sidecar at the absolute path the lead provided as `**Errors sidecar path:** <abs-path>`.
46
+ Record a tool call only when its failure **blocked the step you intended** a test you needed to run did not start, a file you needed to read was missing, a config resolved against the wrong tree, an edit was rejected. When that happens, append a single entry to your worker errors sidecar at the absolute path the lead provided as `**Errors sidecar path:** <abs-path>`.
47
47
 
48
- If the sidecar file does not exist, create it with `{"schemaVersion": 1, "errors": []}` then append.
48
+ A non-zero exit code is NOT by itself a failure to record. Many tools use non-zero to report a normal, expected result. Do **NOT** record an entry when:
49
+
50
+ - a search/probe tool (`rg`, `grep`, `find`, `ls`, `test`, `git check-ignore`) exits non-zero solely because it found **no matches**, and you proceed unaffected;
51
+ - you probe an **optional / expected-absent** path (`graphify-out/`, `.project-docs/`, `.scratch/`, an optional lint config, a review pack) and it is absent — detecting absence was the point of the probe;
52
+ - output was truncated but you re-ran and read the content end-to-end.
53
+
54
+ The test: if the non-zero exit changed nothing about what you could do next, it is a normal result, not a `tool-failure`. When in doubt about whether a failure blocked you, record it — under-recording a real blocker is worse than one noisy entry. These benign non-zero exits are the single largest source of error-log noise; keeping them out is what makes the log a signal.
55
+
56
+ If the sidecar file does not exist and you have a real entry to record, create it with `{"schemaVersion": 1, "errors": []}` then append.
49
57
 
50
58
  ### Entry schema
51
59
 
@@ -109,7 +117,13 @@ For the **improvement-discovery phase** specifically, the dispatched prompt MUST
109
117
 
110
118
  - `**Phase 1.5 Grilling Log:** <absolute-path>` — the lead's Phase 1.5 reflect-back log (`<RUN_DIR>/state/phase-1.5-grilling.md`, written before dispatch). Its `Resolved scope` / `Resolved lenses` blocks are the authoritative scope and lens definition for this run. Read the file at this absolute path; do NOT re-derive it from `<RUN_DIR>`, and do NOT re-interpret the brief's raw `scan-scope` / `priority-lenses`. The presence of this anchor is itself the signal that this is an improvement-discovery run — no separate task-type detection is required.
111
119
 
112
- When a worker reads any project-relative path from the prompt, it MUST resolve it against `Project Root` (e.g. `<Project Root>/<Result Path>`) — never use bare relative paths that depend on cwd.
120
+ ### Path anchoring (BLOCKING the single largest source of worker tool-failures)
121
+
122
+ Every `.okstra/...` path is anchored at `**Project Root:**`, **never** at your cwd. When your run has a worktree (`**Worktree:**` is present), your cwd is that worktree — and the stage/task worktree does **not** contain the project's `.okstra/` directory. So a bare `Read`/`cat .okstra/tasks/...` from cwd resolves inside the worktree and fails "no such file". This is the same trap whether the path came from the prompt, a manifest field, a prior report's citation, or your own reasoning.
123
+
124
+ - Resolve any project-relative path (`.okstra/...`, `<Result Path>`, `<Prompt History Path>`, a prior-report `path:line` citation) against `**Project Root:**`: `<Project Root>/.okstra/...`. Never use the bare relative form.
125
+ - Mutating build/test commands still run from the worktree cwd (`cd <Worktree> && ...`) — but reads of okstra artifacts (`.okstra/**`) always go through `<Project Root>`, even while cwd is the worktree.
126
+ - If a `.okstra/...` read fails "no such file / does not exist in the worktree", do not retry the same relative path — re-anchor it to `<Project Root>` and read the absolute path.
113
127
 
114
128
  ---
115
129
 
@@ -1800,8 +1800,8 @@ _SINGLE_VOTE_BLOCKING_KINDS = {"a", "d"}
1800
1800
  def _classify_plan_item_gate(item: dict) -> str:
1801
1801
  """Recompute one plan item's gate class from its per-worker verdicts,
1802
1802
  per `prompts/lead/plan-body-verification.md` "Round protocol". Returns one of
1803
- ``majority-disagree`` / ``has-dissent`` / ``full-consensus`` /
1804
- ``all-non-result``. Collapses ``partial-consensus`` and
1803
+ ``majority-disagree`` / ``needs-reverify`` / ``has-dissent`` /
1804
+ ``full-consensus`` / ``all-non-result``. Collapses ``partial-consensus`` and
1805
1805
  ``dissent-isolated`` into ``has-dissent`` because they resolve to the
1806
1806
  same gate value; only the majority-disagree boundary changes the gate.
1807
1807
  """
@@ -1827,6 +1827,13 @@ def _classify_plan_item_gate(item: dict) -> str:
1827
1827
  # `a`/`d` for any item; `f` only for P-Req items (requirement coverage).
1828
1828
  is_req = str(item.get("id") or "").upper().startswith("P-REQ")
1829
1829
  if disagree_kinds & _SINGLE_VOTE_BLOCKING_KINDS or (is_req and "f" in disagree_kinds):
1830
+ # "One confirmed DISAGREE" presupposes the item was actually
1831
+ # cross-verified. When the peer returned a non-result nothing confirmed
1832
+ # the dissent, so blocking here would reproduce the same
1833
+ # worker-failure-makes-the-gate-stricter paradox the majority branch
1834
+ # below guards against. Route it to a re-verify round instead.
1835
+ if len(non_error) < 2:
1836
+ return "needs-reverify"
1830
1837
  return "majority-disagree"
1831
1838
  # Otherwise a genuine majority is required — and a majority needs at least
1832
1839
  # two participating votes, so a lone surviving DISAGREE (its peer returned a
@@ -1852,7 +1859,7 @@ def _recompute_plan_body_gate(pbv: dict) -> str | None:
1852
1859
  return "aborted-non-result"
1853
1860
  if any(c == "majority-disagree" for c in classes):
1854
1861
  return "blocked-by-disagreement"
1855
- if any(c == "has-dissent" for c in classes):
1862
+ if any(c in ("has-dissent", "needs-reverify") for c in classes):
1856
1863
  return "passed-with-dissent"
1857
1864
  return "passed"
1858
1865
 
@@ -2402,8 +2409,14 @@ def _validate_plan_body_clarification_matching(data: dict, failures: list[str])
2402
2409
  )
2403
2410
 
2404
2411
 
2412
+ # Stop reasons that justify promoting a still-broken planner-fixable item to the
2413
+ # user: the self-fix budget ran out, or a round produced no net resolution so
2414
+ # further rounds would repeat themselves.
2415
+ _SELF_FIX_EXHAUSTED_REASONS = frozenset({"max-rounds-reached", "no-progress"})
2416
+
2417
+
2405
2418
  def _validate_self_fix_before_clarification(data: dict, failures: list[str]) -> None:
2406
- """A planner-fixable defect MUST pass through a self-fix round before it is
2419
+ """A planner-fixable defect MUST exhaust the self-fix budget before it is
2407
2420
  promoted to a `## 1. Clarification Items` row. Closes the hole where the
2408
2421
  lead dumps a fixable plan defect (abbreviated path, prose command,
2409
2422
  placeholder, coverage remap) onto the user instead of having report-writer
@@ -2418,7 +2431,14 @@ def _validate_self_fix_before_clarification(data: dict, failures: list[str]) ->
2418
2431
  round_count = pbv.get("roundCount")
2419
2432
  if not isinstance(round_count, int) or round_count < 1:
2420
2433
  return
2421
- if pbv.get("selfFixRoundApplied") is True:
2434
+ rounds_applied = pbv.get("selfFixRoundsApplied")
2435
+ stop_reason = pbv.get("selfFixStopReason")
2436
+ budget_exhausted = (
2437
+ isinstance(rounds_applied, int)
2438
+ and rounds_applied >= 1
2439
+ and stop_reason in _SELF_FIX_EXHAUSTED_REASONS
2440
+ )
2441
+ if budget_exhausted:
2422
2442
  return
2423
2443
  for item in pbv.get("planItems") or []:
2424
2444
  if not isinstance(item, dict):
@@ -2431,12 +2451,16 @@ def _validate_self_fix_before_clarification(data: dict, failures: list[str]) ->
2431
2451
  ]
2432
2452
  fixable = [v for v in disagrees if v.get("fixability") == "planner-fixable"]
2433
2453
  if disagrees and len(fixable) * 2 > len(disagrees):
2454
+ allowed = " / ".join(sorted(_SELF_FIX_EXHAUSTED_REASONS))
2434
2455
  failures.append(
2435
2456
  "final-report data.json: plan item "
2436
2457
  f"`{item.get('id') or '<unknown>'}` is majority-disagree with a "
2437
- "planner-fixable majority but `selfFixRoundApplied` is not true. A "
2438
- "planner-fixable defect MUST be corrected by a report-writer self-fix "
2439
- "round before it becomes a clarification row "
2458
+ f"planner-fixable majority but the self-fix budget is not "
2459
+ f"exhausted (`selfFixRoundsApplied`={rounds_applied!r}, "
2460
+ f"`selfFixStopReason`={stop_reason!r}; need >=1 rounds and a stop "
2461
+ f"reason of {allowed}). A planner-fixable defect MUST be corrected "
2462
+ "by report-writer self-fix rounds until the budget runs out or a "
2463
+ "round makes no progress, before it becomes a clarification row "
2440
2464
  "(plan-body-verification.md Self-fix round)."
2441
2465
  )
2442
2466