okstra 0.192.0 → 0.193.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/cli.md CHANGED
@@ -801,7 +801,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
801
801
  | `okstra plan-items derivations --data <data.json> --response <user-response sidecar> [--clarification C-NNN]` | List the plan statements an answered clarification may have falsified. Extracts the symbols, paths, and ids the answer names (backticked spans plus `R-001` / `DEV-10174`-style ids) and reports every string in the plan body that mentions one, as a JSON pointer plus excerpt. Advisory: it says where a decision's subject is mentioned, never which mentions are now wrong — the supersession rule (`prompts/profiles/_common-contract.md`) requires the author to enumerate before editing, and this supplies the enumeration |
802
802
  | `okstra plan-items <prepare\|prompt\|validate-prepared> --run-manifest <path> …` | Bind the implementation-planning verification queue to the run manifest. `prepare` extracts the exact queue from `--narrative` and, when `designPreparation.mode` is `no-design-inputs` and the Stage Map has one row, flips `convergence.planBodyVerification.gating` to `false` (stdout `Gating`). `prompt` emits its fixed lossless view, ending with the parser-facing `## Response format` block so the block reaches every verifier with the queue; `validate-prepared` proves the prepared queue still matches the narrative. With `--state <plan-body-verification.json>` the round is a re-verification: `prepare` also carries each queued item's recorded votes and `selfFixNote` into the envelope as `priorRounds`, `prompt` renders them as that item's `**Prior round dissent**` block behind a re-verification preamble, and `validate-prepared` re-derives the carry and rejects an envelope that dropped it. Python resolves the convergence-owned state path, so model callers never choose it. |
803
803
  | `okstra plan-items seed --narrative <report-narrative.md> --state <plan-body-verification.json> [--prior-state <previous plan-body-verification.json>]` | Create the convergence-owned `planBodyVerification.planItems[]` rows every verdict lands in, from the same deterministic extraction `extract` uses. The historical v2 form is `--data <data.json>`. Idempotent by id: an existing row keeps its verdicts and carried fields. Reports `seeded` / `existing` counts. `--prior-state` carries the previous **run**'s verdicts into this one: a newly seeded item whose `contentHash` equals that run's `verifiedContentHash` for the same id inherits its `verdicts[]` and is tagged `carriedForwardFromSeq` with the seq read off the prior filename, so round 1 does not re-judge text nobody changed. A matching id alone never carries — `P-*` ids are positional and shift. It requires `--state`, refuses a prior state whose task root differs from the one `--state` lives under (the state file carries no task identity, so its path is the only identity there is), and when it carries anything it rewrites the sibling `plan-items-*.json` `dispatchQueue` the way `incremental-carry` does. Adds `carried` / `carriedForwardFromSeq` to the reported counts. |
804
- | `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker-id>=<result.md>… --round <N>` | Read each worker's Markdown verdict directly, validate every current `P-*` id, reject duplicate worker submissions, and overwrite that round's stored verdicts. The historical `--verdicts <file>` form remains automation compatibility only. |
804
+ | `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker-id>=<result.md>… --round <N>` | Read each worker's Markdown verdict directly, validate every current `P-*` id, reject duplicate worker submissions, and overwrite that round's stored verdicts. The historical `--verdicts <file>` form remains automation compatibility only. Without `--append` every recorded verdict row of the queued items is replaced; when a row belongs to a round `complete-round` never closed, the command refuses before writing and names the `complete-round --round <M>` to run first. `--discard-open-rounds` replaces anyway — the recovery path when those rounds are being re-applied from their result files in order (the discarded rows are printed). |
805
805
  | `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N> [--self-fix-note <item-id>=<markdown-file>]… [--self-fix-group <cause-file>=<item-id>[,<item-id>...]]… [--self-fix-stop-reason <all-resolved\|no-progress\|max-rounds-reached>]` | After `plan-verify` succeeds, atomically derive and record the round's per-item votes, gate result, participant counts from the actual assigned roster, immutable completion time, convergence history, and optional self-fix notes/groups read from Markdown files. `--self-fix-group` requires `--self-fix-stop-reason` — there is no default. `--self-fix-stop-reason` alone records a stop for a round that rewrote nothing and leaves `selfFixGroups` / `selfFixRoundsApplied` untouched. Models do not write the state JSON. Stdout also carries `nextDispatch`. |
806
806
  | `okstra plan-items next-dispatch --state <plan-body-verification.json> [--run-manifest <path>]` | After `apply-verdicts`, decide whether this round opens a worker batch. `kind: none` — missing-dependency `UNVERIFIABLE` only, no new batch. `kind: worker-correction` — re-prompt only those workers; peers stay idle. `kind: critic-tie` — unsettled analyser 1-1 on a run that rostered a critic, `critic-worker` on those item ids only. `kind: user-decision` — the same 1-1 on a run with no critic rostered: no in-band vote can break it, so open one `okstra approval-decision open` per item (classification `noncritical-dissent`) plus its `## 1. Clarification Items` row and dispatch no further verification for them. `--run-manifest` is what tells the two apart (`invocationAssignments` `critic/*`); without it the answer stays `critic-tie`. A missing path is never environment-unverifiable. |
807
807
  | `okstra plan-items correction-prompt --state <plan-body-verification.json> --run-manifest <path> --worker <id>` | Emit the planning-time environment-gap paragraph, then the assigned queue. The environment exception is first. Exits 2 unless `next-dispatch` named that worker as a blanket-UNVERIFIABLE correction target. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.192.0",
3
+ "version": "0.193.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.192.0",
3
- "builtAt": "2026-09-09T09:02:57.157Z",
2
+ "package": "0.193.0",
3
+ "builtAt": "2026-09-09T09:27:49.565Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -297,7 +297,7 @@ Call `await_workers(handles)` through the same adapter and apply the shared term
297
297
 
298
298
  **Enforced:** `verify_agent_invocation` in `scripts/okstra_ctl/agent/invocation.py` and `validate_reverify_prompt` in `scripts/okstra_ctl/worker_prompt_contract.py` validate generated delivery before dispatch.
299
299
 
300
- Use `okstra agent-prompt materialize` for every reverify prompt. Render the instruction file with `okstra convergence reverify-prompt --run-manifest <run-manifest> --plan <round-plan.json> --worker <worker-id>` and write its output verbatim; it carries the round's mandate, every planned finding with the origin worker's result file, item id, and audit sidecar (which the verifier is told it may open), and the response format the collector parses. Do not hand-copy evidence or the response format: a hand-copied `**Cited evidence**` line carried part of the origin's citation, so the verifier judged the lead's transcription and refuted five claims as `burden-not-met`, and a hand-written `- Verdict:` format cost the same round (2026-09-09). **Enforced (rendering):** `okstra_ctl.convergence_reverify_prompt`. The materializer generates path headers through `worker_prompt_headers`, the source Worktree from the active run, and the execution identity from the run manifest. It also generates the model, task type, and exact `workflow.forbiddenActions` before the instructions. These values are checked by `verify_agent_invocation` and `validate_reverify_prompt` before publication and dispatch.
300
+ Use `okstra agent-prompt materialize` for every reverify prompt. Render the instruction file with `okstra convergence reverify-prompt --run-manifest <run-manifest> --plan <round-plan.json> --worker <worker-id>` and write its output verbatim; it carries the round's mandate, every planned finding with the origin worker's result file, item id, and audit sidecar (which the verifier is told it may open), and the response format the collector parses. Do not hand-copy evidence or the response format: a hand-copied `**Cited evidence**` line carried part of the origin's citation, so the verifier judged the lead's transcription and refuted five claims as `burden-not-met`, and a hand-written `- Verdict:` format cost the same round (2026-09-09). **Enforced (rendering):** `okstra_ctl.convergence_reverify_prompt`. **Enforced (pre-dispatch):** the rendered body's first line is `**Rendered by:** okstra convergence reverify-prompt`, and `validate_reverify_prompt` in `scripts/okstra_ctl/worker_prompt_contract.py` refuses a `reverify-r*` instruction without that line — a hand-written instruction cannot be materialized. The materializer generates path headers through `worker_prompt_headers`, the source Worktree from the active run, and the execution identity from the run manifest. It also generates the model, task type, and exact `workflow.forbiddenActions` before the instructions. These values are checked by `verify_agent_invocation` and `validate_reverify_prompt` before publication and dispatch.
301
301
 
