okstra 0.189.1 → 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.
Files changed (31) hide show
  1. package/docs/architecture.md +2 -0
  2. package/docs/cli.md +3 -3
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/prompts/duties/direction-selection-worker.md +2 -2
  6. package/runtime/prompts/lead/report-writer.md +1 -1
  7. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
  8. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  9. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  10. package/runtime/prompts/profiles/implementation-option-selection.md +2 -2
  11. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +5 -1
  12. package/runtime/python/okstra_ctl/approval_decisions.py +85 -0
  13. package/runtime/python/okstra_ctl/implementation_options.py +35 -0
  14. package/runtime/python/okstra_ctl/plan_items_cli.py +12 -0
  15. package/runtime/python/okstra_ctl/render.py +13 -0
  16. package/runtime/python/okstra_ctl/report_html/common.py +60 -4
  17. package/runtime/python/okstra_ctl/report_html/context_links.py +137 -0
  18. package/runtime/python/okstra_ctl/report_html/filters.py +22 -20
  19. package/runtime/python/okstra_ctl/report_html/render.py +18 -5
  20. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +7 -0
  21. package/runtime/python/okstra_ctl/report_synthesis_packet.py +5 -0
  22. package/runtime/python/okstra_ctl/report_translation.py +8 -0
  23. package/runtime/python/okstra_ctl/scope_provenance.py +28 -6
  24. package/runtime/python/okstra_ctl/worker_audit_check.py +23 -8
  25. package/runtime/templates/reports/html/assets/base.css +10 -0
  26. package/runtime/templates/reports/html/base.template.html +10 -0
  27. package/runtime/templates/reports/html/i18n/en.json +71 -2
  28. package/runtime/templates/reports/html/i18n/ko.json +71 -2
  29. package/runtime/templates/reports/html/macros/forms.html +1 -0
  30. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +55 -11
  31. package/runtime/validators/validate-run.py +107 -0
@@ -877,6 +877,8 @@ The Phase 7 `render-views` step accepts either a final-report data.json or its M
877
877
  - **Human summary**: `humanSummary` is the sole v2 top-level human summary contract. It is not copied into AI Markdown. Each task view decides how to present it together with the task deliverable instead of sharing a generic dashboard body.
878
878
  - **Audit isolation**: worker execution, convergence, and token/cost material remain available for traceability but are subordinate to the user's findings and decisions. They never replace the task analysis narrative.
879
879
  - **Implementation-planning activity**: activity-contract reports show each agent's task, summary, and outcome in the default view. Commands, exit codes, file-and-line evidence, and result paths remain inside expandable detail. Approval decision cards link to the relevant `id-A-NNN` activity anchors and preserve each option's disposition in the exported user response.
880
+ - **Cited ids resolve to a home**: every id the record defines is an in-page anchor (`#id-<id>`; cross-check consensus and dissent cards are `#id-xv-<id>`), and prose, citation lists, and table cells link to it (`report_html/common.py` `anchor_index`, `report_html/filters.py`). Two id families are cited everywhere and defined nowhere in the record, so the renderer follows the two pointers the run pinned (`report_html/context_links.py`): the brief's end-state rows (`EB`/`PB`/`EO`) are read from `<task>/instruction-set/task-brief.md` and rendered once as the "End states the brief pinned" section with `#id-EB-001` anchors, and the clarification ids a previous run settled link to that run's page through `clarificationCarryIn.sourceFile` (`../../<type>/reports/final-report-<type>-<seq>.html#id-C-005`). A missing file yields no rows and no links, never a render failure. An id several rows of the task block share — a direction's `IC-`/`PI-` rows are numbered from 001 inside each direction — has no single home and stays plain text.
881
+ - **Implementation-option-selection cards**: each ranked direction reads as four blocks — what it changes (goal, expected change areas, scope commitments), how it is done (core mechanism, architecture boundaries), why it ranks where it does (criterion scores beside the criterion weights, feasibility votes with counterevidence, safety blockers, unresolved facts), and the expected outcome (coverage count and verdict, requirement coverage, planning invariants). The evaluation criteria and their weights render as their own section before the ranking, the direction picker shows each option's goal, and the candidate audit shows each rejected candidate's goal.
880
882
  - **Schema v1 compatibility**: existing v1 data and quick Markdown reports keep the legacy conditional renderer, including `readerSummary`, reader modes, and the original Markdown-parsing path.
