okstra 0.154.2 → 0.156.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.
@@ -805,6 +805,7 @@ Errors that occur while provider workers, the report writer, or the Okstra lead
805
805
  - When tmux is reachable in the lead environment, the wrapper automatically splits a sibling pane and runs `tail -F <log-path>`. The trace-pane title appends `-tail` to the caller (worker) pane title: `<cli>-<role>-<pid>-tail` (for example, `codex-worker-93421-tail`). At the same time, the caller (worker) pane title is set to `<cli>-<role>-<pid>`. `<pid>` is the wrapper's own PID, so multiple workers with the same role spawned concurrently remain distinguishable, and operators can visually map `<caller> ↔ <caller>-tail`. **Caller-pane resolution**—because the Claude Code Bash tool now removes both `$TMUX` and `$TMUX_PANE` from the environment, the wrapper does not depend on environment variables. It (1) derives `<RUN_DIR>` as `dirname(dirname(prompt_path))` from the prompt path (paths.py SSOT), and (2) reads `<RUN_DIR>/state/lead-pane.id`, written once by the lead in its foreground pane, as the split anchor. This remains reliable for background dispatches, unlike active-pane guessing, even if the user changes panes. If the file is absent or the pane is stale, it falls back to `tmux display-message -p '#{pane_id}'` (the active pane). The trace split explicitly anchors to that caller pane with `-t`. The role is the wrapper's fifth optional positional argument and defaults to `worker`. The caller pane title is captured and restored by an EXIT trap, preventing stale titles across dispatches. Focus returns to the caller pane, and the trace pane remains after CLI exit so its scrollback is available. All paths silently degrade when tmux is unreachable, splitting fails, or tmux is outdated.
806
806
  - **Run-scoped tagging for cleanup**: A trace pane's `tail -F` is a child of the tmux shell and survives Claude's exit. The wrapper tags each spawned pane with `tmux set-option -p @okstra_trace_run=<RUN_DIR>`, and `okstra-trace-cleanup.sh` discovers panes server-wide from that tag via `tmux list-panes -a` and runs `tmux kill-pane`. It requires neither tmux environment variables nor a pane-ID registry. Because the tag is run-scoped, it does not kill trace panes from other simultaneous okstra runs. Cleanup has two entry forms: the lead invokes it with `--run-dir <RUN_DIR>` to clean traces and worker-agent panes for that run, or the `hooks.SessionEnd` entry in `templates/reports/settings.template.json` invokes it with `--reap` to clean all trace panes tagged below `$CLAUDE_PROJECT_DIR/.okstra/` when no single run directory exists at session end. Missing tmux and stale pane IDs silently degrade.
807
807
  - **Automatic cleanup on phase transitions, including worker-agent panes**: `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` closes not only tagged trace panes but also worker-agent panes occupied by dispatched subagents. These harness-owned panes cannot be tagged, so the script identifies them within the lead's window (`tmux list-panes -t <lead-pane>`, no `-s`) through a title allowlist: `claude-worker` / `codex-worker` / `antigravity-worker` / `report-writer-worker`. Implementation role titles such as `claude-executor` / `codex-verifier` / `agy-executor-tail`, and FleetView teammate prefixes `✳ ` / `⠂ `, are also treated as okstra panes. Split-pane teammates always land in the lead's window, so window scope still catches all of this run's worker panes while leaving a second okstra lead running in another window of the same tmux session out of range. Window scoping and exclusion of the lead's own pane are determined by `<RUN_DIR>/state/lead-pane.id`; the lead pane is never killed even if its title matches. At every worker round boundary — after collecting that round's results and token usage, immediately before the next dispatch and before the `PROGRESS: phase-5.5-convergence` / `phase-6-synthesis` marker — the lead calls this script with `--run-dir` to reclaim the prior round's completed panes without prompting. `--keep <substr>` (repeatable) excludes panes whose title contains the substring, which is how an in-flight `report-writer-worker` survives the boundary. The lead first runs the same command with `--list` to count the panes it is about to reclaim and reports that count as `PROGRESS: phase-batch-cleanup panes=<n>`.
808
+ - **Cleanup survives compaction and precedes user gates**: A `SessionStart` hook with matcher `compact` (`okstra-compact-reminder.sh`, seeded in `settings.template.json`) fires after every `/compact` or auto-compaction. It reads `cwd` from the hook's stdin JSON, finds any in-progress okstra run for that project via `active.jsonl` (`pane_reclaim.active_run_dirs_for_project`), and, if one exists, prints a factual reminder naming the run dir and how completed panes/tasks are reclaimed — restoring the boundary/gate cleanup obligation that a compaction summary can otherwise drop. The hook never kills panes itself: a teammate pane is untagged and title-only, so a hook cannot tell an in-flight worker from a finished one; the actual `kill-pane` stays with the lead, which knows completion state. Separately, the lead runs the same completed-pane reclaim immediately before any user approval/clarification/decision gate (`PROGRESS: phase-gate-cleanup panes=<n>`), so a gate is never shown with finished worker panes still open. At every cleanup point the pane-kill (`trace-cleanup`) and the task-stop (`TaskStop`) are paired — a `TaskStop` alone idles the roster task but leaves the pane open.
808
809
  - **User confirmation at phase end**: At the final step of the run, the lead calls `okstra-trace-cleanup.sh --list --run-dir <RUN_DIR>` to show remaining okstra panes (worker-agent + trace), then asks once whether to "close all and clean up teammates / keep them." It follows the response (see *Phase wrap-up* in `prompts/profiles/_common-contract.md`). If approved, the lead cleans the panes. For a split-pane run, it then uses `okstra-team-reconcile.sh` to mark dead-pane members inactive and sends each completed teammate a `SendMessage` shutdown_request (`TeamDelete` was removed in v2.1.178; the implicit team disappears with the session). The lead does not gate this pane step by interpreting `lead-pane.id`; it **always** invokes the script, which safely returns an empty pane list and no-ops outside tmux. The teammate step is determined by the existence of an on-disk team configuration whose `leadSessionId` matches (`~/.claude/teams/session-*/config.json`), not by `teamCreate.status`. `--list` does not kill panes and prints only `<pane_id>\t<pane_title>`, so the user can see exactly what would be closed.
809
810
  - Disk accumulation is handled by the `okstra-inspect logs` flow, which offers a read-only inventory and suggests cleanup commands for the user to copy and paste.
810
811
 
package/docs/cli.md CHANGED
@@ -802,4 +802,4 @@ Both `wait_for_input` and `replan` stop before a stage worktree is provisioned a
802
802
 
803
803
  ### Live-log sidecar
804
804
 
