okstra 0.201.2 → 0.201.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/okstra CHANGED
@@ -50,4 +50,4 @@ async function main(argv) {
50
50
  }
51
51
  }
52
52
 
53
- main(process.argv).then((code) => process.exit(code));
53
+ process.exitCode = await main(process.argv);
@@ -29,7 +29,7 @@ Current baseline:
29
29
  - Python orchestration authority: `scripts/okstra_ctl/run.py::prepare_task_bundle`
30
30
  - lifecycle: `requirements-discovery → error-analysis → implementation-option-selection → implementation-planning → implementation → final-verification → release-handoff`
31
31
  - installed skills: 13
32
- - provider workers: `claude`, `codex`, `antigravity`, `grok`, `kimi`; functional report writer: `report-writer`
32
+ - provider workers: `claude`, `codex`, `antigravity`, `grok`, `kimi`, `zai` (GLM via Claude Code); functional report writer: `report-writer`
33
33
  - final report SSOT: `schemas/final-report-v2.0.schema.json` + `*.data.json`
34
34
 
35
35
  Design principles:
@@ -224,7 +224,7 @@ Top-level scripts:
224
224
  | File | Role |
225
225
  |---|---|
226
226
  | `okstra.sh` | Bash CLI wrapper around `prepare_task_bundle`, optionally launches `claude` |
227
- | `okstra-{claude,codex,antigravity,grok,kimi}-exec.sh` | Worker CLI entrypoints — four lines each, `exec`ing `okstra-provider-exec.py` with the provider id. They hold no provider logic; adding a flag to one of these instead of to the provider adapter is exactly the drift this shape exists to prevent |
227
+ | `okstra-{claude,codex,antigravity,grok,kimi,zai}-exec.sh` | Worker CLI entrypoints — four lines each, `exec`ing `okstra-provider-exec.py` with the provider id (`zai` selects the Z.ai GLM provider, which reuses the installed `claude` executable with process-local connection settings). They hold no provider logic; adding a flag to one of these instead of to the provider adapter is exactly the drift this shape exists to prevent |
228
228
  | `okstra-provider-exec.py` | The one worker entrypoint: parses the shared positional contract plus `--presentation`, resolves the provider's `ExecutionStrategy` from the registry, refuses a missing CLI before any artifact is written, then hands the run to `okstra_ctl.worker_runner` |
229
229
  | `okstra-wrapper-status.py` | Standalone writer for one worker status sidecar. No longer on the dispatch path — `worker_runner.py` writes the same document in-process |
230
230
  | `okstra-token-usage.py` | Token usage CLI entrypoint |
@@ -245,6 +245,8 @@ Important modules:
245
245
  | `exact_coverage.py` | Shared pure calculator for requirement coverage and scope precision in option selection and selected-direction planning |
246
246
  | `implementation_options.py` | Option-selection criteria, weighting, candidate fingerprint convergence, ranking, and semantic validation |
247
247
  | `implementation_direction.py` | Selected report/response validation, direction snapshot materialization, and selected-direction reference validation |
248
+ | `technical_verification.py` | Optional `technical-verification` phase backend — resolves and freezes the explicitly classified unresolved facts from the same task's implementation-option-selection report into `state/technical-verification-input-<seq>.json` (`write_technical_verification_input` / `resolve_technical_verification_input`, driven from `run.py`). No selected direction is required and unresolved user decisions still block entry; it links test inputs to observed results and never produces adoption approval or changes candidate feasibility |
249
+ | `verification_target.py` | Shared reader for the prepared final-verification target snapshot (`verification-target.md`) written by `run.write_verification_target_snapshot`. One implementation of the digest/scope rule serves both consumers — report assembly (records `verificationScope`) and `validators/validate-run.py` (re-checks the published report against the target) — so the two cannot drift |
248
250
  | `implementation_stage.py` | `implementation` single-stage run orchestration — read the Stage Lifecycle Snapshot → pick an available Stage Map entry → provision an isolated stage worktree → publish the selected stage as run context (extracted from `run.py`) |
249
251
  | `stage_targets.py` | Stage readiness/verification policy SSOT — from the Stage Lifecycle Snapshot (`consumers.jsonl` ledger + carry sidecar backfill + active registry reservation) it decides which stage is runnable, which commit it branches from, and what final-verification checks. `acquire_final_verification_target()` acquires the ledger, registry, worktree, Git, and optional whole-task integration facts behind one task-key mutex and returns a typed target without render-context coupling. `order_stage_closure` topologically sorts (Kahn) the dependency closure of the wizard's multi-selected stage set to produce the unattended `chain-stages` chaining order |
250
252
  | `stage_fix_carry.py` | fix-run carry derivation for a re-run on an `implementation` stage whose latest final-report data.json carries verifier `FAIL` verdicts — collects the previous report path, previous run HEAD, failed verifiers, carried blocking findings, and a routing recommendation, which `run.py` renders into the analysis profile through the `{{FIX_RUN_CONTEXT}}` token. A first run, or a re-run after `PASS`, yields no carry and renders the token empty |
@@ -300,7 +302,8 @@ Important modules:
300
302
  | `wizard/confirmation.py` | `confirmation_block` and the confirm step |
301
303
  | `wizard/registry.py` | the ordered `STEPS` literal (order is question order), `STEP_BY_ID`, `_reset_from`, and the steps that rewind the registry (edit target, brief carry) |
302
304
  | `wizard/engine.py` | public API — `init_state`, `next_prompt`, `submit`, progress / simulation, host interaction payloads |
303
- | `wizard/render.py` | `render_args`, `render_role_args`, `wizard_outcome` |
305
+ | `wizard/render.py` | `render_args`, `render_role_args`, and the stage-intent helpers |
306
+ | `wizard/outcome.py` | `wizard_outcome` — assembles the run argv, confirmation screen, and persistence actions from an approved `WizardState` (extracted from `render.py`) |
304
307
  | `wizard/cli.py`, `wizard/__main__.py` | `okstra wizard` argparse entrypoint (`main`); `python3 -m okstra_ctl.wizard` |
305
308
  | `wizard_stage_intent.py` | stage-related intent projection of the `okstra-run` wizard output — normalizes whole-task (`__whole_task__`) vs single/multi stage selection into render-args (`resolve_wizard_stage_intent`) |
306
309
  | `index.py`, `jsonl.py`, `reconcile.py`, `listing.py`, `backfill.py` | `~/.okstra` run index and history operations — `record_start` (index.py) writes a run's start; the end is closed either by `settle_run_row` (reconcile.py, records a verdict the caller already knows — used by `validate-run.py`) or by `reconcile_home` (infers one from disk — used by `run._reconcile_prior_runs` as the backstop for runs that died before validation) |
@@ -392,7 +395,7 @@ Project resolver and read-only state helpers:
392
395
 
393
396
  Token/cost accounting:
394
397
 