302
302
  The generated `**Project Root:**` owns .okstra artifacts; `**Worktree:**` names the source checkout. Use the prompt's `**Invocation metadata path:**` for its invocation metadata. The result passed as `--result` is the worker's own result and carries the canonical `-worker-` token used by `audit_sidecar_rel`. Reverify has one result path, so omit `--audit-source`. The materializer supplies the audit, errors, read scope, and provider-specific plain-file write instructions.
303
303
 
@@ -341,6 +341,8 @@ Rendered by `okstra convergence reverify-prompt` when `config.adversarial` is fa
341
341
  ```
342
342
  ## Instructions
343
343
 
344
+ **Rendered by:** okstra convergence reverify-prompt
345
+
344
346
  Perform re-verification for <task-key> (round <N>).
345
347
 
346
348
  Review the following findings discovered by other workers.
@@ -383,6 +385,8 @@ Used instead of the lightweight/full-reanalysis prompt when `config.adversarial
383
385
  ```
384
386
  ## Instructions
385
387
 
388
+ **Rendered by:** okstra convergence reverify-prompt
389
+
386
390
  Perform ADVERSARIAL re-verification for <task-key> (round <N>).
387
391
 
388
392
  Your job is to BREAK each finding below, not to confirm it. For EACH finding,
@@ -434,6 +438,8 @@ UNVERIFIABLE is **not** `verification-error`. A verifier that opened the evidenc
434
438
  ```
435
439
  ## Instructions
436
440
 
441
+ **Rendered by:** okstra convergence reverify-prompt
442
+
437
443
  Perform deep re-verification for <task-key> (round <N>).
438
444
 
439
445
  Independently verify the following findings by examining the original materials.
@@ -427,7 +427,7 @@ round before any host or provider process starts.
427
427
  - **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).
428
428
  - **Classify each cause group before instructing it (BLOCKING).** A group is either an *authoring* defect — the plan says something wrong, incomplete, or self-contradictory, which self-fix owns — or a *citation* defect, where the plan points at an analysis artifact incorrectly. Only the first is self-fix work. For the second the finding already exists and already went through convergence, so the fix is to re-cite the converged artifact; instructing report-writer to re-derive the fact means the author reads the source material and produces a **finding that never went through convergence**, which the plan then carries as if it had. That is the role boundary the lead contract draws ("keep analysis, execution, verification, and report authoring responsibilities distinct; return defects to the role that owns them"), and report-writer is authoring-only by its own contract. `P-Req-*` items with breakage kind `f` are where this goes wrong most often: the question is usually whether a coverage row points correctly at something already measured, not whether the measurement is right. State the classification in the group's instruction so the author knows which of the two it is being asked to do.
429
429
  - **A verdict older than the last self-fix is not a verdict unless the item's content is unchanged (BLOCKING).** A verdict cast in round 1 judged the text before the only automatic rewrite. Once that rewrite runs, a changed item's judgement is about a plan that no longer exists. `--round <N>` on `apply-verdicts` stamps each row and copies `contentHash` onto `verifiedContentHash`. `validators/validate-run.py` `_validate_verdict_rounds_outlive_self_fix` fails an in-scope item whose verdict round is at or before `selfFixRoundsApplied` **and** whose `contentHash` does not match `verifiedContentHash`. Matching hashes keep the prior verdict — that is what avoids a sweep round over unchanged stages. Deferred and observed items are out of the gate and do not need a post-self-fix verdict. **Enforced:** `_validate_verdict_rounds_outlive_self_fix`.
430
- - Lead re-runs plan-body verification, then records each worker Markdown result through `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker>=<result.md> --round <N>`. Score the result with `okstra plan-verify --narrative <report-writer-narrative.md> --state <plan-body-verification.json>`, then call `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N>`. These commands fail on an assigned item the worker left unanswered, on a verdict for an item outside the queue, and on a duplicate worker result.
430
+ - Lead re-runs plan-body verification, then records each worker Markdown result through `okstra plan-items apply-verdicts --state <plan-body-verification.json> --result <worker>=<result.md> --round <N>`. Score the result with `okstra plan-verify --narrative <report-writer-narrative.md> --state <plan-body-verification.json>`, then call `okstra plan-items complete-round --state <plan-body-verification.json> --run-manifest <current-run-manifest.json> --round <N>`. These commands fail on an assigned item the worker left unanswered, on a verdict for an item outside the queue, and on a duplicate worker result. `apply-verdicts` without `--append` replaces every recorded row of the queued items, so it also refuses — before writing — when a row belongs to a round that `complete-round` never closed, naming the round to close first; the votes of a closed round live in `planItems[].rounds`. Skipping `complete-round` between rounds and applying the next one lost 19 items' round-1 votes (2026-09-09). `--discard-open-rounds` is only for re-applying the lost rounds from their result files in order. **Enforced:** `okstra_ctl.plan_items_cli._reject_uncompleted_round_loss`.
431
431
  - For a self-fix, record the correction through the typed convergence command rather than writing `selfFixNote` or `selfFixGroups` JSON. A resolved item does not create a clarification.
432
432
  - **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.
433
433
  - **Round completion.** A round is complete only after `okstra plan-verify` exits 0 and `okstra plan-items complete-round` succeeds. A round left with a non-zero exit carries its defect into the next round's inputs. Exit 0 with a non-empty `advisories[]` is a complete round: those findings are recorded, not round-blocking (step 5, §"`failures[]` carries only the blocking findings"). Report assembly and rendering occur only after the convergence state is terminal. **Enforced:** `validators/validate-run.py` `_validate_plan_body_state_rounds` requires one stored round per round number and a corresponding item vote.
@@ -46,7 +46,7 @@ template's check; that template is gone.
46
46
  - **DB / IO / SQL changes require real execution — mock-only is NOT validation evidence:** when this run's diff touches DB/IO/SQL (ORM / query-builder code — sequelize / typeorm / prisma / knex / raw SQL — `*.repository.*`, model/entity files, `migrations/**`, `*.sql`, or any changed query string), a mocked unit test cannot observe the SQL the query builder actually emits (observed failure class: `_implementation-verifier.md` §"DB / IO / SQL change — real-execution gate"). The executor MUST run the change against a real (or faithful-replica) datastore — the `db-test` validation step (plan `validation` db step, else `project.json.qaCommands.db-test`), targeting a **local / replica** DB — and cite its exact command + exit code in the final report's `Validation evidence`. If no real DB / `db-test` command is reachable, do NOT claim the change verified: label the DB portion `static-analysis only …, unverified (not executed)` in the report, surface it in the routing recommendation, and never downplay the real run as "too heavy". `git push` stays forbidden (universal list); the unverified DB state is carried forward so `final-verification` cannot accept it and `release-handoff` cannot push.