805
- For every dispatch, the Codex and Antigravity wrappers create a `runs/<task-type>/prompts/<worker>-prompt-<phase>-<seq>.log` sidecar and mirror stdout and stderr into it. When the lead runs inside tmux, the wrapper automatically splits a `tail -F` pane. The trace pane title is `<cli>-<role>-<pid>-tail`, and the caller/worker pane title is `<cli>-<role>-<pid>`; the wrapper PID distinguishes concurrent dispatches with the same role. Split trace panes are tagged with the `@okstra_trace_run=<RUN_DIR>` pane user option, and tmux-pane backend worker-compute panes with `@okstra_worker_run=<RUN_DIR>`. When Claude receives `/exit`, the `SessionEnd` hook automatically cleans them up within `$CLAUDE_PROJECT_DIR/.okstra/` scope by running `okstra-trace-cleanup.sh --reap`. When the lead calls the same script with `--run-dir <RUN_DIR>`, it removes the run's trace panes, worker-compute panes, and dispatched worker-agent panes within the lead-window scope (title scan uses `tmux list-panes -t <lead-pane>`, no `-s`, so a second lead in another window of the same session is out of range), while excluding the lead's own pane. Worker-agent titles include `claude-worker`, `codex-worker`, `antigravity-worker`, `report-writer-worker`, implementation role titles, and FleetView teammate prefixes `✳ ` / `⠂ `. The lead runs `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` at every worker round boundary — after collecting that round's results and before the next dispatch, not once per phase — to reclaim the completed panes. `--keep <substr>` (repeatable) excludes panes whose title contains the substring, which is how an in-flight report writer is preserved (`--keep report-writer-worker`), and `--list` prints the same set without killing so the lead can count what it is about to reclaim.
805
+ For every dispatch, the Codex and Antigravity wrappers create a `runs/<task-type>/prompts/<worker>-prompt-<phase>-<seq>.log` sidecar and mirror stdout and stderr into it. When the lead runs inside tmux, the wrapper automatically splits a `tail -F` pane. The trace pane title is `<cli>-<role>-<pid>-tail`, and the caller/worker pane title is `<cli>-<role>-<pid>`; the wrapper PID distinguishes concurrent dispatches with the same role. Split trace panes are tagged with the `@okstra_trace_run=<RUN_DIR>` pane user option, and tmux-pane backend worker-compute panes with `@okstra_worker_run=<RUN_DIR>`. When Claude receives `/exit`, the `SessionEnd` hook automatically cleans them up within `$CLAUDE_PROJECT_DIR/.okstra/` scope by running `okstra-trace-cleanup.sh --reap`. When the lead calls the same script with `--run-dir <RUN_DIR>`, it removes the run's trace panes, worker-compute panes, and dispatched worker-agent panes within the lead-window scope (title scan uses `tmux list-panes -t <lead-pane>`, no `-s`, so a second lead in another window of the same session is out of range), while excluding the lead's own pane. Worker-agent titles include `claude-worker`, `codex-worker`, `antigravity-worker`, `report-writer-worker`, implementation role titles, and FleetView teammate prefixes `✳ ` / `⠂ `. The lead runs `okstra-trace-cleanup.sh --run-dir <RUN_DIR>` at every worker round boundary — after collecting that round's results and before the next dispatch, not once per phase — to reclaim the completed panes. `--keep <substr>` (repeatable) excludes panes whose title contains the substring, which is how an in-flight report writer is preserved (`--keep report-writer-worker`), and `--list` prints the same set without killing so the lead can count what it is about to reclaim. The lead also runs the same reclaim immediately before any user approval/clarification/decision gate (`PROGRESS: phase-gate-cleanup panes=<n>`), and an `okstra-compact-reminder.sh` `SessionStart` hook (matcher `compact`) re-injects this obligation after a `/compact`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.154.2",
3
+ "version": "0.156.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.154.2",
3
- "builtAt": "2026-08-05T18:59:33.609Z",
2
+ "package": "0.156.0",
3
+ "builtAt": "2026-08-07T06:39:03.548Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # okstra-compact-reminder.sh — SessionStart(matcher: compact) 훅.
4
+ #
5
+ # 컴팩션(/compact 또는 자동) 후 배치/게이트 정리 의무가 리드의 working context
6
+ # 에서 사라질 수 있다. 이 훅은 SessionStart stdin JSON 의 cwd 로 이 프로젝트의
7
+ # 진행 중 okstra run 을 찾아, 완료 pane/task 가 어떻게 회수되는지 사실 서술
8
+ # 리마인더를 stdout 으로 낸다(SessionStart 는 평문 stdout 이 컨텍스트로 주입됨).
9
+ # 활성 run 이 없으면 무출력. 세션 진행을 절대 막지 않는다 — 실패는 삼키고 exit 0.
10
+ set -u
11
+
12
+ _dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
13
+ home="${OKSTRA_HOME:-$HOME/.okstra}"
14
+ # pane_reclaim 패키지는 repo(scripts/okstra_ctl)·설치($OKSTRA_HOME/lib/python)
15
+ # 양쪽에 있을 수 있으므로 둘 다 PYTHONPATH 에 둔다.
16
+ export PYTHONPATH="${_dir}:${home}/lib/python${PYTHONPATH:+:$PYTHONPATH}"
17
+
18
+ _stdin="$(cat 2>/dev/null || true)"
19
+ cwd="$(printf '%s' "$_stdin" | python3 -c 'import json,sys
20
+ try:
21
+ print(json.load(sys.stdin).get("cwd","") or "")
22
+ except Exception:
23
+ pass' 2>/dev/null || true)"
24
+ [ -n "$cwd" ] || cwd="$PWD"
25
+
26
+ while IFS= read -r run_dir; do
27
+ [ -n "$run_dir" ] || continue
28
+ cat <<EOF
29
+ An okstra run is in progress for this project: ${run_dir}
30
+ For this run, completed worker teammate panes are reclaimed at every worker round/phase boundary and immediately before the lead asks the user for any approval, clarification, or decision, by running \$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "${run_dir}" (with --keep report-writer-worker while a report writer is still in flight), and the completed workers' background tasks are then stopped with TaskStop. TaskStop alone idles the roster task but does not close the tmux pane, so it is not cleanup on its own.
31
+ EOF
32
+ done < <(python3 -m okstra_ctl.pane_reclaim --active-dirs-for "$home" "$cwd" 2>/dev/null || true)
33
+
34
+ exit 0
@@ -30,6 +30,7 @@ from okstra_ctl.final_report_paths import final_report_markdown_path # noqa: E4
30
30
  from okstra_ctl.render_final_report import ( # noqa: E402
31
31
  FinalReportRenderError,
32
32
  render_to_file,
33
+ snapshot_last_valid,
33
34
  )
34
35
 
35
36
 
@@ -75,6 +76,7 @@ def main(argv: list[str]) -> int:
75
76
  print(f"error: {exc}", file=sys.stderr)
76
77
  return 1
77
78
 
79
+ snapshot_last_valid(args.data)
78
80
  print(f"wrote {bytes_written} bytes -> {output}")
79
81
  return 0
80
82
 
@@ -90,6 +90,7 @@ This adapter maps the neutral Okstra lead operations to Claude Code host primiti
90
90
  - Collect and persist token usage before any live-roster cleanup, including cleanup between batches and the run-end shutdown sequence.
