okstra 0.179.0 → 0.179.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.179.0",
3
+ "version": "0.179.1",
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.179.0",
3
- "builtAt": "2026-08-20T05:34:55.598Z",
2
+ "package": "0.179.1",
3
+ "builtAt": "2026-08-20T08:40:17.534Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -687,7 +687,7 @@ say so explicitly for that half; silence on one half is an incomplete result.
687
687
  ```
688
688
 
689
689
  ### Gap verification (1 adversarial reverify round)
690
- Each critic gap enters the verification queue as a finding with `originWorker = "<provider>-critic"` and `source = "critic"`. The lead runs ONE adversarial reverify round (§"Adversarial Verification Mode" classifier) with the Phase 4 analysers (excluding the critic itself) as voters. Only gaps classified `full-consensus` / `partial-consensus` merge into the final report findings; `contested` / `worker-unique` gaps are treated as hallucinations and dropped (recorded in the convergence state, not promoted).
690
+ Each critic gap enters the verification queue as a finding with `originWorker = "<provider>-critic"` and `source = "critic"`. The lead runs ONE adversarial reverify round (§"Adversarial Verification Mode" classifier) with the Phase 4 analysers as voters, **excluding every analyser whose provider is the critic's provider** — the exclusion matches on the provider name (`codex`, `codex-worker`), not on the critic's worker id, so picking a critic provider that is already in the analyser roster removes that analyser from the vote and shrinks the quorum by one. `okstra apply-critic-gaps` refuses a vote from an excluded worker (`critic voter must be a non-critic analyser`), so dispatching one spends a worker whose verdict cannot be counted. Only gaps classified `full-consensus` / `partial-consensus` merge into the final report findings; `contested` / `worker-unique` gaps are treated as hallucinations and dropped (recorded in the convergence state, not promoted).
691
691
 
692
692
  **A gap that received no verdict is NOT a rejected gap (BLOCKING).** Dropping applies only to gaps the voters actually judged. A gap can also end the round *unjudged* — the verification dispatch returned a terminal non-result (`timeout`, `error`, no result file), the returned result covered only some of the gaps, or no non-critic analyser was available to vote at all. Nobody inspected those, so classifying them as hallucinations is a fabricated verdict. Each one MUST be recorded as a `## 5. Missing Information and Risks` row (`missingInformation`, `source: "critic-unverified"`) whose `risk` names the gap and the reason verification did not complete, and counted in `config.critic.gapsUnverified`. They are **not** promoted to findings (unverified) and **not** raised as `clarification` items — an unverified gap needs an analyser to verify it on the next run, not a decision from the user. Silently losing them is a contract violation: the batch that times out is exactly the batch of gaps too expensive to check, so the highest-risk items are the ones that vanish.
693
693
 
@@ -738,7 +738,7 @@ so explicitly.
738
738
 
739
739
  ### Verification — confirm-or-downgrade (BLOCKING)
740
740
 
741
- Each candidate blocker is verified by the Phase 4 analysers (excluding the critic). Do NOT use the adversarial finding classifier's "uncertain → reject" rule here.
741
+ Each candidate blocker is verified by the Phase 4 analysers, excluding every analyser whose provider is the critic's provider (same rule as §"critic gaps" above). Do NOT use the adversarial finding classifier's "uncertain → reject" rule here.
742
742
  - Do NOT run `apply-critic-gaps` for this mode. That reducer implements coverage merge/drop semantics and rejects `acceptance-devils-advocate` input.
743
743
  - **Confirmed** (an analyser reproduces it or cites supporting evidence) → promote to a `## 5.8 Acceptance Blockers` row (keep severity + recommended follow-up phase).
744
744
  - **Not confirmed** (cannot reproduce, or evidence is weak) → **downgrade to a Residual Risk row — never drop it.** Record the escalation trigger so the user can re-judge a high-severity-but-unconfirmed candidate.
@@ -230,7 +230,7 @@ After context-loader completes, read **only the compact intake files below** in
230
230
 
231
231
  The `implementation` profile's thin core (`prompts/profiles/implementation.md`) is intentionally minimal so the Phase 1 baseline stays small. Three sidecar files carry the bulk of the rules and MUST be read at the listed phase — do NOT pre-load them at Phase 1. The sidecar list and each one's `Read at` phase live in that profile's "Lazy section pointers" table, which arrives in the Phase 1 intake via `analysis-profile.md`, so it is already in context whenever this discipline applies.
232
232
 
233
- **Entry guard (BLOCKING).** Before transitioning into Phase 5 or Phase 6 for an `implementation` run, lead MUST emit a single Read tool call for the sidecar(s) whose `Read at` (per that table) matches the entering phase. If lead enters the phase without that Read recorded in the selected adapter's conformance evidence/event source, phase entry is refused — lead writes a `contract-violation` to the run-level errors log with `--message "implementation-sidecar-not-loaded"` and stops. Re-entry requires the sidecar Read first. **Enforcement:** the Phase 7 validator (`validate_session_conformance.py`) verifies post-hoc that all three sidecar Reads exist in the selected adapter's declared source within this run's window, and that they precede the `phase-6-synthesis` / `phase-7-persist` checkpoints respectively.
233
+ **Entry guard (BLOCKING).** Before transitioning into Phase 5 or Phase 6 for an `implementation` run, lead MUST load the sidecar(s) whose `Read at` (per that table) matches the entering phase — either a single `Read` tool call, or a shell command naming that file (`cat`, `sed -n`), since some hosts steer file reads to the shell. If lead enters the phase without that load recorded in the selected adapter's conformance evidence/event source, phase entry is refused — lead writes a `contract-violation` to the run-level errors log with `--message "implementation-sidecar-not-loaded"` and stops. Re-entry requires the sidecar Read first. **Enforcement:** the Phase 7 validator (`validate_session_conformance.py`) verifies post-hoc that all three sidecar loads exist in the selected adapter's declared source within this run's window, and that they precede the `phase-6-synthesis` / `phase-7-persist` checkpoints respectively.
234
234
 