881
883
  - **`C-*` select option order (schema v1)**: the legacy renderer parses `Expected form`, puts the `Recommended:` answer **first**, and relabels the `Alternatives:` items consecutively as `(a)`, `(b)`, and so on (the original character labels are not retained). Schema v2 parses nothing here — a `Kind=decision` row carries `options[]`, and the presentation order is the array order with the `role: recommended` entry first.
882
884
 
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,11 +837,11 @@ 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. |
844
- | `okstra approval-decision <open\|resolve\|carry> --ledger <approval-decisions.json> …` | Write the lead-owned clarification and approval ledger. `open` validates classification-specific dispositions and complete option fields, `resolve` requires real `A-NNN` check references, and `carry` keeps prior resolved decisions outside the active clarification list. `carry --from-responses <instruction-set/clarification-response.md>` is the source of truth for an answer given in an earlier run: the bundle is task-level and cumulative, each response section names the report that posed the question, and `--clarification-id` repeats to carry several ids in one call. `carry --source-ledger` remains for a prior run's ledger that is still on disk and needs `--source-run-ref`. |
844
+ | `okstra approval-decision <open\|resolve\|carry> --ledger <approval-decisions.json> …` | Write the lead-owned clarification and approval ledger. `open` validates classification-specific dispositions and complete option fields, `resolve` requires real `A-NNN` check references, and `carry` keeps prior resolved decisions outside the active clarification list. `carry --from-responses <instruction-set/clarification-response.md>` is the source of truth for an answer given in an earlier run: the bundle is task-level and cumulative, each response section names the report that posed the question, and `--clarification-id` repeats to carry several ids in one call. `carry --source-ledger` remains for a prior run's ledger that is still on disk and needs `--source-run-ref`. Prepare seeds `carriedDecisions[]` itself when it creates a run's ledger from a `--clarification-response` that names a report record — every row that record answered or resolved, plus rows its user-responses sidecars answered (`scripts/okstra_ctl/approval_decisions.py` `seed_carried_decisions`) — so `carry` is for ids that record does not answer. |
845
845
  | `okstra design-snapshot --narrative <report-narrative.md> --output <design-preparation.json>` | Detect implementation-planning design surfaces and write the detector-owned snapshot consumed by final report assembly. |
846
846
  | `okstra plan-verify --narrative <report-narrative.md> --state <plan-body-verification.json>` | Recompute the plan-body gate from the convergence-owned state before `data.json` publication. `--report <historical-data.json>` remains the v2 reader. |
847
847
  | `okstra report-finalize --project-root <dir> --run-manifest <path> --report <final-report.md>` | Run Phase 7 in the manifest's contract order. Contract v3 collects usage into team state, assembles all single-owner inputs into `data.json` once, then checks, renders, spawns follow-ups, validates, and tears down eligible stage worktrees. Contract v2 retains its historical in-place projection sequence as a read-only compatibility path. A failed step still runs every later check through `validate-run`; only `teardown-stages` is skipped so a failed run does not reclaim worktrees. Reports each step and prints the ordered `--only` recovery tail from the earliest failure. This is the shared path for every lead adapter. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.189.1",
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.1",
3
- "builtAt": "2026-09-04T19:05:25.979Z",
2
+ "package": "0.189.3",
3
+ "builtAt": "2026-09-05T22:53:00.281Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -13,7 +13,7 @@ Compare feasible directions before planning in `candidate-comparison` mode, or v
13
13
 
14
14
  ## Required conduct
15
15
 
16
- In `candidate-comparison` mode, inspect the evidence needed to distinguish candidates, submit no more than three candidates, state the strongest counterevidence for each one, and map every candidate to the stable brief end-state IDs it satisfies, preserves, or leaves unresolved. In `preselected-validation` mode, validate the one preselected direction against that evidence and mapping; the worker must not generate new candidates.
16
+ In `candidate-comparison` mode, inspect the evidence needed to distinguish candidates, submit no more than three candidates, give each one a feasibility verdict (`feasible`, `not-feasible`, or `uncertain`) with a one-sentence rationale, state the strongest counterevidence for each one, and map every candidate to the stable brief end-state IDs it satisfies, preserves, or leaves unresolved. In `preselected-validation` mode, validate the one preselected direction against that evidence and mapping with the same verdict; the worker must not generate new candidates.
17
17
 
18
18
  ## Decision principles