91
91
  - Before each new worker batch (and before the next phase's render-bundle), reclaim the prior round's completed teammate panes in two passes, adding `--keep report-writer-worker` to **both** passes while the report writer is in flight. First source the count: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` never kills and prints one `<pane_id>\t<pane_title>` line per pane it would reclaim — count those lines as `<n>`. Then perform the reclaim by running the same command **without** `--list`, and emit the neutral contract's `PROGRESS: phase-batch-cleanup panes=<n>` checkpoint with that count. Call both passes after collecting that round's results and token usage and before the next dispatch, so no in-flight worker pane is caught. This `tmux kill-pane`s the harness teammate panes; `shutdown_request` only idles the agent and never frees the pane, so it stays part of the run-end sequence for roster/token hygiene. In a non-tmux session there are no panes, both passes no-op, and `<n>` is `0` — still emit the checkpoint. The lead pane (read from `<RUN_DIR>/state/lead-pane.id`) is always preserved.
92
92
  - Reclaiming a pane does not stop the worker's background task. Every `dispatch_worker` Agent runs with `run_in_background: true`, so a worker whose result is already collected stays a live background task for the rest of the session — that residue is what fills the harness's exit-time `Background work is running` list. At the same batch boundary, right after the pane reclaim, call `TaskStop(task_id: "<name>")` once per worker of the completed batch, passing the exact `name` used at dispatch (`<workerId>-worker`, `<workerId>-worker-reverify-r<N>`, `<provider>-worker-critic`, `report-writer`). Stop only workers whose results were already collected — never an in-flight worker, never the lead, and keep `report-writer` while it is in flight, matching the pane pass's `--keep report-writer-worker`. `TaskStop` on an already-finished task is a no-op; treat a failure as benign, record nothing, and continue the boundary. This runs in a non-tmux session too, where the pane passes no-op but the background tasks still exist.
93
+ - Before any `prompt_user`/`AskUserQuestion` that follows worker dispatch — an approval, clarification, or decision gate — run the same two-pass reclaim used at a round boundary: `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>" [--keep report-writer-worker]` to count `<n>`, then the same command **without** `--list` to reclaim, and emit `PROGRESS: phase-gate-cleanup panes=<n>`. Then `TaskStop(task_id: "<name>")` each completed worker, exactly as at a batch boundary. A bare `TaskStop` idles the roster task but **does not close** the tmux pane, so it is never cleanup on its own — the `trace-cleanup` call is what frees the pane. In a non-tmux session both passes no-op and `<n>` is `0`; still emit the checkpoint. `--keep report-writer-worker` preserves an in-flight report writer, and the lead pane is always preserved. This keeps the user from being shown a gate while finished worker panes are still open.
93
94
  - After batch cleanup, record the current live session generation with `okstra token-usage "<TEAM_STATE_PATH>" --record-observed-session --project-root "<PROJECT_ROOT>"`. This protects usage accounting when Claude Code re-issues the session id after resume or compaction.
94
95
  - Claude Code cannot delete the implicit team or surgically remove an idle roster entry. Explain that teammates may remain visible until session end and, when needed, give the manual action `Delete team <teamName> in Teams/FleetView`.
95
96
  - The `SessionEnd` hook runs `$HOME/.okstra/bin/okstra-team-reconcile.sh --session-end` as the safety net for the current live session.
@@ -294,6 +294,8 @@ finding does not make evidence obtained across the phase boundary admissible.
294
294
 
295
295
  If none of the three is available, **abort the reverify dispatch for that role** and record a `contract-violation` event via `okstra error-log append-observed`. Do NOT guess or fall back to a runtime default. The selected adapter receives the exact value through the assignment and owns how it reaches the worker runtime. The current Codex catalog default is `gpt-5.6-sol`, but that reference value never replaces the manifest assignment.
296
296
 
297
+ **Enforced:** `okstra_ctl.dispatch_state` hands the job's own `modelExecutionValue` to `validate_reverify_prompt(expected_model=...)`, so a `**Model:**` header that disagrees with the model this dispatch will run fails **before** launch. Carrying a stale value from an earlier round's prompt — the round-1 model into a round-2 header — is the shape this catches; without it the mismatch surfaces as a provider 400 that reads as a worker fault. `tests/contract/test_reverify_dispatch_anchors.py` pins both the check and the dispatch-side hand-off.
298
+
297
299
  ### Required reverify output contract (BLOCKING)
298
300
 
299
301
  Every lightweight, adversarial, full-reanalysis, and plan-body reverify prompt
@@ -105,6 +105,7 @@ Required checkpoints:
105
105
  - `PROGRESS: phase-5.6-critic provider=<provider> gaps=<n>` — after the critic result is collected (Phase 5.6, opt-in; the critic dispatch itself fires concurrently with the first 5.5 reverify round). Omitted when `convergence.critic.enabled == false`.
106
106
  - `PROGRESS: phase-batch-cleanup panes=<n>` — immediately after cleaning up the previous batch's panes, at each batch boundary (① just before the first `phase-5.5-convergence` round ② just before the `phase-6-synthesis` report-writer dispatch). `<n>` is the number of panes reclaimed at that boundary — trace panes plus completed teammate panes, which are panes too — read from the cleanup's `--list` pass taken immediately before the reclaim, never estimated. Expose only the counts and NEVER expose `%NNN`/lead-pane.id/raw worker handles. Just before the first batch (analysis-worker dispatch) there is nothing to clean up, so it is a no-op and the marker is omitted.
107
107
  - `PROGRESS: phase-6-synthesis dispatching report-writer-worker` — at the start of Phase 6.
108
+ - `PROGRESS: phase-5.5.9-plan-verify round=<N> items=<count>` — immediately before dispatching each plan-body verification round (`implementation-planning` only; see [plan-body-verification](./plan-body-verification.md) §"Round protocol"). Each round is a worker batch like any other, so round 2 and later MUST be preceded by a `phase-batch-cleanup` line reclaiming the previous round's verifiers. The numbering keeps this line sorted where the work happens — after Phase 6, because the round verifies the drafted plan body.
108
109
  - `PROGRESS: phase-7-persist updating manifests` — at the start of Phase 7.
109
110
  - `PROGRESS: phase-7-teardown shutting-down-workers` — only after usage collection and user approval, immediately before `shutdown_workers`; omitted when no cleanup resource exists or the user keeps it.
110
111
  - `PROGRESS: complete final-report=<relative-path>` — final summary line, after all persistence.
@@ -113,7 +114,7 @@ Do NOT replace them with prose ("Now I'm starting Phase 2..."), do NOT skip a ch
113
114
 
114
115
  `okstra-run` surfaces these lines to the user directly; other launch paths persist them in the selected adapter's declared conformance evidence/event source for post-hoc retrieval.
115
116
 
116
- **Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. `phase-7-teardown` and `complete` fire after validation and are not checked.
117
+ **Enforcement:** the Phase 7 validator (`validators/validate-run.py` → `validate_session_conformance.py`) reads the selected adapter's declared conformance evidence/event source within the run window and fails the run as `contract-violated` when a required checkpoint is missing — including the per-worker `phase-4-dispatch` / `phase-5-collect` lines (which must name each worker's role) and the `phase-batch-cleanup` lines that MUST precede the first `phase-5.5-convergence` round and the `phase-6-synthesis` report-writer dispatch. When the plan-body state file records two or more rounds, `_check_plan_verify_cleanup_checkpoints` additionally requires a `phase-5.5.9-plan-verify` line per round and a `phase-batch-cleanup` between consecutive rounds — that boundary sat outside both older checks, so a five-round self-fix loop left every round's verifiers holding their panes. `phase-7-teardown` and `complete` fire after validation and are not checked.
117
118
 
