okstra 0.189.2 → 0.189.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/cli.md CHANGED
@@ -816,7 +816,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
816
816
  | `okstra usage-report [--days <positive-int>] [--project-root <dir>] [--cwd <dir>] [--text\|--json]` | Read-only backend for the okstra-usage skill. `--text` emits ordered fixed labels for model use. The default and `--json` preserve the full machine JSON contract and exit codes. Defaults to the current project's last 30 days. |
817
817
  | `okstra worker-state transition --team-state <path> --worker <id> --status <in-progress\|completed\|timeout\|error\|not-run> [--reason <text>] [--model <execution-value>]` | Atomically update one persisted worker row. `in-progress` records the authoritative `startedAt` and clears `endedAt`; terminal states record `endedAt`; `timeout`, `error`, and `not-run` require a reason. Dispatch adapters use this same transition path, so CLI-backed and in-process orchestration share the status timestamp contract |
818
818
  | `okstra worker-liveness [--team-state <path> --worker <id>]… [--max-idle <seconds>] [--launch-grace <seconds>] [--stall-confirm <seconds>] [--json]` | Judge whether pending workers are still alive so the lead's poll ends a stalled wait early instead of paying the full deadline. The selector repeats; each `--team-state` must have a paired `--worker`. The worker row's `livenessMode` picks the probe: `audit-heartbeat` reads its `auditSidecarPath` and reports `stalled` when the `- PROGRESS:` heartbeat is past the idle budget; `wrapper-status` reads its `promptPath` and reports `did-not-launch` when neither the wrapper `.log` nor `.status.json` appears. Both graces start at the persisted `startedAt`, never at an artifact mtime — the audit sidecar is reused on re-dispatch, so a heartbeat older than this dispatch counts as no signal yet rather than a stall. A heartbeat budget breach is confirmed before it is reported: the probe re-reads the sidecar after `--stall-confirm` seconds (default: half that stage's budget; `0` disables) and reports `stalled` only when the newest heartbeat has not advanced, so a worker inside one long uninterruptible tool call is not judged dead for being slow. Healthy probes report `live`. It only judges—it never kills or re-dispatches. Exit 1 on an unhealthy verdict, so a poll loop can branch without parsing JSON. The heartbeat line shape and budget come from the `okstra_ctl.worker_heartbeat` SSOT shared with the Phase 7 audit (`validators/validate_session_conformance.py`) |
819
- | `okstra worker-audit-check --run-dir <runs/<task-type>/> --task-type <type> --seq <nnn> [--worker <id>]` | Apply the Phase 7 worker audit-sidecar rules mid-run, while the worker session is still alive. For each of this run's `worker-results/<worker>-<task-type>-<seq>.md` it checks that the file carries no `## 0. Reading Confirmation` heading, that the matching audit sidecar exists, and — for prompts carrying the required-v1 evidence-ledger marker — that every backticked `path:line` citation has an Evidence read row in that sidecar. `--worker` scopes it to the role that just returned. Emits `{ok, failures[]}` and exits 2 when `failures[]` is non-empty. The rules come from the `okstra_ctl.worker_audit_ledger` SSOT shared with `validate-run.py`, so an early pass and the Phase 7 pass cannot disagree. Run it right after collecting a result: the same failure at Phase 7 leaves only a retroactive edit, which breaks the audit chain, or a failed run |
819
+ | `okstra worker-audit-check --run-dir <runs/<task-type>/> --task-type <type> --seq <nnn> [--worker <id>]` | Apply the Phase 7 worker audit-sidecar rules mid-run, while the worker session is still alive. For each of this run's `worker-results/<worker>-<task-type>-<seq>.md` it checks that the file carries no `## 0. Reading Confirmation` heading, that the matching audit sidecar exists, and — for prompts carrying the required-v1 evidence-ledger marker — that every backticked `path:line` citation has an Evidence read row in that sidecar. `--worker` scopes it to the role that just returned. Emits `{ok, inspected, inspectedFiles[], failures[], blocking[], advisory[], runImpact}` and exits 2 when `failures[]` (= `blocking` + `advisory`) is non-empty. Exit 2 means "fix it now", not "the run fails": only `blocking` rows (no audit sidecar) fail the run at Phase 7, while `advisory` rows (a citation with no matching Evidence read row) never fail the run and are only repairable while the worker session is alive — do not reject or re-dispatch a result over an advisory row alone. The rules come from the `okstra_ctl.worker_audit_ledger` SSOT shared with `validate-run.py`, so an early pass and the Phase 7 pass cannot disagree. Run it right after collecting a result: the same failure at Phase 7 leaves only a retroactive edit, which breaks the audit chain, or a failed run |
820
820
  | `okstra log-report [--project-root <dir>] [--cwd <dir>] [--top <N>] [--json]` | Read-only inventory of wrapper transcript `.log` files and their sibling prompt `.md` files. Each ranked entry preserves `path` / `sizeBytes` for compatibility and also reports `transcriptPath`, `transcriptBytes`, `promptPath`, `promptBytes`, and `transcriptToPromptRatio`; totals distinguish prompt bytes from transcript bytes and count paired files. Ranking remains transcript-size descending |
821
821
  | `okstra recap <assemble\|record\|note> <task-root\|task-key> …` | Backend for the okstra-inspect `recap` facet. `assemble` is read-only and prints a JSON summary of phase transitions across a task's runs. `record --kind <summary\|qa> --mode <artifact\|code> --answer <text> [--question <text>] [--citation <path:line> …]` appends one line to `<task-root>/recap/recap-log.jsonl` and never mutates other artifacts. `note --kind <verification-evidence\|decision-draft\|analysis-note> --slug <topic> --purpose <text> --scope-note <text> (--body <markdown>\|--body-file <path>)` writes an agent-authored note to `<task-root>/notes/` and prints its path plus the `--clarification-response` argument for feeding it into a later run |
822
822
  | `okstra user-response <list-view\|show-view\|begin\|answer\|plan-decision\|legacy-report-authoring\|finalize> …` | Backend for the `/okstra-user-response` skill. `list-view` and `show-view --report <md\|data.json> --project-root <dir>` are fixed-text model views; `show-view` validates that the report belongs to the explicit project root and prints each open row's why-asked line, linked plan items, and cited `path:line` artifacts so the skill can read them before asking. The legacy `list` and `show` JSON reads retain their automation-compatible fields. `begin --report <md\|data.json> --task-key <key>` returns an opaque transaction id. A predefined clarification choice uses `answer --transaction <id> --clarification-id <C-NNN> --kind <kind> --option-number <N>`; Python resolves the answer, disposition, reach, and scope effects from the validated report. Direct input instead uses `--disposition <answer\|reframe> --value-file <md> [--rationale-file <md>]`. Every value, rationale, and reason file must be a regular file under `<PROJECT_ROOT>/.okstra/tmp/user-response/`; external paths and symbolic links are rejected. `plan-decision` accepts `approved`, `revision-requested`, or `rejected`, validates any `--implementation-option` against the report candidates, and requires `--reason-file` for the latter two statuses. `legacy-report-authoring` is restricted to report contract 2.0. `finalize` validates the complete existing sidecar before a lossless merge, uses compare-and-swap under a run-local lock, and atomically publishes only the user-owned sidecar; exit 0 ok / 1 error. |
@@ -837,7 +837,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
837
837
  | `okstra codex-run <args…>` | Codex lead-adapter dry-run entry point. Accepts the same arguments as `render-bundle` but owns `--render-only --lead-runtime codex`. It prepares the task bundle and prints the prompt for the Codex lead without dispatching workers |
838
838
  | `okstra worker-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Provider-neutral deterministic dispatcher for `runner=cli-wrapper` assignments. It verifies each adjacent invocation specification against the immutable run manifest immediately before process creation and records `core-pre-dispatch`; native-session rows stay with the host. The default selects CLI analysis assignments only. Phase 6 uses explicit `--workers report-writer`, and a mixed analysis/report batch is rejected. `--dry-run` performs the same verification and resolution without starting a provider process. |
839
839
  | `okstra codex-dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--dry-run]` | Compatibility alias for `okstra worker-dispatch`; it no longer selects a Codex-only transport-agent path. |
840
- | `okstra agent-prompt materialize\|check-corrections\|apply-corrections\|verify\|record-dispatch\|link-result\|reject-result\|abandon-attempt\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Every run-branch report-writer prompt gets its `## Output` section (narrative, pointer record, reading audit) rendered by okstra, and an instruction body that writes a `## Output` or `## Corrections` heading is refused. A corrective report-writer round — the narrative at `reportNarrativePath` already exists and its structure parses, value defects included — must pass `--corrections <ledger>` (`schemas/report-writer-corrections-v1.0.schema.json`: `replace` / `remove` / `rewrite` entries keyed by the validator's field-path grammar, `baseNarrativePath` naming a preserved copy of the attempt): the ledger is applied to that base and checked against the writer-owned schema and the task's semantic validator before dispatch, every defect is reported at once, and okstra renders the prompt's `## Corrections` section from it; a report-writer materialization without a ledger over such a narrative is refused before any prompt is written, while a narrative whose structure does not parse (line grammar, unknown top-level field) is re-authored without one. `check-corrections --run-manifest <path> --corrections <ledger> [--json]` runs the same check without materializing (exit 1 lists the defects; `mechanical: true` means every entry is a `replace` or `remove` that passed). `apply-corrections` with the same arguments applies such a mechanical ledger without a writer round: it writes the corrected narrative to `reportNarrativePath` and records a `lead-correction-applied` activity row (`evidenceRefs` = ledger path + correction ids) through the run's activity contract; it refuses a ledger with `rewrite` entries or any defect, a base that is the live narrative, a run without `activityContractVersion` 1, and a ledger already applied. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result; one result path belongs to one dispatch, so a corrective round retires the first attempt with `reject-result --dispatch-id <first> --superseded-by <corrective> --reason <text>` before the new link is accepted — the rejected row stays in `agentResultLinks` carrying `supersededBy` and `rejectionReason` rather than being deleted. `abandon-attempt --invocation-ref <ref> --reason <text>` closes a started attempt whose worker died without producing a result — the one case neither `link-result` (which needs the result file) nor the dispatch-failure path covers — so a retry can follow it instead of the run having to be re-rendered. It refuses any attempt whose `writePolicy.sourcePolicy.mode` is not `source-readonly`: closing an attempt records `failed-no-mutation`, which is true by policy for a read-only worker and a guess for a mutating one. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. A published prompt is immutable, so re-running `materialize` with an edited instruction file fails as `existing_invocation_conflict`; `--replace-undispatched` is the one exit, for a call that failed a pre-dispatch gate and therefore ran nowhere — it covers a differing prompt and a differing metadata alike, since the two are published together and describe one call. It republishes prompt and metadata together, and it is verified rather than trusted — a row in `agentDispatches` or `workerDispatches` naming this `invocationId` refuses the replacement and names the dispatch that used it. |
840
+ | `okstra agent-prompt materialize\|check-corrections\|apply-corrections\|verify\|record-dispatch\|link-result\|reject-result\|abandon-attempt\|materialize-result\|complete\|verify-completion` | Internal invocation-contract CLI. `materialize` composes model assignment, functional duty, and task instructions; `verify` rejects identity, path, snapshot, assignment, source, or digest drift. Every run-branch report-writer prompt gets its `## Output` section (narrative, pointer record, reading audit) rendered by okstra, and an instruction body that writes a `## Output` or `## Corrections` heading is refused. A corrective report-writer round — the narrative at `reportNarrativePath` already exists and its structure parses, value defects included — must pass `--corrections <ledger>` (`schemas/report-writer-corrections-v1.0.schema.json`: `replace` / `remove` / `rewrite` entries keyed by the validator's field-path grammar, `baseNarrativePath` naming a preserved copy of the attempt): the ledger is applied to that base and checked against the writer-owned schema and the task's semantic validator before dispatch, every defect is reported at once, and okstra renders the prompt's `## Corrections` section from it; a report-writer materialization without a ledger over such a narrative is refused before any prompt is written, while a narrative whose structure does not parse (line grammar, unknown top-level field) is re-authored without one. `check-corrections --run-manifest <path> --corrections <ledger> [--json]` runs the same check without materializing (exit 1 lists the defects; `mechanical: true` means every entry is a `replace` or `remove` that passed). `apply-corrections` with the same arguments applies such a mechanical ledger without a writer round: it writes the corrected narrative to `reportNarrativePath` and records a `lead-correction-applied` activity row (`evidenceRefs` = ledger path + correction ids) through the run's activity contract; it refuses a ledger with `rewrite` entries or any defect, a base that is the live narrative, a run without `activityContractVersion` 1, and a ledger already applied. Run-backed calls resolve `assignmentRef` from the manifest, enforce `authorizedPaths`, and reject real-path or symbolic-link escape. `record-dispatch` records a verified host-native specification before dispatch and `link-result` binds the accepted result; one result path belongs to one dispatch, so a corrective round retires the first attempt with `reject-result --dispatch-id <first> --superseded-by <corrective> --reason <text>` before the new link is accepted — the rejected row stays in `agentResultLinks` carrying `supersededBy` and `rejectionReason` rather than being deleted. The corrective dispatch is a new invocation: an invocation whose last attempt finished with a mutation takes no further attempt (`execution_manifest._validate_next_attempt` lets only `failed-no-mutation` be followed), so a retry attempt of the rejected invocation itself is refused by the manifest, and `reject-result` does not make it possible. `abandon-attempt --invocation-ref <ref> --reason <text>` closes a started attempt whose worker died without producing a result — the one case neither `link-result` (which needs the result file) nor the dispatch-failure path covers — so a retry can follow it instead of the run having to be re-rendered. It refuses any attempt whose `writePolicy.sourcePolicy.mode` is not `source-readonly`: closing an attempt records `failed-no-mutation`, which is true by policy for a read-only worker and a guess for a mutating one. Standalone calls are identified by `(purpose, invocationId)` under `.okstra/agent-invocations/<purpose>/`; they publish a canonical result envelope and publish the completion marker last. Consumers use only the `returnedBody` from `verify-completion`. Metadata contains exactly `catalogDigest`, `assignmentDigest`, `dutyDigest`, `instructionDigest`, and `promptDigest`; JSON inputs use UTF-8, sorted keys, compact separators, and no non-finite values, while duty files use versioned sorted-name/byte framing. Instruction sources use `{kind: project\|runtime, path: <relative POSIX path>}` and never persist an installed absolute runtime path. A published prompt is immutable, so re-running `materialize` with an edited instruction file fails as `existing_invocation_conflict`; `--replace-undispatched` is the one exit, for a call that failed a pre-dispatch gate and therefore ran nowhere — it covers a differing prompt and a differing metadata alike, since the two are published together and describe one call. It republishes prompt and metadata together, and it is verified rather than trusted — a row in `agentDispatches` or `workerDispatches` naming this `invocationId` refuses the replacement and names the dispatch that used it. |
841
841
  | `okstra team dispatch --project-root <dir> --run-manifest <path> [--workers <csv>] [--jobs-file <path>] [--dry-run]` / `okstra team await --project-root <dir> --run-manifest <path> [--json]` / `okstra team teardown --project-root <dir> --run-manifest <path> [--dry-run] [--json]` | Read a `leadRuntime=external` run manifest and dispatch, await, or tear down pane-backed workers. Default dispatch excludes report writer; Phase 6 selects it explicitly, and mixed analysis/report jobs are rejected. If a pane cannot be opened, gracefully degrade to the CLI wrapper and record the fallback in `workerDispatches[].degradedFrom` |
842
842
  | `okstra agent-activity append --project-root <dir> --run-manifest <path> --kind <kind> --agent <assigned-id> (--summary <text>\|--summary-file <markdown>) --outcome <outcome> [--plan-item-id <current-id>]… [--command <text> --command-cwd <dir> --command-exit-code <n> --command-output-file <markdown>] [--request-ref <returned-ref>]` | Append one structured activity after checking the agent against this run's role assignments and every plan item against its current convergence state. Python returns an `activityRequestRef`; supply only that returned value with `--request-ref` to retry idempotently. A new call without it remains a distinct activity even with identical contents. Legacy JSON command records remain automation compatibility only. |
843
843
  | `okstra agent-activity project --project-root <dir> --run-manifest <path> --data <data.json>` | Project this run's canonical activity events into `agentActivity[]`. The command preserves event order, rejects duplicate or decreasing activity IDs, and replaces no other report field. A historical manifest without `activityContractVersion: 1` returns an empty projection and leaves data.json unchanged. Normal Phase 7 execution reaches this behavior through `report-finalize`; use the standalone command only for diagnostics. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.189.2",
3
+ "version": "0.189.3",
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.189.2",
3
- "builtAt": "2026-09-04T22:24:35.201Z",
2
+ "package": "0.189.3",
3
+ "builtAt": "2026-09-05T22:53:00.281Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -22,7 +22,7 @@ are collected and convergence finished. Phase 1-5 do not need it.
22
22
  - their independent verdict (PASS / CONCERNS / FAIL),
23
23
  - cited diff snippets supporting the verdict,
24
24
  - the verifier's `Read-only command log` (every command they ran with exact invocation and exit code, in execution order — copied verbatim from the worker result),
25
- - **independent validation re-run results** — per plan-validation command: command line, exit code, and tail of output captured by the verifier (not the executor); any divergence from the executor's reported result MUST be called out as a `Discrepancy` line citing both sides, and when nothing diverged the line is omitted rather than written as `None` — an empty `discrepancy` is how "no divergence" is recorded, and non-empty text there with verdict `PASS` fails the run,
25
+ - **independent validation re-run results** — per plan-validation command: command line, exit code, and tail of output captured by the verifier (not the executor); any divergence from the executor's reported result MUST be called out as a `Discrepancy` line citing both sides — and, when the divergence rests on a plan `validationChecklist` row, naming that row with its `phase` (`VC-003 (phase: mid)`; `_validate_verifier_discrepancy_names_checklist_phase` fails a row cited without it) — and when nothing diverged the line is omitted rather than written as `None` — an empty `discrepancy` is how "no divergence" is recorded, and non-empty text there with verdict `PASS` fails the run,
26
26
  - **style / lint / type-check results** — each check-only tool the verifier ran, its exit code, and the count of new findings attributable to lines this run introduced. When no tool is configured for a touched language, record the single line `no lint/style tool configured for <language>`,
27
27
  - any fix recommendations the verifier declined to apply.
28
28
  The Okstra lead synthesises a unified verdict but MUST preserve dissent — do not collapse opinions into one paragraph. External Tier 3 advisory results are excluded from this aggregate promotion and remain user-owned follow-up evidence. If any other verifier issued `FAIL` on a `Discrepancy` line, the synthesised verdict MUST be `FAIL`. There is no exception and no override: `_validate_verifier_fail_blocks_verdict` in `validators/validate-run.py` fails any report whose `finalVerdict.verdictToken` passes while a `verifierResults[]` row records `FAIL`, and it reads that row alone — a rationale written beside it changes nothing. A divergence the lead believes is not the code's (a committed flaky-test record, a documented environment delta) belongs in the routing recommendation and the user-owned follow-up, and is settled in the next fix run where the verifier re-checks the finding and cites it `resolved`.
@@ -23,7 +23,7 @@ Every verifier acts as a QA gate, not just a diff reviewer. Trusting the executo
23
23
 
24
24
  Verifier obtains the QA command set from exactly two declared sources, in order — there is **no fallback to guessing tools from manifest files**.
25
25
 
26
- 1. **Tier 1 — plan validation set (task-specific):** every command listed under the approved plan's `validation` block (pre / mid / post). The plan is the file at this prompt's `**Approved plan:**` anchor, scoped to the stage its `**Stage for this implementation run:**` anchor names; both are generated headers, so a missing one is `contract-violated`, never a value to infer.
26
+ 1. **Tier 1 — plan validation set (task-specific):** every command listed under the approved plan's `validation` block (pre / mid / post). The plan is the file at this prompt's `**Approved plan:**` anchor, scoped to the stage its `**Stage for this implementation run:**` anchor names; both are generated headers, so a missing one is `contract-violated`, never a value to infer. Each checklist row also carries `phase`, and the phase is part of what the row asserts: `pre` runs before the stage edits, `mid` between the edits and the stage commit, `post` after the commit. A `mid` diff-scope check (`git diff --name-only` listing the touched paths) is reproduced over the stage range — `git diff --name-only <stage base>...HEAD` — once the stage has committed; its empty output on the clean post-commit tree is the plan's own step order, not a divergence. A `Discrepancy` that cites a checklist row names the row and its phase as `VC-NNN (phase: mid)`; a verifier that reads the command without the phase has read half the row. **Enforced:** `_validate_verifier_discrepancy_names_checklist_phase` in `validators/validate-run.py` fails a divergence that cites a `VC-` row without that row's phase.
27
27
  2. **Tier 2 — project baseline:** the project's standing QA baseline from the `Project QA Commands` section emitted by `okstra model-io project-context --project-root <PROJECT_ROOT> --task-ref <task-ref>`.
28
28
  ```json
29
29
  {
@@ -146,7 +146,7 @@ Tier 3 external-advisory discrepancies are excluded from this promotion: preserv
146
146
 
147
147
  **When the re-run matched, leave `discrepancy` empty and omit the `Discrepancy` line.** The field records a divergence, so an empty field *is* the record of "no divergence" — the schema makes it optional for exactly that. Do not write `None`, `n/a`, or a sentence explaining that nothing diverged: the check reads any non-empty text as a recorded divergence, so a verifier that states its clean result in prose is failed for the result it is reporting.
148
148
 
149
- **Enforced:** `_validate_verifier_discrepancy_is_not_passed` in `validators/validate-run.py` fails a non-empty `discrepancy` recorded with verdict `PASS`. `CONCERNS` remains available for the Tier 3 external-advisory divergence this rule excludes — the report carries no tier field, so `PASS` is the only case decidable here.
149
+ **Enforced:** `_validate_verifier_discrepancy_is_not_passed` in `validators/validate-run.py` fails a non-empty `discrepancy` recorded with verdict `PASS`, and `_validate_verifier_discrepancy_names_checklist_phase` fails a `discrepancy` that cites a plan `validationChecklist` row without that row's `phase` (§ Tier 1). `CONCERNS` remains available for the Tier 3 external-advisory divergence this rule excludes — the report carries no tier field, so `PASS` is the only case decidable here.
150
150
 
151
151
  ### Read-only command log (per verifier)
152
152
 
@@ -175,7 +175,11 @@ def _add_dispatch_parsers(commands: argparse._SubParsersAction) -> None:
175
175
  reject_result = commands.add_parser(
176
176
  "reject-result",
177
177
  help="mark a linked result rejected so a corrective re-dispatch can "
178
- "claim its path",
178
+ "claim its path. The corrective dispatch is a new invocation: an "
179
+ "invocation whose last attempt finished with a mutation takes no "
180
+ "further attempt (only `failed-no-mutation` may be followed), so "
181
+ "a retry of the rejected invocation is refused by the execution "
182
+ "manifest, not by this command",
179
183
  )
180
184
  _common_paths(reject_result)
181
185
  reject_result.add_argument("--run-manifest", required=True)
@@ -1374,6 +1374,18 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1374
1374
  )
1375
1375
  if args.round_number < 1:
1376
1376
  raise PlanItemContractError("--round must be 1 or greater")
1377
+ if (
1378
+ isinstance(verification, Mapping)
1379
+ and verification.get("gating") is False
1380
+ and args.round_number > 1
1381
+ ):
1382
+ # `complete-round` 가 같은 조건으로 거절하지만 그때는 이미 라운드 2 판정이
1383
+ # 상태에 쓰인 뒤다 — 판정은 있는데 `roundHistory` 에 그 라운드가 없는
1384
+ # 불일치가 남고, 상태 파일은 수렴 엔진 소유라 복구 경로가 없었다
1385
+ # (2026-09-05 실측, dev-10626 planning). 쓰기 전에 같은 문장으로 거절한다.
1386
+ raise PlanItemContractError(
1387
+ "advisory plan-body gating allows one verification round"
1388
+ )
1377
1389
  project_root = _probe_project_root(getattr(args, "run_manifest", None))
1378
1390
  writer = (
1379
1391
  _append_item_verdicts if getattr(args, "append", False)
@@ -13,8 +13,16 @@ import sys
13
13
  from pathlib import Path
14
14
 
15
15
  from okstra_ctl.worker_audit_ledger import (
16
- check_worker_results_audit,
17
16
  worker_result_files,
17
+ worker_results_audit_findings,
18
+ )
19
+
20
+ # Phase 7 은 같은 규칙을 두 등급으로 판정한다. 여기서 그 등급을 함께 내지 않으면
21
+ # exit 2 하나로 "run 이 실패한다" 고 읽힌다 — 2026-09-05 실측(dev-10626 stage-1)에서
22
+ # 리드가 권고 등급의 미매칭 인용을 차단 실패로 읽고 결과를 거절·재배치했다.
23
+ RUN_IMPACT = (
24
+ "blocking rows fail the run at Phase 7 (validate-run); advisory rows do not "
25
+ "fail the run at Phase 7 and can only be fixed now, while the worker is alive"
18
26
  )
19
27
 
20
28
 
@@ -25,16 +33,19 @@ _CLI_EPILOG = r"""Usage:
25
33
  --run-dir the run directory; worker-results/ and prompts/ hang off it
26
34
  --worker check only this worker (default: every worker in the run)
27
35
 
28
- Emits one JSON object — `{ok, failures[]}` — and exits 2 when failures[] is
29
- non-empty, 0 otherwise.
36
+ Emits one JSON object — `{ok, inspected, inspectedFiles[], failures[],
37
+ blocking[], advisory[], runImpact}` — and exits 2 when failures[] is
38
+ non-empty, 0 otherwise. `failures` is `blocking` followed by `advisory`.
30
39
 
31
40
  Runs the Phase 7 audit-sidecar rules now, while the worker session is still
32
41
  alive: that every result file carries no `## 0. Reading Confirmation` heading,
33
- that its audit sidecar exists, and that every backticked `path:line` citation
34
- has a matching Evidence read row in that sidecar.
42
+ that its audit sidecar exists (blocking), and that every backticked `path:line`
43
+ citation has a matching Evidence read row in that sidecar (advisory).
35
44
 
36
- Call it right after collecting a worker result. The same failure at Phase 7
37
- leaves only a retroactive edit which breaks the audit chain or a failed run.
45
+ Exit 2 means "fix it now", not "the run fails": only `blocking` rows fail the
46
+ run at Phase 7, and `advisory` rows are the ones nothing can repair once the
47
+ worker session is gone. Do not reject or re-dispatch a result over an advisory
48
+ row alone.
38
49
  """
39
50
 
40
51
 
@@ -58,9 +69,10 @@ def _parser() -> argparse.ArgumentParser:
58
69
 
59
70
  def main(argv: list[str] | None = None) -> int:
60
71
  args = _parser().parse_args(argv)
61
- failures = check_worker_results_audit(
72
+ blocking, advisory = worker_results_audit_findings(
62
73
  args.run_dir, args.task_type, args.seq, worker=args.worker
63
74
  )
75
+ failures = blocking + advisory
64
76
  # A selector that narrows to nothing also produces no failures, so `ok`
65
77
  # alone cannot tell a real pass from a check that judged zero files —
66
78
  # a mistyped `--worker` used to read as a clean bill of health. The count
@@ -77,6 +89,9 @@ def main(argv: list[str] | None = None) -> int:
77
89
  "inspected": len(inspected),
78
90
  "inspectedFiles": inspected,
79
91
  "failures": failures,
92
+ "blocking": blocking,
93
+ "advisory": advisory,
94
+ "runImpact": RUN_IMPACT,
80
95
  },
81
96
  ensure_ascii=False,
82
97
  indent=2,
@@ -3093,6 +3093,9 @@ def validate_final_report_data(
3093
3093
 
3094
3094
  task_type = (data.get("header") or {}).get("taskType")
3095
3095
  _validate_verifier_fail_blocks_verdict(data, failures)
3096
+ _validate_verifier_discrepancy_names_checklist_phase(
3097
+ data, report_path, project_root, failures
3098
+ )
3096
3099
  if task_type == "implementation-option-selection":
3097
3100
  selection = data.get("implementationOptionSelection") or {}
3098
3101
  validation_root = project_root or report_path.parent
@@ -5149,6 +5152,110 @@ def _validate_verifier_discrepancy_is_not_passed(
5149
5152
  )
5150
5153
 
5151
5154
 
5155
+ _CHECKLIST_ID_RE = re.compile(r"\bVC-\d{3,}\b")
5156
+ _CHECKLIST_PHASE_RE = re.compile(r"\bphase\W{0,3}(pre|mid|post)\b", re.IGNORECASE)
5157
+
5158
+
5159
+ def _approved_plan_record(
5160
+ data: dict, report_path: Path, project_root: Path | None
5161
+ ) -> dict | None:
5162
+ """이 구현 리포트가 가리키는 승인 계획 레코드(data.json). 못 찾으면 None.
5163
+
5164
+ `approvedPlanReference.planFile` 은 실물에서 프로젝트 상대(`.okstra/tasks/...`)로,
5165
+ fixture 에서 태스크 상대(`runs/implementation-planning/...`)로 나오고 확장자는
5166
+ `.md` 와 `.data.json` 둘 다 쓰인다. 어느 형태든 레코드로 되짚고, 없으면 None 을
5167
+ 돌려 호출자가 판정을 건너뛰게 한다 — 계획 부재는 다른 검사의 몫이고, 없는
5168
+ 파일을 여기서 위반으로 세지 않는다.
5169
+ """
5170
+ implementation = data.get("implementation")
5171
+ if not isinstance(implementation, dict):
5172
+ return None
5173
+ reference = implementation.get("approvedPlanReference")
5174
+ plan_file = reference.get("planFile") if isinstance(reference, dict) else None
5175
+ if not isinstance(plan_file, str) or not plan_file.strip():
5176
+ return None
5177
+ from okstra_ctl.final_report_paths import final_report_data_path
5178
+
5179
+ candidate = Path(plan_file.strip())
5180
+ if candidate.name.endswith(".md"):
5181
+ candidate = final_report_data_path(candidate)
5182
+ task_root = next(
5183
+ (parent.parent for parent in report_path.parents if parent.name == "runs"),
5184
+ None,
5185
+ )
5186
+ roots: list[Path | None] = (
5187
+ [None] if candidate.is_absolute()
5188
+ else [root for root in (project_root, task_root) if root is not None]
5189
+ )
5190
+ for root in roots:
5191
+ path = candidate if root is None else root / candidate
5192
+ if not path.is_file():
5193
+ continue
5194
+ try:
5195
+ loaded = json.loads(path.read_text(encoding="utf-8"))
5196
+ except (OSError, ValueError):
5197
+ return None
5198
+ return loaded if isinstance(loaded, dict) else None
5199
+ return None
5200
+
5201
+
5202
+ def _validate_verifier_discrepancy_names_checklist_phase(
5203
+ data: dict,
5204
+ report_path: Path,
5205
+ project_root: Path | None,
5206
+ failures: list[str],
5207
+ ) -> None:
5208
+ """계획 `validationChecklist` 행을 근거로 적은 divergence 는 그 행의 `phase` 를 인용한다.
5209
+
5210
+ 2026-09-05 실측(fontsninja-v3-site dev-10626 stage-1): codex 검증자가 `VC-003` 의
5211
+ `git diff --name-only` 가 커밋 뒤 빈 출력이라며 FAIL 을 냈다. 그 행은 계획 레코드에
5212
+ `phase: mid` — 편집과 커밋 사이의 체크포인트 — 로 선언돼 있어, 커밋 뒤의 빈 출력은
5213
+ 계획의 단계 순서 그 자체였다. 수렴에서 제기자 본인이 반대 읽기에 AGREE 했지만 FAIL
5214
+ 행은 남아 stage 가 `failed` 로 갔고, 리드도 그 주장을 열어 보지 않고 라우팅에 옮겼다.
5215
+ `pre`/`mid`/`post` 는 행이 언제 성립하는지를 정하므로, 행을 인용하는 문장이 그 값을
5216
+ 함께 적어야 한다 — 읽지 않은 행을 근거로 쓰는 문장은 그러면 쓸 수 없다.
5217
+
5218
+ 행에 `phase` 가 없거나 계획 레코드를 못 찾으면 판정하지 않는다.
5219
+ """
5220
+ plan = _approved_plan_record(data, report_path, project_root)
5221
+ if plan is None:
5222
+ return
5223
+ planning = plan.get("implementationPlanning")
5224
+ rows = planning.get("validationChecklist") if isinstance(planning, dict) else None
5225
+ phases = {
5226
+ str(row["id"]): str(row["phase"]).strip().lower()
5227
+ for row in (rows if isinstance(rows, list) else [])
5228
+ if isinstance(row, dict)
5229
+ and isinstance(row.get("id"), str)
5230
+ and isinstance(row.get("phase"), str)
5231
+ }
5232
+ if not phases:
5233
+ return
5234
+ for who, row in _verifier_rows(data):
5235
+ discrepancy = row.get("discrepancy")
5236
+ if not isinstance(discrepancy, str) or not discrepancy.strip():
5237
+ continue
5238
+ cited = sorted(set(_CHECKLIST_ID_RE.findall(discrepancy)) & set(phases))
5239
+ if not cited:
5240
+ continue
5241
+ named = {
5242
+ match.group(1).lower()
5243
+ for match in _CHECKLIST_PHASE_RE.finditer(discrepancy)
5244
+ }
5245
+ missing = [
5246
+ f"{row_id} (phase: {phases[row_id]})"
5247
+ for row_id in cited
5248
+ if phases[row_id] not in named
5249
+ ]
5250
+ if missing:
5251
+ failures.append(
5252
+ f"verifier-discrepancy: {who} 가 계획 체크리스트 행을 근거로 divergence 를 "
5253
+ f"적었지만 그 행의 phase 를 인용하지 않았다 — {', '.join(missing)}. "
5254
+ "`pre`/`mid`/`post` 는 행이 언제 성립하는지를 정하므로 인용 문장에 "
5255
+ "`VC-NNN (phase: <값>)` 으로 적는다 (`_implementation-verifier.md` § Tier 1)."
5256
+ )
5257
+
5258
+
5152
5259
  def _verifier_rows(data: dict):
5153
5260
  """(표시 이름, verifierResults 행) 쌍."""
5154
5261
  implementation = data.get("implementation")