okstra 0.179.0 → 0.179.2
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 +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +5 -2
- package/runtime/prompts/wizard/prompts.ko.json +1 -0
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +22 -3
- package/runtime/python/okstra_ctl/convergence_store.py +13 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +60 -12
- package/runtime/python/okstra_ctl/dispatch_state.py +14 -1
- package/runtime/python/okstra_ctl/execution_manifest.py +22 -3
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +34 -5
- package/runtime/python/okstra_ctl/paths.py +11 -5
- package/runtime/python/okstra_ctl/render_final_report.py +19 -14
- package/runtime/python/okstra_ctl/run.py +31 -10
- package/runtime/python/okstra_ctl/schema_excerpt.py +86 -11
- package/runtime/python/okstra_ctl/wizard.py +59 -13
- package/runtime/python/okstra_ctl/write_policy.py +16 -13
- package/runtime/validators/validate-run.py +67 -11
- package/runtime/validators/validate_session_conformance.py +44 -8
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -230,6 +230,9 @@ round before any host or provider process starts.
|
|
|
230
230
|
**Verdict provenance (BLOCKING).** Every verdict recorded in `planItems[].verdicts[]` MUST trace back to a dispatch that actually returned a result file at the path above. The whole gate — classification, self-fix eligibility, promotion, `gateBlockedBy` — is computed from these votes, so an unbacked vote lets the round be skipped while the gate still reads `passed`. **Enforced:** `validators/validate-run.py` `_validate_plan_body_verdict_provenance` fails any `verdicts[].worker` with no matching `<worker>-plan-verify-r<N>-<task-type>-<seq>.md` result file. Recording a `verification-error` for a dispatch that produced no result is the correct way to represent a failed worker — inventing an `AGREE` is a contract violation.
|
|
231
231
|
|
|
232
232
|
4. After all dispatches return, lead aggregates verdicts per `P-*` item across workers and classifies each:
|
|
233
|
+
|
|
234
|
+
**Every item carries at least one verdict row (BLOCKING).** Aggregation covers the whole queue, not the subset that came back cleanly. An item left with an empty `verdicts[]` is not a weak signal the gate can discount — it classifies `all-non-result`, states as `needs-reverify`, and folds into `passed-with-dissent` next to items two verifiers actually agreed on, so a plan item nobody judged reads as a passing one. This is the shape a self-fix round produces when the planner adds an item and the targeted round-N queue never picks it up. A worker that returned nothing is a `verification-error` row (step 3), not a missing row; if an item was never dispatched, dispatch it before scoring the round. **Enforced:** `validators/validate-run.py` `_validate_round_recorded_verdicts` fails any run whose `roundCount` ≥ 1 leaves an extracted item with no verdict row.
|
|
235
|
+
|
|
233
236
|
- `full-consensus` — all participating analysers `AGREE` (SUPPLEMENT counts as agree on the item itself).
|
|
234
237
|
- `partial-consensus` — majority `AGREE`, dissenting `DISAGREE` recorded.
|
|
235
238
|
- `dissent-isolated` — only one worker `DISAGREE`s, others `AGREE` — treat as `partial-consensus` for gate purposes; record dissent. (Distinct from finding-convergence `worker-unique`, which means the *opposite*: only one worker AGREEs. Plan-body classifications use this dedicated label to avoid the collision.)
|
|
@@ -290,7 +293,7 @@ round before any host or provider process starts.
|
|
|
290
293
|
- `Blocks=approval`
|
|
291
294
|
- the item's `planItems[].clarificationId` set to that `C-<N>` (1:1 link). `validators/validate-run.py` `_validate_plan_body_clarification_matching` recomputes each item's class and fails when a majority-disagree item's `clarificationId` is missing, dangling, or points at a non-`approval` row.
|
|
292
295
|
- set `approvalContext.classification` to `user-decision` for a majority `needs-user-input` item, `correctness-critical` for `DISAGREE(a)`, `DISAGREE(f)` on `P-Req-*`, or an independent Requirement Coverage blocker, and `noncritical-dissent` for another surviving majority disagreement.
|
|
293
|
-
- populate `approvalContext.planItemIds`, `activityIds`, `unblockCondition`, and `recommendedDisposition`. Every option carries a `disposition`: `select` only for `user-decision`, `accept-risk` only for `noncritical-dissent`, and `request-revision` / `reject` for any classification. `correctness-critical` never offers or records `accept-risk`. **Enforced:** `validators/validate-run.py` `_validate_approval_context`.
|
|
296
|
+
- populate `approvalContext.planItemIds`, `activityIds`, `unblockCondition`, and `recommendedDisposition`. `planItemIds` carries the **extracted item ids verbatim** — the ordinal form the extractor issues (`P-Opt-1`, `P-Step-1.1`), never the human label the plan prose uses for the same thing ("Option C", "Completion B"). The letter label is what that item's `subject` records (§"subject" above: `P-Opt-1` → "Option A: …"), so cite the id and let the subject carry the name; a label written into `planItemIds` reads as an unknown plan item and fails the run. Every option carries a `disposition`: `select` only for `user-decision`, `accept-risk` only for `noncritical-dissent`, and `request-revision` / `reject` for any classification. `correctness-critical` never offers or records `accept-risk`. **Enforced:** `validators/validate-run.py` `_validate_approval_context`.
|
|
294
297
|
- **Self-fix exhaustion is not risk acceptance.** A `noncritical-dissent` item remains blocking until the user explicitly selects `accept-risk`. Record the user's non-empty original text and the `user-decision-required` / `user-decision-evaluated` activity references in `approvalContext.resolution`; only then does `validators/validate-run.py` `_resolved_noncritical_dissent_ids` let `_is_dissent_downgraded` fold it into `passed-with-dissent`.
|
|
295
298
|
- **Correctness-critical defects cannot be waived.** After the user-directed correction, targeted re-verification of every linked item MUST record only `AGREE` or an acceptable `SUPPLEMENT`, and any independent Requirement Coverage blocker MUST be removed before the row becomes `resolved`. A `DISAGREE` or `verification-error` returns it to `open`. **Enforced:** `validators/validate-run.py` `_validate_correctness_resolution`.
|
|
296
299
|
- When a correctness-critical `planner-fixable` item is promoted, its `Statement` MUST state "planner self-fix attempted but unresolved" and name the stop reason. `validators/validate-run.py` `_validate_self_fix_before_clarification` fails when a planner-fixable majority item is promoted while the budget is not exhausted — it requires `selfFixRoundsApplied >= 1` **and** `selfFixStopReason` in `{no-progress, max-rounds-reached}`, so neither `all-resolved` nor `not-attempted` can excuse a promotion.
|
|
@@ -553,7 +556,7 @@ Changes from the round-1 template:
|
|
|
553
556
|
|
|
554
557
|
`**Prior dissent**: resolved | unresolved | none — <which prior objection, and what the current text does about it>`
|
|
555
558
|
|
|
556
|
-
**Enforced
|
|
559
|
+
**Enforced — on the answer, not on the question.** `validators/validate-run.py` `_validate_reverify_result_addresses_prior_dissent` fails a run whose round 2+ plan-verify **worker results** lack the `**Prior dissent**` line described directly above. Nothing reads the prompt file, so the `**Prior round dissent**` block is a guideline: it is how you make that line answerable, and a worker cannot write it without one. Note the two spellings are different anchors for different artifacts — `**Prior round dissent**` is the block you put in the prompt, `**Prior dissent**` is the line the worker puts in its result, and only the latter is checked (`scripts/okstra_ctl/verdict_blocks.py` parses it as a response field). A prompt that carries the block while the response format omits the line fails this check with the block present.
|
|
557
560
|
|
|
558
561
|
## Worker non-result handling in plan-body round (BLOCKING)
|
|
559
562
|
|
|
@@ -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
|
|
524
|
-
"invocation
|
|
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=
|
|
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"),
|
|
@@ -91,7 +91,11 @@ from .initial_prompt_materialization import (
|
|
|
91
91
|
)
|
|
92
92
|
from .agent_invocation import AgentInvocationError, materialize_retry_invocation
|
|
93
93
|
from .path_hints import hydrate_active_run_context
|
|
94
|
-
from .schema_excerpt import
|
|
94
|
+
from .schema_excerpt import (
|
|
95
|
+
bundle_excerpt_path,
|
|
96
|
+
describe_changed,
|
|
97
|
+
excerpt_contract_skew,
|
|
98
|
+
)
|
|
95
99
|
from .seeding import installed_version
|
|
96
100
|
from .report_finalize import (
|
|
97
101
|
STEP_VALIDATE_RUN,
|
|
@@ -359,7 +363,9 @@ def build_dispatch_plan(
|
|
|
359
363
|
default_provider_by_worker_id=dict(default_provider_by_worker_id or {}),
|
|
360
364
|
)
|
|
361
365
|
if jobs_file:
|
|
362
|
-
jobs = _jobs_from_file(
|
|
366
|
+
jobs = _jobs_from_file(
|
|
367
|
+
project_root, workspace_root, jobs_file, manifest, active_context, options
|
|
368
|
+
)
|
|
363
369
|
else:
|
|
364
370
|
jobs = _jobs_from_roster(
|
|
365
371
|
project_root,
|
|
@@ -416,7 +422,10 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
|
416
422
|
return 0
|
|
417
423
|
round_artifact_paths = _round_artifact_paths(plan)
|
|
418
424
|
handles = [
|
|
419
|
-
_spawn_job(
|
|
425
|
+
_spawn_job(
|
|
426
|
+
plan, job, _next_attempt(plan, job),
|
|
427
|
+
batch_artifact_paths=round_artifact_paths,
|
|
428
|
+
)
|
|
420
429
|
for job in plan.jobs
|
|
421
430
|
]
|
|
422
431
|
_record_dispatch_facts(plan.team_state_path, _mode_from_handles(handles))
|
|
@@ -1292,6 +1301,7 @@ def _jobs_from_file(
|
|
|
1292
1301
|
workspace_root: Path,
|
|
1293
1302
|
jobs_file: Path | None,
|
|
1294
1303
|
manifest: Mapping[str, Any],
|
|
1304
|
+
active_context: Mapping[str, Any],
|
|
1295
1305
|
options: _BuildOptions,
|
|
1296
1306
|
) -> list[WorkerJob]:
|
|
1297
1307
|
if jobs_file is None:
|
|
@@ -1300,6 +1310,7 @@ def _jobs_from_file(
|
|
|
1300
1310
|
project_root,
|
|
1301
1311
|
jobs_file,
|
|
1302
1312
|
manifest=manifest,
|
|
1313
|
+
active_context=active_context,
|
|
1303
1314
|
backend=options.default_backend,
|
|
1304
1315
|
idle_timeout_seconds=options.idle_timeout_seconds,
|
|
1305
1316
|
default_dispatch_kind=options.dispatch_kind,
|
|
@@ -1390,6 +1401,34 @@ def _spawn_cli_job_nonblocking(
|
|
|
1390
1401
|
)
|
|
1391
1402
|
|
|
1392
1403
|
|
|
1404
|
+
def _next_attempt(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
1405
|
+
"""이 invocation 이 다음에 청구할 attempt 번호.
|
|
1406
|
+
|
|
1407
|
+
attempt 를 1 로 고정하면 같은 워커를 두 번째로 디스패치할 수 없다. 원장은
|
|
1408
|
+
단조 증가를 요구하므로(`execution_manifest._validate_next_attempt`) 두 번째
|
|
1409
|
+
호출이 항상 `next attempt must be 2` 로 거부되고, 재시도 예산이 계약에는
|
|
1410
|
+
있는데 그 경로에는 쓸 수단이 없는 상태가 된다. 예산은 프로세스가 아니라
|
|
1411
|
+
invocation 에 붙어 있고, 그 잔액이 적힌 곳은 원장뿐이다.
|
|
1412
|
+
"""
|
|
1413
|
+
if not job.has_execution_identity:
|
|
1414
|
+
return job.attempt
|
|
1415
|
+
manifest = read_execution_manifest(plan.manifest_path)
|
|
1416
|
+
prior = [
|
|
1417
|
+
row.attempt for row in manifest.attempts
|
|
1418
|
+
if row.invocation_ref == job.invocation_ref
|
|
1419
|
+
]
|
|
1420
|
+
if not prior:
|
|
1421
|
+
return job.attempt
|
|
1422
|
+
spent = max(prior)
|
|
1423
|
+
if spent >= MAX_WORKER_ATTEMPTS:
|
|
1424
|
+
raise DispatchError(
|
|
1425
|
+
f"worker retry budget is spent: {job.invocation_ref} used "
|
|
1426
|
+
f"{spent} of {MAX_WORKER_ATTEMPTS} attempts. Materialize a new "
|
|
1427
|
+
"invocation to dispatch this worker again."
|
|
1428
|
+
)
|
|
1429
|
+
return spent + 1
|
|
1430
|
+
|
|
1431
|
+
|
|
1393
1432
|
def _prepare_job_attempt(
|
|
1394
1433
|
plan: DispatchPlan, job: WorkerJob, attempt: int
|
|
1395
1434
|
) -> WorkerJob:
|
|
@@ -1622,7 +1661,7 @@ def _dispatch_round(dispatch_kind: str) -> int:
|
|
|
1622
1661
|
|
|
1623
1662
|
|
|
1624
1663
|
def _dispatch_job_with_retry(plan: DispatchPlan, job: WorkerJob) -> int:
|
|
1625
|
-
for attempt in range(
|
|
1664
|
+
for attempt in range(_next_attempt(plan, job), MAX_WORKER_ATTEMPTS + 1):
|
|
1626
1665
|
handle = _spawn_job(plan, job, attempt)
|
|
1627
1666
|
if handle.completed_process is None:
|
|
1628
1667
|
return await_dispatches(plan, timeout_seconds=None)
|
|
@@ -2942,11 +2981,17 @@ def _reject_stale_schema_excerpt(
|
|
|
2942
2981
|
schema. A run long enough to straddle a runtime upgrade therefore has the
|
|
2943
2982
|
author writing to one contract and the validator reading another — and the
|
|
2944
2983
|
only thing that noticed was the renderer, in Phase 6, after the worker had
|
|
2945
|
-
authored the whole report. The
|
|
2984
|
+
authored the whole report. The same inputs are already on hand the moment the
|
|
2946
2985
|
dispatch is built, and the remedy is the same either way, so it belongs here.
|
|
2947
2986
|
|
|
2948
2987
|
Only the report writer is stopped: it is the only worker that authors against
|
|
2949
2988
|
the excerpt. Re-running bundle prep re-cuts it from the installed schema.
|
|
2989
|
+
|
|
2990
|
+
The stamp alone is not the test. Most releases change nothing this task-type
|
|
2991
|
+
authors against, and re-prep is not a cheap way to rewrite one stamp line: it
|
|
2992
|
+
opens a new run, so every artifact bound to the current one — convergence
|
|
2993
|
+
state, critic merges, the plan-item queue and its verdicts — is left behind.
|
|
2994
|
+
The guard therefore fires on the contract text and names what moved.
|
|
2950
2995
|
"""
|
|
2951
2996
|
writer = next(
|
|
2952
2997
|
(job for job in jobs if job.worker_id == REPORT_WRITER_WORKER_ID), None
|
|
@@ -2954,21 +2999,24 @@ def _reject_stale_schema_excerpt(
|
|
|
2954
2999
|
if writer is None:
|
|
2955
3000
|
return
|
|
2956
3001
|
expected = _string_value(manifest.get("expectedReportRecordPath"))
|
|
2957
|
-
|
|
3002
|
+
task_type = _string_value(manifest.get("taskType"))
|
|
3003
|
+
if not expected or not task_type:
|
|
2958
3004
|
return
|
|
2959
3005
|
excerpt_path = bundle_excerpt_path(_resolve_project_path(project_root, expected))
|
|
2960
3006
|
if excerpt_path is None:
|
|
2961
3007
|
return
|
|
2962
3008
|
installed = installed_version()
|
|
2963
|
-
|
|
2964
|
-
if
|
|
3009
|
+
skew = excerpt_contract_skew(excerpt_path, task_type, installed)
|
|
3010
|
+
if skew is None:
|
|
2965
3011
|
return
|
|
2966
3012
|
raise DispatchError(
|
|
2967
3013
|
f"the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
|
|
2968
|
-
f"{cut_from}
|
|
2969
|
-
f"
|
|
2970
|
-
f"
|
|
2971
|
-
f"
|
|
3014
|
+
f"{skew.cut_from} and this runtime is {installed}, which states "
|
|
3015
|
+
f"{task_type}'s contract differently: {describe_changed(skew.changed)}. "
|
|
3016
|
+
f"The report writer authors against that excerpt and validation runs "
|
|
3017
|
+
f"against the installed schema, so dispatching now spends a full authoring "
|
|
3018
|
+
f"pass on the wrong contract. Re-prepare the task bundle to re-cut the "
|
|
3019
|
+
f"excerpt, then dispatch again."
|
|
2972
3020
|
)
|
|
2973
3021
|
|
|
2974
3022
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
575
|
-
# The plan predates the declared path column, so
|
|
576
|
-
#
|
|
577
|
-
#
|
|
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
|
|
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(
|
|
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))
|
|
@@ -59,7 +59,7 @@ from okstra_ctl.report_contract import (
|
|
|
59
59
|
markdown_template_for,
|
|
60
60
|
)
|
|
61
61
|
from okstra_ctl.report_markdown import ReportSections
|
|
62
|
-
from okstra_ctl.schema_excerpt import
|
|
62
|
+
from okstra_ctl.schema_excerpt import describe_changed, excerpt_contract_skew
|
|
63
63
|
from okstra_ctl.seeding import installed_version
|
|
64
64
|
from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
|
|
65
65
|
|
|
@@ -753,34 +753,37 @@ def _bundle_excerpt_path(data_path: Path) -> Path | None:
|
|
|
753
753
|
|
|
754
754
|
|
|
755
755
|
def _with_excerpt_drift_hint(
|
|
756
|
-
exc: FinalReportRenderError, data_path: Path
|
|
756
|
+
exc: FinalReportRenderError, data_path: Path, task_type: str
|
|
757
757
|
) -> FinalReportRenderError:
|
|
758
|
-
"""Name the
|
|
758
|
+
"""Name the contract drift behind a schema failure, when that is the cause.
|
|
759
759
|
|
|
760
760
|
A long run straddles its own runtime upgrade: the report-writer authors
|
|
761
761
|
against the excerpt frozen into the bundle at prep time, while the renderer
|
|
762
762
|
validates against the installed schema. Without this the author sees only
|
|
763
763
|
`additional property ... not allowed` for a field the excerpt told it to
|
|
764
764
|
write, and has no way to tell a real mistake from a stale bundle.
|
|
765
|
+
|
|
766
|
+
An older stamp is not itself the cause. When the excerpt still states this
|
|
767
|
+
task-type's contract exactly, the failure came from somewhere else, and
|
|
768
|
+
pointing at the version would send the author to re-prepare a bundle that
|
|
769
|
+
was never wrong. The hint is attached only when a contract member actually
|
|
770
|
+
moved, and it names which.
|
|
765
771
|
"""
|
|
766
772
|
if "schema validation" not in str(exc):
|
|
767
773
|
return exc
|
|
768
774
|
excerpt_path = _bundle_excerpt_path(data_path)
|
|
769
|
-
if excerpt_path is None:
|
|
770
|
-
return exc
|
|
771
|
-
try:
|
|
772
|
-
cut_from = excerpt_cut_from_version(
|
|
773
|
-
json.loads(excerpt_path.read_text(encoding="utf-8"))
|
|
774
|
-
)
|
|
775
|
-
except (OSError, json.JSONDecodeError):
|
|
775
|
+
if excerpt_path is None or not task_type:
|
|
776
776
|
return exc
|
|
777
777
|
current = installed_version()
|
|
778
|
-
|
|
778
|
+
skew = excerpt_contract_skew(excerpt_path, task_type, current)
|
|
779
|
+
if skew is None:
|
|
779
780
|
return exc
|
|
780
781
|
return FinalReportRenderError(
|
|
781
782
|
f"{exc} — the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
|
|
782
|
-
f"{cut_from} but validation ran on {current}
|
|
783
|
-
"
|
|
783
|
+
f"{skew.cut_from} but validation ran on {current}, which states "
|
|
784
|
+
f"{task_type}'s contract differently: {describe_changed(skew.changed)}; "
|
|
785
|
+
"author against the installed schema, not the excerpt, or re-prepare the "
|
|
786
|
+
"bundle."
|
|
784
787
|
)
|
|
785
788
|
|
|
786
789
|
|
|
@@ -815,7 +818,9 @@ def render_to_file(
|
|
|
815
818
|
template_path=resolved_template,
|
|
816
819
|
)
|
|
817
820
|
except FinalReportRenderError as exc:
|
|
818
|
-
|
|
821
|
+
header = data.get("header") if isinstance(data, dict) else None
|
|
822
|
+
task_type = header.get("taskType") if isinstance(header, dict) else ""
|
|
823
|
+
raise _with_excerpt_drift_hint(exc, data_path, str(task_type or "")) from exc
|
|
819
824
|
|
|
820
825
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
821
826
|
tmp = output_path.with_suffix(output_path.suffix + f".tmp.{os.getpid()}")
|
|
@@ -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
|
-
|
|
3182
|
-
|
|
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
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
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
|
)
|
|
@@ -23,9 +23,12 @@ from __future__ import annotations
|
|
|
23
23
|
|
|
24
24
|
import json
|
|
25
25
|
import re
|
|
26
|
+
from collections.abc import Sequence
|
|
27
|
+
from dataclasses import dataclass
|
|
26
28
|
from pathlib import Path
|
|
27
29
|
|
|
28
|
-
from .
|
|
30
|
+
from .final_report_schema import load_schema_version
|
|
31
|
+
from .report_contract import CURRENT_REPORT_SCHEMA_VERSION, TASK_TYPE_DATA_PROPERTY
|
|
29
32
|
|
|
30
33
|
|
|
31
34
|
_ALL_PER_TYPE_PROPERTIES = frozenset(TASK_TYPE_DATA_PROPERTY.values())
|
|
@@ -79,28 +82,100 @@ def excerpt_cut_from_version(excerpt: dict) -> str:
|
|
|
79
82
|
return value if isinstance(value, str) else ""
|
|
80
83
|
|
|
81
84
|
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class ExcerptSkew:
|
|
87
|
+
"""A bundle excerpt whose contract text differs from the installed one.
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
89
|
+
``cut_from`` is the older okstra version stamped into the bundle;
|
|
90
|
+
``changed`` names the excerpt members that differ, as ``properties.<name>``
|
|
91
|
+
/ ``$defs.<name>`` / a bare top-level key.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
cut_from: str
|
|
95
|
+
changed: tuple[str, ...]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _changed_members(section: str, old: object, fresh: object) -> list[str]:
|
|
99
|
+
old_map = old if isinstance(old, dict) else {}
|
|
100
|
+
fresh_map = fresh if isinstance(fresh, dict) else {}
|
|
101
|
+
return [
|
|
102
|
+
f"{section}.{name}"
|
|
103
|
+
for name in sorted(set(old_map) | set(fresh_map))
|
|
104
|
+
if old_map.get(name) != fresh_map.get(name)
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _changed_excerpt_members(old: dict, fresh: dict) -> tuple[str, ...]:
|
|
109
|
+
"""Every member of *old* that *fresh* states differently.
|
|
110
|
+
|
|
111
|
+
The stamp is excluded by definition — it is the thing that differs whenever
|
|
112
|
+
the two were cut by different runtimes, and it says nothing about what the
|
|
113
|
+
author is being told to write.
|
|
114
|
+
"""
|
|
115
|
+
changed = []
|
|
116
|
+
for section in ("properties", "$defs"):
|
|
117
|
+
changed.extend(_changed_members(section, old.get(section), fresh.get(section)))
|
|
118
|
+
changed.extend(
|
|
119
|
+
key
|
|
120
|
+
for key in sorted(set(old) | set(fresh))
|
|
121
|
+
if key not in ("properties", "$defs", EXCERPT_VERSION_KEY)
|
|
122
|
+
and old.get(key) != fresh.get(key)
|
|
123
|
+
)
|
|
124
|
+
return tuple(changed)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def describe_changed(changed: Sequence[str], limit: int = 5) -> str:
|
|
128
|
+
"""A one-clause rendering of :attr:`ExcerptSkew.changed` for an error message."""
|
|
129
|
+
if not changed:
|
|
130
|
+
return "no contract member differs"
|
|
131
|
+
head = ", ".join(f"`{name}`" for name in changed[:limit])
|
|
132
|
+
rest = len(changed) - limit
|
|
133
|
+
return f"{head} and {rest} more" if rest > 0 else head
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def excerpt_contract_skew(
|
|
137
|
+
excerpt_path: Path, task_type: str, installed: str
|
|
138
|
+
) -> ExcerptSkew | None:
|
|
139
|
+
"""What this task-type's contract states differently since the bundle was cut.
|
|
140
|
+
|
|
141
|
+
``None`` means the bundle excerpt still states the installed contract, and a
|
|
142
|
+
caller that blocks on skew must let the run through. Four ways to get it:
|
|
143
|
+
the file is absent or unreadable, it carries no stamp, the stamp matches, or
|
|
144
|
+
— the case a version comparison gets wrong — the stamp is older but every
|
|
145
|
+
contract member is identical because the release changed nothing this
|
|
146
|
+
task-type authors against. A patch release that touches only dispatch or
|
|
147
|
+
wizard code lands in that fourth case, and blocking an in-flight run on it
|
|
148
|
+
costs a full bundle re-prep to rewrite one stamp line.
|
|
149
|
+
|
|
150
|
+
The installed schema is loaded here rather than passed in, so a caller only
|
|
151
|
+
needs the excerpt path and the manifest's task-type. An install too old to
|
|
152
|
+
carry `schemas/` cannot be compared at all: that returns ``None`` too, since
|
|
153
|
+
an unprovable drift is not grounds to stop a dispatch.
|
|
88
154
|
|
|
89
155
|
The comparison used to live only in the renderer's error decorator, so it ran
|
|
90
156
|
in Phase 6 — after a worker had already authored a whole report against a
|
|
91
|
-
stale excerpt. The same
|
|
157
|
+
stale excerpt. The same inputs are available much earlier, and the fix
|
|
92
158
|
(re-prepare the bundle) is the same either way.
|
|
93
159
|
"""
|
|
94
160
|
if not installed:
|
|
95
|
-
return
|
|
161
|
+
return None
|
|
96
162
|
try:
|
|
97
163
|
excerpt = json.loads(excerpt_path.read_text(encoding="utf-8"))
|
|
98
164
|
except (OSError, json.JSONDecodeError):
|
|
99
|
-
return
|
|
165
|
+
return None
|
|
100
166
|
if not isinstance(excerpt, dict):
|
|
101
|
-
return
|
|
167
|
+
return None
|
|
102
168
|
cut_from = excerpt_cut_from_version(excerpt)
|
|
103
|
-
|
|
169
|
+
if not cut_from or cut_from == installed:
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
fresh = build_schema_excerpt(
|
|
173
|
+
load_schema_version(CURRENT_REPORT_SCHEMA_VERSION), task_type, installed
|
|
174
|
+
)
|
|
175
|
+
except Exception: # noqa: BLE001 — an unloadable schema proves no drift
|
|
176
|
+
return None
|
|
177
|
+
changed = _changed_excerpt_members(excerpt, fresh)
|
|
178
|
+
return ExcerptSkew(cut_from, changed) if changed else None
|
|
104
179
|
|
|
105
180
|
|
|
106
181
|
def bundle_excerpt_path(start: Path) -> Path | None:
|
|
@@ -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.
|
|
512
|
-
#
|
|
513
|
-
#
|
|
514
|
-
#
|
|
515
|
-
#
|
|
516
|
-
#
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
521
|
-
|
|
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
|
|
531
|
+
return (), False
|
|
529
532
|
|
|
530
533
|
|
|
531
534
|
def _rooted(project_root: Path, value: str) -> Path:
|
|
@@ -4001,7 +4001,14 @@ def _recompute_plan_body_gate(
|
|
|
4001
4001
|
return "aborted-non-result"
|
|
4002
4002
|
if any(c == "majority-disagree" for c in classes):
|
|
4003
4003
|
return "blocked-by-disagreement"
|
|
4004
|
-
if any(c in ("has-dissent", "needs-reverify") for c in classes):
|
|
4004
|
+
if any(c in ("has-dissent", "needs-reverify", "all-non-result") for c in classes):
|
|
4005
|
+
# `all-non-result` belongs here for the same reason `needs-reverify`
|
|
4006
|
+
# does — it IS that shape with zero participating votes instead of one
|
|
4007
|
+
# (`_state_classification` maps it there, and the contract's step 5
|
|
4008
|
+
# lists `needs-reverify` under `passed-with-dissent`). Left out, an
|
|
4009
|
+
# item no verifier could judge scored `passed`: the all-error case
|
|
4010
|
+
# already reads `needs-reverify` in the state file while the gate it
|
|
4011
|
+
# feeds says every item reached consensus.
|
|
4005
4012
|
return "passed-with-dissent"
|
|
4006
4013
|
return "passed"
|
|
4007
4014
|
|
|
@@ -4448,10 +4455,18 @@ def _validate_approval_activity_refs(
|
|
|
4448
4455
|
}
|
|
4449
4456
|
unknown_activity_ids = sorted(activity_ids - set(activities))
|
|
4450
4457
|
if not activity_ids or unknown_activity_ids:
|
|
4458
|
+
# `recorded` names what the ACTIVITY LEDGER holds, not what this row
|
|
4459
|
+
# cited. Printing the row's own ids put the same value on both sides —
|
|
4460
|
+
# `unknown=['A-501'], recorded=['A-501']` — and hid the actual cause,
|
|
4461
|
+
# which is an empty `agentActivity[]` (the run never called
|
|
4462
|
+
# `okstra agent-activity append`). A reader cannot reach that from a
|
|
4463
|
+
# message that contradicts itself.
|
|
4464
|
+
recorded = sorted(activities)
|
|
4451
4465
|
failures.append(
|
|
4452
4466
|
f"final-report data.json: approval clarification `{row_id}` activityIds "
|
|
4453
4467
|
f"must reference agentActivity[].activityId values; unknown="
|
|
4454
|
-
f"{unknown_activity_ids or 'none'}, recorded=
|
|
4468
|
+
f"{unknown_activity_ids or 'none'}, recorded in agentActivity[]="
|
|
4469
|
+
f"{recorded or 'none — the activity ledger is empty'}."
|
|
4455
4470
|
)
|
|
4456
4471
|
elif not all(
|
|
4457
4472
|
_is_decision_required_activity(activities.get(ref)) for ref in activity_ids
|
|
@@ -4913,9 +4928,17 @@ def _validate_approval_context(
|
|
|
4913
4928
|
historical_coverage_ids,
|
|
4914
4929
|
activities,
|
|
4915
4930
|
):
|
|
4931
|
+
# 사람이 읽는 옵션 라벨(`P-Opt-C`)을 ID 로 착각하는 경우가 여기로
|
|
4932
|
+
# 온다. ID 는 추출기가 발행한 서수(`P-Opt-1`)이고, 문자 라벨은 그
|
|
4933
|
+
# 항목의 `subject` 가 나른다. 무엇을 써야 하는지 알려 주지 않으면
|
|
4934
|
+
# 작성자가 두 체계 사이에서 되돌아갈 곳이 없다.
|
|
4916
4935
|
failures.append(
|
|
4917
4936
|
f"final-report data.json: approval clarification `{row_id}` planItemIds "
|
|
4918
|
-
f"reference unknown plan items {sorted(unknown_ids)}."
|
|
4937
|
+
f"reference unknown plan items {sorted(unknown_ids)}. Use the "
|
|
4938
|
+
"extracted item id verbatim (ordinal, e.g. `P-Opt-1`) — the human "
|
|
4939
|
+
"label it carries (\"Option C\") lives in that item's `subject`, "
|
|
4940
|
+
f"not in its id. Extracted ids in this report: "
|
|
4941
|
+
f"{sorted(plan_items_by_id)[:12] or 'none'}."
|
|
4919
4942
|
)
|
|
4920
4943
|
linked_items = [
|
|
4921
4944
|
plan_items_by_id[item_id]
|
|
@@ -6606,7 +6629,7 @@ def _validate_stage_carry_sidecar_exists(
|
|
|
6606
6629
|
|
|
6607
6630
|
|
|
6608
6631
|
def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
6609
|
-
"""A round that ran must leave the votes it ran on.
|
|
6632
|
+
"""A round that ran must leave the votes it ran on — item by item.
|
|
6610
6633
|
|
|
6611
6634
|
The gate is re-derived from `planItems[].verdicts[]`, so an empty table
|
|
6612
6635
|
removes the very evidence the recompute judges. A *healthier* declared gate
|
|
@@ -6615,6 +6638,22 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
6615
6638
|
declaration: a lead writing `aborted-non-result` over an empty table
|
|
6616
6639
|
produces a gate nothing can audit, indistinguishable from a round that was
|
|
6617
6640
|
dispatched and whose results were never transcribed.
|
|
6641
|
+
|
|
6642
|
+
The per-item form is what survives a self-fix loop. Round 2+ queues are
|
|
6643
|
+
targeted, so an item the planner adds mid-loop and never puts in one keeps
|
|
6644
|
+
an empty `verdicts[]` while every neighbour carries votes — and nothing
|
|
6645
|
+
downstream reads that as a gap. An empty table classifies `all-non-result`
|
|
6646
|
+
(`_classify_plan_item_gate`), which states as `needs-reverify`, which
|
|
6647
|
+
`_recompute_plan_body_gate` folds into `passed-with-dissent`: a plan item
|
|
6648
|
+
no verifier ever judged leaves the gate in a passing value. The whole-table
|
|
6649
|
+
check could not see it, since it stands down the moment any one item has a
|
|
6650
|
+
vote.
|
|
6651
|
+
|
|
6652
|
+
An unjudged item is distinguishable from a legitimately unresolved one, and
|
|
6653
|
+
the difference is what is recorded rather than what is missing. A peer that
|
|
6654
|
+
returned nothing is a `verification-error` VOTE (§"Round protocol" step 3),
|
|
6655
|
+
so an all-error item still carries rows and still folds to `needs-reverify`
|
|
6656
|
+
on purpose. An empty table means no dispatch was accounted for at all.
|
|
6618
6657
|
"""
|
|
6619
6658
|
ip = data.get("implementationPlanning")
|
|
6620
6659
|
if not isinstance(ip, dict):
|
|
@@ -6628,16 +6667,33 @@ def _validate_round_recorded_verdicts(data: dict, failures: list[str]) -> None:
|
|
|
6628
6667
|
items = [it for it in (pbv.get("planItems") or []) if isinstance(it, dict)]
|
|
6629
6668
|
if not items:
|
|
6630
6669
|
return
|
|
6631
|
-
|
|
6670
|
+
empty = [str(it.get("id") or "<unnamed>") for it in items if not it.get("verdicts")]
|
|
6671
|
+
if not empty:
|
|
6672
|
+
return
|
|
6673
|
+
if len(empty) == len(items):
|
|
6674
|
+
failures.append(
|
|
6675
|
+
"final-report data.json: planBodyVerification declares "
|
|
6676
|
+
f"`roundCount`={round_count} but every one of the {len(items)} "
|
|
6677
|
+
"`planItems[]` carries an empty `verdicts[]`. A round that ran MUST "
|
|
6678
|
+
"record the votes it produced — the gate is re-derived from this "
|
|
6679
|
+
"table, so an empty one leaves the declared `gateResult` unauditable. "
|
|
6680
|
+
"A dispatch that returned nothing is recorded as `verification-error`, "
|
|
6681
|
+
'not omitted (plan-body-verification.md §"Round protocol" step 4).'
|
|
6682
|
+
)
|
|
6632
6683
|
return
|
|
6684
|
+
shown = ", ".join(f"`{item_id}`" for item_id in empty[:5])
|
|
6685
|
+
more = f" and {len(empty) - 5} more" if len(empty) > 5 else ""
|
|
6633
6686
|
failures.append(
|
|
6634
6687
|
"final-report data.json: planBodyVerification declares "
|
|
6635
|
-
f"`roundCount`={round_count} but
|
|
6636
|
-
"`planItems[]`
|
|
6637
|
-
"
|
|
6638
|
-
"
|
|
6639
|
-
"
|
|
6640
|
-
|
|
6688
|
+
f"`roundCount`={round_count} but {len(empty)} of {len(items)} "
|
|
6689
|
+
f"`planItems[]` carry an empty `verdicts[]`: {shown}{more}. Every "
|
|
6690
|
+
"extracted plan item MUST be judged by the round — an item with no "
|
|
6691
|
+
"vote at all is not a dissent the gate can weigh, it is a plan item "
|
|
6692
|
+
"nobody verified, and it currently folds into `passed-with-dissent` "
|
|
6693
|
+
"alongside items that were properly cross-checked. Either dispatch it "
|
|
6694
|
+
"in this round's queue, or record the non-result as a "
|
|
6695
|
+
"`verification-error` verdict per plan-body-verification.md "
|
|
6696
|
+
'§"Round protocol" step 3 — an item is never left with no row.'
|
|
6641
6697
|
)
|
|
6642
6698
|
|
|
6643
6699
|
|
|
@@ -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
|
|
13
|
-
injected skill text
|
|
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"
|
|
251
|
-
base
|
|
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
|
|
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
|
|
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
|
|
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})."
|