19
19
 
@@ -33,7 +33,7 @@ Reason independently from other workers. Do not collapse overlapping candidates
33
33
 
34
34
  ## Completion criteria
35
35
 
36
- In `candidate-comparison` mode, every submitted candidate has a criterion score, counterevidence, and stable requirement mapping. In `preselected-validation` mode, the one preselected direction has a validation result, counterevidence, and stable requirement mapping. Rejected candidates retain their audit reason and evidence.
36
+ In `candidate-comparison` mode, every submitted candidate has a criterion score, a feasibility verdict with its rationale, counterevidence, and stable requirement mapping. In `preselected-validation` mode, the one preselected direction has a validation result, a feasibility verdict with its rationale, counterevidence, and stable requirement mapping. Rejected candidates retain their audit reason and evidence.
37
37
 
38
38
  ## Forbidden conduct
39
39
 
@@ -24,7 +24,7 @@ Report assembly reads the role-owned inputs, validates them, derives links and s
24
24
 
25
25
  An active clarification exists only in `activeClarifications[]`. A decision carried from a previous run exists only in `carriedDecisions[]`; do not recreate it as an active question.
26
26
 
27
- A carried plan row's `requirementCoverage[].decisionRefs` may name a `C-NNN` this run never asked. Carry it before assembly: `okstra approval-decision carry --ledger <approvalDecisionsPath> --from-responses <instruction-set/clarification-response.md> --clarification-id C-NNN` — repeat `--clarification-id` to take several in one call. That bundle is the source of truth for an earlier run's answer: it is task-level and cumulative, so no prior run seq has to be located, and each response section names the report that posed the question, which is where the row's `statement`, `expectedForm`, and options come from. A carried row lands as `answered`, not `resolved` — it was resolved in another run, and `resolution.checkRefs` names *this* run's activity rows. Carrying an answer also obliges a `supersessionLedger` entry for it.
27
+ Prepare seeds `carriedDecisions[]` when it creates the ledger: every clarification the run's carry-in record answered or resolved, plus every row that record's user-responses sidecars answered, arrives carried (`scripts/okstra_ctl/approval_decisions.py` `seed_carried_decisions`). A carried plan row's `requirementCoverage[].decisionRefs` may still name a `C-NNN` the carry-in record does not answer — a decision from an older run. Carry it before assembly: `okstra approval-decision carry --ledger <approvalDecisionsPath> --from-responses <instruction-set/clarification-response.md> --clarification-id C-NNN` — repeat `--clarification-id` to take several in one call. That bundle is the source of truth for an earlier run's answer: it is task-level and cumulative, so no prior run seq has to be located, and each response section names the report that posed the question, which is where the row's `statement`, `expectedForm`, and options come from. A carried row lands as `answered`, not `resolved` — it was resolved in another run, and `resolution.checkRefs` names *this* run's activity rows. Carrying an answer also obliges a `supersessionLedger` entry for it.
28
28
 
29
29
  Each decision option has `role`, `answer`, `rationale`, `disposition`, `reach`, optional `scopeEffects`, `addedWork`, and `directionChange`. `reach` is exactly one of `in-repo` or `cross-repo`. `scopeEffects` may contain `new-schema` and `deferrable`. A `correctness-critical` option cannot use `select` or `accept-risk`; a `noncritical-dissent` option cannot use `select`.
30
30
 
@@ -8,7 +8,7 @@
8
8
  - `directionChange` — one sentence naming what this choice reverses: an approved plan item, a recorded decision, an earlier answer. Name that item. When it reverses nothing, say so.
9
9
  - `disposition` — the effect of selecting the option. Use `select` for `user-decision`. Use `accept-risk` on any classification, including `correctness-critical`, when the user ends the gate and leaves the DISAGREE on the record. Use `request-revision` or `reject` when the option sends the plan back.
10
10
  - report assembly derives `approvalContext`, status, and resolution. `approvalContext` contains only `classification`, `unblockCondition`, and `recommendedDisposition`; it never copies plan or activity identifiers.