395
- - provider adapters: `claude.py`, `codex.py`, `antigravity.py`, `grok.py` (`grok.py` reads the Grok Build session docs under `~/.grok/sessions/<percent-encoded-cwd>/`, taking cumulative tokens from the last `params.update.usage.modelUsage` snapshot in `updates.jsonl`)
398
+ - provider adapters: `claude.py`, `codex.py`, `antigravity.py`, `grok.py`, `kimi.py`, `zai/adapter.py` (`grok.py` reads the Grok Build session docs under `~/.grok/sessions/<percent-encoded-cwd>/`, taking cumulative tokens from the last `params.update.usage.modelUsage` snapshot in `updates.jsonl`; `zai/adapter.py` wraps the Claude Code executor for GLM workers — reads `ZAI_API_KEY` from the process env or the current user's `~/.env`, runs each child against `https://api.z.ai/api/anthropic` with `--setting-sources ""`, attests the served model only from response `message.model`, and prices usage at Z.ai's public API rates for `glm-5.3` / `glm-5.3-flash`)
396
399
  - aggregation: `collect.py`, `blocks.py`, `jsonl_io.py`, `paths.py`
397
400
  - incremental scan cache: `cursor.py` (`$OKSTRA_HOME/cache/token-usage/` byte cursor + usage event extracts; bypass with `--no-cache`)
398
401
  - pricing: `pricing.py`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.201.2",
3
+ "version": "0.201.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.201.2",
3
- "builtAt": "2026-09-13T23:51:12.747Z",
2
+ "package": "0.201.3",
3
+ "builtAt": "2026-09-14T12:27:48.872Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -343,6 +343,8 @@ For `improvement-discovery`, Lead records `## Primary Pass Assignments` in the P
343
343
 
344
344
  ### Phase 4 / Phase 5 — Dispatch, await, and error-log recording
345
345
 
346
+ Before redispatch, inspect the invocation's last attempt in the run manifest. A running attempt is awaited; only `failed-no-mutation` can append another attempt within its retry budget. Other terminal states retain their original result and audit. For a legacy implementer rejected solely by `declared out-of-plan path did not change`, `okstra team dispatch` prepares a separate evidence-recovery invocation with the same assignment and model and distinct prompt/result paths. Collect that recovery result, then continue the selected independent verifier. Do not replay completed implementation or ask the user to reauthorize unchanged scope. For other terminal failures, use `okstra agent-prompt materialize` with a new `--invocation-id` and distinct `--prompt`/`--result` paths for the bounded verified defect or missing evidence. These retry restrictions are enforced by `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt`.
347
+
346
348
  For each selected worker assignment, persist the exact prompt history, emit the per-worker Phase 4 checkpoint, and call `dispatch_worker(assignment, promptPath)` through the selected adapter — the dispatch hands the worker the persisted prompt's path, never a re-inlined copy of its body. Then call `await_workers(handles)`. A dispatch acknowledgement or process/pane creation is never completion: verify the terminal status, Result Path, and worker-results audit path required by `team-contract` before emitting the Phase 5 collection checkpoint.
347
349
 
348
350
  Retries and convergence re-verification always call `redispatch_worker` to create a fresh one-shot session. Never reuse a worker conversation or switch adapters/providers to hide a failed assignment.
@@ -357,7 +359,7 @@ The launch prompt's `## Run Logs (error-log wiring)` section gives Lead the reso
357
359
 