235
235
  The guard is not satisfied by memory from a prior run — each implementation run re-reads the sidecar fresh, since `okstra install` may have updated it between runs.
236
236
 
@@ -623,6 +623,7 @@
623
623
  "static_role": " static-role : {role}#{ordinal} / {model}",
624
624
  "dynamic_role": " dynamic-role : {role} / reuse selected participant model",
625
625
  "workers_implementation_default": " workers : (프로필 기본 — executor + verifier 2 + report-writer)",
626
+ "critic_shares_analyser_provider": " ⚠ critic provider 가 분석 워커 `{worker}` 와 같습니다 — gap 검증에서 그 워커는 투표할 수 없어 유효 투표자가 {remaining}명으로 줍니다",
626
627
  "base_ref_stage_isolated": " base-ref : (stage 격리 — 의존 stage 기준으로 run 준비 시점에 자동 해소)",
627
628
  "base_ref_reuse_task_dir": " base-ref : (기존 `{task_key}` 디렉터리 재사용 — 최초 base 유지)",
628
629
  "worktree_new": " worktree : 새 브랜치 `{branch}` (base-ref `{base_ref}`) → `{path}`",
@@ -97,7 +97,11 @@ def _parser() -> argparse.ArgumentParser:
97
97
  action="store_true",
98
98
  help="rewrite a prompt this invocation id already wrote, allowed only "
99
99
  "while no dispatch has referenced it — the exit for a prompt that "
100
- "failed a pre-dispatch gate and never ran",
100
+ "failed a pre-dispatch gate and never ran. Not available for v2 "
101
+ "reverify prompts (--assignment-ref reverify/…): their reservation "
102
+ "records the prompt's input digest in the run manifest and "
103
+ "reservations are append-only, so a rewrite needs a fresh "
104
+ "invocation id and prompt path",
101
105
  )
102
106
  materialize.add_argument("--json", action="store_true")
103
107
 