47
47
  - **External-source adapters — structure AND fixture both derive from a captured real sample; a self-authored fixture is NOT reality evidence:** when this run's diff builds or changes an `external-interface` or `transformation-mapping` surface (an HTTP / network client, or a parser / mapper of a third-party payload — HTML / JSON / XML / CSV originating outside this repo), the adapter's structural assumptions (selectors, field paths, expected response shape) AND the static fixture / golden that tests them MUST BOTH derive from a **captured real sample** of that payload — the capture cited in the stage's `external-interface` / `transformation-mapping` design-prep item, or one captured this run and recorded with its `source` + capture time. The captured sample is a static fixture (no live socket), so a parser test against it stays in source like any unit test — the Real-IO isolation rule below governs *live* calls, not the captured bytes. Do NOT hand-invent the shape and then hand-write a fixture that agrees with it: the passing test then only proves the code matches your assumption, never that the assumption matches reality (self-confirming oracle — the observed failure was a parser whose selectors existed in its synthetic fixture and in zero real pages: hundreds of green units over a fiction, and the whole structure built on the wrong shape). When no real sample is reachable (no network this run, or the brief supplied none), do NOT synthesize a stand-in and present its green tests as correctness: mark the adapter's shape `reality-unverified (no captured sample)` in `Validation evidence`, keep any placeholder fixture explicitly labelled an assumption (never validation evidence), and surface an explicit **user-owned** item in the routing recommendation to confirm against real data. Unlike the DB gate above this does NOT itself block acceptance — live external verification stays a user-owned item per `final-verification`'s External QA advisory policy — but a synthetic external fixture presented as reality-verified is exactly the mock-only external evidence the `final-verification` test-correctness pass is meant to reject.
48
48
  - **Real-IO test isolation (BLOCKING).** A test that exercises a **real** datastore, HTTP endpoint, external service, message queue, or filesystem — a live DB connection / DSN, a real `fetch` / `axios` / `http` request, an actual S3 / queue client, anything the project's normal CI test suite cannot run because that backend is absent — MUST be written under the task's qa scripts directory `<task_root>/qa/scripts/` (`<TASK_QA_PATH>/scripts`; the `qa/` root itself holds only data sidecars — the Tier 3 conformance manifest and `result-*.json`). It MUST NOT be written into the project source test tree — `src/**`, `test/**`, `tests/**`, `**/__test__/**`, `**/__tests__/**`, `*.spec.*`, `*.test.*`, or anywhere the project's lint/test globs collect. Two reasons: (a) the project's CI / normal suite has no real DB or network, so a real-IO test placed in source silently breaks the pipeline; (b) it is an okstra verification artifact, and the artifact-home rule confines okstra outputs to `.okstra/`. **The dividing line is the IO, not the intent:** a unit test that stubs/spies only *injected collaborators* (mock — no real socket, no real DB handle) is a TDD red-green artifact and stays in source; the moment a test opens a real connection or makes a real network call it belongs in qa. A stage's real-IO requirement check is a Tier 3 conformance script under `<task_root>/qa/scripts/` (declared via the implementation-planning conformance entry) — never smuggle real IO into a `*.spec.*` in source to make it run "as a unit test". The `db-test` real-execution gate above is satisfied by the conformance/db-test path against the replica, NOT by adding a live-DB `*.spec.*` to the project suite. **Author qa specs with the project's own test framework — never hand-roll `describe`/`it`/`expect`.** When the project ships a test runner as a devDependency (jest / vitest / pytest …), the qa spec uses it, invoked with the project config plus a discovery override pointing at the qa scripts dir (jest: `npx jest --config <project jest config> --roots <task_root>/qa/scripts --runInBand <spec-name>`) — the project config keeps module aliases resolving while the default sweep never collects the file; never widen the project's own test config to include qa paths. For TypeScript qa specs also write `<task_root>/qa/scripts/tsconfig.json` (`extends` the project tsconfig, adds the runner's `types` entry, `"include": ["**/*.ts"]`) so editors resolve path aliases and test globals — it is a qa artifact like the rest (untracked). **These qa artifacts stay untracked — never commit them.** `.okstra/**` is gitignored (the artifact-home rule); conformance scripts and their results are *executed* and recorded in the carry sidecar / verifier result, never written into git history. A committed `.okstra/qa` file is a stage-branch defect that leaks okstra internals into the eventual PR (see the `git add` rules below).
49
- - **Stage conformance script (BLOCKING when the approved plan declared `Conformance tests:`).** Planning only declared the path and `requires`. This run MUST write the script to that path under `<task_root>/qa/scripts/` and add the matching `<task_root>/qa/conformance-manifest.json` entry: `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (the set the plan declared), `passContract`, `exemption: null`, `waiver: null`. Do not skip this when the plan declared tests. If the plan declared `Conformance exemption:`, do not invent a script. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <reason>` line per requirement. The verifier runs `runCommand` from the **worktree cwd**, and that cwd is the tree under test. `runCommand` MUST NOT repoint it: a leading `cd <checkout> &&` sends the script at a tree without this stage's changes. Absolute paths are fine and usually necessary — the script and its `tsconfig` live under `<task_root>/qa/scripts/`, i.e. under `.okstra/`, and a worktree does not carry `.okstra/`. Point at those by absolute path; leave the cwd alone. **Enforced:** `scripts/okstra_ctl/conformance.py` `_check_entry` rejects a `runCommand` whose first word in any `&&` / `;` segment changes directory; `validators/validate-run.py` `_validate_conformance` fails the run if the inherited declaration has no script file.
49
+ - **Stage conformance script (BLOCKING when the approved plan declared `Conformance tests:`).** Planning only declared the path and `requires`. This run MUST write the script to that path under `<task_root>/qa/scripts/` and add the matching `<task_root>/qa/conformance-manifest.json` entry: `stageKey` (= `<task-id>-stage-<N>`), `script`, `runCommand`, `requirementIds`, `requires` (the set the plan declared), `passContract`, `exemption: null`, `waiver: null`. Do not skip this when the plan declared tests. If the plan declared `Conformance exemption:`, do not invent a script — with one exception: when this stage's diff touches a db/io/http/external surface the exemption promised it would not (the verifier's diff-surface cross-check names the surface), write the script and the manifest entry for this stage exactly as for a declared stage, with `requires` covering those surfaces. The approved plan is not rewritten; `validate-run.py` `_declared_conformance_errors` accepts an entry for a stage the plan exempted and still rejects one for a stage the plan does not have. The script's standard interface: a `main` that exits `0`=PASS / non-zero=FAIL, and whose stdout ends with `QA-RESULT: PASS|FAIL` followed by one `REQ <id>: PASS|FAIL: <reason>` line per requirement. The verifier runs `runCommand` from the **worktree cwd**, and that cwd is the tree under test. `runCommand` MUST NOT repoint it: a leading `cd <checkout> &&` sends the script at a tree without this stage's changes. Absolute paths are fine and usually necessary — the script and its `tsconfig` live under `<task_root>/qa/scripts/`, i.e. under `.okstra/`, and a worktree does not carry `.okstra/`. Point at those by absolute path; leave the cwd alone. **Enforced:** `scripts/okstra_ctl/conformance.py` `_check_entry` rejects a `runCommand` whose first word in any `&&` / `;` segment changes directory; `validators/validate-run.py` `_validate_conformance` fails the run if the inherited declaration has no script file.
50
50
  - read the approved plan at this prompt's `**Approved plan:**` anchor end-to-end and parse the `## 5.5 Stage Map`. Read this prompt's `**Stage for this implementation run:**` anchor: the single stage number this run owns. The runtime already selected and reserved this stage (one run = one stage) — do NOT recompute the start stage from `consumers.jsonl`. Both anchors are generated headers; when either is missing, stop and report `contract-violated` rather than inferring the value.