118
119
  ## Model assignments
119
120
 
@@ -399,6 +400,8 @@ After persistence, reply briefly in the resolved Report Language with: completio
399
400
 
400
401
  - At run start, call the selected adapter's setup required to distinguish lead-owned resources from worker-owned resources.
401
402
  - Before every new worker batch, and between worker rounds within a phase, close **every** resource the prior round's completed workers still hold — display surfaces, roster entries, and live execution handles alike — before the next dispatch; never the lead and never an in-flight worker. Call `record_lead_event` for the batch-cleanup checkpoint. Which resources exist and how each one is released is the selected adapter's mapping.
403
+ - Before the lead asks the user for any approval, clarification, or decision through the runtime's prompt primitive after workers have been dispatched, first close every resource the completed workers still hold, exactly as at a round boundary — so no user gate is shown while a finished worker's display surface is still open. Never the lead and never an in-flight worker. Record the gate-cleanup checkpoint.
404
+ - Closing a completed worker's resources is one paired operation: releasing its display surface and stopping its live execution handle together. Stopping the execution handle alone idles the roster entry but leaves its display surface open, so a handle-stop by itself is never cleanup — every cleanup point (round boundary, user gate, run end) does both.
402
405
  - After Phase 7 persistence and `collect_usage`, enumerate residual adapter-owned resources. If none remain, skip the question.
403
406
  - If resources remain, call `prompt_user` once with a binary keep-or-clean choice. The answer controls the entire residual set; do not ask a second backend-specific cleanup question.
404
407
  - On keep, preserve all resources and provide the selected adapter's manual-cleanup instruction.