@@ -426,6 +430,7 @@ def _materialize_run(
426
430
  args=args,
427
431
  manifest=manifest,
428
432
  manifest_path=manifest_path,
433
+ active_context=active_context,
429
434
  source_role_execution_ref=dynamic_source,
430
435
  artifact_paths=_dynamic_verifier_artifact_paths(
431
436
  project_root,
@@ -440,12 +445,23 @@ def _materialize_run(
440
445
  return prepare_agent_invocation(request)
441
446
 
442
447
 
448
+ def _run_worktree(
449
+ manifest: Mapping[str, Any], active_context: Mapping[str, Any]
450
+ ) -> Path | None:
451
+ """이 런의 워커 루트. 디스패치가 job 에 싣는 값과 같은 seam 에서 읽는다."""
452
+ from .dispatch_state import worktree_path
453
+
454
+ value = worktree_path(manifest, active_context)
455
+ return Path(value) if value else None
456
+
457
+
443
458
  def _reserve_dynamic_verifier_request(
444
459
  request: AgentInvocationRequest,
445
460
  *,
446
461
  args: argparse.Namespace,
447
462
  manifest: Mapping[str, Any],
448
463
  manifest_path: Path,
464
+ active_context: Mapping[str, Any],
449
465
  source_role_execution_ref: str,
450
466
  artifact_paths: tuple[Path, ...],
451
467
  ) -> AgentInvocationRequest:
@@ -464,6 +480,7 @@ def _reserve_dynamic_verifier_request(
464
480
  input_digest="sha256:" + hashlib.sha256(prompt_bytes).hexdigest(),
465
481
  invocation_ref=args.invocation_id,
466
482
  artifact_paths=artifact_paths,
483
+ worktree=_run_worktree(manifest, active_context),
467
484
  )
468
485
  bound = replace(
469
486
  request,
@@ -520,8 +537,10 @@ def _dynamic_verifier_source(
520
537
  if is_v2 and is_reverify:
521
538
  if args.replace_undispatched:
522
539
  raise AgentPromptCliError(
523
- "v2 dynamic verifier prompts are append-only; use a fresh "
524
- "invocation ID and prompt path"
540
+ "v2 dynamic verifier prompts are append-only: this "
541
+ "invocation's reservation already recorded this prompt's input "
542
+ "digest in the run manifest, and a reservation cannot be "
543
+ "rewritten. Use a fresh invocation ID and prompt path."
525
544
  )
526
545
  if not source:
527
546
  raise AgentPromptCliError(
@@ -87,8 +87,19 @@ def reserve_dynamic_verifier(
87
87
  input_digest: str,
88
88
  invocation_ref: str | None = None,
89
89
  artifact_paths: tuple[Path, ...],
90
+ worktree: Path | None = None,
90
91
  ) -> tuple[RoleExecution, Invocation]:
91
- """Reserve one provider-neutral verifier identity for a logical round."""
92
+ """Reserve one provider-neutral verifier identity for a logical round.
93
+
94
+ `worktree` 는 이 런의 워커가 실제로 서는 루트다. 디스패치가 정책을 다시
95
+ 계산할 때 쓰는 값(`dispatch_core._canonical_write_contract` 이 job 의
96
+ worktree 를 넘긴다)과 같아야 한다. 여기서 None 으로 고정하면 예약된 정책의
97
+ `sourcePolicy.allowedRoot` 는 프로젝트 루트, 디스패치가 계산한 정책은 스테이지
98
+ 워크트리가 되어 writePolicyDigest 가 갈리고, 같은 invocationRef 가
99
+ `invocationRef drift` 로 거부된다 — 워크트리를 쓰는 런(implementation stage)
100
+ 에서만 나타나고 워크트리가 없는 런(implementation-planning)에서는 안 나타나
101
+ 버전 문제로 보이기 쉽다.
102
+ """
92
103
  if round_number < 1:
93
104
  raise ExecutionManifestError("dynamic verifier round must be positive")
94
105
  manifest_path = Path(manifest_path).resolve()
@@ -128,7 +139,7 @@ def reserve_dynamic_verifier(
128
139
  policy, enforcement = build_invocation_write_contract(
129
140
  role="verifier",
130
141
  project_root=project_root,
131
- worktree=None,
142
+ worktree=worktree,
132
143
  artifact_paths=artifact_paths,
133
144
  maximum_precision=capability.max_boundary_precision,
134
145
  auxiliary_roots=verifier_extra_dirs("verifier"),
@@ -359,7 +359,9 @@ def build_dispatch_plan(
359
359
  default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
360
360
  )
361
361
  if jobs_file:
362
- jobs = _jobs_from_file(project_root, workspace_root, jobs_file, manifest, options)
362
+ jobs = _jobs_from_file(
363
+ project_root, workspace_root, jobs_file, manifest, active_context, options
364
+ )
363
365
  else:
364
366
  jobs = _jobs_from_roster(
365
367
  project_root,
@@ -416,7 +418,10 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
416
418
  return 0
417
419
  round_artifact_paths = _round_artifact_paths(plan)
418
420
  handles = [
419
- _spawn_job(plan, job, 1, batch_artifact_paths=round_artifact_paths)
421
+ _spawn_job(
422
+ plan, job, _next_attempt(plan, job),
423
+ batch_artifact_paths=round_artifact_paths,
424
+ )
420
425
  for job in plan.jobs
421
426
  ]
422
427
  _record_dispatch_facts(plan.team_state_path, _mode_from_handles(handles))
@@ -1292,6 +1297,7 @@ def _jobs_from_file(
1292
1297
  workspace_root: Path,
1293
1298
  jobs_file: Path | None,
1294
1299
  manifest: Mapping[str, Any],
1300
+ active_context: Mapping[str, Any],
1295
1301
  options: _BuildOptions,
1296
1302
  ) -> list[WorkerJob]:
1297
1303
  if jobs_file is None:
@@ -1300,6 +1306,7 @@ def _jobs_from_file(
1300
1306
  project_root,
1301
1307
  jobs_file,
1302
1308
  manifest=manifest,
1309
+ active_context=active_context,
1303
1310
  backend=options.default_backend,
1304
1311
  idle_timeout_seconds=options.idle_timeout_seconds,
1305
1312
  default_dispatch_kind=options.dispatch_kind,
@@ -1390,6 +1397,34 @@ def _spawn_cli_job_nonblocking(
1390
1397
  )
1391
1398
 
1392
1399
 
1400
+ def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
1401
+ """이 invocation 이 다음에 청구할 attempt 번호.
1402
+
1403
+ attempt 를 1 로 고정하면 같은 워커를 두 번째로 디스패치할 수 없다. 원장은
1404
+ 단조 증가를 요구하므로(`execution_manifest._validate_next_attempt`) 두 번째
1405
+ 호출이 항상 `next attempt must be 2` 로 거부되고, 재시도 예산이 계약에는
1406
+ 있는데 그 경로에는 쓸 수단이 없는 상태가 된다. 예산은 프로세스가 아니라
1407
+ invocation 에 붙어 있고, 그 잔액이 적힌 곳은 원장뿐이다.
1408
+ """
1409
+ if not job.has_execution_identity:
1410
+ return job.attempt
1411
+ manifest = read_execution_manifest(plan.manifest_path)
1412
+ prior = [
1413
+ row.attempt for row in manifest.attempts
1414
+ if row.invocation_ref == job.invocation_ref
1415
+ ]
1416
+ if not prior:
1417
+ return job.attempt
1418
+ spent = max(prior)
1419
+ if spent >= MAX_WORKER_ATTEMPTS:
1420
+ raise DispatchError(
1421
+ f"worker retry budget is spent: {job.invocation_ref} used "
1422
+ f"{spent} of {MAX_WORKER_ATTEMPTS} attempts. Materialize a new "
1423
+ "invocation to dispatch this worker again."
1424
+ )
1425
+ return spent + 1
1426
+
1427
+
1393
1428
  def _prepare_job_attempt(
1394
1429
  plan: DispatchPlan, job: WorkerJob, attempt: int
1395
1430
  ) -> WorkerJob:
@@ -1622,7 +1657,7 @@ def _dispatch_round(dispatch_kind: str) -> int:
1622
1657
 
1623
1658
 
1624
1659
  def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
1625
- for attempt in range(1, MAX_WORKER_ATTEMPTS + 1):
1660
+ for attempt in range(_next_attempt(plan, job), MAX_WORKER_ATTEMPTS + 1):
1626
1661
  handle = _spawn_job(plan, job, attempt)
1627
1662
  if handle.completed_process is None:
1628
1663
  return await_dispatches(plan, timeout_seconds=None)
@@ -1763,6 +1763,7 @@ def worker_jobs_from_file(
1763
1763
  jobs_file: Path,
1764
1764
  *,
1765
1765
  manifest: Mapping[str, Any],
1766
+ active_context: Mapping[str, Any] | None = None,
1766
1767
  backend: str,
1767
1768
  idle_timeout_seconds: int,
1768
1769
  default_dispatch_kind: str,
@@ -1782,6 +1783,7 @@ def worker_jobs_from_file(
1782
1783
  project_root,
1783
1784
  item,
1784
1785
  manifest=manifest,
1786
+ active_context=active_context,
1785
1787
  backend=backend,
1786
1788
  idle_timeout_seconds=idle_timeout_seconds,
1787
1789
  dispatch_kind=dispatch_kind,
@@ -1800,6 +1802,7 @@ def _worker_job_from_file(
1800
1802
  item: Mapping[str, Any],
1801
1803
  *,
1802
1804
  manifest: Mapping[str, Any],
1805
+ active_context: Mapping[str, Any] | None = None,
1803
1806
  backend: str,
1804
1807
  idle_timeout_seconds: int,
1805
1808
  dispatch_kind: str,
@@ -1850,7 +1853,17 @@ def _worker_job_from_file(
1850
1853
  result_path=result_path,
1851
1854
  worker_result_path=worker_result_path,
1852
1855
  completion_paths=completion_paths,
1853
- worktree_path=string_value(item.get("worktreePath")),
1856
+ # 파일이 적지 않았으면 run authority 에서 해소한다. 로스터 경로는
1857
+ # 언제나 그렇게 하고(`dispatch_core._jobs_from_roster`), 예약 쪽
1858
+ # (`agent_prompt_cli._run_worktree`)도 같은 seam 을 읽는다. 이 자리만
1859
+ # 손으로 적은 값을 유일한 출처로 삼던 동안, 같은 run 이 어떤 경로로
1860
+ # 디스패치됐는지에 따라 워커의 소스 루트가 달라졌다 — 그 값은
1861
+ # writePolicy 의 `sourcePolicy.allowedRoot` 이므로, 예약본과 갈리면
1862
+ # 같은 invocationRef 가 `invocationRef drift` 로 거부된다.
1863
+ worktree_path=(
1864
+ string_value(item.get("worktreePath"))
1865
+ or worktree_path(manifest, active_context or {})
1866
+ ),
1854
1867
  role=require_string(item, "role"),
1855
1868
  idle_timeout_seconds=idle_timeout_seconds,
1856
1869
  dispatch_kind=dispatch_kind,
@@ -118,6 +118,22 @@ def append_role_execution(
118
118
  )
119
119
 
120
120
 
121
+ def _drift_detail(existing: Invocation, incoming: Invocation) -> str:
122
+ """어긋난 필드를 이름으로 돌려준다.
123
+
124
+ `invocationRef drift: <ref>` 만으로는 무엇이 달라졌는지 알 수 없어, 리드가
125
+ 새 invocation id 를 몇 개씩 만들어 보는 것 말고 할 수 있는 일이 없었다.
126
+ 같은 ref 로 두 번째 예약이 오는 것 자체는 정상 경로(재materialize)이고,
127
+ 거절해야 하는 것은 '내용이 달라진' 경우뿐이므로 그 내용을 이름 붙인다.
128
+ """
129
+ before = existing.to_payload()
130
+ after = incoming.to_payload()
131
+ changed = sorted(
132
+ key for key in (*before, *after) if before.get(key) != after.get(key)
133
+ )
134
+ return ", ".join(changed) if changed else "(no field differs)"
135
+
136
+
121
137
  def reserve_derived_role_invocation(
122
138
  path: Path,
123
139
  *,
@@ -169,7 +185,8 @@ def reserve_derived_role_invocation(
169
185
  if existing_invocation is not None:
170
186
  if existing_invocation != invocation:
171
187
  raise ExecutionManifestError(
172
- f"invocationRef drift: {invocation.invocation_ref}"
188
+ f"invocationRef drift: {invocation.invocation_ref} "
189
+ f"(differs in: {_drift_detail(existing_invocation, invocation)})"
173
190
  )
174
191
  if created:
175
192
  raise ExecutionManifestError(
@@ -242,7 +259,8 @@ def ensure_invocation(path: Path, row: Invocation, *, task_key: str) -> Invocati
242
259
  if existing is not None:
243
260
  if existing != row:
244
261
  raise ExecutionManifestError(
245
- f"invocationRef drift: {row.invocation_ref}"
262
+ f"invocationRef drift: {row.invocation_ref} "
263
+ f"(differs in: {_drift_detail(existing, row)})"
246
264
  )
247
265
  return existing
248
266
  _validate_next_invocation(manifest, row)
@@ -289,7 +307,8 @@ def record_invocation_attempt(
289
307
  )
290
308
  if existing is not None and existing != invocation:
291
309
  raise ExecutionManifestError(
292
- f"invocationRef drift: {invocation.invocation_ref}"
310
+ f"invocationRef drift: {invocation.invocation_ref} "
311
+ f"(differs in: {_drift_detail(existing, invocation)})"
293
312
  )
294
313
  invocations = manifest.invocations
295
314
  if existing is None:
@@ -528,6 +528,32 @@ def _stable_git_projection(snapshot: MutationSnapshot) -> dict[str, Any]:
528
528
  }
529
529
 
530
530
 
531
+ def _path_ledger_is_unenforceable(policy: WritePolicy) -> bool:
532
+ """이 정책의 경로 장부를 근거로 변경을 거절할 수 있는가.
533
+
534
+ 승인된 계획서에 `plannedPaths` 컬럼이 있으면 실행기는 그 목록에 묶이고,
535
+ 목록은 반드시 비어 있지 않다(`write_policy._planned_paths_from_report` 는
536
+ 선언된 경우에만 항목을 싣는다). 그 컬럼이 없는 옛 계획서에서는 실을 값이
537
+ 없어 장부가 빈 채로 온다 — 종전에는 산문에서 유도한 문장 조각(`28 rows)`,
538
+ `captured in Stage 1)`)을 실었고, 그래서 계획이 지시한 파일 전부가 미허가
539
+ 변경으로 읽혔다.
540
+
541
+ `project-mutation` 정책에서만 빈 장부가 "물을 수 없음" 을 뜻한다.
542
+ `source-readonly` 워커는 장부가 원래 비어 있고 그것이 "아무것도 바꾸지
543
+ 말라" 는 뜻이므로, 그쪽 집행은 건드리지 않는다.
544
+
545
+ 이 판정은 감사의 두 절반이 같은 함수를 읽는다. 종전에는 git 쪽만
546
+ `plannedPathsDeclared` 를 봤는데 그 키는 `build_write_policy` 가 만드는
547
+ sourcePolicy 에 아예 실리지 않아(4개 키 고정, `validate_write_policy_payload`
548
+ 가 그 집합을 강제) 어느 쪽에서도 참이 된 적이 없다.
549
+ """
550
+ source = policy.source_policy
551
+ return (
552
+ source.get("mode") == "project-mutation"
553
+ and not source.get("plannedPaths")
554
+ )
555
+
556
+
531
557
  def _source_policy_failures(
532
558
  policy: WritePolicy,
533
559
  changed: set[str],
@@ -537,7 +563,10 @@ def _source_policy_failures(
537
563
  declared = set(out_of_plan_edits)
538
564
  protected = set(policy.source_policy.get("protectedPaths", ()))
539
565
  failures: list[str] = []
540
- if not changed <= planned | declared:
566
+ if (
567
+ not _path_ledger_is_unenforceable(policy)
568
+ and not changed <= planned | declared
569
+ ):
541
570
  failures.append("source changes exceed planned and declared out-of-plan paths")
542
571
  if not declared <= changed:
543
572
  failures.append("declared out-of-plan path did not change")
@@ -571,10 +600,10 @@ def _git_policy_failures(
571
600
  if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
572
601
  failures.append("final HEAD is not a fast-forward descendant")
573
602
  allowed = set(policy.source_policy.get("plannedPaths", ())) | set(out_of_plan_edits)
574
- if policy.source_policy.get("plannedPathsDeclared") is False:
575
- # The plan predates the declared path column, so `allowed` was derived
576
- # from prose and is not a ledger anyone can be held to. Every other
577
- # check above still applies; only the path comparison stands down.
603
+ if _path_ledger_is_unenforceable(policy):
604
+ # The plan predates the declared path column, so there is no ledger
605
+ # anyone can be held to. Every other check above still applies; only
606
+ # the path comparison stands down.
578
607
  return failures
579
608
  if any(
580
609
  not paths <= allowed
@@ -377,16 +377,22 @@ def container_paths(project_root: Path, task_group: str, task_id: str) -> dict:
377
377
 
378
378
 
379
379
  def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
380
- """run_seq_dir 안에서 `*-<task-type>-NNN.<ext>` 파일을 스캔해 다음 seq 번호를
380
+ """run_seq_dir 안에서 `*-<task-type>-NNN[.<ext>]` 항목을 스캔해 다음 seq 번호를
381
381
  돌려준다. 디렉터리 부재 시 1.
382
+
383
+ 파일과 디렉터리를 함께 센다. 이 카운터가 배정하는 자리 중 하나는
384
+ `prompts/duty-contracts-<task-type>-NNN/` 이라는 **디렉터리**다(확장자도 없다).
385
+ 파일만 세면 중단된 prepare 가 남긴 그 디렉터리가 카운터에 안 잡히고, 다음 런이
386
+ 같은 seq 를 배정받아 남은 스냅샷 위에 착지한다. digest 가 다르면
387
+ `run._snapshot_duty_catalog` 이 매번 같은 자리에서 PrepareError 를 내므로,
388
+ 잔재를 손으로 치우기 전까지 그 task 의 prepare 가 영구히 막힌다.
382
389
  """
383
- pattern = re.compile(rf"-{re.escape(task_type_segment)}-(\d{{3,}})\.[^.]+$")
390
+ pattern = re.compile(
391
+ rf"-{re.escape(task_type_segment)}-(\d{{3,}})(?:\.[^.]+)?$"
392
+ )
384
393
  max_seq = 0
385
394
  if run_seq_dir.is_dir():
386
395
  for entry in os.listdir(run_seq_dir):
387
- full = run_seq_dir / entry
388
- if not full.is_file():
389
- continue
390
396
  m = pattern.search(entry)
391
397
  if m:
392
398
  n = int(m.group(1))
@@ -3102,6 +3102,23 @@ def _write_prior_run_error_digest(ctx: dict, instruction_set: Path) -> None:
3102
3102
  (instruction_set / "prior-run-errors.md").write_text(digest, encoding="utf-8")
3103
3103
 
3104
3104
 
3105
+ def _stage_or_clear(path: Path, body: str) -> None:
3106
+ """이 런의 값을 쓰거나, 값이 없으면 이전 런이 남긴 파일을 지운다.
3107
+
3108
+ instruction-set 은 run 단위가 아니라 **task 단위** 디렉터리다
3109
+ (`paths.py`: `task_root / "instruction-set"`). 그래서 값이 있을 때만 쓰고
3110
+ 없을 때 아무것도 안 하면, 이전 런의 파일이 그대로 남아 다음 런의 입력이 된다.
3111
+ directive 를 비우고 준비한 런에서 워커들이 예전 directive 를 읽고, 그 내용이
3112
+ 현재 기록과 어긋나 반박에 시간을 쓴 사례가 보고됐다. carry-in 답변
3113
+ (`clarification-response.md`)은 같은 형태에 결과가 더 나쁘다 — 남은 파일이
3114
+ analysis packet 에 그대로 실린다.
3115
+ """
3116
+ if body:
3117
+ path.write_text(body, encoding="utf-8")
3118
+ return
3119
+ path.unlink(missing_ok=True)
3120
+
3121
+
3105
3122
  def _write_instruction_set_sources(
3106
3123
  inp: PrepareInputs,
3107
3124
  ctx: dict,
@@ -3178,22 +3195,26 @@ def _write_instruction_set_sources(
3178
3195
  )
3179
3196
  ctx["HOST_ORCHESTRATION_RULES_RELATIVE_PATH"] = host_rules_relative
3180
3197
  if inp.clarification_response_path:
3181
- (instruction_set / "clarification-response.md").write_text(
3182
- clarification_response_with_sidecars(Path(inp.clarification_response_path)),
3183
- encoding="utf-8",
3198
+ clarification_body = clarification_response_with_sidecars(
3199
+ Path(inp.clarification_response_path)
3184
3200
  )
3185
3201
  elif inp.task_type == "implementation" and inp.approved_plan_path:
3186
3202
  # implementation carry-in: the approved plan reaches this run by path
3187
3203
  # (executor re-reads it), so attach ONLY the planning HTML form answers
3188
3204
  # (`runs/implementation-planning/.../user-responses/`) — without these
3189
3205
  # the user's clarification answers never reach the implementation run.
3190
- plan_responses = attached_user_responses_section(Path(inp.approved_plan_path))
3191
- if plan_responses:
3192
- (instruction_set / "clarification-response.md").write_text(
3193
- plan_responses, encoding="utf-8",
3194
- )
3195
- if inp.directive:
3196
- (instruction_set / "directive.txt").write_text(inp.directive + "\n", encoding="utf-8")
3206
+ clarification_body = attached_user_responses_section(
3207
+ Path(inp.approved_plan_path)
3208
+ )
3209
+ else:
3210
+ clarification_body = ""
3211
+ _stage_or_clear(
3212
+ instruction_set / "clarification-response.md", clarification_body
3213
+ )
3214
+ _stage_or_clear(
3215
+ instruction_set / "directive.txt",
3216
+ inp.directive + "\n" if inp.directive else "",
3217
+ )
3197
3218
  render_reference_expectations(
3198
3219
  str(inp.brief_path), str(instruction_set / "reference-expectations.md"), ctx,
3199
3220
  )
@@ -5138,6 +5138,39 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
5138
5138
  stage=stage, path=str(decision.path), branch=decision.branch)
5139
5139
 
5140
5140
 
5141
+ def _critic_collision_lines(state: WizardState) -> list[str]:
5142
+ """critic provider 가 분석 로스터와 겹칠 때 그 대가를 확인 화면에서 말한다.
5143
+
5144
+ gap 검증 투표자는 `convergence_engine._is_critic_worker` 가 critic **provider
5145
+ 이름**으로 걸러낸다 — critic 워커 id 가 아니라 provider 다. 그래서 같은
5146
+ provider 의 분석 워커가 투표에서 빠지고, 분석 워커가 둘뿐인 로스터에서는
5147
+ 정족수가 1로 준다. 그 사실이 선택 시점에는 어디에도 드러나지 않아, critic 도
5148
+ 돌고 gap 검증 라운드도 완료된 뒤 `apply-critic-gaps` 가 거부할 때에야 알려졌다
5149
+ — 이미 워커 실행 비용을 쓴 다음이다.
5150
+
5151
+ 막지는 않는다. 겹치는 provider 를 고르는 것이 잘못은 아니고, 그 대가를 알고
5152
+ 고르는 것과 모르고 고르는 것의 차이만 없앤다.
5153
+ """
5154
+ if not state.critic or state.critic == "off":
5155
+ return []
5156
+ analysers = [
5157
+ worker for worker in _resolved_roster(state) if worker != "report-writer"
5158
+ ]
5159
+ excluded = [
5160
+ worker for worker in analysers
5161
+ if worker in {state.critic, f"{state.critic}-worker"}
5162
+ ]
5163
+ if not excluded:
5164
+ return []
5165
+ return [
5166
+ _msg(
5167
+ state.workspace_root, "confirmation", "critic_shares_analyser_provider",
5168
+ worker=excluded[0],
5169
+ remaining=len(analysers) - len(excluded),
5170
+ )
5171
+ ]
5172
+
5173
+
5141
5174
  def _build_confirm(state: WizardState) -> Prompt:
5142
5175
  t = _p(state.workspace_root, "confirm")
5143
5176
  return Prompt(
@@ -5406,19 +5439,6 @@ STEPS: list[Step] = [
5406
5439
  applies=lambda s: s.base_ref_pending_text,
5407
5440
  build=_build_base_ref_text, submit=_submit_base_ref_text,
5408
5441
  owns=("base_ref", "base_ref_pending_text")),
5409
- Step(S_SELECTED_DIRECTION_PICK,
5410
- applies=lambda s: (
5411
- s.task_type == "implementation-planning"
5412
- and not s.selected_direction_path
5413
- and not _planning_rerun_selected(s)
5414
- and _brief_resolved(s)
5415
- and _base_ref_ready(s)
5416
- and not s.base_ref_pending_text
5417
- and S_SELECTED_DIRECTION_PICK not in s.answered
5418
- ),
5419
- build=_build_selected_direction_pick,
5420
- submit=_submit_selected_direction_pick,
5421
- owns=("selected_direction_path",)),
5422
5442
  Step(S_APPROVED_PLAN_PICK,
5423
5443
  applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
5424
5444
  and not s.approved_plan_path
@@ -5635,6 +5655,31 @@ STEPS: list[Step] = [
5635
5655
  and S_CLARIFICATION not in s.answered),
5636
5656
  build=_build_clarification, submit=_submit_clarification,
5637
5657
  owns=("clarification_response_path", "clarification_pending_text")),
5658
+ # clarification 뒤에 온다. `_planning_rerun_selected` 는 clarification 답을 읽어
5659
+ # "이 런은 기존 계획서의 재실행" 인지 판정하는데, 이 단계가 clarification 앞에
5660
+ # 있던 동안 그 값은 언제나 비어 있었다 — 가드가 참이 될 수 없는 자리에 놓여
5661
+ # 있었다. 그 결과 재실행이어야 할 런이 매번 방향 선택으로 들어갔고,
5662
+ # implementation-option-selection 리포트가 없는 레거시 후보비교 task 는
5663
+ # 거기서 끝났다.
5664
+ Step(S_SELECTED_DIRECTION_PICK,
5665
+ applies=lambda s: (
5666
+ s.task_type == "implementation-planning"
5667
+ and not s.selected_direction_path
5668
+ and not _planning_rerun_selected(s)
5669
+ and _brief_resolved(s)
5670
+ and _base_ref_ready(s)
5671
+ and not s.base_ref_pending_text
5672
+ and S_SELECTED_DIRECTION_PICK not in s.answered
5673
+ # 고를 것이 없으면 묻지 않는다. 물으면 build 가 WizardError 를 내고
5674
+ # `current: null` 로 끝나 재프롬프트조차 불가능해진다 — 사용자가
5675
+ # 되돌릴 수 없는 막다른 길이다. 방향 없이 진행한 런은
5676
+ # `run._validate_planning_entry_inputs` 가 두 입력을 모두 이름 붙여
5677
+ # 거절하므로, 실패는 복구 가능한 자리로 옮겨간다.
5678
+ and bool(_selected_direction_candidates(s))
5679
+ ),
5680
+ build=_build_selected_direction_pick,
5681
+ submit=_submit_selected_direction_pick,
5682
+ owns=("selected_direction_path",)),
5638
5683
  Step(S_PR_TEMPLATE_PICK,
5639
5684
  applies=lambda s: (s.use_defaults is False
5640
5685
  and s.task_type == "release-handoff"
@@ -6451,6 +6496,7 @@ def confirmation_block(state: WizardState) -> str:
6451
6496
  if (not role_selection
6452
6497
  and state.task_type in ("requirements-discovery", "error-analysis", "implementation-planning", "final-verification")):
6453
6498
  lines.append(f" critic : {state.critic or '(off)'}")
6499
+ lines.extend(_critic_collision_lines(state))
6454
6500
  if state.task_type in _STAGE_SCOPED_TASK_TYPES:
6455
6501
  lines.append(f" approved-plan : {state.approved_plan_path}")
6456
6502
  stage = wizard_stage_confirmation_label(
@@ -508,24 +508,27 @@ def _planned_paths_from_report(
508
508
  if paths:
509
509
  return _relative_paths(tuple(sorted(paths))), True
510
510
  # A plan approved before `plannedPaths` existed carries its paths only in
511
- # the prose `files` cell. Deriving them there is exactly what this change
512
- # removed — it mis-splits globs and parentheticals — so the derived set is
513
- # not used to reject anything. It travels with `plannedPathsDeclared:
514
- # false`, which turns the path comparison off for that stage rather than
515
- # failing every file the plan asked for. An in-flight task keeps running;
516
- # the next planning run produces a plan the audit can actually enforce.
517
- derived = {
518
- part.strip().strip("`")
511
+ # the prose `files` cell. That prose does not survive being split into
512
+ # paths — it mis-splits globs and parentheticals — so the ledger stays
513
+ # EMPTY rather than carrying values nobody can be held to. An empty ledger
514
+ # on a `project-mutation` policy is what tells the mutation audit to stand
515
+ # its path comparison down (`execution_mutation_audit`); carrying the
516
+ # derived fragments instead is what made every file the plan asked for read
517
+ # as an unauthorized source change. An in-flight task keeps running; the
518
+ # next planning run produces a plan the audit can actually enforce.
519
+ #
520
+ # The prose is still read, for one decision only: telling a pre-column plan
521
+ # apart from a malformed one whose rows name no files at all.
522
+ names_files = any(
523
+ str(step.get("files") or "").strip()
519
524
  for step in rows if isinstance(step, Mapping)
520
- for part in str(step.get("files") or "").replace("<br>", ",").split(",")
521
- if part.strip()
522
- }
523
- if not derived:
525
+ )
526
+ if not names_files:
524
527
  raise WritePolicyError(
525
528
  "implementer write policy stage has no planned paths: the approved "
526
529
  "plan's stepwise rows carry neither `plannedPaths` nor `files`"
527
530
  )
528
- return _relative_paths(tuple(sorted(derived))), False
531
+ return (), False
529
532
 
530
533
 
531
534
  def _rooted(project_root: Path, value: str) -> Path:
@@ -9,8 +9,9 @@ Design: docs/superpowers/specs/2026-06-10-blocking-contract-posthoc-conformance-
9
9
  | 3. implementation entry guard | prompts/lead/okstra-lead-contract.md "Entry guard (BLOCKING)" + prompts/launch.template.md "Host Orchestration Rules" (only when the run staged the rules file) | selected adapter evidence source |
10
10
 
11
11
  Evidence rules prevent false passes:
12
- - `claude-jsonl` accepts only assistant text and Read tool-use records, excluding
13
- injected skill text and sidechain records.
12
+ - `claude-jsonl` accepts only assistant text and file-read tool-use records (a
13
+ `Read` call, or a shell command naming the file), excluding injected skill text
14
+ and sidechain records.
14
15
  - Session evidence is scoped to the current run window so a previous run cannot
15
16
  satisfy the neutral lead lifecycle contract.
16
17
  - Checkpoints emitted after validation begins (`phase-7-teardown`, `complete`)
@@ -143,6 +144,40 @@ _TRACKED_READ_BASENAMES = (
143
144
  )
144
145
 
145
146
 
147
+ # 셸 read 도 증거다. 계약이 요구하는 것은 "이 run 안에서 그 파일의 내용을 실제로
148
+ # 적재했는가" 이고, `cat` / `sed -n` 는 Read 도구와 동일하게 그것을 만족한다.
149
+ # 도구 이름만 인정하면, 호스트가 "가능하면 Bash 로 파일을 읽으라"고 지시하는 모드
150
+ # (Claude Code auto 모드)에서는 계약을 그대로 이행한 lead 가 매번
151
+ # `contract-violated` 로 끝난다 — 두 지시가 정면으로 충돌한다.
152
+ #
153
+ # basename 이 경로 토큰으로 등장할 때만 인정한다. 트리 전체를 훑는 명령
154
+ # (`grep -rn "..." prompts/`)은 그 파일을 지목하지 않았으므로 매칭되지 않고,
155
+ # 파일을 인자로 준 명령은 매칭된다 — 후자는 실제로 그 파일을 읽는다.
156
+ _SHELL_READ_TOOL_NAME = "Bash"
157
+ _SHELL_READ_PATTERNS = {
158
+ base: re.compile(rf"(?:^|[\s'\"`=(/]){re.escape(base)}(?:$|[\s'\"`);:,])")
159
+ for base in _TRACKED_READ_BASENAMES
160
+ }
161
+
162
+
163
+ def _read_evidence_basenames(block: dict) -> tuple[str, ...]:
164
+ """tool_use 블록 하나가 증거로 인정되는 tracked basename 들."""
165
+ name = block.get("name")
166
+ payload = block.get("input") or {}
167
+ if name == "Read":
168
+ base = Path(str(payload.get("file_path") or "")).name
169
+ return (base,) if base in _TRACKED_READ_BASENAMES else ()
170
+ if name == _SHELL_READ_TOOL_NAME:
171
+ command = str(payload.get("command") or "")
172
+ if not command:
173
+ return ()
174
+ return tuple(
175
+ base for base, pattern in _SHELL_READ_PATTERNS.items()
176
+ if pattern.search(command)
177
+ )
178
+ return ()
179
+
180
+
146
181
  @dataclass
147
182
  class SessionConformanceResult:
148
183
  errors: list[str] = field(default_factory=list)
@@ -247,9 +282,8 @@ def _scan_one_jsonl(
247
282
  for m in _PROGRESS_LINE_RE.finditer(block.get("text") or ""):
248
283
  line = f"PROGRESS: {m.group('phase')}{m.group('rest')}".rstrip()
249
284
  progress.append((ts, m.group("phase"), line))
250
- elif block.get("type") == "tool_use" and block.get("name") == "Read":
251
- base = Path(str((block.get("input") or {}).get("file_path") or "")).name
252
- if base in _TRACKED_READ_BASENAMES:
285
+ elif block.get("type") == "tool_use":
286
+ for base in _read_evidence_basenames(block):
253
287
  reads.setdefault(base, []).append(ts)
254
288
  return progress, reads, agent_name
255
289
 
@@ -1326,7 +1360,8 @@ def _check_cmux_adapter_read(
1326
1360
  if evidence.sidecar_reads.get(CMUX_ADAPTER_BASENAME):
1327
1361
  return
1328
1362
  errors.append(
1329
- f"cmux adapter: no `Read` of `{CMUX_ADAPTER_BASENAME}` found in the "
1363
+ f"cmux adapter: no read of `{CMUX_ADAPTER_BASENAME}` (a `Read` call or a "
1364
+ f"shell command naming it) found in the "
1330
1365
  "selected adapter evidence source within this run's window — the cmux "
1331
1366
  "adapter is selected by environment, not by lead runtime, so it MUST be "
1332
1367
  f"read before dispatch ({_CMUX_ADAPTER_CITE})."
@@ -1358,7 +1393,8 @@ def _check_implementation_entry_guard(
1358
1393
  else "the staged copy MUST be read at least once inside that window"
1359
1394
  )
1360
1395
  errors.append(
1361
- f"implementation entry guard: no `Read` of `{row.basename}` found in "
1396
+ f"implementation entry guard: no read of `{row.basename}` (a `Read` call "
1397
+ f"or a shell command naming it) found in "
1362
1398
  f"the selected adapter evidence source within this run's window — "
1363
1399
  f"{demand} ({row.cite})."
1364
1400
  )
@@ -1369,7 +1405,7 @@ def _check_implementation_entry_guard(
1369
1405
  anchor_ts = anchors.get(anchor_phase)
1370
1406
  if anchor_ts and min(ts_list) >= anchor_ts:
1371
1407
  errors.append(
1372
- f"implementation entry guard: `{row.basename}` was first Read at "
1408
+ f"implementation entry guard: `{row.basename}` was first read at "
1373
1409
  f"{min(ts_list)}, not before the first `PROGRESS: {anchor_phase}` "
1374
1410
  f"line ({anchor_ts}) — it must be loaded {when}, before "
1375
1411
  f"that checkpoint ({row.cite})."