51
51
  - load every `runs/<plan-key>/carry/stage-<i>.json` for `i ∈ depends-on(this stage)` and inject them into the executor's working context as "runtime carry-in". For a `depends-on (none)` stage, no sidecar load — task-brief only.
52
52
  - this stage's `depends-on` are all already `status:done`. Its file list, step order, Stage Validation commands, Stage Exit Contract, and rollback path are the authoritative scope.
@@ -82,7 +82,7 @@ also remain contract violations.
82
82
  ```
83
83
  `overall` is exactly one of `PASS` / `FAIL` / `MISSING`. Writing the honest sidecar is mandatory whenever the script runs and on the exemption/waiver skip path. A missing `io`-only sidecar blocks; a missing external-advisory sidecar is reported as `ADVISORY` rather than accepted as hidden evidence.
84
84
  - **Read-only command log.** Record the `runCommand` exact line + its exit code in the Read-only command log. Tier 3 external non-PASS evidence MUST remain visible with status `ADVISORY`. Unlike Tiers 1·2, a conformance script MAY mutate the **replica datastore** (exercising integrated state is its whole purpose) — but only the `qaEnv` replica target, never a shared/staging/prod store. The `runCommand` itself is still subject to the same source/lockfile mutation deny-list as Tier 2 (`--fix`, `npm install` without `ci`, etc.); a denied token aborts with `contract-violated`.
85
- - **No manifest / no entry for this stage.** If the approved plan declared `Conformance exemption:` for this stage, and the manifest is absent or has no matching `stageKey`, record `conformance: no manifest entry for <stageKey>` and proceed. If the approved plan declared `Conformance tests:` and the script file or matching entry is absent, that is a FAIL — do not treat it as a skip. **Enforced:** `validators/validate-run.py` `_validate_conformance`.
85
+ - **No manifest / no entry for this stage.** If the approved plan declared `Conformance exemption:` for this stage, and the manifest is absent or has no matching `stageKey`, record `conformance: no manifest entry for <stageKey>` and proceed. If, in that same situation, the stage diff touches a db/io/http/external surface (`validate-run.py` `_validate_conformance_surfaces`, default patterns in `okstra_ctl.conformance._DEFAULT_SURFACE_PATTERNS`), the run cannot pass on the exemption alone: report the touched surface and the missing entry as the blocking finding, and name the way forward — a Tier 3 script plus manifest entry for this stage with `requires` covering the surface, which the executor may add even though the plan exempted the stage. If the approved plan declared `Conformance tests:` and the script file or matching entry is absent, that is a FAIL — do not treat it as a skip. **Enforced:** `validators/validate-run.py` `_validate_conformance`.
86
86
 
87
87
  ### Self-mock detection (changed test files)
88
88
 
@@ -174,7 +174,7 @@ roles:
174
174
  - **Never read an `.okstra/` artifact back out of a git object.** `.okstra/**` is gitignored and never committed — the executor aborts a commit that stages an ignored path and the verifier reports a committed `.okstra` path as a branch defect — so `git cat-file -e <tag>:.okstra/…`, `git show <tag>:.okstra/…`, and every variant of that read can never resolve, at any tag, in any stage. A later stage that needs a QA artifact reads it from the working tree or receives it through the carry sidecar / verifier result; do not design a stage contract around one being reachable from a tag. Validator S12 rejects the read.
175
175
  - **Per-stage conformance declaration (mandatory one line, in the stage section — same placement freedom as `TDD exemption:`):** the stage MUST carry exactly one of:
176
176
  - `Conformance tests: stage-<N> — <task_root>/qa/scripts/stage-<N>.<ext> (requires=[db|io|http|external,...])` — declare that a Tier3 verification script will prove this stage's upstream requirements (brief / requirements-discovery / error-analysis / improvement-discovery → this stage's `Acceptance`) hold against **real** DB rows, real endpoints, or the real external API — NOT mocks. This phase emits the line and the `requires` set only. Do NOT write `<task_root>/qa/scripts/stage-<N>.*` and do NOT add a `runCommand` or `conformance-manifest.json` entry here — the matching `implementation` stage run creates the script file and the manifest `runCommand`. A plan that declares tests with no script file on disk is valid at this gate. The data.json `conformanceTests` value carries only the remainder after the `Conformance tests: stage-<N> — ` prefix — never the `stage-<N> — ` label itself (report assembly strips a leftover label at publication, and the implementation entry gate rejects one).
177
- - `Conformance exemption: <reason>` — only for stages that touch no db/io/http/external surface, or where unit tests fully cover the increment. Exemption stays a planning declaration; do not move it to implementation. (If the eventual `implementation` diff actually touches one of those surfaces, `validate-run.py`'s diff-surface cross-check is BLOCKING — an exemption cannot hide a real db/io/http/external change.)
177
+ - `Conformance exemption: <reason>` — only for stages that touch no db/io/http/external surface, or where unit tests fully cover the increment. Exemption stays a planning declaration; do not move it to implementation. (If the eventual `implementation` diff actually touches one of those surfaces, `validate-run.py`'s diff-surface cross-check is BLOCKING — an exemption cannot hide a real db/io/http/external change.) **Enforced at planning:** `validators/validate-run.py` `_validate_planning_conformance_declared` runs the same surface patterns over the exempted stage's `stepwiseExecution[].plannedPaths` (`okstra_ctl.conformance.exempt_stage_surface_conflicts`) and fails the planning run — a plan that exempts a stage while planning a `*repository*` / `*.controller.*` / `*migration*` path is corrected here, where the plan is still editable, not after the implementation is done (observed 2026-09-09, dev-10784 Stage 2).
178
178
  - **External QA outcome guideline:** after satisfying the S11 declaration above, a line whose `requires` contains
179
179
  `db`, `http`, or `external` should name those capabilities here so the later `runCommand` can be written against them.
180
180
  Okstra may start the environment and run it automatically, but `FAIL`, missing evidence, or an
@@ -152,6 +152,80 @@ def malformed_conformance_stages(data: object) -> list[int]:
152
152
  return bad
153
153
 
154
154
 
155
+ def exempt_stage_surface_conflicts(
156
+ data: object, patterns: object = None,
157
+ ) -> list[dict[str, object]]:
158
+ """`Conformance exemption:` 을 선언했는데 계획된 경로가 capability 표면을 건드리는 stage.
159
+
160
+ 면제 규칙(prompts/profiles/implementation-planning.md "Per-stage conformance
161
+ declaration")은 "db/io/http/external 표면을 건드리지 않는 stage" 에만 허용하고,
162
+ 구현 diff 가 그 표면을 건드리면 validate-run 의 diff-surface 대조가 막는다고
163
+ 적는다. 그 대조는 구현이 끝난 뒤에만 돌았고, 승인된 계획은 불변이라 그때는
164
+ 고칠 수 없었다(실측 2026-09-09, dev-10784 Stage 2: plannedPaths 에 ORM
165
+ repository 를 넣고 면제 선언 → 구현 완료 후 `contract-violated`). 같은 모순은
166
+ 계획의 `stepwiseExecution[].plannedPaths` 로 승인 전에 판별된다 — 이 함수가
167
+ 그 판별이고, 구현 게이트와 같은 `detect_surfaces` 를 쓴다.
168
+
169
+ 반환 행: `{"stage": <int>, "surfaces": [..], "paths": [..]}`. `plannedPaths`
170
+ 가 없는 step 은 `files` 문자열(쉼표 구분)을 대신 읽는다.
171
+ """
172
+ planning = data.get("implementationPlanning") if isinstance(data, dict) else None
173
+ stages = planning.get("stages") if isinstance(planning, dict) else None
174
+ conflicts: list[dict[str, object]] = []
175
+ for stage in stages if isinstance(stages, list) else []:
176
+ if not isinstance(stage, dict):
177
+ continue
178
+ exemption = stage.get("conformanceExemption")
179
+ if not isinstance(exemption, str) or not exemption.strip():
180
+ continue
181
+ number = stage.get("stage")
182
+ if not isinstance(number, int) or isinstance(number, bool) or number < 1:
183
+ continue
184
+ paths = _stage_planned_paths(stage)
185
+ touching = sorted(
186
+ path for path in paths if detect_surfaces([path], patterns)
187
+ )
188
+ surfaces = detect_surfaces(touching, patterns)
189
+ if surfaces:
190
+ conflicts.append(
191
+ {"stage": number, "surfaces": sorted(surfaces), "paths": touching}
192
+ )
193
+ return conflicts
194
+
195
+
196
+ def _stage_planned_paths(stage: dict) -> list[str]:
197
+ """stage 의 step 들이 계획한 경로 — 경로 모양인 문자열만.
198
+
199
+ plannedPaths 에는 `(none — read-only repository command)` 같은 산문
200
+ 자리표시자도 들어온다(실측 2026-09-09 dev-10627). 공백이 든 문자열을 표면
201
+ 패턴에 대면 `*repository*` 가 그 산문에 걸려 거짓 양성이 된다. 실제 경로는
202
+ 공백이 없고 `/` 나 `.` 을 품는다."""
203
+ paths: list[str] = []
204
+ for step in stage.get("stepwiseExecution") or []:
205
+ if not isinstance(step, dict):
206
+ continue
207
+ planned = step.get("plannedPaths")
208
+ candidates: list[str] = []
209
+ if isinstance(planned, list):
210
+ candidates = [p for p in planned if isinstance(p, str)]
211
+ elif isinstance(step.get("files"), str):
212
+ candidates = step["files"].split(",")
213
+ paths.extend(c.strip() for c in candidates if _looks_like_path(c.strip()))
214
+ return paths
215
+
216
+
217
+ def _looks_like_path(value: str) -> bool:
218
+ if not value or any(ch.isspace() for ch in value):
219
+ return False
220
+ if value.startswith(".okstra/"):
221
+ # task 산출물(qa 스크립트·fixture·decision 기록)은 코드 표면이 아니다 —
222
+ # 구현 diff 는 워크트리에서 나오므로 그 경로는 구현 게이트에도 닿지 않는다.
223
+ # 실측(84개 계획 sweep): `.okstra/**` 를 세면 `*migration*` 이 decision
224
+ # 파일명에 걸려 거짓 양성이 14건 늘었다.
225
+ return False
226
+ return "/" in value or "." in value
227
+
228
+
155
229
  def is_advisory_conformance_entry(entry: object) -> bool:
156
230
  """Return whether one entry depends on user-owned external QA."""
157
231
  if not isinstance(entry, dict):
@@ -34,6 +34,12 @@ class ReverifyPromptError(ValueError):
34
34
  """지시문을 결정적으로 만들 수 없다."""
35
35
 
36
36
 
37
+ # 렌더된 지시문의 서명. 리드가 손으로 쓴 지시문은 이 줄이 없어 materialize 의
38
+ # `validate_reverify_prompt` 에서 거절된다 — 저작(convergence.md "write its output
39
+ # verbatim")만 있고 집행이 없으면 리드는 다시 손으로 쓴다.
40
+ RENDERED_BY_LINE = "**Rendered by:** okstra convergence reverify-prompt"
41
+
42
+
37
43
  @dataclass(frozen=True)
38
44
  class ReverifyFinding:
39
45
  """검증 큐의 finding 하나와, 그 원 워커의 실물 인용 위치."""
@@ -204,6 +210,7 @@ def reverify_prompt_body(
204
210
  response = _ADVERSARIAL_RESPONSE if adversarial else _COLLABORATIVE_RESPONSE
205
211
  rows = [
206
212
  "## Instructions\n\n",
213
+ f"{RENDERED_BY_LINE}\n\n",
207
214
  f"Perform {mode} for {task_key} (round {round_number}).\n\n",
208
215
  mandate, "\n\n",
209
216
  _EVIDENCE_ACCESS, "\n\n",
@@ -38,6 +38,12 @@ class MutationSnapshot:
38
38
  # 때문이다(다른 라운드의 재시도 프롬프트, 로그, 상태 사이드카가 그 창에
39
39
  # 들어온다).
40
40
  orchestrator_paths: tuple[str, ...] = ()
41
+ # artifact root(프로젝트 루트)의 HEAD. 소스 root 가 워크트리라 `git_projection`
42
+ # 은 워크트리를 보고, 프로젝트 루트에서 사람이 브랜치를 바꾸면 그 사실이 어디에도
43
+ # 남지 않았다. 다이제스트에는 넣지 않는다 — 이 필드가 없던 시절의 `before`
44
+ # 스냅샷이 아직 실행 중인 디스패치에 남아 있고, 그것을 못 읽으면 그 워커가
45
+ # 통째로 error 가 된다. 두 루트가 같으면 None.
46
+ artifact_git_head: str | None = None
41
47
 
42
48
  def to_payload(self) -> dict[str, Any]:
43
49
  return {
@@ -50,6 +56,7 @@ class MutationSnapshot:
50
56
  "gitProjection": dict(self.git_projection),
51
57
  "digest": self.digest,
52
58
  "orchestratorPaths": list(self.orchestrator_paths),
59
+ "artifactGitHead": self.artifact_git_head,
53
60
  }
54
61
 
55
62
  @classmethod
@@ -64,6 +71,10 @@ class MutationSnapshot:
64
71
  git_projection=dict(payload["gitProjection"]),
65
72
  digest=str(payload["digest"]),
66
73
  orchestrator_paths=tuple(payload.get("orchestratorPaths", ())),
74
+ artifact_git_head=(
75
+ str(payload["artifactGitHead"])
76
+ if payload.get("artifactGitHead") else None
77
+ ),
67
78
  )
68
79
  expected = _snapshot_digest(
69
80
  snapshot.root,
@@ -168,6 +179,9 @@ class ExecutionMutationAudit:
168
179
  git_projection=git_projection,
169
180
  digest=digest,
170
181
  orchestrator_paths=orchestrator,
182
+ artifact_git_head=(
183
+ None if artifact_root == root else _git_head(artifact_root)
184
+ ),
171
185
  )
172
186
 
173
187
  def compare(
@@ -206,8 +220,8 @@ class ExecutionMutationAudit:
206
220
  source_changes,
207
221
  out_of_plan_edits,
208
222
  )
209
- artifact_failures, untracked_artifact_changes = _artifact_policy_failures(
210
- before, rows, artifact_changed
223
+ artifact_failures, untracked_artifact_changes, switched = (
224
+ _artifact_policy_failures(before, rows, artifact_changed, after=after)
211
225
  )
212
226
  violations.extend(artifact_failures)
213
227
  status = _terminal_status(
@@ -234,7 +248,8 @@ class ExecutionMutationAudit:
234
248
  after_digest=after.digest,
235
249
  git_projection=after.git_projection,
236
250
  untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
237
- warnings=_audit_warnings(untracked_artifact_changes),
251
+ warnings=_audit_warnings(untracked_artifact_changes)
252
+ + _branch_switch_warnings(before, after, switched),
238
253
  )
239
254
 
240
255
 
@@ -250,6 +265,24 @@ def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
250
265
  )
251
266
 
252
267
 
268
+ def _branch_switch_warnings(
269
+ before: MutationSnapshot, after: MutationSnapshot, switched: set[str],
270
+ ) -> tuple[str, ...]:
271
+ if not switched:
272
+ return ()
273
+ shown = sorted(switched)[:5]
274
+ more = len(switched) - len(shown)
275
+ tail = f" (+{more} more)" if more else ""
276
+ return (
277
+ "artifact root HEAD moved "
278
+ f"{(before.artifact_git_head or '?')[:12]} → "
279
+ f"{(after.artifact_git_head or '?')[:12]} during the dispatch; "
280
+ f"{len(switched)} tracked path(s) that differ between those commits are "
281
+ "attributed to that switch, not to the worker: "
282
+ + ", ".join(shown) + tail,
283
+ )
284
+
285
+
253
286
  def _validate_batch(policies: tuple[WritePolicy, ...]) -> tuple[Path, Path]:
254
287
  if not policies:
255
288
  raise MutationAuditError("mutation audit requires at least one policy")
@@ -611,14 +644,24 @@ def _artifact_policy_failures(
611
644
  snapshot: MutationSnapshot,
612
645
  policies: Sequence[WritePolicy],
613
646
  changed: set[str],
614
- ) -> tuple[list[str], set[str]]:
615
- """``(위반 목록, 위반이 아닌 비추적 신규 경로)``.
647
+ *,
648
+ after: MutationSnapshot | None = None,
649
+ ) -> tuple[list[str], set[str], set[str]]:
650
+ """``(위반 목록, 위반이 아닌 비추적 신규 경로, 브랜치 전환으로 설명되는 경로)``.
616
651
 
617
652
  artifact root 의 변경 중 위반으로 남는 것은 두 부류다 — okstra 산출물
618
653
  서브트리(`.okstra/`) 안의 허용 밖 쓰기와, git 이 추적하는 파일의 변경.
619
654
  그 밖의 비추적 신규 파일은 워커의 도구가 남긴 로그·캐시이므로 소스 root 의
620
655
  `_split_tracked` 와 같은 이유로 기록만 한다. artifact root 가 git 레포가
621
656
  아니면 추적 여부를 알 수 없으므로 종전대로 전부 위반으로 본다.
657
+
658
+ 추적 파일의 변경 중 **artifact root 의 HEAD 가 실행 창 안에서 옮겨졌고 그
659
+ 두 커밋 사이에서 실제로 달라지는 경로**는 워커의 쓰기가 아니라 사람의 브랜치
660
+ 전환이다. 실측(2026-09-09, `fontsninja-v3-site` dev-10627 reverify r1b): 워커가
661
+ 워크트리에서 읽기만 하는 8분 동안 프로젝트 루트에서 `checkout preprod →
662
+ rebase` 가 있었고, run 브랜치에만 있는 `CardHero.{tsx,styled.ts}` 가 사라져
663
+ 완주한 결과가 `contract-failed-unattributed` 로 폐기됐다. 그 경로는 위반에서
664
+ 빼고 경고로 남긴다. 전환으로 설명되지 않는 추적 파일 변경은 그대로 위반이다.
622
665
  """
623
666
  allowed = _allowed_artifact_paths(policies)
624
667
  orchestrator = {
@@ -632,7 +675,7 @@ def _artifact_policy_failures(
632
675
  and not any(_is_within(path, item) for item in orchestrator)
633
676
  }
634
677
  if snapshot.artifact_root == snapshot.root:
635
- return [], set()
678
+ return [], set(), set()
636
679
  tracked = _tracked_paths(snapshot.artifact_root)
637
680
  if tracked is None:
638
681
  untracked_outside: set[str] = set()
@@ -642,9 +685,54 @@ def _artifact_policy_failures(
642
685
  if path not in tracked
643
686
  and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
644
687
  }
645
- violating = unauthorized - untracked_outside
688
+ # 전환으로 설명되는 경로는 사라진 쪽(after 에서 비추적)과 나타난 쪽(after 에서
689
+ # 추적) 양쪽에 걸친다 — 둘 다 워커의 흔적이 아니므로 두 집합에서 함께 뺀다.
690
+ switched = {
691
+ path for path in _branch_switch_paths(snapshot, after)
692
+ if path in unauthorized and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
693
+ }
694
+ untracked_outside -= switched
695
+ violating = unauthorized - untracked_outside - switched
646
696
  failures = ["artifact-root change exceeds batch policy union"] if violating else []
647
- return failures, untracked_outside
697
+ return failures, untracked_outside, switched
698
+
699
+
700
+ def _branch_switch_paths(
701
+ before: MutationSnapshot, after: MutationSnapshot | None,
702
+ ) -> set[str]:
703
+ """artifact root 의 HEAD 가 before→after 사이에 옮겨졌을 때 두 커밋 간 달라지는 경로."""
704
+ if after is None:
705
+ return set()
706
+ old_head, new_head = before.artifact_git_head, after.artifact_git_head
707
+ if not old_head or not new_head or old_head == new_head:
708
+ return set()
709
+ listing = subprocess.run(
710
+ ["git", "-C", str(before.artifact_root), "diff", "--name-only", old_head, new_head],
711
+ capture_output=True,
712
+ text=True,
713
+ check=False,
714
+ )
715
+ if listing.returncode != 0:
716
+ return set()
717
+ return {line.strip() for line in listing.stdout.splitlines() if line.strip()}
718
+
719
+
720
+ def _git_head(root: Path) -> str | None:
721
+ probe = subprocess.run(
722
+ ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
723
+ capture_output=True,
724
+ text=True,
725
+ check=False,
726
+ )
727
+ if probe.returncode != 0:
728
+ return None
729
+ head = subprocess.run(
730
+ ["git", "-C", str(root), "rev-parse", "HEAD"],
731
+ capture_output=True,
732
+ text=True,
733
+ check=False,
734
+ )
735
+ return head.stdout.strip() or None if head.returncode == 0 else None
648
736
 
649
737
 
650
738
  def _allowed_artifact_paths(policies: Sequence[WritePolicy]) -> set[str]:
@@ -255,7 +255,15 @@ def _parser() -> argparse.ArgumentParser:
255
255
  )
256
256
  apply_verdicts.add_argument(
257
257
  "--append", action="store_true",
258
- help="add these votes to existing rows instead of replacing the round",
258
+ help="add these votes to the existing rows (the critic's tie vote); "
259
+ "without it every recorded verdict row is replaced, so a round "
260
+ "that was never closed with complete-round is refused first",
261
+ )
262
+ apply_verdicts.add_argument(
263
+ "--discard-open-rounds", action="store_true",
264
+ help="replace even the rows of a round that complete-round never "
265
+ "closed — the recovery path when those rows are being re-applied "
266
+ "from their result files in order; the discarded rows are listed",
259
267
  )
260
268
  complete = commands.add_parser(
261
269
  "complete-round",
@@ -1387,10 +1395,13 @@ def _apply_verdicts(args: argparse.Namespace) -> dict[str, Any]:
1387
1395
  "advisory plan-body gating allows one verification round"
1388
1396
  )
1389
1397
  project_root = _probe_project_root(getattr(args, "run_manifest", None))
1390
- writer = (
1391
- _append_item_verdicts if getattr(args, "append", False)
1392
- else _replace_item_verdicts
1393
- )
1398
+ append = bool(getattr(args, "append", False))
1399
+ if not append:
1400
+ _reject_uncompleted_round_loss(
1401
+ recorded, rows, data.get("roundHistory"), args.round_number, target,
1402
+ discard_open_rounds=bool(getattr(args, "discard_open_rounds", False)),
1403
+ )
1404
+ writer = _append_item_verdicts if append else _replace_item_verdicts
1394
1405
  for item in recorded:
1395
1406
  if isinstance(item, Mapping) and item.get("id") in rows:
1396
1407
  writer(item, rows[item["id"]], args.round_number, project_root)
@@ -1439,12 +1450,86 @@ def _append_item_verdicts(
1439
1450
  if clash:
1440
1451
  raise PlanItemContractError(
1441
1452
  f"plan item {item.get('id')} already has a vote from {clash} — "
1442
- "the extra vote must come from a worker who has not voted on it"
1453
+ "the extra vote must come from a worker who has not voted on it. "
1454
+ "--append is the critic's tie vote; a worker re-voting in a later "
1455
+ "round is recorded without --append, after the earlier round is "
1456
+ "closed with complete-round"
1443
1457
  )
1444
1458
  item["verdicts"] = [*current, *stamped]
1445
1459
  _remember_verified_hash(item)
1446
1460
 
1447
1461
 
1462
+ def _reject_uncompleted_round_loss(
1463
+ recorded: Sequence[Mapping[str, Any]],
1464
+ rows: Mapping[str, Any],
1465
+ history: object,
1466
+ round_number: int,
1467
+ target: Path,
1468
+ *,
1469
+ discard_open_rounds: bool = False,
1470
+ ) -> None:
1471
+ """이번 라운드가 아직 닫히지 않은 다른 라운드의 표를 지우려 하면 쓰기 전에 거절한다.
1472
+
1473
+ `--append` 없는 apply-verdicts 는 항목의 verdicts 를 통째로 교체한다 — 지난
1474
+ 라운드 표는 `complete-round` 가 `planItems[].rounds` 에 스냅샷으로 남길 때만
1475
+ 살아남는다. 그 스냅샷 없이 교체하면 표는 복구할 수 없이 사라지고, 유일한
1476
+ 가드였던 `_reject_round_gap` 은 complete-round 안에 있어 사라진 뒤에야 말했다.
1477
+ 실측(2026-09-09, `fontsninja-v3-site` dev-10627 planning 002): r1·r2 를 닫지
1478
+ 않고 r3 를 적용해 큐 19건의 r1 표가 없어졌다.
1479
+
1480
+ 이력이 없는 데이터(final-report `--data` 경로)는 대상이 아니다 — 라운드
1481
+ 스냅샷을 갖는 것은 convergence 소유 상태 파일뿐이다.
1482
+ """
1483
+ if not isinstance(history, list):
1484
+ return
1485
+ closed = {
1486
+ row.get("round")
1487
+ for row in history
1488
+ if isinstance(row, Mapping) and isinstance(row.get("round"), int)
1489
+ }
1490
+ at_risk: dict[int, list[str]] = {}
1491
+ for item in recorded:
1492
+ if not isinstance(item, Mapping) or item.get("id") not in rows:
1493
+ continue
1494
+ for row in item.get("verdicts") or []:
1495
+ if not isinstance(row, Mapping):
1496
+ continue
1497
+ recorded_round = row.get("round")
1498
+ if (
1499
+ isinstance(recorded_round, int)
1500
+ and recorded_round != round_number
1501
+ and recorded_round not in closed
1502
+ ):
1503
+ at_risk.setdefault(recorded_round, []).append(str(item.get("id")))
1504
+ if not at_risk:
1505
+ return
1506
+ detail = "; ".join(
1507
+ f"round {number}: {', '.join(sorted(set(ids)))}"
1508
+ for number, ids in sorted(at_risk.items())
1509
+ )
1510
+ if discard_open_rounds:
1511
+ # 복구 경로 — 잃어버린 라운드를 결과 파일에서 순서대로 다시 적용할 때는
1512
+ # 지금 남은 뒤 라운드 표가 버려야 할 쪽이다. 무엇을 버리는지는 남긴다.
1513
+ print(
1514
+ f"apply-verdicts: discarding open-round verdicts ({detail})",
1515
+ file=sys.stderr,
1516
+ )
1517
+ return
1518
+ commands = " then ".join(
1519
+ f"`okstra plan-items complete-round --state {target} "
1520
+ f"--run-manifest <run-manifest> --round {number}`"
1521
+ for number in sorted(at_risk)
1522
+ )
1523
+ raise PlanItemContractError(
1524
+ f"round {round_number} would replace verdicts of a round that was never "
1525
+ f"completed ({detail}) — apply-verdicts replaces every recorded row, and "
1526
+ "only complete-round keeps a round's votes in planItems[].rounds. Close "
1527
+ f"the earlier round first: run {commands}, then re-run this command. "
1528
+ "If those rows are themselves being re-applied from their result files "
1529
+ "in order, pass --discard-open-rounds. Nothing was written."
1530
+ )
1531
+
1532
+
1448
1533
  def _gate_module() -> Any:
1449
1534
  # 부모 개수를 세면 체크아웃에서만 맞는다 — 설치본은 패키지가
1450
1535
  # `~/.okstra/lib/python/` 이라 validators 가 `~/.okstra/lib/` 아래다.
@@ -9,6 +9,7 @@ from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import Any, Iterable, Mapping, Sequence
11
11
 
12
+ from .convergence_reverify_prompt import RENDERED_BY_LINE
12
13
  from .worker_prompt_body import analysis_worker_label
13
14
  from .json_boundary import load_owned_object
14
15
  from .worker_prompt_policy import (
@@ -313,6 +314,16 @@ def validate_reverify_prompt(
313
314
  and boundary_position > first_heading.start()
314
315
  ):
315
316
  errors.append("phase boundary block must precede reverify instructions")
317
+ if RENDERED_BY_LINE not in normalized[_task_instructions_offset(normalized):]:
318
+ # 손으로 쓴 지시문이 한 라운드를 버렸다(2026-09-09: `- Verdict:` 형식과 축약된
319
+ # 근거). 렌더러의 서명 줄이 없으면 그 지시문은 렌더러 출력이 아니다.
320
+ errors.append(
321
+ "reverify instruction is not the output of `okstra convergence "
322
+ "reverify-prompt` (missing the `**Rendered by:**` line) — render it with "
323
+ "`okstra convergence reverify-prompt --run-manifest <run-manifest> "
324
+ "--plan <round-plan.json> --worker <worker-id>` and pass that output "
325
+ "verbatim as --instruction; hand-written reverify instructions are refused"
326
+ )
316
327
  errors.extend(_validate_output_contract_block(normalized))
317
328
  return errors
318
329
 
@@ -42,6 +42,7 @@ from okstra_project.resolver import resolve_architecture # noqa: E402
42
42
 
43
43
  from okstra_ctl.conformance import ( # noqa: E402
44
44
  detect_surfaces,
45
+ exempt_stage_surface_conflicts,
45
46
  evaluate_conformance,
46
47
  manifest_required_surfaces,
47
48
  missing_declared_scripts,
@@ -1888,6 +1889,19 @@ def _approved_plan_stage_entries(
1888
1889
  return entries
1889
1890
 
1890
1891
 
1892
+ def _approved_plan_exempted_stages(stages: object) -> list[str]:
1893
+ """`conformanceExemption` 을 선언한 stage 번호들(문자열, stageKey 접미 비교용).
1894
+
1895
+ `_approved_plan_stage_entries` 가 이미 형식을 판정한 뒤에만 부른다."""
1896
+ return sorted(
1897
+ str(stage.get("stage"))
1898
+ for stage in (stages if isinstance(stages, list) else [])
1899
+ if isinstance(stage, dict)
1900
+ and isinstance(stage.get("conformanceExemption"), str)
1901
+ and stage["conformanceExemption"].strip()
1902
+ )
1903
+
1904
+
1891
1905
  def _record_malformed_plan_stages(data_path: Path, failures: list[str]) -> None:
1892
1906
  failures.append(
1893
1907
  f"conformance gate BLOCKING: approved plan evidence has malformed stages at {data_path}"
@@ -1979,7 +1993,12 @@ def _approved_plan_conformance_manifest(
1979
1993
  planning = data.get("implementationPlanning") if isinstance(data, dict) else None
1980
1994
  stages = planning.get("stages") if isinstance(planning, dict) else None
1981
1995
  entries = _approved_plan_stage_entries(stages, data_path, failures)
1982
- return {"entries": entries} if entries is not None else None
1996
+ if entries is None:
1997
+ return None
1998
+ return {
1999
+ "entries": entries,
2000
+ "exemptedStages": _approved_plan_exempted_stages(stages),
2001
+ }
1983
2002
 
1984
2003
 
1985
2004
  def _declared_conformance_errors(
@@ -2022,11 +2041,21 @@ def _declared_conformance_errors(
2022
2041
  )
2023
2042
  if actual_capabilities != frozenset(declaration.get("requires") or []):
2024
2043
  errors.append(f"stage {stage_number} requires mismatch")
2044
+ # 계획이 면제한 stage 에 구현이 실제 Tier 3 항목을 붙이는 것은 허용한다 —
2045
+ # 면제 stage 의 diff 가 db/io/http/external 표면을 건드려 diff-surface 대조에
2046
+ # 걸렸을 때, 승인된 계획은 불변이므로 그 항목이 유일한 진행 경로다
2047
+ # (2026-09-09 dev-10784 Stage 2). 계획에 없는 stage 의 항목은 여전히 거절한다.
2048
+ exempted_stage_numbers = {
2049
+ str(value) for value in declared_manifest.get("exemptedStages") or []
2050
+ }
2025
2051
  for actual_entry in actual:
2026
2052
  if not isinstance(actual_entry, dict):
2027
2053
  continue
2028
2054
  stage_number = str(actual_entry.get("stageKey") or "").rsplit("-stage-", 1)[-1]
2029
- if stage_number not in declared_stage_numbers:
2055
+ if (
2056
+ stage_number not in declared_stage_numbers
2057
+ and stage_number not in exempted_stage_numbers
2058
+ ):
2030
2059
  errors.append(
2031
2060
  f"stage {stage_number} actual manifest entry is not declared by approved plan"
2032
2061
  )
@@ -2062,12 +2091,32 @@ def _planning_conformance_declarations(
2062
2091
  return declarations
2063
2092
 
2064
2093
 
2065
- def _validate_planning_conformance_declared(report_path: Path, failures: list[str]) -> None:
2066
- """계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식만 본다.
2094
+ def _project_surface_patterns(project_root: Path) -> object:
2095
+ """project.json `qaEnv.surfacePatterns` 계획·구현 게이트가 같은 표를 쓴다."""
2096
+ path = project_json_path(project_root)
2097
+ if not path.is_file():
2098
+ return None
2099
+ try:
2100
+ return (json.loads(path.read_text()).get("qaEnv") or {}).get("surfacePatterns")
2101
+ except (OSError, json.JSONDecodeError):
2102
+ return None
2103
+
2104
+
2105
+ def _validate_planning_conformance_declared(
2106
+ report_path: Path,
2107
+ failures: list[str],
2108
+ surface_patterns: object = None,
2109
+ ) -> None:
2110
+ """계획 단계는 `Conformance tests:` / `Conformance exemption:` 선언 형식을 본다.
2067
2111
 
2068
2112
  스크립트 파일과 `runCommand` 는 매칭 implementation stage 가 만든다.
2069
2113
  선언만 있고 파일이 없는 것은 계획 게이트 실패가 아니다. 형식이 깨진
2070
2114
  `conformanceTests` 는 여전히 실패한다.
2115
+
2116
+ 면제 stage 의 `plannedPaths` 가 db/io/http/external 표면을 건드리면 여기서
2117
+ 막는다 — 구현 게이트의 diff-surface 대조(`_validate_conformance_surfaces`)와
2118
+ 같은 패턴이다. 종전에는 그 대조가 구현이 끝난 뒤에만 돌아, 승인된 계획을
2119
+ 고칠 수 없는 자리에서 run 전체가 막혔다(2026-09-09 dev-10784 Stage 2).
2071
2120
  """
2072
2121
  data_path = _data_path_for(report_path)
2073
2122
  if not data_path.is_file():
@@ -2080,6 +2129,19 @@ def _validate_planning_conformance_declared(report_path: Path, failures: list[st
2080
2129
  if not isinstance(ip, dict):
2081
2130
  return
2082
2131
  _planning_conformance_declarations(ip.get("stages"), failures)
2132
+ for conflict in exempt_stage_surface_conflicts(data, surface_patterns):
2133
+ failures.append(
2134
+ "conformance gate BLOCKING: stage "
2135
+ f"{conflict['stage']} declares `Conformance exemption:` but its "
2136
+ f"planned paths touch surface(s) {conflict['surfaces']}: "
2137
+ f"{', '.join(conflict['paths'])} — an exemption cannot hide a "
2138
+ "db/io/http/external change (prompts/profiles/implementation-planning.md "
2139
+ "\"Per-stage conformance declaration\"); declare `Conformance tests:` "
2140
+ f"with requires={conflict['surfaces']} for that stage, or move those "
2141
+ "paths out of it. The implementation run's diff-surface check "
2142
+ "blocks the same stage after the work is done, where the approved "
2143
+ "plan can no longer be corrected."
2144
+ )
2083
2145
 
2084
2146
 
2085
2147
  def _validate_conformance_surfaces(
@@ -2100,7 +2162,10 @@ def _validate_conformance_surfaces(
2100
2162
  "conformance gate BLOCKING: implementation diff touches undeclared "
2101
2163
  f"surface(s) {sorted(uncovered)} — no in-scope stage declares "
2102
2164
  "`requires` for them. Declare a conformance entry (requires=[...]) "
2103
- "for the touching stage. "
2165
+ "for the touching stage — also when the approved plan exempted "
2166
+ "that stage: write the Tier 3 script under <task_root>/qa/scripts/ "
2167
+ "and its conformance-manifest.json entry with requires covering "
2168
+ "those surfaces; the approved plan's exemption is not rewritten. "
2104
2169
  "(silent mock-green 방지 — DEV-9184)"
2105
2170
  )
2106
2171
 
@@ -9398,17 +9463,10 @@ def main() -> int:
9398
9463
  failures,
9399
9464
  )
9400
9465
  if task_type in ("implementation", "final-verification"):
9401
- _sp = None
9402
- _pj = project_json_path(project_root)
9403
- if _pj.is_file():
9404
- try:
9405
- _sp = (json.loads(_pj.read_text()).get("qaEnv") or {}).get("surfacePatterns")
9406
- except (OSError, json.JSONDecodeError):
9407
- _sp = None
9408
9466
  conformance_warnings = _validate_conformance(
9409
9467
  report_path,
9410
9468
  failures,
9411
- surface_patterns=_sp,
9469
+ surface_patterns=_project_surface_patterns(project_root),
9412
9470
  approved_plan_path=_approved_plan_path_from_run_inputs(
9413
9471
  run_manifest_path,
9414
9472
  failures,
@@ -9431,7 +9489,11 @@ def main() -> int:
9431
9489
  )
9432
9490
  if task_type in _END_STATE_PHASES:
9433
9491
  if task_type == "implementation-planning":
9434
- _validate_planning_conformance_declared(report_path, failures)
9492
+ _validate_planning_conformance_declared(
9493
+ report_path,
9494
+ failures,
9495
+ surface_patterns=_project_surface_patterns(project_root),
9496
+ )
9435
9497
  if not selected_direction_plan:
9436
9498
  _validate_end_state_coverage(validation_data, brief_path, failures)
9437
9499
  _validate_end_state_blocked_by(