11
- - a `C-NNN` you name outside the row itself must be a row that exists. One place is checked: a `blocked` `endStateCoverage` row's `blockedBy.ref`, when its `kind` is `clarification` — see each phase profile's `blockedBy` rule and `validators/validate-run.py` `_validate_end_state_blocked_by`. Everywhere else — `coveredBy`, `rationale`, `verdictCard.nextStep`, `finalVerdict.nextStep`, `humanSummary.actions[]`, `recommendedNextSteps[].text` — is free prose and stays uncheckable: a shipped report legitimately writes `C-057 through C-068 are applied or carried` or `C-201 does not apply`, and a validator scanning those fields for ids would fail 15 of the 56 reports on disk. There it is on you not to send a reader after an id with no row. An id an earlier run already answered is admitted with `okstra approval-decision carry`, not by citing it bare. `crossVerification` rows are numbered `CV-NNN` so a `C-NNN` has exactly one meaning.
11
+ - a `C-NNN` you name outside the row itself must be a row that exists. One place is checked: a `blocked` `endStateCoverage` row's `blockedBy.ref`, when its `kind` is `clarification` — see each phase profile's `blockedBy` rule and `validators/validate-run.py` `_validate_end_state_blocked_by`. Everywhere else — `coveredBy`, `rationale`, `verdictCard.nextStep`, `finalVerdict.nextStep`, `humanSummary.actions[]`, `recommendedNextSteps[].text` — is free prose and stays uncheckable: a shipped report legitimately writes `C-057 through C-068 are applied or carried` or `C-201 does not apply`, and a validator scanning those fields for ids would fail 15 of the 56 reports on disk. There it is on you not to send a reader after an id with no row. An id an earlier run already answered reaches this report as a carried decision — prepare seeds `carriedDecisions[]` from the run's carry-in record, and `okstra approval-decision carry` admits one that record does not answer — never by citing it bare. `crossVerification` rows are numbered `CV-NNN` so a `C-NNN` has exactly one meaning.
12
12
  - the three impact fields answer three different questions — how far the change reaches, what new work it creates, and what it overturns. Someone choosing between options needs all three, so never fold them into one sentence: whichever axis is easiest to write would silently stand in for the other two.
13
13
  - a row that omits `options[]`, offers fewer than two, or marks zero or two options as `recommended` is incomplete and must be completed before the report is finalised.
14
14
  - `expectedForm` states only the *shape* of the answer — one of the options, a file path, a number, a date. It never lists the choices again; two sources for one fact leave consumers disagreeing about which is authoritative.
@@ -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
 
@@ -34,9 +34,9 @@ roles:
34
34
  - Apply the shared reporter-confirmation precondition exactly as written. Unresolved `intent-check:` and `conversion-block:` rows use `Blocks=next-phase`.
35
35
  - Treat each stable brief end-state ID as a required evaluation target. A missing ID is a preparation failure; do not invent a replacement requirement.
36
36
  - Worker direction-selection procedure:
37
- - In `candidate-comparison` mode, produce candidate, supporting and contradicting evidence, criterion scores, and requirement mappings.
37
+ - In `candidate-comparison` mode, produce candidate, supporting and contradicting evidence, criterion scores, and requirement mappings. For every candidate, state one feasibility verdict — `feasible`, `not-feasible`, or `uncertain` — with a one-sentence rationale and the strongest counterevidence, each citing inspected evidence. The report's `feasibilityVotes` row for this worker is built from that statement, so a candidate without one leaves the writer nothing but another worker's words.
38
38
  - In `candidate-comparison` mode only, submit at most three candidates. A candidate must be feasible from inspected evidence, not from an assumed future change.
39
- - In `preselected-validation` mode, receive one preselected direction from the lead and validate its evidence, counterevidence, criterion scores, and requirement mappings. The worker must not generate new candidates.
39
+ - In `preselected-validation` mode, receive one preselected direction from the lead and validate its evidence, counterevidence, criterion scores, and requirement mappings, and state the same feasibility verdict with its rationale and counterevidence. The worker must not generate new candidates.
40
40
  - Do not produce detailed file lists, stage maps, execution commands, or a plan approval request.
41
41
  - Pre-selection context exploration:
42
42
  - In `candidate-comparison` mode, inspect the code paths, interfaces, tests, and constraints needed to distinguish candidates before assigning scores.
@@ -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)
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from typing import Any, Mapping, Sequence
13
13
 
14
14
  from .clarification_items.sidecars import (
15
+ sidecar_answers,
15
16
  attached_response_sections,
16
17
  response_source_report,
17
18
  )