358
360
  Workers are contractually required to extract this line and abort with `<WORKER>_ERRORS_PATH_MISSING` if it is absent (see each worker definition's "Path extraction (BLOCKING)" block). A worker records its tool failure through the typed `okstra error-log append-observed` form in that contract; it does not write an intermediate JSON file.
359
361
 
360
- After each worker terminates, BEFORE classifying its terminal status, verify the canonical result file exists at the absolute path resolved from the `**Result Path:**` header. If it is absent — or the deterministic provider process returned `CODEX_RESULT_MISSING` / `ANTIGRAVITY_RESULT_MISSING` re-dispatch the SAME worker once with the byte-identical prompt. Only after the second attempt also misses may the role be classified `error` with `--message "result-missing after 1 retry"`. Full rules: [team-contract](./team-contract.md) "Lead Redispatch Policy on Result-Missing".
362
+ After each worker terminates, verify the canonical result file at `**Result Path:**` and inspect the recorded mutation status. A missing result permits the same worker/prompt retry only for `failed-no-mutation` within its retry budget. A terminal state with changes or unresolved attribution requires a separate bounded corrective invocation, preserving existing work and evidence. Full rules: [team-contract](./team-contract.md) "Lead Redispatch Policy on Result-Missing".
361
363
 
362
364
  `--agent`, `--agent-role`, and `--error-type` are **closed enums**, not free-form labels — the role names used elsewhere in these contracts (`Codex worker`, `Claude worker`) are rejected. Use exactly:
363
365
 
@@ -568,3 +570,13 @@ The run-level error log lives at `<runDir>/logs/errors-<task-type>-<seq>.jsonl`.
568
570
  | Re-sending a finding absent from the persisted round plan | Dispatch exactly the engine-returned `findingIds`; see [convergence](./convergence.md) "Re-verification Dispatch" |
569
571
  | Aggregating a `timeout`/`error` reverify dispatch as `DISAGREE` | Put the terminal outcome in round results; `apply-round` records `verification-error`. See [convergence](./convergence.md) "Worker failure handling in reverify" |
570
572
  | Bypassing `report-finalize` and running its Phase 7 steps manually | Run `okstra report-finalize ...`; it owns token substitution and the remaining persistence order. |
573
+
574
+ ## Recoverable completion discrepancies
575
+
576
+ Treat `plannedPaths` as an expected change inventory, not an exhaustive authorization list. `ExecutionMutationAudit.compare` enforces write authority and emits plan discrepancies as warnings; `dispatch_state.missing_completion_paths` checks required result availability and usability. Collect usable results with warnings and pass the observed changes and unresolved evidence to independent verification before declaring task completion.
577
+
578
+ Preserve original results and attempt identities when correcting declaration formatting, missing rationale, or required artifacts. Continue the smallest affected repair within the approved objectives. Do not restart implementation or request renewed permission solely for a plan-path mismatch. If a required artifact is unusable, repair that artifact and keep only its dependent steps waiting. If the same repair produces no new evidence, report the unresolved requirement and resume point instead of repeating the whole implementation.
579
+
580
+ Ask for user judgment only when the proposed work changes approved objectives or acceptance conditions, crosses an explicit authority boundary, or adds an unapproved external action. A warning does not waive read-only roles, protected paths, independent verification, or final acceptance checks. Never convert an old closed failed attempt into success by overwriting its audit; use a recorded recovery with independent evidence.
581
+
582
+ For a closed attempt whose only failure was a declaration discrepancy, preserve that attempt and its original result. Use the existing `agent-prompt materialize` and dispatch workflow to create a bounded evidence-recovery invocation in the same run, with a distinct result path and explicit references to the original invocation, result, and audit. Ask it to reconcile the declaration and required artifacts against current evidence, not repeat completed implementation. Then dispatch the selected independent verifiers against the current source and artifacts. Adopt the new verified result through normal result collection; retain the original failure as history. Do not use this route to bypass a real authority violation or unresolved code defect.
@@ -167,6 +167,8 @@ Branch on the exit code, not the JSON: without `--wait`, `0` = every probe healt
167
167
 
168
168
  ## Lead Redispatch Policy on Result-Missing
169
169
 
170
+ For V2 calls, check the recorded attempt before applying any retry below. Only `failed-no-mutation` may append an attempt within its budget. Await an unfinished attempt. Preserve other terminal attempts and use a separate bounded corrective invocation with the same assignment/model and distinct prompt/result paths; do not replay implementation that already changed source or artifacts. `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt` enforce this restriction. A legacy declaration-only implementer rejection is routed by `okstra team dispatch` to evidence recovery before independent verification.
171
+
170
172
  After each worker attempt returns (regardless of role), Lead MUST verify the canonical result file exists at the absolute path resolved from the `**Result Path:**` anchor header (against `**Project Root:**`). The check is identical for host-native workers and deterministic CLI processes.
171
173
 
172
174
  **Triggers (any of):**
@@ -92,8 +92,8 @@ template's check; that template is gone.
92
92
 
93
93
  ## Allowed actions during the run
94
94
 
95
- - **Edit / Write on approved project source files**: scope is bounded first by the shared Resource boundary, then by the approved plan's file list. Editing files outside the plan's list is permitted only when strictly needed to satisfy a step, and MUST be recorded in your worker result's `Out-of-plan edits` block with rationale.
96
- - **The block's shape is fixed, because a machine reads it (BLOCKING).** Write a `## Out-of-plan edits` heading, then one `- ` line per file whose first backticked token is the repository-relative path, followed by the rationale: ``- `src/http/router.ts` the plan's step 3 needs a route the plan did not list``. Nothing else on that line is read. With no such edits, write the heading and the single line `- (none)`. The write audit compares the stage's changed files against the plan's paths union this block: a file edited outside the plan and absent here settles the run as a contract violation, and a path this parser cannot find is the same as one you never declared.
95
+ - **Edit / Write on approved project source files**: scope is bounded by the shared Resource boundary and the approved objectives and acceptance conditions; the plan's file list predicts expected changes. Editing files outside the plan's list is permitted only when strictly needed to satisfy a step, and MUST be recorded in your worker result's `Out-of-plan edits` block with rationale.
96
+ - Use `## Out-of-plan edits` with a backticked path and rationale per item when convenient. The dispatcher reads this shape as supporting evidence, not write authorization. `ExecutionMutationAudit.compare` records observed changes independently and treats missing declarations or declaration/diff mismatches as warnings. Do not rerun implementation merely to repair this block; reconcile the explanation while preserving the original result. Required result usability is checked by `dispatch_state.missing_completion_paths`; source-readonly, protected paths, assigned roots and Git authority remain enforced by the write audit.
97
97
  - read-only inspection commands: `git status`, `git diff`, `git log`, `grep`, `rg`, `find`, `cat`, `ls`, file Read tools
98
98
  - build, lint, type-check, and test commands (`npm test`, `pytest`, `go build`, `cargo test`, `bash -n`, etc.)
99
99
  - **local git operations only**: `git add`, `git commit`. Prefer small commits keyed to plan steps.
@@ -256,3 +256,9 @@ If every verifier present in the resolved roster ends with a non-result terminal
256
256
  ## Executor completion self-check (not this role's gate)
257
257
 
258
258
  - The executor's `Implementation self-check` gate (`prompts/profiles/_implementation-self-check.md`) belongs to the worker that owns the diff, and its body is deliberately not delivered here: it asks for in-place fixes and break-then-restore mutation checks, every one of which this verifier is forbidden to perform. Do not re-derive its items or claim to have run it. What grades the same defects from this side is the blocking taxonomy above, applied to the diff you re-read yourself. When the executor's `Coverage:` / `Self-check coverage:` lines are among the inputs this prompt enumerates, a missing line or one whose file list does not reconcile with the diff is a blocking finding — the gate was skipped or partially run.
259
+
260
+ ## Plan differences and completion recovery
261
+
262
+ Review the observed source and artifact changes, including paths absent from the planned file list or the executor's declaration. Link necessary changes to the approved objectives and acceptance conditions. A file-list discrepancy, declaration spelling, or a restored file is not an implementation failure by itself. Record missing rationale as evidence to reconcile. Reject unrelated scope expansion or weakened acceptance checks on their substantive impact, with the affected requirement and evidence.
263
+
264
+ When the audit reports unavailable historical artifact coverage, verify the current artifact content and its acceptance checks independently. Do not reconstruct missing before-state from the current file or claim that a source-only diff proves an artifact did not change. Preserve the original result and qualify any unverified attribution. Required independent validation remains enforced by `validate-run.py::_validate_verifier_reran_independently` and the existing conformance gates.
@@ -0,0 +1,78 @@
1
+ """종결된 호출의 결과를 보존하는 증거 보완 프롬프트 발행."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from .invocation import (
7
+ AgentInstruction, AgentInstructionSource, AgentInvocationError,
8
+ AgentInvocationRequest, PreparedAgentInvocation, _extract_anchor_lines,
9
+ _split_prompt, agent_model_assignment_from_payload, invocation_metadata_identity,
10
+ prepare_agent_invocation, verify_agent_invocation,
11
+ )
12
+ from ..json_boundary import load_owned_object
13
+ from ..worker_artifact_paths import audit_sidecar_rel
14
+
15
+
16
+ def prepare_evidence_recovery(
17
+ metadata_path: Path, *, project_root: Path, result_path: Path,
18
+ source_attempt: int,
19
+ ) -> tuple[PreparedAgentInvocation, Path]:
20
+ """원래 모델·역할을 유지하고 새 결과 경로에 제한된 증거 보완을 발행한다."""
21
+ metadata = load_owned_object(metadata_path, artifact="recovery source metadata")
22
+ source = metadata["contractSource"]
23
+ manifest = project_root / source["runManifestPath"]
24
+ errors = verify_agent_invocation(
25
+ metadata_path, project_root=project_root, expected_run_manifest_path=manifest,
26
+ )
27
+ if errors:
28
+ raise AgentInvocationError("invalid recovery source: " + "; ".join(errors))
29
+ identity = invocation_metadata_identity(metadata)
30
+ if identity is None or not result_path.is_file():
31
+ raise AgentInvocationError("evidence recovery requires v2 identity and an existing result")
32
+ suffix = f"-evidence-recovery-{source_attempt}"
33
+ invocation_id = identity.invocation_ref + suffix
34
+ old_prompt = project_root / metadata["prompt"]["path"]
35
+ prompt = old_prompt.with_name(old_prompt.stem + suffix + old_prompt.suffix)
36
+ result = result_path.with_name(result_path.name.replace("-worker-", suffix + "-worker-", 1))
37
+ prefix, _ = _split_prompt(old_prompt.read_text(encoding="utf-8"))
38
+ replacements = {
39
+ "**Prompt History Path:**": str(prompt.relative_to(project_root)),
40
+ "Assigned worker prompt history path:": str(prompt),
41
+ "**Result Path:**": str(result.relative_to(project_root)),
42
+ "**Worker Result Path:**": str(result.relative_to(project_root)),
43
+ "**Audit sidecar path:**": audit_sidecar_rel(str(result)),
44
+ }
45
+ anchors = tuple(next((f"{key} {value}" for key, value in replacements.items()
46
+ if line.startswith(key)), line) for line in _extract_anchor_lines(prefix))
47
+ body = _recovery_instructions(identity.invocation_ref, source_attempt, result_path, old_prompt)
48
+ prepared = prepare_agent_invocation(AgentInvocationRequest(
49
+ invocation_id=invocation_id, worker_id=None, audience=metadata["audience"],
50
+ assignment_ref=metadata["assignmentRef"], purpose=None,
51
+ assignment=agent_model_assignment_from_payload(metadata["modelAssignment"]),
52
+ instruction=AgentInstruction(anchor_lines=anchors, body=body, source_paths=(
53
+ AgentInstructionSource("project", str(result_path.relative_to(project_root))),)),
54
+ project_root=project_root, run_manifest_path=manifest,
55
+ duty_root=project_root / source["dutyRootPath"], prompt_path=prompt,
56
+ metadata_path=prompt.with_name(prompt.name + ".meta.json"),
57
+ dispatch_kind=metadata["dispatchKind"], participant_ref=identity.participant_ref,
58
+ role_execution_ref=identity.role_execution_ref, duty_id=identity.duty_id,
59
+ invocation_ref=invocation_id, attempt=1,
60
+ ))
61
+ return prepared, result
62
+
63
+
64
+ def _recovery_instructions(invocation_ref: str, source_attempt: int, result_path: Path, old_prompt: Path) -> str:
65
+ """완료된 구현을 재실행하지 않는 보완 범위를 전달한다."""
66
+ return (
67
+ "## Evidence recovery scope\n\n"
68
+ f"Original invocation: {invocation_ref}; attempt: {source_attempt}.\n"
69
+ f"Read and preserve the original result: {result_path}.\n"
70
+ f"Read the original audit: {old_prompt}.mutation-audit.json.\n"
71
+ "The prior attempt failed only on a declaration discrepancy. Preserve existing source and QA work. "
72
+ "Do not repeat completed implementation steps or edit production source. Reconcile the declaration "
73
+ "and required evidence against current artifacts; retain historical validation as historical. "
74
+ "Do not invent a missing before-state or claim independent verification. If a substantive defect "
75
+ "or authority problem is found, report it for the lead instead of expanding this repair. "
76
+ "Write a new completion result in the original role's required format, citing the original result "
77
+ "and clearly separating recovered evidence from remaining independent checks.\n"
78
+ )
@@ -13,6 +13,7 @@ from typing import Any, Mapping, Sequence
13
13
 
14
14
  from .dispatch_state import abandon_agent_dispatch_attempt
15
15
  from .agent.invocation import invocation_input_digest
16
+ from .agent.evidence_recovery import prepare_evidence_recovery
16
17
  from .dispatch_state import (
17
18
  CompletedWithoutResultError,
18
19
  BACKEND_CLI_WRAPPER,
@@ -71,7 +72,7 @@ from .domain.worker_runtime import (
71
72
  WorkerSpawnRequest,
72
73
  )
73
74
  from .ports.worker_runtime import WorkerRuntimePort
74
- from .execution_identity import Attempt, Invocation, RoleExecution, model_spec_digest
75
+ from .execution_identity import Attempt, ExecutionManifest, Invocation, RoleExecution
75
76
  from .execution_manifest import (
76
77
  ExecutionManifestError,
77
78
  finish_attempt_mutation,
@@ -153,7 +154,6 @@ _WRAPPER_LOG_TAIL_BYTES = 800
153
154
  class RevalidatedBinding:
154
155
  role_execution_ref: str
155
156
  binding: HostModelBinding
156
- model_spec_digest: str
157
157
 
158
158
 
159
159
  def revalidate_role_execution(
@@ -165,7 +165,6 @@ def revalidate_role_execution(
165
165
  if (
166
166
  role_execution.model_ref is None
167
167
  or role_execution.binding is None
168
- or role_execution.model_spec_digest is None
169
168
  ):
170
169
  raise DispatchError("model binding changed: role execution is unbound")
171
170
  try:
@@ -188,18 +187,16 @@ def revalidate_role_execution(
188
187
  binding = assignment.binding
189
188
  if binding is None:
190
189
  raise AssignmentResolutionError("selected model has no worker binding")
191
- digest = model_spec_digest(pool.resolve(role_execution.model_ref), binding)
192
190
  except (ValueError, RuntimeError) as exc:
193
191
  raise DispatchError(f"model binding changed: {exc}") from exc
194
192
  identity_matches = (
195
193
  assignment.provider_id == role_execution.provider
196
194
  and assignment.model_id == role_execution.model_id
197
195
  and binding == role_execution.binding
198
- and digest == role_execution.model_spec_digest
199
196
  )
200
197
  if not identity_matches:
201
198
  raise DispatchError("model binding changed for selected role execution")
202
- return RevalidatedBinding(role_execution.role_execution_ref, binding, digest)
199
+ return RevalidatedBinding(role_execution.role_execution_ref, binding)
203
200
 
204
201
 
205
202
  def dispatch_revalidated_role_execution(
@@ -407,6 +404,9 @@ def build_dispatch_plan(
407
404
 
408
405
 
409
406
  def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
407
+ plan = _recover_declaration_failures(plan)
408
+ if not plan.jobs:
409
+ return 0
410
410
  plan = _ensure_runtime_chain(plan)
411
411
  _validate_report_writer_isolation(plan.jobs)
412
412
  _validate_implementation_phase_order(plan.jobs)
@@ -508,6 +508,9 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
508
508
  """Start one dependency-free CLI batch before collecting any worker."""
509
509
  if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
510
510
  raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
511
+ plan = _recover_declaration_failures(plan)
512
+ if not plan.jobs:
513
+ return 0
511
514
  plan = _ensure_runtime_chain(plan)
512
515
  _validate_report_writer_isolation(plan.jobs)
513
516
  _validate_implementation_phase_order(plan.jobs)
@@ -1617,6 +1620,74 @@ def _spawn_cli_job_nonblocking(
1617
1620
  )
1618
1621
 
1619
1622
 
1623
+ def _recover_declaration_failures(plan: DispatchPlan) -> DispatchPlan:
1624
+ """선언만으로 거부된 구현은 같은 호출을 재시도하지 않고 증거 보완으로 연결한다."""
1625
+ if not any(job.has_execution_identity for job in plan.jobs):
1626
+ return plan
1627
+ manifest = read_execution_manifest(plan.manifest_path)
1628
+ jobs: list[WorkerJob] = []
1629
+ for job in plan.jobs:
1630
+ source_attempt = _declaration_recovery_attempt(manifest, job)
1631
+ if source_attempt is None:
1632
+ jobs.append(job)
1633
+ continue
1634
+ recovered_ref = f"{job.invocation_ref}-evidence-recovery-{source_attempt}"
1635
+ recovered = [row for row in manifest.attempts if row.invocation_ref == recovered_ref]
1636
+ if recovered and max(recovered, key=lambda row: row.attempt).status == "ok":
1637
+ continue
1638
+ try:
1639
+ prepared, result = prepare_evidence_recovery(
1640
+ job.prompt_metadata_path, project_root=plan.project_root,
1641
+ result_path=job.result_path, source_attempt=source_attempt,
1642
+ )
1643
+ except AgentInvocationError as exc:
1644
+ raise DispatchError(f"evidence recovery could not be prepared: {exc}") from exc
1645
+ jobs.append(replace(
1646
+ job, invocation_id=prepared.invocation_id, invocation_ref=prepared.invocation_ref,
1647
+ attempt=1, prompt_path=prepared.prompt_path, prompt_metadata_path=prepared.metadata_path,
1648
+ result_path=result, worker_result_path=result, completion_paths=(result,),
1649
+ result_aliases=(), instruction_digest=prepared.instruction_digest,
1650
+ prompt_digest=prepared.prompt_digest,
1651
+ ))
1652
+ if not recovered:
1653
+ _append_event(plan, "worker-evidence-recovery-prepared", {
1654
+ "sourceInvocationRef": job.invocation_ref, "sourceAttempt": source_attempt,
1655
+ "invocationRef": prepared.invocation_ref, "resultPath": str(result),
1656
+ "modelExecutionValue": job.model_execution_value,
1657
+ })
1658
+ return replace(plan, jobs=tuple(jobs))
1659
+
1660
+
1661
+ def _declaration_recovery_attempt(manifest: ExecutionManifest, job: WorkerJob) -> int | None:
1662
+ """선언 오류만 있는 종결 시도를 고르고 이미 시작한 후속 작업은 보존한다."""
1663
+ prior = [row for row in manifest.attempts if row.invocation_ref == job.invocation_ref]
1664
+ last = max(prior, key=lambda row: row.attempt) if prior else None
1665
+ summary = last.change_summary if last else {}
1666
+ if not (
1667
+ job.audience == "implementation-executor" and last is not None
1668
+ and last.finished_at is not None and last.status == "contract-failed-unattributed"
1669
+ and summary.get("sourceChanged") is False and summary.get("gitChanged") is False
1670
+ and summary.get("violations") == ["declared out-of-plan path did not change"]
1671
+ and "-evidence-recovery-" not in job.invocation_ref
1672
+ ):
1673
+ return None
1674
+ recovered_ref = f"{job.invocation_ref}-evidence-recovery-{last.attempt}"
1675
+ later_refs = {
1676
+ row.invocation_ref for row in manifest.invocations
1677
+ if row.role_execution_ref == job.role_execution_ref
1678
+ and row.invocation_ref not in (job.invocation_ref, recovered_ref)
1679
+ }
1680
+ later = [row for row in manifest.attempts if row.invocation_ref in later_refs
1681
+ and row.started_at >= last.finished_at]
1682
+ if later:
1683
+ raise DispatchError(
1684
+ "a subsequent invocation already exists for this role; collect or await "
1685
+ f"{', '.join(sorted({row.invocation_ref for row in later}))} instead of redispatching "
1686
+ f"the historical failure {job.invocation_ref}"
1687
+ )
1688
+ return last.attempt
1689
+
1690
+
1620
1691
  def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
1621
1692
  """이 invocation 이 다음에 청구할 attempt 번호.
1622
1693
 
@@ -1630,12 +1701,23 @@ def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
1630
1701
  return job.attempt
1631
1702
  manifest = read_execution_manifest(plan.manifest_path)
1632
1703
  prior = [
1633
- row.attempt for row in manifest.attempts
1704
+ row for row in manifest.attempts
1634
1705
  if row.invocation_ref == job.invocation_ref
1635
1706
  ]
1636
1707
  if not prior:
1637
1708
  return job.attempt
1638
- spent = max(prior)
1709
+ last = max(prior, key=lambda row: row.attempt)
1710
+ if last.finished_at is None:
1711
+ raise DispatchError(f"invocation is still running: {job.invocation_ref}; use okstra team await")
1712
+ if last.status != "failed-no-mutation":
1713
+ raise DispatchError(
1714
+ f"invocation {job.invocation_ref} attempt {last.attempt} is already closed as {last.status}; "
1715
+ "preserve its result and audit. Use okstra agent-prompt materialize with a new "
1716
+ "--invocation-id and distinct --prompt/--result paths in the same run, retaining "
1717
+ "--assignment-ref and the selected model. Scope the corrective instruction to "
1718
+ "missing evidence or the verified defect; do not append another attempt or replay completed implementation."
1719
+ )
1720
+ spent = last.attempt
1639
1721
  if spent >= MAX_WORKER_ATTEMPTS:
1640
1722
  raise DispatchError(
1641
1723
  f"worker retry budget is spent: {job.invocation_ref} used "
@@ -2309,15 +2391,7 @@ def _audit_attempt(
2309
2391
 
2310
2392
 
2311
2393
  def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
2312
- """워커가 자기 결과의 `Out-of-plan edits` 블록에 선언한 경로.
2313
-
2314
- 감사는 워커가 끝나는 시점에 돈다. 그때 디스크에 있는 것은 워커 결과
2315
- 마크다운뿐이고, `implementation.outOfPlanEdits` 는 리드가 나중에 쓰는 최종
2316
- 리포트의 필드다. JSON 형태만 읽었기 때문에 executor 의 선언이 한 번도 보이지
2317
- 않았고, 계약대로 선언한 편집까지 미허가 변경으로 집계됐다. 블록의 형태는 이
2318
- 판독기를 위해 `_implementation-executor.md` 가 고정한다 — `- ` 줄마다 첫 백틱
2319
- 토큰이 경로다.
2320
- """
2394
+ """지원하는 결과 표기에서 참고 선언을 읽는다. 권한 판정에는 사용하지 않는다."""
2321
2395
  if not result_path.is_file() or result_path.suffix != ".md":
2322
2396
  return ()
2323
2397
  try:
@@ -283,10 +283,17 @@ def model_spec_digest(
283
283
  write_policy_digest: str | None = None,
284
284
  served_model_attestation: ServedModelAttestation | None = None,
285
285
  ) -> str:
286
- """Digest preparation facts; invocation policy and observations are excluded."""
286
+ """실행 준비 사실을 해시하며 요금·호출 정책·관측 결과는 제외한다."""
287
287
  del write_policy_digest, served_model_attestation
288
+ specification = _canonical_model_spec(model_spec)
289
+ # 요금 미설정 실행의 기존 해시와 동일한 표현을 유지한다.
290
+ specification["pricing"] = None
291
+ return _digest_model_binding(specification, binding)
292
+
293
+
294
+ def _digest_model_binding(specification: Mapping[str, Any], binding: HostModelBinding) -> str:
288
295
  canonical = {
289
- "modelSpec": _canonical_model_spec(model_spec),
296
+ "modelSpec": specification,
290
297
  "binding": _binding_payload(binding),
291
298
  }
292
299
  encoded = json.dumps(
@@ -559,7 +559,7 @@ def _validate_role_identity(
559
559
  role = _required_text(row, "role")
560
560
  provider = _required_text(row, "provider")
561
561
  model_id = _required_text(row, "modelId")
562
- unknown = row.get("modelRef") is None or row.get("modelSpecDigest") is None
562
+ unknown = row.get("modelRef") is None
563
563
  if unknown and not _is_current_session_unknown_leader(payload, row, participant):
564
564
  raise ExecutionManifestError("unknown model is allowed only for current-session leader")
565
565
  if not unknown and row.get("binding") is None:
@@ -901,7 +901,11 @@ def _validate_next_attempt(manifest: ExecutionManifest, row: Attempt) -> None:
901
901
  raise ExecutionManifestError("previous attempt is not terminal")
902
902
  if last.status != "failed-no-mutation":
903
903
  raise ExecutionManifestError(
904
- "invocation cannot append another attempt after a terminal mutation"
904
+ "invocation cannot append another attempt after a terminal mutation; "
905
+ "preserve the existing result and audit. Materialize a new corrective invocation "
906
+ "with okstra agent-prompt materialize, a new --invocation-id and distinct "
907
+ "--prompt/--result paths, retaining the same assignment and selected model. "
908
+ "Do not replay completed implementation."
905
909
  )
906
910
 
907
911
 
@@ -44,6 +44,7 @@ class MutationSnapshot:
44
44
  # 스냅샷이 아직 실행 중인 디스패치에 남아 있고, 그것을 못 읽으면 그 워커가
45
45
  # 통째로 error 가 된다. 두 루트가 같으면 None.
46
46
  artifact_git_head: str | None = None
47
+ artifact_observation_roots: tuple[str, ...] = ()
47
48
 
48
49
  def to_payload(self) -> dict[str, Any]:
49
50
  return {
@@ -57,6 +58,7 @@ class MutationSnapshot:
57
58
  "digest": self.digest,
58
59
  "orchestratorPaths": list(self.orchestrator_paths),
59
60
  "artifactGitHead": self.artifact_git_head,
61
+ "artifactObservationRoots": list(self.artifact_observation_roots),
60
62
  }
61
63
 
62
64
  @classmethod
@@ -75,6 +77,7 @@ class MutationSnapshot:
75
77
  str(payload["artifactGitHead"])
76
78
  if payload.get("artifactGitHead") else None
77
79
  ),
80
+ artifact_observation_roots=tuple(payload.get("artifactObservationRoots", ())),
78
81
  )
79
82
  expected = _snapshot_digest(
80
83
  snapshot.root,
@@ -85,6 +88,7 @@ class MutationSnapshot:
85
88
  snapshot.scratch_digests,
86
89
  snapshot.git_projection,
87
90
  snapshot.orchestrator_paths,
91
+ snapshot.artifact_observation_roots,
88
92
  )
89
93
  if snapshot.digest != expected:
90
94
  raise MutationAuditError("mutation snapshot digest does not match payload")
@@ -115,6 +119,9 @@ class MutationAuditResult:
115
119
  # 에러 원장(contract-violation)이 따로 잡는다.
116
120
  untracked_artifact_paths: tuple[str, ...] = ()
117
121
  warnings: tuple[str, ...] = ()
122
+ changed_artifact_paths: tuple[str, ...] = ()
123
+ declared_out_of_plan_paths: tuple[str, ...] = ()
124
+ unobserved_artifact_paths: tuple[str, ...] = ()
118
125
 
119
126
  def change_summary(self) -> dict[str, Any]:
120
127
  return {
@@ -128,6 +135,9 @@ class MutationAuditResult:
128
135
  "retryAllowed": self.retry_allowed,
129
136
  "violations": list(self.violations),
130
137
  "warnings": list(self.warnings),
138
+ "changedArtifactPaths": list(self.changed_artifact_paths),
139
+ "declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
140
+ "unobservedArtifactPaths": list(self.unobserved_artifact_paths),
131
141
  "beforeDigest": self.before_digest,
132
142
  "afterDigest": self.after_digest,
133
143
  }
@@ -147,12 +157,14 @@ class ExecutionMutationAudit:
147
157
  generated = _generated_paths(rows)
148
158
  file_digests = source_content_snapshot(root, generated)
149
159
  artifact_digests = (
150
- file_digests
160
+ dict(file_digests)
151
161
  if artifact_root == root
152
162
  else _content_snapshot(
153
163
  artifact_root, generated | _non_source_paths(artifact_root)
154
164
  )
155
165
  )
166
+ observation_roots = _artifact_observation_roots(rows)
167
+ artifact_digests.update(_observed_artifact_contents(artifact_root, observation_roots))
156
168
  scratch_digests = _scratch_snapshot(rows)
157
169
  git_projection = _git_projection(root)
158
170
  orchestrator = tuple(
@@ -168,6 +180,7 @@ class ExecutionMutationAudit:
168
180
  scratch_digests,
169
181
  git_projection,
170
182
  orchestrator,
183
+ observation_roots,
171
184
  )
172
185
  return MutationSnapshot(
173
186
  root=root,
@@ -182,6 +195,7 @@ class ExecutionMutationAudit:
182
195
  artifact_git_head=(
183
196
  None if artifact_root == root else _git_head(artifact_root)
184
197
  ),
198
+ artifact_observation_roots=observation_roots,
185
199
  )
186
200
 
187
201
  def compare(
@@ -203,6 +217,13 @@ class ExecutionMutationAudit:
203
217
  artifact_changed = _changed_keys(
204
218
  before.artifact_digests, after.artifact_digests
205
219
  )
220
+ unobserved = {
221
+ path for path in artifact_changed
222
+ if path not in before.artifact_digests
223
+ and any(_is_within(path, root) for root in after.artifact_observation_roots)
224
+ and not any(_is_within(path, root) for root in before.artifact_observation_roots)
225
+ }
226
+ artifact_changed -= unobserved
206
227
  scratch_changed = _changed_keys(
207
228
  before.scratch_digests, after.scratch_digests
208
229
  )
@@ -218,22 +239,27 @@ class ExecutionMutationAudit:
218
239
  after,
219
240
  rows,
220
241
  source_changes,
221
- out_of_plan_edits,
222
242
  )
223
243
  artifact_failures, untracked_artifact_changes, switched = (
224
244
  _artifact_policy_failures(before, rows, artifact_changed, after=after)
225
245
  )
226
246
  violations.extend(artifact_failures)
247
+ worker_artifact_changes = {
248
+ path for path in artifact_changed
249
+ if path not in untracked_artifact_changes and path not in switched
250
+ and not any(_is_relative_to(before.artifact_root / path, Path(item))
251
+ for item in before.orchestrator_paths)
252
+ }
227
253
  status = _terminal_status(
228
254
  rows,
229
- source_changed=bool(source_changes),
255
+ source_changed=bool(source_changes or worker_artifact_changes),
230
256
  git_changed=git_changed,
231
257
  attempt_succeeded=attempt_succeeded,
232
258
  result_present=result_present,
233
259
  violations=violations,
234
260
  )
235
261
  retry_allowed = _retry_allowed(
236
- status, bool(source_changes), git_changed, bool(scratch_changed)
262
+ status, bool(source_changes or worker_artifact_changes), git_changed, bool(scratch_changed)
237
263
  )
238
264
  return MutationAuditResult(
239
265
  status=status,
@@ -249,10 +275,84 @@ class ExecutionMutationAudit:
249
275
  git_projection=after.git_projection,
250
276
  untracked_artifact_paths=tuple(sorted(untracked_artifact_changes)),
251
277
  warnings=_audit_warnings(untracked_artifact_changes)
252
- + _branch_switch_warnings(before, after, switched),
278
+ + _branch_switch_warnings(before, after, switched)
279
+ + _plan_change_warnings(rows, source_changes | untracked_changes,
280
+ artifact_changed, out_of_plan_edits, unobserved),
281
+ changed_artifact_paths=tuple(sorted(artifact_changed)),
282
+ declared_out_of_plan_paths=tuple(out_of_plan_edits),
283
+ unobserved_artifact_paths=tuple(sorted(unobserved)),
253
284
  )
254
285
 
255
286
 
287
+ def _observed_artifact_contents(root: Path, observation_roots: Sequence[str]) -> dict[str, str]:
288
+ """관측 경계의 링크 자체를 기록하고 링크 목적지는 탐색하지 않는다."""
289
+ artifact_digests: dict[str, str] = {}
290
+ for relative in observation_roots:
291
+ target = root / relative
292
+ link = next((parent for parent in (*target.parents, target)
293
+ if parent != root and _is_relative_to(parent, root)
294
+ and parent.is_symlink()), None)
295
+ if link is not None:
296
+ artifact_digests[link.relative_to(root).as_posix()] = _path_digest(link)
297
+ continue
298
+ if target.is_symlink() or target.is_file():
299
+ artifact_digests[relative] = _path_digest(target)
300
+ else:
301
+ artifact_digests.update({
302
+ f"{relative}/{key}": value
303
+ for key, value in _content_snapshot(target, frozenset()).items()
304
+ })
305
+ return artifact_digests
306
+
307
+
308
+ def _artifact_observation_roots(policies: Sequence[WritePolicy]) -> tuple[str, ...]:
309
+ """현재 배정의 QA와 실행 산출물은 Git 무시 규칙과 무관하게 관측한다."""
310
+ roots: set[str] = set()
311
+ for path in _allowed_artifact_paths(policies):
312
+ parts = Path(path).parts
313
+ if len(parts) < 5 or parts[:2] != (".okstra", "tasks"):
314
+ continue
315
+ if parts[4] == "qa":
316
+ roots.add(Path(*parts[:5]).as_posix())
317
+ elif parts[4] == "runs":
318
+ boundary = next((i for i, part in enumerate(parts)
319
+ if part in {"worker-results", "prompts"}), len(parts) - 1)
320
+ roots.add(Path(*parts[:boundary]).as_posix())
321
+ else:
322
+ roots.add(path)
323
+ return tuple(sorted(roots))
324
+
325
+
326
+ def _plan_change_warnings(
327
+ policies: Sequence[WritePolicy], source_changes: set[str],
328
+ artifact_changes: set[str], declared: Sequence[str], unobserved: set[str],
329
+ ) -> tuple[str, ...]:
330
+ """예상 목록과의 차이는 검토 자료이며 쓰기 권한 위반이 아니다."""
331
+ warnings: list[str] = []
332
+ for policy in policies:
333
+ if policy.source_mode != "project-mutation":
334
+ continue
335
+ planned = policy.source_policy.get("plannedPaths", ())
336
+ extra = sorted(path for path in source_changes
337
+ if not any(_is_within(path, item) for item in planned))
338
+ if extra:
339
+ warnings.append("source changes outside expected plan; review scope: " + ", ".join(extra))
340
+ observed = {Path(str(policy.source_policy["allowedRoot"])) / path
341
+ for policy in policies for path in source_changes}
342
+ observed.update(Path(str(policy.artifact_policy["allowedRoot"])) / path
343
+ for policy in policies for path in artifact_changes)
344
+ roots = {Path(str(policy.source_policy["allowedRoot"])) for policy in policies}
345
+ roots.update(Path(str(policy.artifact_policy["allowedRoot"])) for policy in policies)
346
+ missing = sorted(path for path in declared
347
+ if not any(root / path in observed for root in roots))
348
+ if missing:
349
+ warnings.append("declared changes not observed; reconcile evidence: " + ", ".join(missing))
350
+ if unobserved:
351
+ warnings.append(f"artifact baseline coverage unavailable ({len(unobserved)} paths); "
352
+ "independently verify current artifacts; see unobservedArtifactPaths")
353
+ return tuple(warnings)
354
+
355
+
256
356
  def _audit_warnings(untracked_artifact_changes: set[str]) -> tuple[str, ...]:
257
357
  if not untracked_artifact_changes:
258
358
  return ()
@@ -595,6 +695,7 @@ def _snapshot_digest(
595
695
  scratch: Mapping[str, str],
596
696
  git_projection: Mapping[str, Any],
597
697
  orchestrator_paths: Sequence[str],
698
+ observation_roots: Sequence[str] = (),
598
699
  ) -> str:
599
700
  encoded = json.dumps(
600
701
  {
@@ -606,6 +707,7 @@ def _snapshot_digest(
606
707
  "scratch": scratch,
607
708
  "gitProjection": git_projection,
608
709
  "orchestratorPaths": list(orchestrator_paths),
710
+ **({"artifactObservationRoots": list(observation_roots)} if observation_roots else {}),
609
711
  },
610
712
  sort_keys=True,
611
713
  separators=(",", ":"),
@@ -680,7 +782,7 @@ def _artifact_policy_failures(
680
782
  and not any(_is_within(path, item) for item in orchestrator)
681
783
  }
682
784
  if snapshot.artifact_root == snapshot.root:
683
- return [], set(), set()
785
+ unauthorized = {path for path in unauthorized if _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)}
684
786
  tracked = _tracked_paths(snapshot.artifact_root)
685
787
  if tracked is None:
686
788
  untracked_outside: set[str] = set()
@@ -761,7 +863,6 @@ def _policy_violations(
761
863
  after: MutationSnapshot,
762
864
  policies: tuple[WritePolicy, ...],
763
865
  source_changes: set[str],
764
- out_of_plan_edits: Sequence[str],
765
866
  ) -> list[str]:
766
867
  if all(policy.source_mode == "source-readonly" for policy in policies):
767
868
  failures = ["readonly source changed"] if source_changes else []
@@ -769,8 +870,8 @@ def _policy_violations(
769
870
  failures.append("gitPolicy disabled but Git projection changed")
770
871
  return failures
771
872
  policy = next(row for row in policies if row.source_mode == "project-mutation")
772
- failures = _source_policy_failures(policy, source_changes, out_of_plan_edits)
773
- failures.extend(_git_policy_failures(before, after, policy, source_changes, out_of_plan_edits))
873
+ failures = _source_policy_failures(policy, source_changes)
874
+ failures.extend(_git_policy_failures(before, after, policy))
774
875
  return failures
775
876
 
776
877
 
@@ -797,37 +898,14 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
797
898
  }
798
899
 
799
900
 
800
- def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
801
- """옛 계획의 미선언 목록만 면제하고, 산출물 전용 계획의 빈 소스 목록은 집행한다.
802
-
803
- 선언 여부가 저장되기 전 정책은 기존의 빈 목록 해석을 유지한다.
804
- 새 정책은 경로 분류 후에도 선언 여부를 보존하므로 산출물만 남은 계획이
805
- 임의의 소스 변경이나 커밋을 허용하지 않는다.
806
- """
807
- source = policy.source_policy
808
- return (
809
- source.get("mode") == "project-mutation"
810
- and not source.get("plannedPaths")
811
- and not source.get("plannedPathsDeclared", False)
812
- )
813
-
814
-
815
901
  def _source_policy_failures(
816
902
  policy: WritePolicy,
817
903
  changed: set[str],
818
- out_of_plan_edits: Sequence[str],
819
904
  ) -> list[str]:
820
- planned = set(policy.source_policy.get("plannedPaths", ()))
821
- declared = set(out_of_plan_edits)
822
905
  protected = set(policy.source_policy.get("protectedPaths", ()))
823
906
  failures: list[str] = []
824
- if (
825
- not _path_ledger_is_unenforceable(policy)
826
- and not changed <= planned | declared
827
- ):
828
- failures.append("source changes exceed planned and declared out-of-plan paths")
829
- if not declared <= changed:
830
- failures.append("declared out-of-plan path did not change")
907
+ if policy.source_mode == "source-readonly" and changed:
908
+ failures.append("readonly source changed")
831
909
  if any(_is_within(path, item) for path in changed for item in protected):
832
910
  failures.append("protected path changed")
833
911
  return failures
@@ -837,8 +915,6 @@ def _git_policy_failures(
837
915
  before: MutationSnapshot,
838
916
  after: MutationSnapshot,
839
917
  policy: WritePolicy,
840
- changed: set[str],
841
- out_of_plan_edits: Sequence[str],
842
918
  ) -> list[str]:
843
919
  git = policy.git_policy
844
920
  root = before.root
@@ -857,23 +933,16 @@ def _git_policy_failures(
857
933
  failures.append("final Git index contains staged changes")
858
934
  if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
859
935
  failures.append("final HEAD is not a fast-forward descendant")
860
- allowed = set(policy.source_policy.get("plannedPaths", ())) | set(out_of_plan_edits)
861
- if _path_ledger_is_unenforceable(policy):
862
- # The plan predates the declared path column, so there is no ledger
863
- # anyone can be held to. Every other check above still applies; only
864
- # the path comparison stands down.
865
- return failures
936
+ protected = policy.source_policy.get("protectedPaths", ())
866
937
  if any(
867
- not paths <= allowed
938
+ any(_is_within(path, item) for path in paths for item in protected)
868
939
  for paths in _commit_paths_by_commit(
869
940
  root,
870
941
  str(git.get("expectedBaseCommit")),
871
942
  str(after.git_projection.get("head")),
872
943
  )
873
944
  ):
874
- failures.append("commit chain changed paths outside source policy")
875
- if not changed <= allowed:
876
- failures.append("working tree changed paths outside source policy")
945
+ failures.append("commit chain changed protected paths")
877
946
  return failures
878
947
 
879
948
 
@@ -244,6 +244,8 @@ Output: `{ok: true, outcome: {renderArgv: ["--lead-runtime", "...", ...], render
244
244
 
245
245
  `userAuthorization`, when present, preserves the confirmation prompt actually emitted by this wizard and the relayed `proceed` response. Its `--user-authorization-json` token carries that record into the run manifest; preserve it unchanged. Do not synthesize a receipt for older confirmed states. Include the translator's disclosed provider/model and report material when citing this record in host execution requests, including `report-finalize`. The record documents task scope; it does not grant host execution privileges.
246
246
 
247
+ Before retrying a worker in an existing run, inspect its last invocation attempt: await running work; append an attempt only after `failed-no-mutation` within the retry budget. Preserve every other terminal attempt. `okstra team dispatch` routes the legacy declaration-only implementer failure to a separate same-model evidence-recovery invocation; collect its result before independent verification. Other terminal failures need `okstra agent-prompt materialize` with a new `--invocation-id`, distinct `--prompt`/`--result` paths, and unchanged assignment/model for a bounded correction. Do not restart completed implementation or request renewed approval for unchanged scope. Enforcement: `dispatch_core._next_attempt` and `execution_manifest._validate_next_attempt`.
248
+
247
249
  Run every `outcome.persistActions[]` entry BEFORE `render-bundle`. The only supported action is:
248
250
 
249
251
  ```json
@@ -5443,19 +5443,8 @@ def _validate_plan_body_state_rounds(
5443
5443
  )
5444
5444
 
5445
5445
 
5446
- def _validate_out_of_plan_edits_are_real(data: dict, failures: list[str]) -> None:
5447
- """선언한 out-of-plan 편집은 실제로 바뀐 파일이어야 한다.
5448
-
5449
- 입력 템플릿의 §"Forbidden In This Run" 은 계획 목록 밖에서 건드린 파일을 전부
5450
- `Out-of-plan edits` 에 적으라고 요구한다. 그 목록이 실제 diff 와 무관하면 기록은
5451
- 검토자에게 성실해 보이는 문자열일 뿐이다 — 실제로 바꾼 적 없는 파일이 적혀
5452
- 있으면 나머지 행도 믿을 근거가 없다.
5453
-
5454
- 계획 목록 자체와의 대조(계획 밖 파일이 여기 빠졌는지)는 하지 않는다. 그러려면
5455
- `approvedPlanReference.planFile` 이 가리키는 다른 리포트를 열어 stage 범위까지
5456
- 풀어야 하고, 그 경로 해소를 검증할 실물 run 이 저장소에 없다. 여기서는 리포트
5457
- 안에서 닫히는 방향만 본다.
5458
- """
5446
+ def _warn_out_of_plan_edits_not_in_diff(data: dict, warnings: list[str]) -> None:
5447
+ """소스 차이 목록은 별도 QA 산출물의 변경 여부를 증명하지 못한다."""
5459
5448
  implementation = data.get("implementation")
5460
5449
  if not isinstance(implementation, dict):
5461
5450
  return
@@ -5470,7 +5459,7 @@ def _validate_out_of_plan_edits_are_real(data: dict, failures: list[str]) -> Non
5470
5459
  continue
5471
5460
  target = row.get("file")
5472
5461
  if isinstance(target, str) and target and target not in changed:
5473
- failures.append(
5462
+ warnings.append(
5474
5463
  f"out-of-plan-edit: {row.get('id') or 'OOP-???'} 가 `{target}` 을 "
5475
5464
  "계획 밖 편집으로 신고했지만 diffSummary 에 그 파일이 없다"
5476
5465
  )
@@ -9755,7 +9744,10 @@ def main() -> int:
9755
9744
  _validate_verifier_command_log_is_read_only(validation_data, failures)
9756
9745
  _validate_verifier_reran_independently(validation_data, failures)
9757
9746
  _validate_verifier_discrepancy_is_not_passed(validation_data, failures)
9758
- _validate_out_of_plan_edits_are_real(validation_data, failures)
9747
+ declaration_warnings: list[str] = []
9748
+ _warn_out_of_plan_edits_not_in_diff(validation_data, declaration_warnings)
9749
+ for warning in declaration_warnings:
9750
+ print(f"validate-run: warning: {warning}", file=sys.stderr)
9759
9751
  if task_type == "implementation-planning":
9760
9752
  _validate_plan_body_state_file(
9761
9753
  validation_data,