@@ -167,7 +167,7 @@ When `config.adversarial == true` (the default for `implementation-planning`; se
167
167
  - The burden of proof sits on the plan: an item earns `AGREE` only if the verifier actively tried to break it and could not.
168
168
  - The verifier MUST open the file paths / symbols / commands the item cites and confirm they exist and are **defined** as written. This is the one allowed widening of the lightweight "judge from internal consistency and stated commands / paths" rule — confirming the existence of cited paths is not "re-analyzing the original requirements". The widening stops at *definition*: a build/test command's **execution success** is out of scope here, because the planning worktree has no dependencies installed (§"Planning-time environment gap"). Confirm the script is declared; do not treat its failure to run as evidence against the plan.
169
169
  - If a cited path / command / validation signal cannot be confirmed, the verifier responds `DISAGREE(<kind>)` with the applicable breakage kind (a–f); uncertainty resolves toward DISAGREE, not AGREE.
170
- - **Single-vote-blocking kinds.** A single `DISAGREE` is approval-blocking on its own — no majority needed — when the breakage kind is `a` (cited path/symbol mismatch) on any plan item other than a `P-Var-*` one, or `f` (requirement-coverage mismatch) on a `P-Req-*` item. On a `P-Var-*` item kind `a` never blocks on one vote — a variation-point defect is a design judgement and takes a majority exactly like `b` / `e` (see §"Plan-body verdict semantics"). These defects are concrete, safety-critical, and adversarially verifiable (the verifier confirmed the cited path / requirement), so one correct dissent must not be outvoted. Each creates a `majority-disagree` classification and MUST become a `Blocks=approval` clarification row. Kinds `b` / `c` / `e` still need a majority — `b` especially is prone to planning-vs-implementation environment false positives. **Rollback ordering (`d`) never blocks the gate at all** — a rollback is executed by a human, not by okstra's workers or verifiers, so a `DISAGREE(d)` is recorded as dissent and dropped from every gate tally; it can only ever fold an item into `passed-with-dissent`. Because `a` is reserved for a concrete contradiction between two spelled-out references (see §"Plan-body verdict semantics"), an abbreviated / ellipsis / under-specified path is raised as `b` (majority-gated), never `a` — a lone "this path is abbreviated" dissent must not single-vote-block on notation alone, and it is especially not blocking on a *rejected* option that will never be implemented. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` (`_SINGLE_VOTE_BLOCKING_KINDS = {a}`, `_ADVISORY_ONLY_KINDS = {d}` + the P-Req `f` rule).
170
+ - **Single-vote-blocking kinds.** A single `DISAGREE` is approval-blocking on its own — no majority needed — when the breakage kind is `a` (cited path/symbol mismatch) on any plan item other than a `P-Var-*` one, or `f` (requirement-coverage mismatch) on a `P-Req-*` item. On a `P-Var-*` item kind `a` never blocks on one vote — a variation-point defect is a design judgement and takes a majority exactly like `b` / `e` (see §"Plan-body verdict semantics"). These defects are concrete, safety-critical, and adversarially verifiable (the verifier confirmed the cited path / requirement), so one correct dissent must not be outvoted. Each creates a `majority-disagree` classification and MUST become a `Blocks=approval` clarification row. Lead never applies this rule by hand — `okstra plan-verify` scores it (see §"Round protocol" step 5). Kinds `b` / `c` / `e` still need a majority — `b` especially is prone to planning-vs-implementation environment false positives. **Rollback ordering (`d`) never blocks the gate at all** — a rollback is executed by a human, not by okstra's workers or verifiers, so a `DISAGREE(d)` is recorded as dissent and dropped from every gate tally; it can only ever fold an item into `passed-with-dissent`. Because `a` is reserved for a concrete contradiction between two spelled-out references (see §"Plan-body verdict semantics"), an abbreviated / ellipsis / under-specified path is raised as `b` (majority-gated), never `a` — a lone "this path is abbreviated" dissent must not single-vote-block on notation alone, and it is especially not blocking on a *rejected* option that will never be implemented. **Enforced:** `validators/validate-run.py` `_classify_plan_item_gate` (`_SINGLE_VOTE_BLOCKING_KINDS = {a}`, `_ADVISORY_ONLY_KINDS = {d}` + the P-Req `f` rule).
171
171
 
172
172
  Plan-body verification stays **lightweight** even under this posture — the `verificationMode = "full-reanalysis"` forcing in [convergence](./convergence.md) §"Adversarial Verification Mode" applies to finding convergence only (see §"Mode constraint"); the adversarial posture here only changes verifier behaviour, not the mode. This raises verification *quality* (active refutation, plan-side burden). The gate *threshold* stays majority-based for the majority-gated kinds (`b`/`c`/`e`), with the single-vote-blocking exception above for the concrete, safety-critical kinds (`a`, and `f` on P-Req); rollback ordering (`d`) is advisory and never blocks. A majority requires at least two participating (non-error) votes, so a lone surviving `DISAGREE` whose peer returned a non-result does NOT block on a majority-gated kind — a worker failure must not make the gate stricter than a healthy roster would.
173
173
 
@@ -191,6 +191,16 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
191
191
  - any `partial-consensus` / `dissent-isolated` / `needs-reverify` present, no `majority-disagree` → `passed-with-dissent`
192
192
  - all items `full-consensus` → `passed`
193
193
 
194
+ **Score the gate with `okstra plan-verify`, never by hand (BLOCKING).** Once this round's verdicts are in the data.json, lead runs
195
+
196
+ ```
197
+ okstra plan-verify --report <runs/<task-type>/reports/final-report-<task-type>-<seq>.md>
198
+ ```
199
+
200
+ and records what it returns: `gate.recomputed` is the round's gate value, `gate.blockedBy` its `gateBlockedBy` causes, `gate.blockingItems` the items that block. The same call runs every plan-body check a round can be judged on alone — provenance, fixability, subject substance, self-fix grouping, round recording, clarification matching, state-file rounds — and exits 2 with `failures[]` when the round is not contract-clean. **A round is not complete while that exit code is non-zero.**
201
+
202
+ Reading the rules in this section and tallying the votes in an ad-hoc script is a contract violation, not a shortcut. The classification carries five special cases that a re-derivation drops one at a time — single-vote-blocking `a`/`f`, advisory-only `d`, the `P-Var-*` majority gate, the `P-Rb-*` exemption, and the `needs-reverify` shape — and a hand-written tally re-derives them from scratch every round, so the loop's later rounds score differently from its first. One implementation, called once per round, is what keeps round 5 scored the same way as round 1.
203
+
194
204
  **Record the cause, not just the outcome.** The gate value names the outcome; `planBodyVerification.gateBlockedBy` (array) names every input that blocked it — `majority-disagree`, `coverage-gap`, `non-result`. Two independent inputs can block: a `majority-disagree` plan item, and a Requirement Coverage `gap` / `blocked C-NNN` row (`prompts/profiles/implementation-planning.md` §"Requirement Coverage"). A coverage-only block still renders as `blocked-by-disagreement` because that is the only blocking non-abort value, so **without `gateBlockedBy` the report asserts a worker disagreement that never happened** and the reader hunts for a dissent that does not exist. Leave the array empty for a passing gate. **Enforced:** `validators/validate-run.py` `_validate_gate_blocked_by` cross-checks the declared causes against the recorded verdicts and coverage rows, and fails a passing gate that has a blocking coverage row — the coverage rule was prose-only before.
195
205
 
196
206
  **A coverage row citing this run's own `C-NNN` is not an independent blocker.** When a coverage row's `blocked C-NNN` points at a clarification that step 8 below promoted from a `majority-disagree` item in *this same run*, that blocker is already counted once as the plan item. Counting it again as a coverage gap makes the run block on a clarification it just authored, and the row carries into the next run as a fresh blocker — the Requirement Coverage ↔ Clarification cycle. Such rows are excluded from `coverage-gap`. **Enforced:** `validators/validate-run.py` `_independent_coverage_blockers`.
@@ -202,7 +212,8 @@ Plan-body verification stays **lightweight** even under this posture — the `ve
202
212
  - **Drop plan items whose element the round deleted.** A self-fix rewrite may remove a plan element (a validation check, a rollback row). `P-*` ids are positional, so a deletion shifts every later row and silently re-points surviving verdicts at their neighbours — and a verdict recorded against a removed element keeps blocking a gate while being unfindable in the plan, so reading the plan never reveals the cause. After each round, re-extract plan items with `okstra plan-items extract` and re-verify any item whose `subject` no longer matches; never carry the old vote forward across a shift. **Enforced:** `validators/validate-run.py` `_validate_verdicts_match_current_subjects` (re-pointing) and `_validate_plan_item_extraction_completeness` (dangling ids).
203
213
  - 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. **The round's verdicts MUST be transcribed into `planBodyVerification.planItems[].verdicts` in the final report's data.json before the gate is declared** — the gate is re-derived from that table, so declaring a gate over an empty one leaves it unauditable. **Enforced:** `_validate_round_recorded_verdicts`.
204
214
  - 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.
205
- - **Round completion.** A round is complete only after the renderer has run on the corrected data.json, lead has appended the round to the state file per step 6, and lead has reconciled instructed groups against applied corrections every `itemIds` entry either carries a `selfFixNote` or is still recorded as broken. A round that was instructed but never rendered has not happened, and counting it inflates the budget that gates promotion. The state-file append is not optional bookkeeping: the next re-verification overwrites data.json's `planItems[].verdicts`, so a round that never reached `roundHistory[]` leaves no record anywhere of what it blocked on which is the whole reason this file exists. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_rounds` requires one `roundHistory[]` entry per round `1..roundCount`, each carrying its own `gateResult` and cited by at least one item's `rounds[]`, and requires the file's `selfFixRoundsApplied` to match the report's.
215
+ - **Each round is a worker batch.** Before dispatching round N 2, reclaim the previous round's completed verifiers exactly as at any other batch boundary ([okstra-lead-contract](./okstra-lead-contract.md) "Run-scoped worker-resource lifecycle") and emit `PROGRESS: phase-batch-cleanup panes=<n>`, then announce the round with `PROGRESS: phase-5.5.9-plan-verify round=<N> items=<count>`. Saying a round will "reuse" the previous verifiers and then dispatching under fresh names leaves every prior round holding its panes five rounds of that is what exhausts the pane budget and blocks the next dispatch. **Enforced:** `validators/validate_session_conformance.py` `_check_plan_verify_cleanup_checkpoints` requires both lines once the state file records two or more rounds.
216
+ - **Round completion.** A round is complete only after the renderer has run on the corrected data.json, lead has appended the round to the state file per step 6, lead has reconciled instructed groups against applied corrections — every `itemIds` entry either carries a `selfFixNote` or is still recorded as broken — and **`okstra plan-verify --report <report>` exits 0** (step 5). A round left with a non-zero exit carries its defect into the next round's inputs, which is how a mis-scored gate survives a whole self-fix budget. A round that was instructed but never rendered has not happened, and counting it inflates the budget that gates promotion. The state-file append is not optional bookkeeping: the next re-verification overwrites data.json's `planItems[].verdicts`, so a round that never reached `roundHistory[]` leaves no record anywhere of what it blocked on — which is the whole reason this file exists. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_rounds` requires one `roundHistory[]` entry per round `1..roundCount`, each carrying its own `gateResult` and cited by at least one item's `rounds[]`, and requires the file's `selfFixRoundsApplied` to match the report's.
206
217
  - **Loop termination.** Lead — not the report-writer worker — records the round count in `planBodyVerification.selfFixRoundsApplied` at each round's end, and why the loop stopped in `planBodyVerification.selfFixStopReason`. The count must equal the highest `round` in `selfFixGroups[]`, so it is derivable from recorded work rather than self-reported:
207
218
  - `all-resolved` — no planner-fixable `majority-disagree` item remains. Exit.
208
219
  - `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.
@@ -124,20 +124,31 @@ Terminal statuses that can be recorded for a worker:
124
124
 
125
125
  ### Mid-run liveness probes
126
126
 
127
- Between wakes, `okstra worker-liveness` is the **only** sanctioned way to ask whether a pending worker is still alive. Do NOT hand-roll a polling script, an `ls` / `stat` loop, or any ad-hoc file-existence check: a lead that writes its own probe owns that probe's bugs, and those bugs surface as *worker* failures — a shell quoting slip silently turns the probe into a no-op that reports health it never measured.
127
+ Between wakes, `okstra worker-liveness` is the **only** sanctioned way to ask whether a pending worker is still alive. Do NOT hand-roll a polling script, an `ls` / `stat` loop, or any ad-hoc file-existence check: a lead that writes its own probe owns that probe's bugs, and those bugs surface as *worker* failures — a shell quoting slip silently turns the probe into a no-op that reports health it never measured, and a stall test written against an artifact's absolute mtime declares a freshly launched worker dead on its first iteration, because the audit sidecar it inherits from the previous dispatch has not been touched yet.
128
128
 
129
- Each probe matches exactly one dispatch backend. The worker row's `livenessMode` is authoritative and the probe reads it for you: `audit-heartbeat` registers the in-process audit sidecar, while `wrapper-status` registers the CLI wrapper status sidecar. Do not infer either transport from a worker filename or provider name, and do not pass the artifact path yourself.
129
+ **Waiting is part of the probe, not something you build around it.** `--wait` polls until every named worker's persisted `resultPath` lands (exit 0), one worker probes unhealthy (exit 1), or `--timeout` passes (exit 2):
130
130
 
131
- | Worker | Backend | Flag | What it reads |
132
- |---|---|---|---|
133
- | any in-process worker (including `claude-worker` and `report-writer-worker`) | in-process dispatch | `--audit` | its registered audit sidecar's newest `- PROGRESS:` heartbeat |
134
- | `codex-worker` / `antigravity-worker` | CLI wrapper | `--team-state <path> --worker <id>` | the worker's persisted `promptPath` and `startedAt`, then `<prompt>.log` / `<prompt>.status.json` |
131
+ ```
132
+ okstra worker-liveness --wait \
133
+ --team-state <path> --worker <id> \
134
+ --team-state <path> --worker <id2> \
135
+ [--interval 20] [--timeout 2400]
136
+ ```
137
+
138
+ Run it as a background command and act on the exit code when it returns. Both the "is it done" test (the persisted `resultPath`, never a filename you assemble) and the "is it dead" test (graces anchored to the row's `startedAt`) live inside the command — which is the whole reason a hand-written loop is forbidden: each one re-derives those two tests, and each re-derivation gets one of them wrong.
135
139
 
136
- The mismatch is not intermittent, it is guaranteed: only the `okstra-*-exec.sh` wrappers ever write `<prompt>.log` / `<prompt>.status.json`, so an in-process worker produces neither by construction. Probe in-process workers with `--audit`; use the paired `--team-state` / `--worker` selector only for a wrapper assignment. Its launch grace begins at the atomic `in-progress` transition's `startedAt`, never at prompt-materialization time.
140
+ One selector serves every worker kind: `--team-state <path> --worker <id>`, repeated once per worker. The worker row's `livenessMode` is authoritative and the probe reads it for you do not infer the transport from a worker filename or provider name, and do not pass the artifact path yourself.
141
+
142
+ | `livenessMode` | Backend | What it reads |
143
+ |---|---|---|
144
+ | `audit-heartbeat` | in-process dispatch (including `claude-worker` and `report-writer-worker`) | the row's `auditSidecarPath` — its newest `- PROGRESS:` heartbeat, measured from `startedAt` |
145
+ | `wrapper-status` | CLI wrapper (`codex-worker` / `antigravity-worker`) | the row's `promptPath`, then `<prompt>.log` / `<prompt>.status.json`, measured from `startedAt` |
146
+
147
+ The mismatch is not intermittent, it is guaranteed: only the `okstra-*-exec.sh` wrappers ever write `<prompt>.log` / `<prompt>.status.json`, so an in-process worker produces neither by construction. That is why the mode decides the artifact and you never name it. Each grace begins at the atomic `in-progress` transition's `startedAt`, never at prompt-materialization time and never at an artifact's mtime.
137
148
 
138
149
  This is a transport-adapter liveness choice only. It does not change the reducer's worker identity or its verification responsibility: both remain bound to the registered worker instance and canonical artifacts.
139
150
 
140
- Branch on the exit code, not the JSON: `0` = every probe healthy, `1` = at least one `stalled` / `did-not-launch`. The probe reports; it never kills or re-dispatches. Acting on an unhealthy verdict means spending the existing one-retry budget below.
151
+ Branch on the exit code, not the JSON: without `--wait`, `0` = every probe healthy and `1` = at least one `stalled` / `did-not-launch`; with `--wait`, `0` = every result landed, `1` = a worker died, `2` = the deadline passed. The probe reports; it never kills or re-dispatches. Acting on an unhealthy verdict means spending the existing one-retry budget below.
141
152
 
142
153
  ## Lead Redispatch Policy on Result-Missing
143
154
 
@@ -307,12 +318,27 @@ At the start of Phase 7, call `collect_usage(source)` through the selected adapt
307
318
 
308
319
  Information to be recorded in the team-state JSON file:
309
320
  - `teamName` — record the manifest-provided audit label set in Phase 3 (`okstra-<task-key>` + stage suffix) and preserve that exact value consistently within the run. The selected adapter owns any runtime-session matching derived from this audit field.
310
- - Current status of each worker role
311
- - Start/end times for each worker
312
- - Prompt history path for each worker
313
- - Path to the result file for each worker
314
- - Usage metadata for each worker (totalTokens, toolUses, durationMs)
321
+ - Usage metadata for each worker (totalTokens, toolUses, durationMs) under `workers[].usage`
315
322
  - Lead usage metadata (totalTokens, toolUses, durationMs) under `leadUsage`
316
323
  - Current status of the entire run
317
324
  - Path to the run-level error log file (`runs/<task-type>/logs/errors-<task-type>-<seq>.jsonl`) under `errorsLogPath`
318
- - Per-worker errors sidecar path under `workers[].errorsSidecarPath`
325
+
326
+ ### `workers[]` row — the field names, verbatim
327
+
328
+ Every consumer of this file reads these keys by name. Write them exactly as spelled here; a plausible synonym (`auditPath` for `auditSidecarPath`, `started` for `startedAt`) is not a near-miss, it is a row the probe cannot read, and the failure surfaces as a *worker* problem minutes later rather than as the typo it is.
329
+
330
+ | Key | Required for | Value |
331
+ |---|---|---|
332
+ | `workerId` | every row | the selector `okstra worker-liveness --worker` matches |
333
+ | `role` | every row | the roster label (`Claude worker`, `Codex worker`, `Report writer worker`) copied into the report's execution-status row |
334
+ | `status` | every row | the dispatch's current state |
335
+ | `startedAt` | every row | UTC ISO timestamp of the atomic `in-progress` transition — every liveness grace is measured from this, so a missing or re-used value is what makes a live worker read as stalled |
336
+ | `endedAt` | terminal rows | UTC ISO timestamp |
337
+ | `promptPath` | every row | the persisted prompt; the `wrapper-status` probe derives `<prompt>.log` / `<prompt>.status.json` from it |
338
+ | `resultPath` | every row | the canonical result file; `--wait` waits on exactly this path |
339
+ | `auditSidecarPath` | `audit-heartbeat` rows | the audit sidecar the in-process heartbeat probe reads |
340
+ | `errorsSidecarPath` | every row | the per-worker errors sidecar |
341
+ | `livenessMode` | every row | `audit-heartbeat` (in-process) or `wrapper-status` (CLI wrapper) — authoritative, never inferred from the provider |
342
+ | `usage` | terminal rows | `{totalTokens, toolUses, durationMs}` |
343
+
344
+ **Enforced:** `okstra_ctl.worker_liveness.probe_target` refuses a row with a missing/unknown `livenessMode`, a missing artifact field for that mode, or a missing `startedAt`; `--wait` additionally refuses a row with no `resultPath`. `tests/contract/test_team_state_worker_row_fields.py` fails when this table drifts from the keys `dispatch_core` writes.
@@ -23,6 +23,10 @@ from .convergence_migration import (
23
23
  decide_seed_action,
24
24
  migration_record,
25
25
  )
26
+ from .convergence_provenance import (
27
+ GROUPS_BASENAME_RE,
28
+ provenance_errors,
29
+ )
26
30
  from .convergence_store import (
27
31
  load_json_object,
28
32
  write_final_state_atomic,
@@ -234,8 +238,35 @@ def _resume_after_legacy_final(
234
238
  write_json_atomic(work_state_path, state)
235
239
 
236
240
 
241
+ def _seeded_groups_provenance_errors(groups_path: Path, document: dict) -> list[str]:
242
+ """Provenance errors for a grouping handed to `seed`, or none when the
243
+ layout does not let us resolve the worker results it cites.
244
+
245
+ Checking here is what makes the failure fixable: the lead just wrote this
246
+ file, and the same claims are re-checked at Phase 7 by `validate-run`,
247
+ where a bad `itemId` surfaces hours later as a blocked approval.
248
+ """
249
+ match = GROUPS_BASENAME_RE.match(groups_path.name)
250
+ if match is None:
251
+ return []
252
+ worker_results_dir = groups_path.parent.parent / "worker-results"
253
+ if not worker_results_dir.is_dir():
254
+ return []
255
+ return provenance_errors(
256
+ document,
257
+ worker_results_dir=worker_results_dir,
258
+ suffix=match.group("suffix"),
259
+ groups_label=groups_path.name,
260
+ )
261
+
262
+
237
263
  def _seed(args: argparse.Namespace) -> tuple[str, Path]:
238
264
  grouped_input = load_json_object(args.groups)
265
+ provenance = _seeded_groups_provenance_errors(args.groups, grouped_input)
266
+ if provenance:
267
+ raise ConvergenceContractError(
268
+ "grouping cites source items no worker reported: " + "; ".join(provenance)
269
+ )
239
270
  decision = decide_seed_action(
240
271
  grouped_input=grouped_input,
241
272
  work_state_path=args.work_state,
@@ -0,0 +1,185 @@
1
+ """Round-0 grouping provenance: does every cited source item actually exist?
2
+
3
+ The lead mints its own finding IDs, but each ``sourceItems[].itemId`` and
4
+ ``discoveredBy.<worker>.itemId`` is copied verbatim from a worker's result. A
5
+ grouping citing ``codex-worker:F-007`` when codex never wrote F-007 is a
6
+ fabricated provenance link, and the shapes it arrives in are all
7
+ transcription damage: a descriptive suffix (``F-105 FontLookupPort row``), two
8
+ ids comma-joined into one field, a section title, or a reverify verdict cited
9
+ as if it were a discovery.
10
+
11
+ This module is the single implementation. ``okstra convergence seed`` runs it
12
+ when the grouping is handed over — while the lead can still fix it — and
13
+ ``validators/validate-run.py`` runs it again over the finished run, so a
14
+ grouping written out of band is still caught.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from pathlib import Path
21
+
22
+ from okstra_ctl.convergence_engine import grouped_input_digest
23
+
24
+ GROUPS_BASENAME_RE = re.compile(
25
+ r"^convergence-groups-(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
26
+ )
27
+
28
+
29
+ def _nonempty_string(value: object) -> bool:
30
+ return isinstance(value, str) and bool(value.strip())
31
+
32
+
33
+ def group_claims(document: object) -> list[tuple[str, str, str]] | None:
34
+ """Extract `(findingId, worker, itemId)` provenance claims from a grouping.
35
+
36
+ Returns ``None`` when the document does not match the convergence-groups
37
+ schema shape closely enough to read its provenance fields safely — the
38
+ caller refuses to judge such a file. Otherwise returns the deduplicated
39
+ claims drawn from every group's ``sourceItems[]`` and ``discoveredBy`` map.
40
+ """
41
+ if not isinstance(document, dict) or not isinstance(document.get("groups"), list):
42
+ return None
43
+ claims: list[tuple[str, str, str]] = []
44
+ seen: set[tuple[str, str, str]] = set()
45
+ for group in document["groups"]:
46
+ if not isinstance(group, dict):
47
+ return None
48
+ finding_id = group.get("findingId")
49
+ source_items = group.get("sourceItems")
50
+ discovered_by = group.get("discoveredBy")
51
+ if (
52
+ not _nonempty_string(finding_id)
53
+ or not isinstance(source_items, list)
54
+ or not isinstance(discovered_by, dict)
55
+ ):
56
+ return None
57
+ pairs: list[tuple[object, object]] = []
58
+ for item in source_items:
59
+ if not isinstance(item, dict):
60
+ return None
61
+ pairs.append((item.get("worker"), item.get("itemId")))
62
+ for worker, discovery in discovered_by.items():
63
+ if not isinstance(discovery, dict):
64
+ return None
65
+ pairs.append((worker, discovery.get("itemId")))
66
+ for worker, item_id in pairs:
67
+ if not _nonempty_string(worker) or not _nonempty_string(item_id):
68
+ return None
69
+ claim = (finding_id, worker, item_id)
70
+ if claim not in seen:
71
+ seen.add(claim)
72
+ claims.append(claim)
73
+ return claims
74
+
75
+
76
+ def groups_digest_ok(state_dir: Path, suffix: str, document: dict) -> bool:
77
+ """False only when a recorded groupsDigest exists and no longer matches.
78
+
79
+ ``okstra convergence seed`` pins ``grouped_input_digest`` of the grouping
80
+ into the working state. When that record is present and disagrees with the
81
+ on-disk grouping, the file was tampered with or written out of band — not
82
+ evidence of fabrication — so the caller refuses to judge it. A missing
83
+ working state or missing digest means judge the file as parsed.
84
+ """
85
+ work_path = state_dir / f"convergence-work-{suffix}.json"
86
+ try:
87
+ work = json.loads(work_path.read_text(encoding="utf-8"))
88
+ except (OSError, ValueError):
89
+ return True
90
+ recorded = work.get("groupsDigest") if isinstance(work, dict) else None
91
+ if not _nonempty_string(recorded):
92
+ return True
93
+ return grouped_input_digest(document) == recorded
94
+
95
+
96
+ def read_canonical_worker_result(
97
+ worker_results_dir: Path, worker: str, suffix: str
98
+ ) -> str | None:
99
+ """Return the `<worker>-<suffix>.md` text, or ``None`` when it is not a
100
+ resolvable file directly inside ``worker-results/`` (missing, unreadable,
101
+ or a worker slug that is not a single path component)."""
102
+ candidate = worker_results_dir / f"{worker}-{suffix}.md"
103
+ try:
104
+ if candidate.resolve().parent != worker_results_dir.resolve():
105
+ return None
106
+ return candidate.read_text(encoding="utf-8")
107
+ except (OSError, ValueError):
108
+ return None
109
+
110
+
111
+ def id_occurs_wordbounded(text: str, item_id: str) -> bool:
112
+ """True when `item_id` occurs as a literal not flanked by another
113
+ identifier char, so `F-7` matches neither `F-70` nor `xF-7`."""
114
+ pattern = r"(?<![0-9A-Za-z_-])" + re.escape(item_id) + r"(?![0-9A-Za-z_-])"
115
+ return re.search(pattern, text) is not None
116
+
117
+
118
+ def provenance_errors(
119
+ document: object,
120
+ *,
121
+ worker_results_dir: Path,
122
+ suffix: str,
123
+ groups_label: str,
124
+ ) -> list[str]:
125
+ """Every claim in ``document`` whose cited ID is absent from its worker's
126
+ result file. An unjudgeable document yields no errors — a caller that
127
+ blocks on these must not block on a shape it could not read."""
128
+ claims = group_claims(document)
129
+ if claims is None:
130
+ return []
131
+ contents: dict[str, str | None] = {}
132
+ errors: list[str] = []
133
+ for finding_id, worker, item_id in claims:
134
+ if worker not in contents:
135
+ contents[worker] = read_canonical_worker_result(
136
+ worker_results_dir, worker, suffix
137
+ )
138
+ text = contents[worker]
139
+ if text is None or id_occurs_wordbounded(text, item_id):
140
+ continue
141
+ errors.append(
142
+ f"convergence groups `{groups_label}` group {finding_id} "
143
+ f"cites source item `{worker}:{item_id}`, but that ID does not "
144
+ f"occur in the worker's result file `{worker}-{suffix}.md` — a "
145
+ "grouping may not invent a provenance link to an item the "
146
+ "worker never reported."
147
+ )
148
+ return errors
149
+
150
+
151
+ def run_dir_provenance_errors(run_dir: Path) -> list[str]:
152
+ """Provenance errors across every grouping persisted under ``run_dir``.
153
+
154
+ Unjudgeable input is skipped silently: no groups file, unreadable/malformed
155
+ JSON, a groups file that fails the schema shape, a missing worker result
156
+ file (provider-unavailable substitution is legitimate), or a recorded
157
+ groupsDigest that no longer matches.
158
+ """
159
+ state_dir = Path(run_dir) / "state"
160
+ worker_results_dir = Path(run_dir) / "worker-results"
161
+ if not state_dir.is_dir() or not worker_results_dir.is_dir():
162
+ return []
163
+ errors: list[str] = []
164
+ for groups_path in sorted(state_dir.glob("convergence-groups-*.json")):
165
+ match = GROUPS_BASENAME_RE.match(groups_path.name)
166
+ if match is None:
167
+ continue
168
+ suffix = match.group("suffix")
169
+ try:
170
+ document = json.loads(groups_path.read_text(encoding="utf-8"))
171
+ except (OSError, ValueError):
172
+ continue
173
+ if not isinstance(document, dict):
174
+ continue
175
+ if not groups_digest_ok(state_dir, suffix, document):
176
+ continue
177
+ errors.extend(
178
+ provenance_errors(
179
+ document,
180
+ worker_results_dir=worker_results_dir,
181
+ suffix=suffix,
182
+ groups_label=groups_path.name,
183
+ )
184
+ )
185
+ return errors
@@ -320,10 +320,16 @@ def validate_dispatch_prompts(
320
320
  continue
321
321
  errors.extend(
322
322
  f"{job.worker_id}: {error}"
323
+ # The job carries the model this dispatch will actually run, so the
324
+ # hand-written `**Model:**` header is checkable here — and must be,
325
+ # because a header naming a model the runtime does not serve kills
326
+ # the worker with a provider 400 minutes later, where it reads as a
327
+ # worker fault rather than the typo it is.
323
328
  for error in validate_reverify_prompt(
324
329
  text,
325
330
  task_type=task_type,
326
331
  forbidden_actions=forbidden_actions,
332
+ expected_model=job.model_execution_value or None,
327
333
  )
328
334
  )
329
335
  if errors:
@@ -16,13 +16,12 @@ def is_completed_status(status_path: str) -> bool:
16
16
  return status is not None and status.is_terminal
17
17
 
18
18
 
19
- def active_run_dirs(home: Path) -> list[Path]:
20
- """active.jsonl 의 진행 중 run 들의 절대 run-dir 목록.
19
+ def _iter_active_run_dirs(home: Path):
20
+ """active.jsonl 의 진행 중 run 마다 (projectRoot_str, 절대 run_dir) 를 yield.
21
21
  runDirRel 의 base 는 projectRoot(paths.compute_run_paths 의 _rel 기준)."""
22
22
  path = home / "active.jsonl"
23
23
  if not path.is_file():
24
- return []
25
- dirs: list[Path] = []
24
+ return
26
25
  for line in path.read_text(encoding="utf-8").splitlines():
27
26
  line = line.strip()
28
27
  if not line:
@@ -38,8 +37,29 @@ def active_run_dirs(home: Path) -> list[Path]:
38
37
  continue
39
38
  run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
40
39
  if run_dir is not None:
41
- dirs.append(run_dir)
42
- return dirs
40
+ yield row.get("projectRoot"), run_dir
41
+
42
+
43
+ def active_run_dirs(home: Path) -> list[Path]:
44
+ """active.jsonl 의 진행 중 run 들의 절대 run-dir 목록."""
45
+ return [run_dir for _project_root, run_dir in _iter_active_run_dirs(home)]
46
+
47
+
48
+ def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
49
+ """진행 중 run 중 projectRoot 이 cwd(또는 그 상위)인 것의 절대 run-dir 목록.
50
+ SessionStart(compact) 훅이 이 프로젝트의 run 에만 리마인더를 걸도록 스코프한다."""
51
+ cwd_r = Path(cwd).resolve()
52
+ out: list[Path] = []
53
+ for project_root, run_dir in _iter_active_run_dirs(home):
54
+ if not project_root:
55
+ continue
56
+ try:
57
+ project_root_r = Path(project_root).resolve()
58
+ except OSError:
59
+ continue
60
+ if cwd_r == project_root_r or cwd_r.is_relative_to(project_root_r):
61
+ out.append(run_dir)
62
+ return out
43
63
 
44
64
 
45
65
  def main(argv: list[str]) -> int:
@@ -47,6 +67,10 @@ def main(argv: list[str]) -> int:
47
67
  for run_dir in active_run_dirs(Path(argv[1])):
48
68
  print(run_dir)
49
69
  return 0
70
+ if len(argv) == 3 and argv[0] == "--active-dirs-for":
71
+ for run_dir in active_run_dirs_for_project(Path(argv[1]), Path(argv[2])):
72
+ print(run_dir)
73
+ return 0
50
74
  if len(argv) == 1:
51
75
  return 0 if is_completed_status(argv[0]) else 1
52
76
  return 1