@@ -286,6 +287,90 @@ def carry_decision(
286
287
  write_json_atomic(ledger_path, ledger)
287
288
 
288
289
 
290
+ _CARRIED_STATUSES = frozenset({"answered", "resolved"})
291
+
292
+
293
+ def _carried_answer(row: Mapping[str, Any], sidecar_answer: str) -> str:
294
+ """The answer a prior report's row carries, or an empty string.
295
+
296
+ The user-responses sidecar wins over the row: it is the later writing,
297
+ and a row the report left `open` is answered only there. A `resolved`
298
+ row keeps its answer in `resolution.userText`; an `answered` row in
299
+ `userInput`.
300
+ """
301
+ if sidecar_answer.strip():
302
+ return sidecar_answer.strip()
303
+ user_input = row.get("userInput")
304
+ if isinstance(user_input, str) and user_input.strip():
305
+ return user_input.strip()
306
+ resolution = row.get("resolution")
307
+ if isinstance(resolution, Mapping):
308
+ text = resolution.get("userText")
309
+ if isinstance(text, str) and text.strip():
310
+ return text.strip()
311
+ return ""
312
+
313
+
314
+ def seed_carried_decisions(
315
+ ledger_path: Path, source: Path, *, source_run_ref: str,
316
+ ) -> list[str]:
317
+ """Carry every clarification the carry-in report already settled.
318
+
319
+ Prepare calls this when it creates a run's ledger and the run was
320
+ launched with a carry-in. The lead used to carry each id by hand and
321
+ mostly did not (2026-09-04, dev-10626: the error-analysis ledgers carried
322
+ six rows, the option-selection and planning ledgers none), so the report
323
+ cited `C-005` in its prose and had no row for it. Carrying is a copy with
324
+ no judgement in it — the prior record already holds the question, its
325
+ options, and the answer — which is why it lives here rather than in the
326
+ lead's procedure.
327
+
328
+ Only a structured report record (or the Markdown sibling of one) seeds:
329
+ an answers-only file has no question rows to copy, and there
330
+ ``okstra approval-decision carry --from-responses`` remains the path.
331
+ A row is carried when it is `answered` or `resolved`, or when the
332
+ record's user-responses sidecars answer it; an `open` row nobody
333
+ answered and an `obsolete` row are not decisions. Returns the carried
334
+ ids in record order.
335
+ """
336
+ from .clarification_items.rows import _structured_report_data
337
+
338
+ data = _structured_report_data(source)
339
+ if data is None:
340
+ return []
341
+ rows = data.get("clarificationItems")
342
+ if not isinstance(rows, list):
343
+ return []
344
+ answers = sidecar_answers(source)
345
+ carried: list[str] = []
346
+ for row in rows:
347
+ if not isinstance(row, Mapping):
348
+ continue
349
+ clarification_id = row.get("id")
350
+ if not isinstance(clarification_id, str) or not clarification_id:
351
+ continue
352
+ status = str(row.get("status") or "")
353
+ answer = _carried_answer(row, answers.get(clarification_id, ""))
354
+ if status == "obsolete" or not answer:
355
+ continue
356
+ if status not in _CARRIED_STATUSES and clarification_id not in answers:
357
+ continue
358
+ decision = {key: row[key] for key in _QUESTION_FIELDS if key in row}
359
+ if isinstance(decision.get("options"), list):
360
+ decision["options"] = [
361
+ _contract_v3_option(option, clarification_id)
362
+ for option in decision["options"]
363
+ if isinstance(option, Mapping)
364
+ ]
365
+ if "approvalContext" in row:
366
+ decision["approval"] = row["approvalContext"]
367
+ decision["userConfirmation"] = "asked-and-answered"
368
+ decision["userInput"] = answer
369
+ carry_decision(ledger_path, source_run_ref=source_run_ref, decision=decision)
370
+ carried.append(clarification_id)
371
+ return carried
372
+
373
+
289
374
  def _options_from_args(args: argparse.Namespace) -> tuple[DecisionOption, ...]:
290
375
  fields = (
291
376
  args.option_role, args.option_answer, args.option_rationale,
@@ -244,14 +244,49 @@ def _validate_option_feasibility(
244
244
  errors.append(f"{option_id} safetyBlockers must be empty")
245
245
  if require_valid and option.get("unresolvedFeasibilityFacts"):
246
246
  errors.append(f"{option_id} unresolvedFeasibilityFacts must be empty")
247
+ copied = _copied_votes(votes)
248
+ for first, second in copied:
249
+ errors.append(
250
+ f"{option_id} feasibilityVotes for {first} and {second} are identical; "
251
+ "each vote carries that analyser's own rationale and counterevidence"
252
+ )
247
253
  return (
248
254
  all_participated
249
255
  and feasible >= MIN_FEASIBLE_VOTES
250
256
  and not option.get("safetyBlockers")
251
257
  and not option.get("unresolvedFeasibilityFacts")
258
+ and not copied
252
259
  )
253
260
 
254
261
 
262
+ def _copied_votes(votes: Sequence[object]) -> list[tuple[str, str]]:
263
+ """Pairs of analysers whose votes share one rationale and counterevidence.
264
+
265
+ A vote is that analyser's own finding. The writer synthesizes the row from
266
+ each result, and a run (2026-09-04, dev-10626) shipped three votes whose
267
+ sentences matched to the letter — one worker's text copied under the other
268
+ two names — so the votes said nothing a single vote did not. `uncertain`
269
+ votes are exempt: two analysers that never evaluated a candidate say so in
270
+ the same words legitimately.
271
+ """
272
+ seen: dict[tuple[str, str], str] = {}
273
+ copied: list[tuple[str, str]] = []
274
+ for vote in votes:
275
+ if not isinstance(vote, Mapping) or vote.get("verdict") == "uncertain":
276
+ continue
277
+ key = (
278
+ str(vote.get("rationale") or "").strip(),
279
+ str(vote.get("counterevidence") or "").strip(),
280
+ )
281
+ if not any(key):
282
+ continue
283
+ worker = str(vote.get("worker") or "?")
284
+ first = seen.setdefault(key, worker)
285
+ if first != worker:
286
+ copied.append((first, worker))
287
+ return copied
288
+
289
+
255
290
  def _validate_candidate(
256
291
  option: Mapping[str, object],
257
292
  original_ids: Sequence[str],
@@ -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)
@@ -1962,6 +1962,19 @@ def _initialize_report_ledgers(ctx: Mapping[str, Any], manifest: Mapping[str, An
1962
1962
  "activeClarifications": [],
1963
1963
  "carriedDecisions": [],
1964
1964
  })
1965
+ # 이월 결정은 여기서 심는다. 리드가 손으로 carry 하던 동안 대부분의
1966
+ # run 이 빈 채로 갔고, 리포트는 행 없는 C-NNN 을 인용했다.
1967
+ response_value = str(ctx.get("CLARIFICATION_RESPONSE_PATH") or "")
1968
+ if response_value:
1969
+ from .approval_decisions import seed_carried_decisions
1970
+
1971
+ seed_carried_decisions(
1972
+ approval_path,
1973
+ Path(response_value),
1974
+ source_run_ref=str(
1975
+ ctx.get("CLARIFICATION_RESPONSE_RELATIVE_PATH") or response_value
1976
+ ),
1977
+ )
1965
1978
  activity_value = str(ctx.get("LEAD_EVENTS_PATH") or "")
1966
1979
  activity_path = Path(activity_value)
1967
1980
  if activity_value and not activity_path.is_file():
@@ -199,6 +199,49 @@ def _anchorable(row_id: str) -> bool:
199
199
  return bool(row_id) and " " not in row_id and "/" not in row_id
200
200
 
201
201
 
202
+ def _count_ids(value: object, counts: dict[str, int]) -> None:
203
+ if isinstance(value, dict):
204
+ for key in ("id", "activityId", "clarificationId"):
205
+ row_id = value.get(key)
206
+ if isinstance(row_id, str) and row_id:
207
+ counts[row_id] = counts.get(row_id, 0) + 1
208
+ for nested in value.values():
209
+ _count_ids(nested, counts)
210
+ elif isinstance(value, list):
211
+ for nested in value:
212
+ _count_ids(nested, counts)
213
+
214
+
215
+ def _shared_task_block_ids(data: dict, omitted_fields: tuple[str, ...]) -> set[str]:
216
+ """Ids several rows of the task block carry, so no single row owns them.
217
+
218
+ A direction's scope commitments are numbered `IC-001` … inside each
219
+ direction, and its planning invariants `PI-001` … likewise; the same id
220
+ sits in every ranked option and every audited candidate. A link to
221
+ `#id-IC-001` would land on whichever card came first, so the id stays
222
+ plain text. A clarification id the block repeats (a requirements report
223
+ lists `C-001` under two unresolved requirements) is not affected: the
224
+ clarification article is its one home and keeps the anchor.
225
+ """
226
+ from ..report_contract import TASK_TYPE_DATA_PROPERTY
227
+
228
+ property_name = TASK_TYPE_DATA_PROPERTY.get(
229
+ (data.get("header") or {}).get("taskType", "")
230
+ )
231
+ block = data.get(property_name) if property_name else None
232
+ if not isinstance(block, dict):
233
+ return set()
234
+ counts: dict[str, int] = {}
235
+ _count_ids(
236
+ {key: value for key, value in block.items() if key not in omitted_fields},
237
+ counts,
238
+ )
239
+ owned_elsewhere: set[str] = set()
240
+ _collect_ids(data.get("clarificationItems", []), owned_elsewhere)
241
+ _collect_ids(data.get("agentActivity", []), owned_elsewhere)
242
+ return {row_id for row_id, n in counts.items() if n > 1} - owned_elsewhere
243
+
244
+
202
245
  def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str, str]:
203
246
  """Map every row a reader can reach to the anchor name that lands on it.
204
247
 
@@ -211,15 +254,28 @@ def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str,
211
254
  It stops there. `summary` is the AI-facing digest and
212
255
  `analysisCommon.scope` describes the analysis target rather than listing
213
256
  rows; neither renders, so a link to one would land nowhere.
257
+
258
+ Cross-check rows are anchored `id-xv-<id>` by the base template — the
259
+ prefix keeps a legacy consensus row still numbered `C-NNN` from sharing
260
+ an element id with the clarification of that number — so they carry that
261
+ name here, and a legacy row whose id a clarification already owns keeps
262
+ pointing at the clarification.
214
263
  """
215
- found = _own_section_ids(data, omitted_fields) | set(
216
- evidence_index(data, omitted_fields)
217
- )
218
- return {
264
+ found = (
265
+ _own_section_ids(data, omitted_fields)
266
+ - _shared_task_block_ids(data, omitted_fields)
267
+ ) | set(evidence_index(data, omitted_fields))
268
+ index = {
219
269
  row_id: f"id-{row_id}"
220
270
  for row_id in sorted(found)
221
271
  if isinstance(row_id, str) and _anchorable(row_id)
222
272
  }
273
+ for block in ("consensus", "differences"):
274
+ for row in _dig(data, ("crossVerification", block)):
275
+ row_id = row.get("id") if isinstance(row, dict) else None
276
+ if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
277
+ index[row_id] = f"id-xv-{row_id}"
278
+ return index
223
279
 
224
280
 
225
281
  def analysis_review_ids(data: dict) -> tuple[str, ...]:
@@ -0,0 +1,137 @@
1
+ """Rows and links for the ids a report cites but does not define.
2
+
3
+ The human HTML renders from ``data.json``, and every id the record defines
4
+ gets an anchor there. Two id families are cited on almost every page and
5
+ defined on none of them:
6
+
7
+ * the brief's end-state ids (``EB-001``, ``PB-001``, ``EO-001``) — the
8
+ denominator of every requirement-coverage table. The sentence behind each
9
+ id exists only in the task brief; no phase repeats it.
10
+ * the clarification ids a previous run settled (``C-005``) — the record points
11
+ at that run through ``clarificationCarryIn.sourceFile`` and cites the ids
12
+ in its prose, but only the lead's ``carriedDecisions`` put a row for them
13
+ in this record, and a run that carried none leaves the ids as dead text.
14
+
15
+ Both homes are okstra-owned files inside the same task directory, so the
16
+ renderer follows the two pointers the run pinned — ``taskBriefPath`` is
17
+ always ``<task>/instruction-set/task-brief.md`` (``path_hints.py``), and the
18
+ carry-in record is named by the report itself — and only to give a cited id
19
+ a place to land. Neither read is a precondition: a missing or unreadable file
20
+ yields no rows and no links, never a render failure.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ from collections.abc import Container
27
+ from pathlib import Path
28
+
29
+ from ..json_boundary import JsonBoundaryError, load_owned_object
30
+ from ..report_view_artifacts import html_view_path
31
+ from ..scope_provenance import brief_end_state_rows
32
+
33
+ _USER_RESPONSE_RE = re.compile(r"^user-response-(?P<task_type>.+)-(?P<seq>\d{3,})\.md$")
34
+
35
+
36
+ def _task_dir(data_path: Path) -> Path | None:
37
+ """The task directory a report lives under, or None outside the layout.
38
+
39
+ A report sits at ``<task>/runs/<type>/reports/`` — or one level deeper
40
+ for an implementation stage, ``runs/implementation/stage-<N>/reports/`` —
41
+ so the nearest ``runs`` ancestor names the task directory either way.
42
+ """
43
+ for parent in data_path.resolve().parents:
44
+ if parent.name == "runs":
45
+ return parent.parent
46
+ return None
47
+
48
+
49
+ def _project_root(data_path: Path) -> Path | None:
50
+ """The project root, or None when the report is not under ``.okstra/``.
51
+
52
+ Every okstra-owned artifact lives under ``<PROJECT_ROOT>/.okstra/``, and a
53
+ carry-in ``sourceFile`` is recorded relative to that root.
54
+ """
55
+ for parent in data_path.resolve().parents:
56
+ if parent.name == ".okstra":
57
+ return parent.parent
58
+ return None
59
+
60
+
61
+ def brief_end_states(data_path: Path) -> list[dict[str, str]]:
62
+ """The brief's end-state rows, in brief order, as template-ready dicts."""
63
+ task_dir = _task_dir(data_path)
64
+ if task_dir is None:
65
+ return []
66
+ brief = task_dir / "instruction-set" / "task-brief.md"
67
+ return [
68
+ {"id": row.id, "section": row.section, "statement": row.statement}
69
+ for row in brief_end_state_rows(brief)
70
+ ]
71
+
72
+
73
+ def _carry_in_record(source: Path) -> Path | None:
74
+ """The report record a carry-in pointer resolves to.
75
+
76
+ The pointer names either the prior run's record itself or the
77
+ user-responses sidecar exported from that run's page; the sidecar sits in
78
+ ``runs/<type>/user-responses/`` beside the run's ``reports/`` directory
79
+ and carries the run's task type and seq in its name.
80
+ """
81
+ if source.name.endswith(".data.json"):
82
+ return source
83
+ match = _USER_RESPONSE_RE.match(source.name)
84
+ if match is None or source.parent.name != "user-responses":
85
+ return None
86
+ task_type, seq = match.group("task_type"), match.group("seq")
87
+ return source.parent.parent / "reports" / f"final-report-{task_type}-{seq}.data.json"
88
+
89
+
90
+ def _clarification_ids(record: Path) -> list[str]:
91
+ """The clarification ids the carry-in record defines, or none.
92
+
93
+ The record is read through the owned-JSON boundary like every report
94
+ record; a record that fails it is a record this page cannot link into,
95
+ not a reason to refuse this page.
96
+ """
97
+ try:
98
+ payload = load_owned_object(record, artifact="carry-in report record")
99
+ except JsonBoundaryError:
100
+ return []
101
+ rows = payload.get("clarificationItems")
102
+ if not isinstance(rows, list):
103
+ return []
104
+ return [
105
+ row["id"]
106
+ for row in rows
107
+ if isinstance(row, dict) and isinstance(row.get("id"), str) and row["id"]
108
+ ]
109
+
110
+
111
+ def carry_in_links(
112
+ data: dict, data_path: Path, *, exclude: Container[str] = ()
113
+ ) -> dict[str, str]:
114
+ """Map each clarification id the carry-in record defines to its anchor
115
+ on that record's HTML page, as an href relative to this report's page.
116
+
117
+ ``exclude`` names the ids this document already anchors — a row the lead
118
+ did carry keeps its in-page link, and the prior run's page is only for
119
+ the ids this page has no row for.
120
+ """
121
+ carry_in = data.get("clarificationCarryIn")
122
+ source_value = carry_in.get("sourceFile") if isinstance(carry_in, dict) else None
123
+ if not isinstance(source_value, str) or not source_value.strip():
124
+ return {}
125
+ root = _project_root(data_path)
126
+ if root is None:
127
+ return {}
128
+ record = _carry_in_record(root / source_value.strip())
129
+ if record is None or not record.is_file():
130
+ return {}
131
+ page = html_view_path(record)
132
+ href = Path(os.path.relpath(page, data_path.resolve().parent)).as_posix()
133
+ return {
134
+ cid: f"{href}#id-{cid}"
135
+ for cid in _clarification_ids(record)
136
+ if cid not in exclude
137
+ }