okstra 0.195.2 → 0.195.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/cli.md +1 -0
- package/docs/project-structure-overview.md +1 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +2 -0
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +5 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +11 -3
- package/runtime/python/okstra_ctl/agent/prompt_cli/inputs.py +8 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +47 -3
- package/runtime/python/okstra_ctl/blocking_checks.py +12 -0
- package/runtime/python/okstra_ctl/convergence.py +77 -0
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +2 -2
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +221 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +2 -2
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +4 -4
- package/runtime/python/okstra_ctl/convergence_store.py +8 -1
- package/runtime/python/okstra_ctl/dispatch_state.py +4 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +59 -0
- package/runtime/python/okstra_ctl/report_corrections.py +1 -1
- package/runtime/python/okstra_ctl/report_narrative.py +4 -1
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +20 -8
- package/runtime/python/okstra_ctl/wizard/engine.py +33 -10
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +82 -5
- package/runtime/python/okstra_ctl/wizard/roles.py +6 -4
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +22 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +14 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
package/docs/cli.md
CHANGED
|
@@ -791,6 +791,7 @@ The `okstra` Node CLI (`bin/okstra`) provides both installer/admin commands and
|
|
|
791
791
|
| `okstra convergence apply-round --work-state <path> --plan <path> --results <path>` | Validate one complete structured result set and atomically reduce it into working state |
|
|
792
792
|
| `okstra convergence critic-prompt --run-manifest <path>` | Render the coverage-critic task instructions to stdout; the lead writes the output verbatim into the file the prompt materializer's `--instruction` takes. The body carries the run's Round 0 consolidated findings, one line per Phase 4 analyser (worker id, result path, its finding ids), the two mandates plus the `duplicateOf` declaration rule, and — on an implementation-planning re-run — an already-covered index of the prior report's requirement-coverage row ids, clarification row ids, and stage titles (ids and titles only, never body text). Emits the `**Prompt Delivery Mode:**` header and the `Primary analysis packet` line the critic dispatch contract requires, so no part of the critic body is hand-written. Exits 2 when the run has published no grouping yet, or when the manifest carries no `analysisPacketPath` |
|
|
793
793
|
| `okstra convergence reverify-prompt --run-manifest <path> --plan <round-plan.json> --worker <worker-id>` | Render one worker's reverify task instructions to stdout; the lead writes the output verbatim into the file the prompt materializer's `--instruction` takes. The body carries the round's mandate (adversarial or collaborative, from the grouping's `config`), the plan row's findings in plan order — each with its summary, origin worker, cited-evidence line, the origin worker's result file and `### <item-id>` section, and the origin audit sidecar the verifier is told it may open — and the response format the collector parses. Exits 2 when the plan dispatches nothing to that worker, names a finding the grouping lacks, or is not a `dispatch` plan |
|
|
794
|
+
| `okstra convergence critic-verify-prompt --run-manifest <path> --gaps <coverage-batch.json> --worker <worker-id>` | Render one Phase 4 analyser's coverage-critic gap verification instructions to stdout; the lead writes the output verbatim into the file the prompt materializer's `--instruction` takes and materializes it with `--dispatch-kind critic-verify` under the analyser's `reverify/<worker-id>` assignment ref. Input is the pre-vote coverage batch (`mode: coverage`, `gaps[]` with `gapId`, `summary`, `category`, `ticketIds`, `originEvidence`, optional `duplicateOf`) the lead later passes to `apply-critic-gaps`; gaps are assigned round-robin over the grouping's analysis roster exactly as `apply-critic-gaps` checks them, and the body carries only this analyser's share — each gap with the critic's result file (`<provider>-worker-critic-<task-type>-<seq>.md`) and `### [<gapId>]` section, the critic audit sidecar, and the adversarial response format. Exits 2 when the batch's taskKey or mode does not match, the run has no critic assignment, the critic result is not collected yet, the worker is not an analyser, or round-robin assigns it no gap |
|
|
794
795
|
| `okstra convergence apply-critic-gaps --work-state <path> --results <path>` | Apply one verified coverage-critic batch after the main queue reaches a terminal state |
|
|
795
796
|
| `okstra convergence finalize --work-state <path> --output <path>` | Materialize the terminal schema v1.3 convergence state |
|
|
796
797
|
| `okstra convergence validate --state <path> --kind <working\|final>` | Validate replayable working state or a terminal final state |
|
|
@@ -353,6 +353,7 @@ Important modules:
|
|
|
353
353
|
| `convergence_store.py`, `convergence_migration.py` | atomic JSON persistence plus legacy/new-engine seed decisions; valid terminal finals are reused, while invalid state requires byte-preserving archival before restart |
|
|
354
354
|
| `convergence.py` | `okstra convergence` internal CLI orchestration for `seed`, `plan-round`, `apply-round`, `critic-prompt`, `apply-critic-gaps`, `finalize`, `validate`, and `example`; it composes the reducer, store, and migration policy without duplicating their decisions |
|
|
355
355
|
| `convergence_reverify_prompt.py` | renders one worker's reverify instruction body `okstra convergence reverify-prompt` prints — the round mandate, the plan row's findings with each origin worker's result file, item id, and audit sidecar (declared openable), and the collector's response format. Replaces the hand-written instruction whose abbreviated evidence line and `- Verdict:` format cost a round |
|
|
356
|
+
| `convergence_critic_verify_prompt.py` | renders one analyser's coverage-critic gap verification instruction body `okstra convergence critic-verify-prompt` prints — round-robin share of the pre-vote coverage batch (same assignment as `apply-critic-gaps`), each gap with the critic result file's `### [<gapId>]` section and audit sidecar, the adversarial response format, and the `**Rendered by:**` signature `validate_reverify_prompt` requires for dispatch kind `critic-verify` |
|
|
356
357
|
| `convergence_critic_prompt.py` | renders the coverage-critic seed body `okstra convergence critic-prompt` prints — the Round 0 consolidated findings, one line per Phase 4 analyser (worker id, result path, its finding ids), the two mandates plus the `duplicateOf` rule, and, on an implementation-planning re-run, an already-covered index of the prior report's requirement-coverage row ids, clarification row ids, and stage titles. Ids and titles only; the prior report's body is never copied |
|
|
357
358
|
| `plan_items.py`, `plan_items_cli.py` | deterministic extraction of the report-writer narrative `P-*` plan-item queue plus the `okstra plan-items extract` / `validate` / `seed` / `collect-verdicts` / `apply-verdicts` / `derivations` adapter; v2 data.json remains a read input |
|
|
358
359
|
| `claim_reproduction.py` | reproduces a plan-body single-vote `fact` claim before it can block on one vote — runs the declared probe (`path-exists` / `path-absent` / `literal-present` / `literal-absent` / `citations-differ`) inside the resolved project root and returns `reproduced` / `not-reproduced` / `not-runnable`, which `plan-items apply-verdicts --run-manifest` writes into `reproductionResult` (always overwriting the worker-sent value so a verifier cannot score its own claim). A `judgement` claim, or a `fact` that does not reproduce, takes the quorum route |
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -647,6 +647,8 @@ critic sets `duplicateOf` to that finding's id (`schemas/convergence-critic-resu
|
|
|
647
647
|
### Gap verification (1 adversarial reverify round)
|
|
648
648
|
Each critic gap enters the verification queue as a finding with `originWorker = "<provider>-critic"` and `source = "critic"`, except a gap the critic declared `duplicateOf` — that one is recorded and never dispatched. The lead runs ONE adversarial reverify round (§"Adversarial Verification Mode" classifier) in which **each gap is verified by exactly one Phase 4 analyser**: walk the analyser roster in `criticVerification.analyserRoster` order and assign gap *i* to `roster[i % len(roster)]`, then dispatch each assigned analyser once with its own gaps. Rejecting a gap costs the same as accepting one and this round is off the books (`rounds: []`, no `roundHistory` entry) on the serial path, so a batch of two gaps no longer wakes four analysers. **Enforced:** `okstra_ctl.convergence_engine._critic_gap_coverage_errors` accepts a dispatch set that is either the assignee set or the full roster, and rejects anything else — the full roster stays valid because a run finished before this rule cannot say which shape it used, the same dual acceptance `_validate_round_ledger_counts` gives the two round-counting arithmetics. Choosing a critic provider that is already in the analyser roster costs nothing: the critic is a different role contract, a different duty and a different session, so an analyser is not disqualified by sharing its provider name (ADR-0017 — provider and model are not role identity, and the same model assigned to two roles gets two independent workers). The critic cannot judge its own gaps because it is not an analyser: the voter roster is `workers[]` filtered to `audience == "analysis"`, and a critic is not even representable there (the allowed values are `analysis` / `lead` / `report-writer`). `okstra apply-critic-gaps` refuses a vote from anyone outside that roster (`critic voter must be a non-critic analyser`). 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).
|
|
649
649
|
|
|
650
|
+
**Dispatching the gap round.** The gap round is off the round ledger, so `plan-round` writes no plan for it and `reverify-prompt` cannot render it; it has its own generator and dispatch kind. Assemble the coverage batch first — the same `{ schemaVersion, taskKey, mode: "coverage", provider, modelExecutionValue, gaps[] }` document `apply-critic-gaps` will take, with each candidate as a gap (`gapId` = the critic's item id, `summary`, `category`, `ticketIds`, `originEvidence`, `duplicateOf` where declared) and no `votes` or `dispatches` yet. Then, for each assigned analyser, render `okstra convergence critic-verify-prompt --run-manifest <run-manifest> --gaps <coverage-batch.json> --worker <worker-id>` and write its output verbatim as the instruction file: it applies the same round-robin as `apply-critic-gaps` (`okstra_ctl.convergence_engine.critic_gap_assignees`) and carries only that analyser's gaps, each with the critic's result file and `### [<gapId>]` section, the critic audit sidecar the verifier may open, and the adversarial response format the collector parses (gap votes are always read as adversarial). Materialize with the analyser's existing `reverify/<worker-id>` assignment ref, `--dispatch-kind critic-verify`, `--audience reverification-worker`, and on a v2 run the analyser's own `--source-role-execution-ref` exactly as a numbered reverify round; name the prompt `<worker-id>-worker-critic-verify-<task-type>-<seq>.md` and the result `worker-results/<worker-id>-worker-critic-verify-<task-type>-<seq>.md`. Collect each result with `parse_finding_votes` semantics (the `### <gapId>` blocks), write the votes and one `dispatches[]` row per assigned analyser into the batch, and run `apply-critic-gaps`. **Enforced (rendering):** `okstra_ctl.convergence_critic_verify_prompt`. **Enforced (pre-dispatch):** `validate_reverify_prompt` in `scripts/okstra_ctl/worker_prompt_contract.py` requires the `**Rendered by:** okstra convergence critic-verify-prompt` line for dispatch kind `critic-verify`, so a hand-written gap instruction cannot be materialized; `worker_prompt_policy.is_verification_dispatch_kind` routes the kind through the reverify prompt plan, and `convergence_store.reserve_dynamic_verifier` records the kind on the v2 reservation. Before this path existed (2026-09-09, dev-10642 requirements-discovery 001) every attempt to dispatch the round was refused and all three gaps ended `gapsUnverified`.
|
|
651
|
+
|
|
650
652
|
**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.
|
|
651
653
|
|
|
652
654
|
**`category: "unrequested-scope"` candidates are classified the same way but disposed of differently.** A coverage gap the voters contest is a hallucination — nothing was actually missing, so dropping it costs one wasted verification. An over-scope candidate the voters contest is a *disagreement about whether the work was asked for*, and dropping that silently returns the run to the state this half exists to change. So:
|
|
@@ -89,7 +89,7 @@ This section adds report-specific checks to [okstra-lead-contract](./okstra-lead
|
|
|
89
89
|
4. When the check reports `mechanical: true` (every entry is a `replace` or `remove`), run `okstra agent-prompt apply-corrections` with the same arguments: okstra writes the corrected narrative to `reportNarrativePath` and records a `lead-correction-applied` activity row naming the ledger and its correction ids. No writer dispatch, `record-dispatch`, or `link-result` follows; the roster row's result already exists.
|
|
90
90
|
5. Otherwise materialize the writer prompt with the same `--corrections <ledger>` under a new invocation id and prompt path (retire the first attempt's link with `reject-result` as [plan-body-verification](./plan-body-verification.md) describes). okstra renders `## Corrections` (each entry with its label path, current value, replacement or rule, schema constraint, and reason) and `## Output`; the instruction body carries only context.
|
|
91
91
|
|
|
92
|
-
A report-writer materialization without `--corrections` whose narrative already exists and parses is refused before any prompt is written — free-form corrections cannot be checked before the writer runs, and four of six re-runs in the 2026-09-03 measurement were lead instructions that contradicted the authoring contract. Only a narrative whose structure does not parse (line grammar, an unknown top-level field) is re-authored, not corrected: that dispatch needs no ledger, and its body quotes the parser's message. A narrative that breaks the line grammar is not a produced artifact: the dispatcher settles that attempt as `required worker artifact is unusable: narrative does not parse: …` and retries it inside the same batch, so you see the parser's message at collection, not at Phase 7 assembly (**Enforced:** `okstra_ctl.dispatch_state.unusable_result_defect`, read by `missing_completion_paths` and the `team await` record path). The synthesis packet's Authoring Contract carries the line grammar itself (`report_narrative.NARRATIVE_GRAMMAR_INSTRUCTIONS`), so a writer that reads only the packet still sees it. Value defects — an id outside its pattern, a value outside its enum, a missing required field — leave the structure readable and are exactly what the ledger fixes; the a3 attempt of the 2026-09-03 run carried twenty `SC-` ids that assembly refused and was still a corrective base.
|
|
92
|
+
A report-writer materialization without `--corrections` whose narrative already exists and parses is refused before any prompt is written — free-form corrections cannot be checked before the writer runs, and four of six re-runs in the 2026-09-03 measurement were lead instructions that contradicted the authoring contract. Only a narrative whose structure does not parse (line grammar, an unknown top-level field) is re-authored, not corrected: that dispatch needs no ledger, and its body quotes the parser's message. Because re-authoring overwrites the live file in place, okstra copies the existing narrative to `worker-results/<narrative-name>.pre-<invocation-id>.md` at materialization and renders a `## Previous Attempt` section naming that copy (**Enforced:** `_preserve_reauthored_narrative` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`); the 2026-09-09 dev-10642 run lost a 579-line attempt to a failed in-place re-indent command with no copy to fall back on. A narrative that breaks the line grammar is not a produced artifact: the dispatcher settles that attempt as `required worker artifact is unusable: narrative does not parse: …` and retries it inside the same batch, so you see the parser's message at collection, not at Phase 7 assembly (**Enforced:** `okstra_ctl.dispatch_state.unusable_result_defect`, read by `missing_completion_paths` and the `team await` record path). The synthesis packet's Authoring Contract carries the line grammar itself (`report_narrative.NARRATIVE_GRAMMAR_INSTRUCTIONS`), so a writer that reads only the packet still sees it. Value defects — an id outside its pattern, a value outside its enum, a missing required field — leave the structure readable and are exactly what the ledger fixes; the a3 attempt of the 2026-09-03 run carried twenty `SC-` ids that assembly refused and was still a corrective base.
|
|
93
93
|
|
|
94
94
|
**Enforced:** `_with_report_writer_sections` / `_refuse_free_form_correction` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`, `report_corrections.check_corrections`, `agent/prompt_cli/corrections.run_corrections_apply`; `tests/run/test_agent_prompt_corrections.py` and `tests/contract/test_report_writer_v3_contract.py` keep this procedure in the lead contract.
|
|
95
95
|
|
|
@@ -151,6 +151,11 @@ class CapabilityInteractionPort:
|
|
|
151
151
|
"""
|
|
152
152
|
return self._limits.max_options
|
|
153
153
|
|
|
154
|
+
@property
|
|
155
|
+
def native_question_limit(self) -> int:
|
|
156
|
+
"""이 호스트의 네이티브 질문 묶음이 한 화면에 받는 질문 수."""
|
|
157
|
+
return self._limits.max_questions
|
|
158
|
+
|
|
154
159
|
def _native_options_fit(self, prompt: WizardPrompt) -> bool:
|
|
155
160
|
labels = tuple(option.label for option in prompt.options)
|
|
156
161
|
return (
|
|
@@ -25,6 +25,7 @@ from ...convergence_store import (
|
|
|
25
25
|
DYNAMIC_VERIFIER_SOURCE_ROLES,
|
|
26
26
|
reserve_dynamic_verifier,
|
|
27
27
|
)
|
|
28
|
+
from ...worker_prompt_policy import CRITIC_VERIFY_DISPATCH_KIND
|
|
28
29
|
from .inputs import AgentPromptCliError
|
|
29
30
|
|
|
30
31
|
|
|
@@ -46,7 +47,8 @@ def _reserve_dynamic_verifier_request(
|
|
|
46
47
|
manifest_path,
|
|
47
48
|
source_role_execution_ref=source_role_execution_ref,
|
|
48
49
|
duty_id=args.audience,
|
|
49
|
-
round_number=
|
|
50
|
+
round_number=_reservation_round(args.dispatch_kind),
|
|
51
|
+
dispatch_kind=args.dispatch_kind,
|
|
50
52
|
task_key=_required_manifest_string(manifest, "taskKey"),
|
|
51
53
|
input_digest="sha256:" + hashlib.sha256(agent_prompt_task_bytes(prompt_bytes)).hexdigest(),
|
|
52
54
|
invocation_ref=args.invocation_id,
|
|
@@ -100,12 +102,18 @@ def _dynamic_verifier_source(
|
|
|
100
102
|
return None
|
|
101
103
|
|
|
102
104
|
|
|
103
|
-
def
|
|
105
|
+
def _reservation_round(dispatch_kind: str) -> int:
|
|
106
|
+
"""예약에 적는 라운드 번호. critic gap 검증은 라운드 원장 밖이라 1 이다 —
|
|
107
|
+
dispatch 가 attempt 행에 적는 값(`_dispatch_round`: 번호 없는 kind 는 1)과
|
|
108
|
+
같아야 한다."""
|
|
109
|
+
if dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND:
|
|
110
|
+
return 1
|
|
104
111
|
prefix = "reverify-r"
|
|
105
112
|
value = dispatch_kind.removeprefix(prefix)
|
|
106
113
|
if not dispatch_kind.startswith(prefix) or not value.isdigit() or int(value) < 1:
|
|
107
114
|
raise AgentPromptCliError(
|
|
108
|
-
"dynamic verifier dispatch kind must be reverify-r<N>"
|
|
115
|
+
"dynamic verifier dispatch kind must be reverify-r<N> or "
|
|
116
|
+
f"{CRITIC_VERIFY_DISPATCH_KIND}"
|
|
109
117
|
)
|
|
110
118
|
return int(value)
|
|
111
119
|
|
|
@@ -37,7 +37,14 @@ def _authorized_path(
|
|
|
37
37
|
for value in raw_roots
|
|
38
38
|
]
|
|
39
39
|
if not any(_is_relative_to(path, root) for root in roots):
|
|
40
|
-
|
|
40
|
+
# 루트를 말하지 않으면 시행착오로 찾는다(2026-09-09 실측: ledger 의
|
|
41
|
+
# baseNarrativePath 를 `state/` 에 두었다가 거부돼 `worker-results/` 로
|
|
42
|
+
# 옮겨서야 통과).
|
|
43
|
+
listed = ", ".join(str(value) for value in raw_roots)
|
|
44
|
+
raise AgentPromptCliError(
|
|
45
|
+
f"{label} path is outside authorized roots: {path}; authorized "
|
|
46
|
+
f"{label} roots (project-relative): {listed}"
|
|
47
|
+
)
|
|
41
48
|
return path
|
|
42
49
|
|
|
43
50
|
|
|
@@ -34,6 +34,7 @@ from ...assignment_resolver import AssignmentContext, resolve_dispatch_assignmen
|
|
|
34
34
|
from ...path_hints import hydrate_active_run_context
|
|
35
35
|
from ...worker_prompt_headers import worker_prompt_headers
|
|
36
36
|
from ...worker_prompt_contract import complete_reverify_instruction, validate_reverify_prompt
|
|
37
|
+
from ...worker_prompt_policy import is_verification_dispatch_kind
|
|
37
38
|
from ...paths import okstra_home
|
|
38
39
|
from ...final_report_paths import final_report_data_path
|
|
39
40
|
from ...final_report_schema import load_schema_version
|
|
@@ -299,7 +300,7 @@ def _materialize_run(
|
|
|
299
300
|
active_context=active_context,
|
|
300
301
|
))
|
|
301
302
|
body = instruction_path.read_text(encoding="utf-8")
|
|
302
|
-
is_reverify = args.dispatch_kind
|
|
303
|
+
is_reverify = is_verification_dispatch_kind(args.dispatch_kind)
|
|
303
304
|
if is_reverify:
|
|
304
305
|
body = _complete_run_reverify_body(args, manifest, active_context, assignment, body, instruction_path)
|
|
305
306
|
if args.audience == "report-writer":
|
|
@@ -370,6 +371,7 @@ def _materialize_run(
|
|
|
370
371
|
candidate, task_type=str(manifest["taskType"]),
|
|
371
372
|
forbidden_actions=str(active_context["workflow"]["forbiddenActions"]),
|
|
372
373
|
expected_model=assignment.model_execution_value,
|
|
374
|
+
dispatch_kind=args.dispatch_kind,
|
|
373
375
|
)
|
|
374
376
|
if errors:
|
|
375
377
|
raise AgentPromptCliError("; ".join(errors))
|
|
@@ -441,7 +443,8 @@ def _with_report_writer_sections(
|
|
|
441
443
|
raise AgentPromptCliError(
|
|
442
444
|
f"instruction body must not contain {', '.join(conflicts)}: okstra "
|
|
443
445
|
"renders those sections itself (## Output from the run manifest, "
|
|
444
|
-
"## Corrections from the --corrections ledger
|
|
446
|
+
"## Corrections from the --corrections ledger, ## Previous Attempt "
|
|
447
|
+
"from the narrative a re-authoring dispatch replaces)"
|
|
445
448
|
)
|
|
446
449
|
sections: list[str] = []
|
|
447
450
|
if getattr(args, "corrections", None):
|
|
@@ -472,16 +475,57 @@ def _with_report_writer_sections(
|
|
|
472
475
|
sections.append("")
|
|
473
476
|
else:
|
|
474
477
|
_refuse_free_form_correction(project_root, narrative_path)
|
|
478
|
+
preserved = _preserve_reauthored_narrative(
|
|
479
|
+
project_root, narrative_path, invocation_id=str(args.invocation_id),
|
|
480
|
+
)
|
|
481
|
+
if preserved is not None:
|
|
482
|
+
sections.extend(_render_previous_attempt_section(preserved))
|
|
483
|
+
sections.append("")
|
|
475
484
|
sections.extend(render_output_section())
|
|
476
485
|
return body.rstrip("\n") + "\n\n" + "\n".join(sections) + "\n"
|
|
477
486
|
|
|
478
487
|
|
|
488
|
+
def _preserve_reauthored_narrative(
|
|
489
|
+
project_root: Path, narrative_path: Path, *, invocation_id: str,
|
|
490
|
+
) -> str | None:
|
|
491
|
+
"""재저작 디스패치 전에 기존 서사를 사본으로 남기고 그 상대 경로를 돌려준다.
|
|
492
|
+
|
|
493
|
+
교정(ledger) 경로는 리드가 `baseNarrativePath` 사본을 만들지만, 구조가 안
|
|
494
|
+
읽히는 서사는 원장 없이 재저작으로 통과해 사본 요구가 없었다. 실측
|
|
495
|
+
(2026-09-09 dev-10642 requirements-discovery 001): 들여쓰기만 고치는 재저작에서
|
|
496
|
+
작성자의 변환 명령이 실패해 579줄 서사가 0 바이트로 덮였고, `.okstra` 는
|
|
497
|
+
gitignore 라 복구본이 없었다. 재저작은 live 파일을 제자리에서 덮어쓰므로
|
|
498
|
+
okstra 가 사본을 남긴다. 빈 파일은 남길 내용이 없어 건너뛴다.
|
|
499
|
+
"""
|
|
500
|
+
if not narrative_path.is_file() or narrative_path.stat().st_size == 0:
|
|
501
|
+
return None
|
|
502
|
+
copy_path = narrative_path.with_name(
|
|
503
|
+
f"{narrative_path.stem}.pre-{invocation_id}{narrative_path.suffix}"
|
|
504
|
+
)
|
|
505
|
+
if os.path.normpath(copy_path) == os.path.normpath(narrative_path):
|
|
506
|
+
raise AgentPromptCliError("preserved narrative copy resolves to the live narrative")
|
|
507
|
+
shutil.copyfile(narrative_path, copy_path)
|
|
508
|
+
return _relative(project_root, copy_path)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _render_previous_attempt_section(preserved_rel: str) -> list[str]:
|
|
512
|
+
return [
|
|
513
|
+
"## Previous Attempt",
|
|
514
|
+
"",
|
|
515
|
+
f"Your previous attempt is preserved at `{preserved_rel}`. Read that copy for "
|
|
516
|
+
"its content and write the re-authored narrative to `**Result Path:**` as a "
|
|
517
|
+
"fresh file. Do not transform the file at `**Result Path:**` in place with a "
|
|
518
|
+
"shell or script command: a failed command leaves an empty file and the run "
|
|
519
|
+
"loses the attempt. The preserved copy is read-only for you.",
|
|
520
|
+
]
|
|
521
|
+
|
|
522
|
+
|
|
479
523
|
def _refuse_free_form_correction(project_root: Path, narrative_path: Path) -> None:
|
|
480
524
|
"""서사가 이미 있고 구조가 읽히면 이 디스패치는 교정이다 — 원장 없이는 거절한다.
|
|
481
525
|
|
|
482
526
|
구조가 읽히지 않는 서사(줄 문법·소유권 결함)는 교정 대상이 아니라 재저작
|
|
483
527
|
대상이다(원장의 경로가 해소될 자료가 없다). 그 디스패치는 원장 없이
|
|
484
|
-
|
|
528
|
+
통과하고, `_preserve_reauthored_narrative` 가 기존 파일의 사본을 남긴다. 값 결함(패턴 밖 id, enum 밖 값)은 구조가 읽히는 서사이고, 그것이
|
|
485
529
|
원장이 고치는 자리다 — 실측(dev-10626 a3)의 `SC-` id 20곳이 이 경우다.
|
|
486
530
|
"""
|
|
487
531
|
if not narrative_path.is_file():
|
|
@@ -135,6 +135,18 @@ _BLOCKING: tuple[tuple[str, str], ...] = (
|
|
|
135
135
|
"승인 경계(scripts/okstra_ctl/run.py `_validate_approved_plan_conformance`)"
|
|
136
136
|
"가 같은 형식을 하드 거부한다 — 같은 이유로 구현 준비가 막힌다.",
|
|
137
137
|
),
|
|
138
|
+
# Stage 관계 검사(S-검사: depends-on DAG, 병렬 stage 파일 안전, RED→GREEN
|
|
139
|
+
# 순서, TDD 면제 어휘). 계획 런은 validate-run.py `_append_stage_data_failures`
|
|
140
|
+
# 가 이 접두로 기록하고, 구현 진입은 같은 검증기를 subprocess 로 돌려
|
|
141
|
+
# exit≠0 이면 PrepareError 로 거부한다. 2026-09-09 dev-10628 실측: Stage 1
|
|
142
|
+
# 첫 단계가 `RED:` 가 아닌 계획(S10c)이 advisory 로 승인까지 갔다가 구현
|
|
143
|
+
# 준비에서 거부돼, 승인된 불변 계획을 되돌려 다시 계획해야 했다.
|
|
144
|
+
(
|
|
145
|
+
"implementation-planning stage contract invalid",
|
|
146
|
+
"구현 진입(scripts/okstra_ctl/run.py `_validate_stage_structure`)이 같은 "
|
|
147
|
+
"검증기를 하드 거부한다 — 통과 발행된 계획이 다음 phase 에서 준비 자체가 "
|
|
148
|
+
"안 된다.",
|
|
149
|
+
),
|
|
138
150
|
# 3. 게이트 결과가 없거나 무의미하다.
|
|
139
151
|
#
|
|
140
152
|
# `conformance gate BLOCKING` 이 실제로 잡는 범위는 "스크립트가 안 돌았다"
|
|
@@ -22,6 +22,13 @@ from .convergence_engine import (
|
|
|
22
22
|
validate_final_state,
|
|
23
23
|
validate_working_state,
|
|
24
24
|
)
|
|
25
|
+
from .worker_prompt_policy import critic_assignment_ref
|
|
26
|
+
from .convergence_critic_verify_prompt import (
|
|
27
|
+
CriticVerifyPromptError,
|
|
28
|
+
critic_result_paths,
|
|
29
|
+
critic_verify_gaps,
|
|
30
|
+
critic_verify_prompt_body,
|
|
31
|
+
)
|
|
25
32
|
from .convergence_reverify_prompt import (
|
|
26
33
|
ReverifyPromptError,
|
|
27
34
|
reverify_findings,
|
|
@@ -414,6 +421,30 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
414
421
|
reverify_prompt.add_argument("--plan", type=Path, required=True)
|
|
415
422
|
reverify_prompt.add_argument("--worker", required=True)
|
|
416
423
|
|
|
424
|
+
critic_verify_prompt = subparsers.add_parser(
|
|
425
|
+
"critic-verify-prompt",
|
|
426
|
+
help="render one analyser's critic gap verification instructions to stdout",
|
|
427
|
+
description=(
|
|
428
|
+
"Print the gap-verification instruction body for one Phase 4 analyser. "
|
|
429
|
+
"The lead writes it verbatim to the file the prompt materializer's "
|
|
430
|
+
"`--instruction` takes, with `--dispatch-kind critic-verify`. Input is "
|
|
431
|
+
"the coverage batch the lead will later pass to `apply-critic-gaps` "
|
|
432
|
+
"(`gaps[]` filled, votes and dispatches not yet) — gaps are assigned "
|
|
433
|
+
"round-robin over the grouping's analysis roster exactly as "
|
|
434
|
+
"`apply-critic-gaps` checks them, and this analyser gets its own share. "
|
|
435
|
+
"Each gap carries the critic's result file and `### [<gapId>]` section, "
|
|
436
|
+
"the critic audit sidecar the verifier may open, and the adversarial "
|
|
437
|
+
"response format the collector parses. Nothing here is hand-written."
|
|
438
|
+
),
|
|
439
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
440
|
+
)
|
|
441
|
+
critic_verify_prompt.add_argument("--run-manifest", type=Path, required=True)
|
|
442
|
+
critic_verify_prompt.add_argument(
|
|
443
|
+
"--gaps", type=Path, required=True,
|
|
444
|
+
help="coverage batch JSON (schemaVersion 1.0, mode coverage, gaps[])",
|
|
445
|
+
)
|
|
446
|
+
critic_verify_prompt.add_argument("--worker", required=True)
|
|
447
|
+
|
|
417
448
|
apply_critic = subparsers.add_parser(
|
|
418
449
|
"apply-critic-gaps",
|
|
419
450
|
help="apply one coverage-critic verification batch",
|
|
@@ -1462,6 +1493,48 @@ def _reverify_prompt(args: argparse.Namespace) -> str:
|
|
|
1462
1493
|
)
|
|
1463
1494
|
|
|
1464
1495
|
|
|
1496
|
+
def _critic_verify_prompt(args: argparse.Namespace) -> str:
|
|
1497
|
+
"""한 분석자의 critic gap 검증 지시문 본문. 입력은 매니페스트·coverage 배치·워커 id."""
|
|
1498
|
+
authority = validated_run_authority(args.run_manifest)
|
|
1499
|
+
groups_path = authority.run_dir / "state" / _canonical_run_artifact_name(
|
|
1500
|
+
"convergence-groups", authority.task_type, authority.state_sequence
|
|
1501
|
+
)
|
|
1502
|
+
if not groups_path.is_file():
|
|
1503
|
+
raise ConvergenceContractError(
|
|
1504
|
+
"the gap verification prompt needs the Round 0 grouping; run "
|
|
1505
|
+
f"`okstra convergence prepare-groups` first: {groups_path}"
|
|
1506
|
+
)
|
|
1507
|
+
groups = load_owned_json_object(groups_path)
|
|
1508
|
+
batch = load_owned_json_object(args.gaps)
|
|
1509
|
+
task_key = _manifest_authority_string(authority.payload, "taskKey")
|
|
1510
|
+
if batch.get("taskKey") != task_key:
|
|
1511
|
+
raise ConvergenceContractError("coverage batch taskKey does not match the run manifest")
|
|
1512
|
+
if batch.get("mode") != "coverage":
|
|
1513
|
+
raise ConvergenceContractError(
|
|
1514
|
+
"critic-verify-prompt accepts coverage mode only; acceptance candidates "
|
|
1515
|
+
"use confirm-or-downgrade"
|
|
1516
|
+
)
|
|
1517
|
+
critic_ref = critic_assignment_ref(authority.task_type)
|
|
1518
|
+
assignments = authority.payload.get("invocationAssignments")
|
|
1519
|
+
critic = assignments.get(critic_ref) if isinstance(assignments, Mapping) else None
|
|
1520
|
+
if not isinstance(critic, Mapping):
|
|
1521
|
+
raise ConvergenceContractError(
|
|
1522
|
+
f"run manifest has no `{critic_ref}` assignment: this run has no critic"
|
|
1523
|
+
)
|
|
1524
|
+
provider = str(critic.get("provider") or "")
|
|
1525
|
+
result_path, audit_path = critic_result_paths(
|
|
1526
|
+
groups, critic_provider=provider,
|
|
1527
|
+
project_root=authority.project_root, run_dir=authority.run_dir,
|
|
1528
|
+
)
|
|
1529
|
+
return critic_verify_prompt_body(
|
|
1530
|
+
task_key=task_key,
|
|
1531
|
+
critic_worker=f"{provider}-critic",
|
|
1532
|
+
critic_result_path=result_path,
|
|
1533
|
+
critic_audit_path=audit_path,
|
|
1534
|
+
gaps=critic_verify_gaps(batch, groups, args.worker),
|
|
1535
|
+
)
|
|
1536
|
+
|
|
1537
|
+
|
|
1465
1538
|
def _execute(args: argparse.Namespace) -> tuple[str, Path]:
|
|
1466
1539
|
operations: dict[str, Any] = {
|
|
1467
1540
|
"prepare-groups": _prepare_groups,
|
|
@@ -1495,8 +1568,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
1495
1568
|
if args.operation == "reverify-prompt":
|
|
1496
1569
|
print(_reverify_prompt(args), end="")
|
|
1497
1570
|
return 0
|
|
1571
|
+
if args.operation == "critic-verify-prompt":
|
|
1572
|
+
print(_critic_verify_prompt(args), end="")
|
|
1573
|
+
return 0
|
|
1498
1574
|
action, path = _execute(args)
|
|
1499
1575
|
except (ConvergenceContractError, VerdictBlockError, ReverifyPromptError,
|
|
1576
|
+
CriticVerifyPromptError,
|
|
1500
1577
|
json.JSONDecodeError, ValueError) as exc:
|
|
1501
1578
|
print(f"error: {exc}", file=sys.stderr)
|
|
1502
1579
|
return 2
|
|
@@ -82,7 +82,7 @@ def _project_relative(project_root: Path, path: Path) -> str:
|
|
|
82
82
|
return path.as_posix()
|
|
83
83
|
|
|
84
84
|
|
|
85
|
-
def
|
|
85
|
+
def analysis_roster(groups: Mapping[str, Any]) -> list[str]:
|
|
86
86
|
workers = groups.get("workers")
|
|
87
87
|
if not isinstance(workers, list):
|
|
88
88
|
return []
|
|
@@ -152,7 +152,7 @@ def analyser_results(
|
|
|
152
152
|
),
|
|
153
153
|
finding_ids=tuple(by_worker.get(worker, ())),
|
|
154
154
|
)
|
|
155
|
-
for worker in
|
|
155
|
+
for worker in analysis_roster(groups)
|
|
156
156
|
]
|
|
157
157
|
|
|
158
158
|
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""coverage-critic gap 검증 지시문을 gap 배치와 로스터에서 결정적으로 렌더한다.
|
|
2
|
+
|
|
3
|
+
`prompts/lead/convergence.md` §"Gap verification" 은 critic 의 gap 마다 Phase 4
|
|
4
|
+
분석자 한 명이 1라운드 adversarial 반박을 하라고 요구한다. 그런데 그 라운드는
|
|
5
|
+
번호 라운드 원장 밖이라 `plan-round` 가 계획을 내지 않고, `reverify-prompt` 는
|
|
6
|
+
계획 행만 렌더하며, 검증 audience 의 프롬프트는 렌더러 서명 없이는 materialize
|
|
7
|
+
가 거절한다. 실측(2026-09-09 dev-10642 requirements-discovery 001): 리드가 세
|
|
8
|
+
경로를 다 시도해 전부 거부됐고 gap 3건이 `gapsUnverified` 로 남았다.
|
|
9
|
+
|
|
10
|
+
이 모듈이 그 지시문을 만든다. 입력은 리드가 `apply-critic-gaps` 에 줄 것과 같은
|
|
11
|
+
coverage 배치(투표 전, `gaps[]` 만 채운 상태)와 Round 0 그룹(분석 로스터)이다.
|
|
12
|
+
배정은 엔진과 같은 규칙(`critic_gap_assignees`, 로스터 순서 round-robin)이라
|
|
13
|
+
`apply-critic-gaps` 의 커버리지 검사와 어긋나지 않는다. gap 마다 critic 결과
|
|
14
|
+
파일의 `### [<gapId>]` 절과 critic 감사 사이드카를 실어, 검증자가 리드의 전사가
|
|
15
|
+
아니라 critic 이 실제로 인용한 것을 판단하게 한다. 응답 형식은 번호 라운드와
|
|
16
|
+
같은 adversarial 정본이다 — `apply-critic-gaps` 는 gap 표를 언제나
|
|
17
|
+
adversarial 로 읽는다(`_parse_critic_gap` 의 `adversarial=True`).
|
|
18
|
+
|
|
19
|
+
산출물은 프롬프트 materializer 의 `--instruction` 이 받는 본문이다. `## Instructions`
|
|
20
|
+
로 시작하므로 `complete_reverify_instruction` 이 모델·task type·금지 목록을 그
|
|
21
|
+
앞에 붙이고 출력 계약을 뒤에 덧붙인다.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Mapping, Sequence
|
|
28
|
+
|
|
29
|
+
from .convergence_critic_prompt import analysis_roster
|
|
30
|
+
from .convergence_engine import critic_gap_assignees
|
|
31
|
+
from .convergence_provenance import worker_result_suffix
|
|
32
|
+
from .convergence_reverify_prompt import (
|
|
33
|
+
ADVERSARIAL_MANDATE,
|
|
34
|
+
ADVERSARIAL_RESPONSE,
|
|
35
|
+
)
|
|
36
|
+
from .worker_artifact_paths import WorkerArtifactPathError, audit_sidecar_rel
|
|
37
|
+
from .worker_prompt_policy import CRITIC_VERIFY_DISPATCH_KIND
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CriticVerifyPromptError(ValueError):
|
|
41
|
+
"""지시문을 결정적으로 만들 수 없다."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# 렌더된 지시문의 서명. `validate_reverify_prompt` 가 dispatch kind
|
|
45
|
+
# `critic-verify` 에 이 줄을 요구한다 — 번호 라운드의 `reverify-prompt` 서명과
|
|
46
|
+
# 같은 역할이고, 손으로 쓴 지시문은 materialize 에서 거절된다.
|
|
47
|
+
RENDERED_BY_LINE = "**Rendered by:** okstra convergence critic-verify-prompt"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class CriticGap:
|
|
52
|
+
"""검증 큐의 gap 하나와, critic 결과 파일 안의 실물 인용 위치."""
|
|
53
|
+
|
|
54
|
+
gap_id: str
|
|
55
|
+
summary: str
|
|
56
|
+
category: str
|
|
57
|
+
ticket_ids: tuple[str, ...]
|
|
58
|
+
origin_evidence: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _nonempty_string(value: Any) -> str:
|
|
62
|
+
return value if isinstance(value, str) and value.strip() else ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _project_relative(project_root: Path, path: Path) -> str:
|
|
66
|
+
try:
|
|
67
|
+
return path.resolve().relative_to(project_root.resolve()).as_posix()
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise CriticVerifyPromptError(
|
|
70
|
+
f"critic result resolves outside the project root: {path}"
|
|
71
|
+
) from exc
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def critic_result_paths(
|
|
75
|
+
groups: Mapping[str, Any],
|
|
76
|
+
*,
|
|
77
|
+
critic_provider: str,
|
|
78
|
+
project_root: Path,
|
|
79
|
+
run_dir: Path,
|
|
80
|
+
) -> tuple[str, str]:
|
|
81
|
+
"""critic 결과 파일과 그 감사 사이드카의 프로젝트 상대 경로.
|
|
82
|
+
|
|
83
|
+
critic 결과는 `<provider>-worker-critic-<task-type>-<workerResults seq>.md`
|
|
84
|
+
로 쓰인다(`-worker-` 토큰이 사이드카 이름을 결정한다 — convergence.md
|
|
85
|
+
§"Coverage critic pass"). 접미사는 분석자 결과와 같은 규칙으로 그룹의
|
|
86
|
+
`runManifestPath` 에서 읽는다. 파일이 없으면 critic 결과가 아직 수집되지
|
|
87
|
+
않은 것이라 거절한다 — gap 검증은 critic 결과 뒤에만 온다.
|
|
88
|
+
"""
|
|
89
|
+
if not _nonempty_string(critic_provider):
|
|
90
|
+
raise CriticVerifyPromptError("run manifest has no critic assignment provider")
|
|
91
|
+
suffix = worker_result_suffix(Path(run_dir), groups)
|
|
92
|
+
if suffix is None:
|
|
93
|
+
raise CriticVerifyPromptError(
|
|
94
|
+
"cannot resolve the worker-result suffix from the grouping's runManifestPath"
|
|
95
|
+
)
|
|
96
|
+
result = Path(run_dir) / "worker-results" / f"{critic_provider}-worker-critic-{suffix}.md"
|
|
97
|
+
if not result.is_file():
|
|
98
|
+
raise CriticVerifyPromptError(
|
|
99
|
+
f"critic result is not collected yet: {result}; gap verification "
|
|
100
|
+
"follows the critic result"
|
|
101
|
+
)
|
|
102
|
+
result_rel = _project_relative(project_root, result)
|
|
103
|
+
try:
|
|
104
|
+
return result_rel, audit_sidecar_rel(result_rel)
|
|
105
|
+
except WorkerArtifactPathError as exc:
|
|
106
|
+
raise CriticVerifyPromptError(str(exc)) from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def critic_verify_gaps(
|
|
110
|
+
batch: Mapping[str, Any],
|
|
111
|
+
groups: Mapping[str, Any],
|
|
112
|
+
worker_id: str,
|
|
113
|
+
) -> list[CriticGap]:
|
|
114
|
+
"""이 워커에게 배정된 gap 을 배치 순서대로.
|
|
115
|
+
|
|
116
|
+
배정은 `critic_gap_assignees` 와 같다: 선언된 중복을 뺀 gap *i* 가
|
|
117
|
+
`roster[i % len(roster)]` 에게 간다. 로스터는 Round 0 그룹의 분석 audience
|
|
118
|
+
워커 순서다 — `apply-critic-gaps` 가 `analyserRoster` 로 적는 것과 같은
|
|
119
|
+
순서다.
|
|
120
|
+
"""
|
|
121
|
+
gaps_value = batch.get("gaps")
|
|
122
|
+
if not isinstance(gaps_value, list) or not gaps_value:
|
|
123
|
+
raise CriticVerifyPromptError("coverage batch has no gaps array")
|
|
124
|
+
roster = analysis_roster(groups)
|
|
125
|
+
# `<worker>-worker` 슬러그도 같은 워커다 — reverify 의 `plan_row_for_worker`
|
|
126
|
+
# 와 validate-run 의 `_plan_dispatch_finding_ids` 가 같은 규칙으로 맞춘다.
|
|
127
|
+
if worker_id not in roster and worker_id.removesuffix("-worker") in roster:
|
|
128
|
+
worker_id = worker_id.removesuffix("-worker")
|
|
129
|
+
if worker_id not in roster:
|
|
130
|
+
raise CriticVerifyPromptError(
|
|
131
|
+
f"`{worker_id}` is not an analysis worker of this run; roster: "
|
|
132
|
+
f"{', '.join(roster) or 'none'}"
|
|
133
|
+
)
|
|
134
|
+
verifiable = [
|
|
135
|
+
gap for gap in gaps_value
|
|
136
|
+
if isinstance(gap, Mapping) and not gap.get("duplicateOf")
|
|
137
|
+
]
|
|
138
|
+
assignees = critic_gap_assignees(roster, verifiable)
|
|
139
|
+
assigned: list[CriticGap] = []
|
|
140
|
+
seen: set[str] = set()
|
|
141
|
+
for gap, assignee in zip(verifiable, assignees):
|
|
142
|
+
gap_id = _nonempty_string(gap.get("gapId"))
|
|
143
|
+
if not gap_id:
|
|
144
|
+
raise CriticVerifyPromptError("coverage batch gap has no gapId")
|
|
145
|
+
if gap_id in seen:
|
|
146
|
+
raise CriticVerifyPromptError(f"duplicate critic gapId: {gap_id}")
|
|
147
|
+
seen.add(gap_id)
|
|
148
|
+
if assignee != worker_id:
|
|
149
|
+
continue
|
|
150
|
+
ticket_ids = gap.get("ticketIds")
|
|
151
|
+
assigned.append(CriticGap(
|
|
152
|
+
gap_id=gap_id,
|
|
153
|
+
summary=_nonempty_string(gap.get("summary")),
|
|
154
|
+
category=_nonempty_string(gap.get("category")),
|
|
155
|
+
ticket_ids=tuple(
|
|
156
|
+
str(ticket) for ticket in ticket_ids
|
|
157
|
+
) if isinstance(ticket_ids, list) else (),
|
|
158
|
+
origin_evidence=_nonempty_string(gap.get("originEvidence")),
|
|
159
|
+
))
|
|
160
|
+
if not assigned:
|
|
161
|
+
raise CriticVerifyPromptError(
|
|
162
|
+
f"round-robin assigns no gap to `{worker_id}`; assignees in batch "
|
|
163
|
+
f"order: {', '.join(assignees) or 'none'}"
|
|
164
|
+
)
|
|
165
|
+
return assigned
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_EVIDENCE_ACCESS = """The `**Cited evidence**` line is the lead's summary of what the critic cited.
|
|
169
|
+
The complete citation is the critic's own item: before judging, open the
|
|
170
|
+
`**Origin item**` file at the named `### [<gap-id>]` section and read every path,
|
|
171
|
+
line, command, and quote it cites. The `**Origin audit sidecar**` records the
|
|
172
|
+
read-only commands the critic ran and their output; it counts as cited evidence
|
|
173
|
+
and you may open it. A gap claims that something was NOT covered — to refute it,
|
|
174
|
+
show where the coverage exists (an analyser result item, a file, a test); to
|
|
175
|
+
let it survive, confirm the coverage is absent where the critic says it is."""
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def critic_verify_prompt_body(
|
|
179
|
+
*,
|
|
180
|
+
task_key: str,
|
|
181
|
+
critic_worker: str,
|
|
182
|
+
critic_result_path: str,
|
|
183
|
+
critic_audit_path: str,
|
|
184
|
+
gaps: Sequence[CriticGap],
|
|
185
|
+
) -> str:
|
|
186
|
+
"""critic gap 검증 지시문 본문. 같은 입력이면 같은 바이트를 낸다."""
|
|
187
|
+
if not _nonempty_string(task_key):
|
|
188
|
+
raise CriticVerifyPromptError("run manifest carries no taskKey")
|
|
189
|
+
if not gaps:
|
|
190
|
+
raise CriticVerifyPromptError("no gaps to verify")
|
|
191
|
+
rows = [
|
|
192
|
+
"## Instructions\n\n",
|
|
193
|
+
f"{RENDERED_BY_LINE}\n\n",
|
|
194
|
+
f"Perform ADVERSARIAL coverage-gap verification for {task_key} "
|
|
195
|
+
f"(dispatch kind `{CRITIC_VERIFY_DISPATCH_KIND}`, one round).\n\n",
|
|
196
|
+
ADVERSARIAL_MANDATE, "\n\n",
|
|
197
|
+
_EVIDENCE_ACCESS, "\n\n",
|
|
198
|
+
"## Findings to verify\n",
|
|
199
|
+
]
|
|
200
|
+
for gap in gaps:
|
|
201
|
+
rows.append(f"\n### {gap.gap_id}: {gap.summary or '(no summary)'}\n")
|
|
202
|
+
rows.append(f"**Origin**: {critic_worker}\n")
|
|
203
|
+
rows.append(f"**Category**: {gap.category or '(none recorded)'}\n")
|
|
204
|
+
if gap.ticket_ids:
|
|
205
|
+
rows.append(f"**Tickets**: {', '.join(gap.ticket_ids)}\n")
|
|
206
|
+
rows.append(f"**Cited evidence**: {gap.origin_evidence or '(none recorded)'}\n")
|
|
207
|
+
rows.append(
|
|
208
|
+
f"**Origin item**: `{critic_result_path}` — section `### [{gap.gap_id}]`\n"
|
|
209
|
+
)
|
|
210
|
+
rows.append(f"**Origin audit sidecar**: `{critic_audit_path}`\n")
|
|
211
|
+
rows.append("\n## Response format\n\n")
|
|
212
|
+
rows.append(
|
|
213
|
+
"One block per gap, headed by the gap id at exactly three hashes. "
|
|
214
|
+
"Field labels are bold with the colon outside (`**Verdict**: …`); the "
|
|
215
|
+
"collector also reads `**Verdict:** …` and `- Verdict: …` as the same field.\n\n"
|
|
216
|
+
)
|
|
217
|
+
rows.append(ADVERSARIAL_RESPONSE.replace("<finding-id>", gaps[0].gap_id))
|
|
218
|
+
rows.append("\n")
|
|
219
|
+
if len(gaps) > 1:
|
|
220
|
+
rows.append(f"\n### {gaps[1].gap_id}\n**Verdict**: ...\n")
|
|
221
|
+
return "".join(rows)
|
|
@@ -952,7 +952,7 @@ def _parse_critic_dispatches(
|
|
|
952
952
|
return dispatches, completed
|
|
953
953
|
|
|
954
954
|
|
|
955
|
-
def
|
|
955
|
+
def critic_gap_assignees(
|
|
956
956
|
roster_order: list[str],
|
|
957
957
|
gaps: list[Mapping[str, Any]],
|
|
958
958
|
) -> list[str]:
|
|
@@ -990,7 +990,7 @@ def _critic_gap_coverage_errors(
|
|
|
990
990
|
계약 위반이 아니라 더 본 것이고, 그 사이 방치된 gap 은 표가 없어
|
|
991
991
|
`unverifiedGaps` 로 스스로 드러난다.
|
|
992
992
|
"""
|
|
993
|
-
assignees = set(
|
|
993
|
+
assignees = set(critic_gap_assignees(roster_order, gaps))
|
|
994
994
|
if dispatched == set(roster_order) or dispatched == assignees:
|
|
995
995
|
return []
|
|
996
996
|
missing = sorted(assignees - dispatched)
|
|
@@ -53,7 +53,7 @@ class ReverifyFinding:
|
|
|
53
53
|
origin_audit_path: str
|
|
54
54
|
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
ADVERSARIAL_MANDATE = """Your job is to BREAK each finding below, not to confirm it. For EACH finding,
|
|
57
57
|
open the cited evidence directly and actively search for evidence that the claim
|
|
58
58
|
is wrong, overstated, or unproven. Then respond with exactly one verdict:
|
|
59
59
|
|
|
@@ -95,7 +95,7 @@ commands that worker ran and their output; it counts as cited evidence and you m
|
|
|
95
95
|
open it. Judge the claim against what the origin worker actually cited, never against
|
|
96
96
|
the summary line alone."""
|
|
97
97
|
|
|
98
|
-
|
|
98
|
+
ADVERSARIAL_RESPONSE = """### <finding-id>
|
|
99
99
|
**Verdict**: REFUTED | SURVIVES | SURVIVES-WITH-CAVEAT | UNVERIFIABLE
|
|
100
100
|
**Basis** (only if REFUTED): counter-evidence | burden-not-met
|
|
101
101
|
**Explanation**: <2-3 sentences; for counter-evidence include the file:line you found>"""
|
|
@@ -206,8 +206,8 @@ def reverify_prompt_body(
|
|
|
206
206
|
if not findings:
|
|
207
207
|
raise ReverifyPromptError("no findings to verify")
|
|
208
208
|
mode = "ADVERSARIAL re-verification" if adversarial else "re-verification"
|
|
209
|
-
mandate =
|
|
210
|
-
response =
|
|
209
|
+
mandate = ADVERSARIAL_MANDATE if adversarial else _COLLABORATIVE_MANDATE
|
|
210
|
+
response = ADVERSARIAL_RESPONSE if adversarial else _COLLABORATIVE_RESPONSE
|
|
211
211
|
rows = [
|
|
212
212
|
"## Instructions\n\n",
|
|
213
213
|
f"{RENDERED_BY_LINE}\n\n",
|
|
@@ -106,9 +106,16 @@ def reserve_dynamic_verifier(
|
|
|
106
106
|
*,
|
|
107
107
|
input_digest: str,
|
|
108
108
|
invocation_ref: str | None = None,
|
|
109
|
+
dispatch_kind: str | None = None,
|
|
109
110
|
) -> tuple[RoleExecution, Invocation]:
|
|
110
111
|
"""Reserve one provider-neutral verifier identity for a logical round.
|
|
111
112
|
|
|
113
|
+
``dispatch_kind`` 를 주지 않으면 번호 라운드(`reverify-r<N>`)다. critic gap
|
|
114
|
+
검증(`critic-verify`)은 같은 verifier 신원을 예약하되 kind 를 그대로 적는다 —
|
|
115
|
+
validate-run 이 team-state 의 dispatch kind 와 예약된 invocation 의
|
|
116
|
+
`dispatchKind` 를 대조하므로 예약이 `reverify-r1` 로 남으면 그 디스패치가
|
|
117
|
+
거부된다.
|
|
118
|
+
|
|
112
119
|
예약은 정체성만 잡는다. 쓰기 계약은 디스패치가 attempt 를 열 때 한 번만
|
|
113
120
|
계산해 그 attempt 행에 적는다 — 예약이 같은 값을 두 번째로 계산하던 동안,
|
|
114
121
|
두 계산의 입력(worktree)이 갈리면 같은 invocationRef 가 `invocationRef
|
|
@@ -156,7 +163,7 @@ def reserve_dynamic_verifier(
|
|
|
156
163
|
source_invocation_ref=None,
|
|
157
164
|
recovery_ref=None,
|
|
158
165
|
duty_id=duty_id,
|
|
159
|
-
dispatch_kind=f"reverify-r{round_number}",
|
|
166
|
+
dispatch_kind=dispatch_kind or f"reverify-r{round_number}",
|
|
160
167
|
round=round_number,
|
|
161
168
|
input_digest=input_digest,
|
|
162
169
|
)
|
|
@@ -61,6 +61,7 @@ from .worker_prompt_contract import (
|
|
|
61
61
|
validate_prompt_model_header,
|
|
62
62
|
validate_reverify_prompt,
|
|
63
63
|
)
|
|
64
|
+
from .worker_prompt_policy import is_verification_dispatch_kind
|
|
64
65
|
from .worker_runner import LIVE, QUIET
|
|
65
66
|
from .worker_request import verifier_extra_dirs
|
|
66
67
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
@@ -1880,13 +1881,13 @@ def validate_dispatch_prompts(
|
|
|
1880
1881
|
if isinstance(manifest.get("agentContract"), Mapping):
|
|
1881
1882
|
_validate_agent_invocations(manifest, jobs)
|
|
1882
1883
|
initial_jobs = [
|
|
1883
|
-
job for job in jobs if not job.dispatch_kind
|
|
1884
|
+
job for job in jobs if not is_verification_dispatch_kind(job.dispatch_kind)
|
|
1884
1885
|
]
|
|
1885
1886
|
if initial_jobs:
|
|
1886
1887
|
validate_initial_prompts(manifest, initial_jobs)
|
|
1887
1888
|
|
|
1888
1889
|
reverify_jobs = [
|
|
1889
|
-
job for job in jobs if job.dispatch_kind
|
|
1890
|
+
job for job in jobs if is_verification_dispatch_kind(job.dispatch_kind)
|
|
1890
1891
|
]
|
|
1891
1892
|
if not reverify_jobs:
|
|
1892
1893
|
return
|
|
@@ -1919,6 +1920,7 @@ def validate_dispatch_prompts(
|
|
|
1919
1920
|
task_type=task_type,
|
|
1920
1921
|
forbidden_actions=forbidden_actions,
|
|
1921
1922
|
expected_model=job.model_execution_value or None,
|
|
1923
|
+
dispatch_kind=job.dispatch_kind,
|
|
1922
1924
|
)
|
|
1923
1925
|
)
|
|
1924
1926
|
if errors:
|
|
@@ -334,6 +334,65 @@ class _Validator:
|
|
|
334
334
|
self.errors.append(f"{_format_path(path)}: {message}")
|
|
335
335
|
|
|
336
336
|
|
|
337
|
+
def follow_up_task_rules(schema: Mapping[str, Any], task_type: str) -> tuple[str, ...]:
|
|
338
|
+
"""이 task type 의 `followUpTasks` 에 스키마가 못 박은 행 규칙, 저작 문장으로.
|
|
339
|
+
|
|
340
|
+
`allOf` 의 if/then 가지 하나가 비종결 task type 에 phase-continuation 행
|
|
341
|
+
하나를 요구하고(`minItems`, `contains.origin.const`), `FollowUpRow` 의
|
|
342
|
+
가지가 그 행의 `autoSpawn` 을 `no` 로 고정한다. 작성자는 둘 다 읽지 못해
|
|
343
|
+
빈 배열을 냈고 조립이 `array length 0 < minItems 1` 로 거절했다(2026-09-09
|
|
344
|
+
실측, dev-10642 requirements-discovery: 이 계열로만 라운드 3회). 가지가
|
|
345
|
+
이 task type 에 없으면 빈 튜플이다 — 제약이 없다는 뜻이지 실패가 아니다.
|
|
346
|
+
"""
|
|
347
|
+
def _task_matches(condition: Any) -> bool:
|
|
348
|
+
if not isinstance(condition, dict):
|
|
349
|
+
return False
|
|
350
|
+
header = (condition.get("properties") or {}).get("header") or {}
|
|
351
|
+
selector = (header.get("properties") or {}).get("taskType") or {}
|
|
352
|
+
allowed = selector.get("enum")
|
|
353
|
+
if allowed is None and "const" in selector:
|
|
354
|
+
allowed = [selector["const"]]
|
|
355
|
+
return isinstance(allowed, list) and task_type in allowed
|
|
356
|
+
|
|
357
|
+
rules: list[str] = []
|
|
358
|
+
for branch in schema.get("allOf") or []:
|
|
359
|
+
if not isinstance(branch, dict) or not _task_matches(branch.get("if")):
|
|
360
|
+
continue
|
|
361
|
+
follow_up = ((branch.get("then") or {}).get("properties") or {}).get("followUpTasks")
|
|
362
|
+
if not isinstance(follow_up, dict):
|
|
363
|
+
continue
|
|
364
|
+
min_items = follow_up.get("minItems")
|
|
365
|
+
origin = (
|
|
366
|
+
((follow_up.get("contains") or {}).get("properties") or {}).get("origin") or {}
|
|
367
|
+
).get("const")
|
|
368
|
+
if isinstance(min_items, int) and min_items > 0:
|
|
369
|
+
rules.append(
|
|
370
|
+
f"`Follow Up Tasks`: at least {min_items} `- Item N` row(s) for task "
|
|
371
|
+
f"type `{task_type}`; an empty list is refused."
|
|
372
|
+
)
|
|
373
|
+
if isinstance(origin, str) and origin:
|
|
374
|
+
rules.append(
|
|
375
|
+
f"`Follow Up Tasks`: one row must carry `Origin` `{origin}` — the "
|
|
376
|
+
"next phase of this task."
|
|
377
|
+
)
|
|
378
|
+
row_schema = (schema.get("$defs") or {}).get("FollowUpRow") or {}
|
|
379
|
+
for row_branch in row_schema.get("allOf") or []:
|
|
380
|
+
if not isinstance(row_branch, dict):
|
|
381
|
+
continue
|
|
382
|
+
condition = ((row_branch.get("if") or {}).get("properties") or {}).get("origin") or {}
|
|
383
|
+
if condition.get("const") != origin:
|
|
384
|
+
continue
|
|
385
|
+
pinned = ((row_branch.get("then") or {}).get("properties") or {})
|
|
386
|
+
for key, value in pinned.items():
|
|
387
|
+
if isinstance(value, dict) and "const" in value:
|
|
388
|
+
rules.append(
|
|
389
|
+
f"`Follow Up Tasks`: the `{origin}` row's `{key}` "
|
|
390
|
+
f"(schema key; write its Title Case label) is exactly "
|
|
391
|
+
f"`{value['const']}`."
|
|
392
|
+
)
|
|
393
|
+
return tuple(rules)
|
|
394
|
+
|
|
395
|
+
|
|
337
396
|
def verdict_token_rule(schema: Mapping[str, Any], task_type: str) -> tuple[str, ...]:
|
|
338
397
|
"""이 task type 의 `finalVerdict.verdictToken` 에 스키마가 허용하는 값.
|
|
339
398
|
|
|
@@ -35,7 +35,7 @@ from .report_narrative import (
|
|
|
35
35
|
CORRECTION_KINDS = ("replace", "remove", "rewrite")
|
|
36
36
|
MECHANICAL_KINDS = frozenset({"replace", "remove"})
|
|
37
37
|
# okstra 가 렌더하는 절. 지시문 본문에 있으면 두 절이 갈라지므로 거절한다.
|
|
38
|
-
OKSTRA_OWNED_SECTIONS = ("## Corrections", "## Output")
|
|
38
|
+
OKSTRA_OWNED_SECTIONS = ("## Corrections", "## Previous Attempt", "## Output")
|
|
39
39
|
|
|
40
40
|
_SCHEMA_RELATIVE = ("schemas", "report-writer-corrections-v1.0.schema.json")
|
|
41
41
|
_SEGMENT_RE = re.compile(r"^([A-Za-z][A-Za-z0-9]*)((?:\[\d+\])*)$")
|
|
@@ -40,7 +40,10 @@ NARRATIVE_GRAMMAR_INSTRUCTIONS: tuple[str, ...] = (
|
|
|
40
40
|
"nest a child by indenting two more spaces), `- Item <N>` (one array entry, "
|
|
41
41
|
"numbered 1..N without gaps), and `> value` (one scalar; repeat the line for a "
|
|
42
42
|
"multi-line value; `> _none_` for null, an empty object, or an empty array). "
|
|
43
|
-
"
|
|
43
|
+
"A `> value` line is indented exactly two spaces deeper than the `- **Label**` "
|
|
44
|
+
"or `- Item N` line it belongs to — a label at column 0 takes its value at "
|
|
45
|
+
"column 2, a label at column 2 takes it at column 4; a value at column 0 is "
|
|
46
|
+
"outside every field and the file is refused. Blank lines are ignored.",
|
|
44
47
|
"Every other line is rejected — YAML frontmatter (`---` blocks), Markdown "
|
|
45
48
|
"headings (`#`, `##`, `###`), pipe tables at column 0, code fences, bare "
|
|
46
49
|
"paragraphs, JSON. Put such text inside a `> ` value instead. Report assembly "
|
|
@@ -26,7 +26,7 @@ from .report_narrative import writer_owned_data
|
|
|
26
26
|
from .scope_provenance import brief_end_state_id_sequence
|
|
27
27
|
|
|
28
28
|
from .exact_coverage import COVERAGE_VERDICT_PRECEDENCE
|
|
29
|
-
from .final_report_schema import task_block_rules, verdict_token_rule
|
|
29
|
+
from .final_report_schema import follow_up_task_rules, task_block_rules, verdict_token_rule
|
|
30
30
|
from .report_contract import TASK_TYPE_DATA_PROPERTY
|
|
31
31
|
from .report_markdown import humanise
|
|
32
32
|
from .report_narrative import NarrativeContractError, allowed_top_level_fields
|
|
@@ -87,6 +87,10 @@ class ReportSynthesisPacket:
|
|
|
87
87
|
# (2026-09-03 실측: implementation-option-selection 네 회차).
|
|
88
88
|
block_key: str = ""
|
|
89
89
|
block_rules: tuple[str, ...] = ()
|
|
90
|
+
# 비종결 task type 의 `followUpTasks` 행 규칙(최소 행 수, phase-continuation
|
|
91
|
+
# 행, 그 행의 autoSpawn). 스키마의 if/then 가지라 작성자에게 도달하지 않았고
|
|
92
|
+
# 빈 배열이 조립에서야 거절됐다(2026-09-09 실측, dev-10642).
|
|
93
|
+
follow_up_rules: tuple[str, ...] = ()
|
|
90
94
|
# 작성자가 최상위에 쓸 수 있는 필드 전체(서사 스키마의 properties). 필수만
|
|
91
95
|
# 적던 동안 리드가 어느 task type 에도 없는 절을 지시했고, 작성자는 조립이
|
|
92
96
|
# 거절할 때까지 그 지시를 거를 근거가 없었다(2026-09-03 실측).
|
|
@@ -142,6 +146,7 @@ class ReportSynthesisPacket:
|
|
|
142
146
|
f"contain: {labels}. Report assembly refuses any other top-level "
|
|
143
147
|
"field, whichever instruction asked for it."
|
|
144
148
|
)
|
|
149
|
+
lines.extend(self.follow_up_rules)
|
|
145
150
|
if self.block_rules:
|
|
146
151
|
lines.append(
|
|
147
152
|
f"Shape of the `{self.block_key}` block, one line per object, from "
|
|
@@ -736,8 +741,8 @@ def build_report_synthesis_packet(
|
|
|
736
741
|
for worker in roster
|
|
737
742
|
if _string(worker) and _string(worker) != "report-writer"
|
|
738
743
|
)
|
|
739
|
-
required_top_level, verdict_tokens, block_rules =
|
|
740
|
-
sources, task_type
|
|
744
|
+
required_top_level, verdict_tokens, block_rules, follow_up_rules = (
|
|
745
|
+
_schema_authoring_rules(sources, task_type)
|
|
741
746
|
)
|
|
742
747
|
return ReportSynthesisPacket(
|
|
743
748
|
task_key=_string(manifest.get("taskKey")),
|
|
@@ -745,6 +750,7 @@ def build_report_synthesis_packet(
|
|
|
745
750
|
result_path=_relative(project_root, narrative_path),
|
|
746
751
|
required_top_level=required_top_level,
|
|
747
752
|
verdict_tokens=verdict_tokens,
|
|
753
|
+
follow_up_rules=follow_up_rules,
|
|
748
754
|
block_key=TASK_TYPE_DATA_PROPERTY.get(task_type, "") if block_rules else "",
|
|
749
755
|
block_rules=block_rules,
|
|
750
756
|
allowed_top_level=_allowed_top_level_labels(),
|
|
@@ -758,12 +764,13 @@ def build_report_synthesis_packet(
|
|
|
758
764
|
|
|
759
765
|
def _schema_authoring_rules(
|
|
760
766
|
sources: tuple[ReportSynthesisSource, ...], task_type: str,
|
|
761
|
-
) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
|
|
762
|
-
"""넘겨받은(동결된) 완성 리포트 스키마에서 작성자 몫의 규칙
|
|
767
|
+
) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
|
|
768
|
+
"""넘겨받은(동결된) 완성 리포트 스키마에서 작성자 몫의 규칙 네 가지를 뽑는다.
|
|
763
769
|
|
|
764
770
|
최상위 `required` 가운데 작성자 소유 이름(서사 스키마의 properties)만
|
|
765
|
-
사람이 읽는 라벨로, 이 task type 의 `Verdict Token` 허용값,
|
|
766
|
-
|
|
771
|
+
사람이 읽는 라벨로, 이 task type 의 `Verdict Token` 허용값, 이 task type 의
|
|
772
|
+
데이터 블록 안쪽 모양(`task_block_rules`), 그리고 `followUpTasks` 행 규칙
|
|
773
|
+
(`follow_up_task_rules`). 스키마 소스가 없거나
|
|
767
774
|
JSON 이 아니면 빈 값이다 — 이 함수는 조립 검증을 대신하지 않고 도달하지
|
|
768
775
|
못하던 규칙을 저작 계약에 옮길 뿐이다.
|
|
769
776
|
"""
|
|
@@ -789,7 +796,12 @@ def _schema_authoring_rules(
|
|
|
789
796
|
)
|
|
790
797
|
block_key = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
|
|
791
798
|
block_rules = task_block_rules(schema, block_key) if block_key else ()
|
|
792
|
-
return
|
|
799
|
+
return (
|
|
800
|
+
required_top_level,
|
|
801
|
+
verdict_token_rule(schema, task_type),
|
|
802
|
+
block_rules,
|
|
803
|
+
follow_up_task_rules(schema, task_type),
|
|
804
|
+
)
|
|
793
805
|
|
|
794
806
|
|
|
795
807
|
def _allowed_top_level_labels() -> tuple[str, ...]:
|
|
@@ -33,7 +33,13 @@ from .ids import (
|
|
|
33
33
|
)
|
|
34
34
|
from .state import Prompt, WizardError, WizardState, _is_role_selection_step
|
|
35
35
|
from .prompts import _domain_prompt
|
|
36
|
-
from .picker_navigation import
|
|
36
|
+
from .picker_navigation import (
|
|
37
|
+
accept_picker_answer,
|
|
38
|
+
is_split_checkbox,
|
|
39
|
+
merge_split_checkbox_answer,
|
|
40
|
+
present_picker,
|
|
41
|
+
split_checkbox,
|
|
42
|
+
)
|
|
37
43
|
from .roles import _submit_role_prompt, next_role_prompt
|
|
38
44
|
from .steps_identity import _submit_task_pick
|
|
39
45
|
from .steps_analysis import _advance_design_prep_item
|
|
@@ -135,12 +141,14 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
|
|
|
135
141
|
"""호스트 네이티브 선택기 한도에 맞춘 화면.
|
|
136
142
|
|
|
137
143
|
단일 선택이 한도를 넘으면 쪽으로 나눈다(`present_picker`). 체크박스(`multi`)는
|
|
138
|
-
나누지 않는다 —
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
+
쪽으로 나누지 않는다 — 종전엔 한 줄씩 토글하는 쪽으로 내렸는데, claude-code
|
|
145
|
+
한도 4 에서 후보 12개는 쪽당 2개가 됐고, 쪽 사본이 추천 표시를 단 채 단일
|
|
146
|
+
선택이 돼 `Prompt` 의 추천 불변식(단일 선택은 추천 정확히 하나)에 걸려
|
|
147
|
+
화면이 열리지 않았다(실측 2026-09-09, verifier 전체 후보 화면). 대신
|
|
148
|
+
네이티브 질문 묶음에 실리는 크기(claude-code 4×4=16)면 같은 화면의 체크박스
|
|
149
|
+
질문 여러 개로 자른다(`split_checkbox`) — 묶음이 네이티브에 실릴 때만이고,
|
|
150
|
+
아니면 `CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 전체
|
|
151
|
+
목록을 한 번에 보인다.
|
|
144
152
|
"""
|
|
145
153
|
if "native_single_select" not in state.available_functions:
|
|
146
154
|
return prompt
|
|
@@ -148,10 +156,17 @@ def _native_picker_screen(state: WizardState, prompt: Prompt) -> Prompt:
|
|
|
148
156
|
if _interaction_plan(state, prompt).kind == "native-group":
|
|
149
157
|
return prompt
|
|
150
158
|
prompt = prompt.questions[0]
|
|
159
|
+
port = default_host_registry().resolve(state.host_runtime).interaction()
|
|
151
160
|
if prompt.multi:
|
|
161
|
+
split = split_checkbox(
|
|
162
|
+
prompt,
|
|
163
|
+
max_options=port.native_option_limit,
|
|
164
|
+
max_questions=port.native_question_limit,
|
|
165
|
+
)
|
|
166
|
+
if split is not prompt and _interaction_plan(state, split).kind == "native-group":
|
|
167
|
+
return split
|
|
152
168
|
return prompt
|
|
153
|
-
|
|
154
|
-
return present_picker(state, prompt, limit=limit)
|
|
169
|
+
return present_picker(state, prompt, limit=port.native_option_limit)
|
|
155
170
|
|
|
156
171
|
|
|
157
172
|
def _next_prompt_screen(state: WizardState) -> Prompt:
|
|
@@ -204,6 +219,9 @@ def _sim_answer(prompt: Prompt) -> str:
|
|
|
204
219
|
def _sim_advance(state: WizardState, prompt: Prompt) -> None:
|
|
205
220
|
"""기본답으로 한 화면 전진한다. progress 를 재계산하는 submit()/
|
|
206
221
|
_submit_group() 은 호출하지 않고 step.submit 만 직접 호출해 재귀를 막는다."""
|
|
222
|
+
if is_split_checkbox(prompt):
|
|
223
|
+
# 조각 질문의 step 은 등록된 step 이 아니다 — 잘리지 않은 원본으로 낸다.
|
|
224
|
+
prompt = _next_prompt_screen(state)
|
|
207
225
|
try:
|
|
208
226
|
if _is_role_selection_step(prompt.step):
|
|
209
227
|
# 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수 있다 — 기본답은
|
|
@@ -384,7 +402,12 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
|
|
|
384
402
|
value = accept_picker_answer(state, original, value)
|
|
385
403
|
if value is None:
|
|
386
404
|
return {"echo": "", "next": prompt_payload(state, next_prompt(state))}
|
|
387
|
-
if prompt
|
|
405
|
+
if is_split_checkbox(prompt):
|
|
406
|
+
# 질문 묶음으로 잘린 체크박스 — 탭별 CSV 를 한 줄로 합쳐 원본 step 의
|
|
407
|
+
# 제출 경로로 보낸다. 원본의 선택지로 검증한다.
|
|
408
|
+
value = merge_split_checkbox_answer(prompt, value)
|
|
409
|
+
prompt = _next_prompt_screen(state)
|
|
410
|
+
elif prompt.kind == "pick_group":
|
|
388
411
|
return _submit_group(state, prompt, value)
|
|
389
412
|
if _is_role_selection_step(prompt.step):
|
|
390
413
|
# 고정 단일 역할의 화면은 호스트 한도에 맞춰 쪽으로 잘린 사본일 수
|
|
@@ -1,16 +1,93 @@
|
|
|
1
|
-
"""호스트 선택기 한도 안에서
|
|
1
|
+
"""호스트 선택기 한도 안에서 원래 선택지를 보존하는 두 가지 강등.
|
|
2
2
|
|
|
3
|
-
체크박스(`multi`)는
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
단일 선택은 쪽으로 나눈다(`present_picker`). 체크박스(`multi`)는 쪽으로 나누지
|
|
4
|
+
않는다 — 쪽 사본이 추천 표시를 단 채 단일 선택으로 바뀌면 `Prompt` 의 추천
|
|
5
|
+
불변식에 걸리므로, 한 줄씩 토글하던 체크박스 쪽 나누기는 2026-09-09 에 뺐다.
|
|
6
|
+
대신 네이티브 질문 묶음(claude-code `AskUserQuestion` 의 질문 4개 × 옵션 4개)에
|
|
7
|
+
실리는 크기면 같은 화면 안의 체크박스 질문 여러 개로 자른다(`split_checkbox`):
|
|
8
|
+
사용자는 탭마다 체크하고, 답은 하나의 CSV 로 합쳐져(`merge_split_checkbox_answer`)
|
|
9
|
+
원래 step 의 제출 경로로 간다. 그 크기도 넘으면 `numbered-multi` 로 전체 목록을
|
|
10
|
+
한 번에 보인다(`engine._native_picker_screen`).
|
|
7
11
|
"""
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
8
14
|
from dataclasses import replace
|
|
9
15
|
|
|
10
16
|
from .ids import PICK_TYPE_CUSTOM
|
|
11
17
|
from .state import Option, Prompt, WizardError, WizardState
|
|
12
18
|
|
|
13
19
|
_PAGE_PREFIX = "__okstra_picker_page__:"
|
|
20
|
+
_SPLIT_SEPARATOR = "#"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def split_checkbox(
|
|
24
|
+
prompt: Prompt, *, max_options: int, max_questions: int,
|
|
25
|
+
) -> Prompt:
|
|
26
|
+
"""옵션이 `max_options` 를 넘는 체크박스를 같은 화면의 질문 묶음으로 자른다.
|
|
27
|
+
|
|
28
|
+
질문 수는 옵션이 들어가는 최소 개수이고 옵션은 질문에 고르게 나눈다 —
|
|
29
|
+
마지막 질문이 한 줄짜리가 되면 호스트 최소 옵션 수(2)에 걸려 묶음 전체가
|
|
30
|
+
네이티브에 못 실린다. 추천은 원래 목록의 앞머리 run 이라 어느 조각에서도
|
|
31
|
+
앞머리 run 이 된다(`Prompt._check_recommendations`). `max_questions` 도
|
|
32
|
+
넘으면 자르지 않고 그대로 돌려준다.
|
|
33
|
+
"""
|
|
34
|
+
if prompt.kind != "pick" or not prompt.multi or len(prompt.options) <= max_options:
|
|
35
|
+
return prompt
|
|
36
|
+
count = len(prompt.options)
|
|
37
|
+
questions = math.ceil(count / max_options)
|
|
38
|
+
if questions > max_questions:
|
|
39
|
+
return prompt
|
|
40
|
+
base, extra = divmod(count, questions)
|
|
41
|
+
chunks: list[Prompt] = []
|
|
42
|
+
offset = 0
|
|
43
|
+
for index in range(questions):
|
|
44
|
+
size = base + (1 if index < extra else 0)
|
|
45
|
+
shown = prompt.options[offset:offset + size]
|
|
46
|
+
chunks.append(replace(
|
|
47
|
+
prompt,
|
|
48
|
+
step=f"{prompt.step}{_SPLIT_SEPARATOR}{index + 1}",
|
|
49
|
+
label=f"{prompt.label} ({offset + 1}–{offset + size}/{count})",
|
|
50
|
+
options=shown,
|
|
51
|
+
))
|
|
52
|
+
offset += size
|
|
53
|
+
return Prompt(
|
|
54
|
+
step=prompt.step,
|
|
55
|
+
kind="pick_group",
|
|
56
|
+
label=prompt.label,
|
|
57
|
+
help=prompt.help,
|
|
58
|
+
echo_template=prompt.echo_template,
|
|
59
|
+
questions=chunks,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_split_checkbox(prompt: Prompt) -> bool:
|
|
64
|
+
return prompt.kind == "pick_group" and bool(prompt.questions) and all(
|
|
65
|
+
question.multi
|
|
66
|
+
and question.step.startswith(f"{prompt.step}{_SPLIT_SEPARATOR}")
|
|
67
|
+
for question in prompt.questions
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def merge_split_checkbox_answer(prompt: Prompt, value: str) -> str:
|
|
72
|
+
"""질문 묶음 답(JSON, 조각 step → CSV)을 원래 체크박스의 CSV 한 줄로 합친다."""
|
|
73
|
+
try:
|
|
74
|
+
answers = json.loads(value or "{}")
|
|
75
|
+
except json.JSONDecodeError as exc:
|
|
76
|
+
raise WizardError(f"pick_group answer must be a JSON object: {exc}") from exc
|
|
77
|
+
if not isinstance(answers, dict):
|
|
78
|
+
raise WizardError("pick_group answer must be a JSON object")
|
|
79
|
+
known = {question.step for question in prompt.questions}
|
|
80
|
+
unknown = sorted(set(answers) - known)
|
|
81
|
+
if unknown:
|
|
82
|
+
raise WizardError(
|
|
83
|
+
f"wizard step {prompt.step!r}: answer names unknown questions {unknown}; "
|
|
84
|
+
f"expected {sorted(known)}"
|
|
85
|
+
)
|
|
86
|
+
chosen: list[str] = []
|
|
87
|
+
for question in prompt.questions:
|
|
88
|
+
raw = str(answers.get(question.step, "") or "")
|
|
89
|
+
chosen.extend(item.strip() for item in raw.split(",") if item.strip())
|
|
90
|
+
return ",".join(chosen)
|
|
14
91
|
|
|
15
92
|
|
|
16
93
|
def present_picker(state: WizardState, prompt: Prompt, *, limit: int) -> Prompt:
|
|
@@ -10,10 +10,12 @@ verifier: `max > 1`)은 체크박스 한 장이고, 고른 모델 수가 곧 인
|
|
|
10
10
|
체크박스 화면(`role-models:<role>`)은 실행 가능한 전체 후보를 한 번에 싣는다.
|
|
11
11
|
기본 후보(프로젝트 `modelDefaults`, 없으면 카탈로그 기본값)가 앞이고 권장
|
|
12
12
|
수만큼의 앞줄이 추천이다. 호스트 네이티브 선택기의 옵션 한도(claude-code 4,
|
|
13
|
-
codex 3, grok 15)를 넘으면
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
codex 3, grok 15)를 넘으면 네이티브 질문 묶음에 실리는 크기(claude-code 4×4)
|
|
14
|
+
까지는 같은 화면의 체크박스 질문 여러 개로 자르고(`picker_navigation.split_checkbox`),
|
|
15
|
+
그것도 넘으면 `CapabilityInteractionPort.plan` 이 `numbered-multi` 로 내려 번호
|
|
16
|
+
목록이 된다 — 기본 후보만 실은 짧은 화면과 "직접 선택" 이 여는 두 번째 화면으로
|
|
17
|
+
나누던 설계는 2026-09-09 사용자 요청으로 뺐다(후보 12개 중 3개만 보이고, 두
|
|
18
|
+
번째 화면은 쪽 나누기가 추천 불변식을 깨 열리지도 않았다).
|
|
17
19
|
"""
|
|
18
20
|
from __future__ import annotations
|
|
19
21
|
|
|
@@ -10,11 +10,15 @@ from pathlib import Path
|
|
|
10
10
|
from typing import Any, Iterable, Mapping, Sequence
|
|
11
11
|
|
|
12
12
|
from .convergence_reverify_prompt import RENDERED_BY_LINE
|
|
13
|
+
from .convergence_critic_verify_prompt import (
|
|
14
|
+
RENDERED_BY_LINE as CRITIC_VERIFY_RENDERED_BY_LINE,
|
|
15
|
+
)
|
|
13
16
|
from .worker_prompt_body import analysis_worker_label
|
|
14
17
|
from .json_boundary import load_owned_object
|
|
15
18
|
from .worker_prompt_policy import (
|
|
16
19
|
ERRORS_PATH_HEADERS,
|
|
17
20
|
IMPLEMENTATION_HEADERS,
|
|
21
|
+
CRITIC_VERIFY_DISPATCH_KIND,
|
|
18
22
|
PromptPlan,
|
|
19
23
|
resolve_prompt_plan_for_manifest,
|
|
20
24
|
)
|
|
@@ -273,6 +277,7 @@ def validate_reverify_prompt(
|
|
|
273
277
|
task_type: str,
|
|
274
278
|
forbidden_actions: str,
|
|
275
279
|
expected_model: str | None = None,
|
|
280
|
+
dispatch_kind: str = "reverify-r1",
|
|
276
281
|
) -> list[str]:
|
|
277
282
|
"""Require the active phase boundary in a lightweight reverify prompt.
|
|
278
283
|
|
|
@@ -281,6 +286,11 @@ def validate_reverify_prompt(
|
|
|
281
286
|
a model the runtime does not serve does not fail here — it fails as a
|
|
282
287
|
provider 400 once the worker launches, where it reads as a worker fault.
|
|
283
288
|
Pass the dispatch's model so the mismatch is caught before launch.
|
|
289
|
+
|
|
290
|
+
``dispatch_kind`` selects the renderer whose signature the instruction must
|
|
291
|
+
carry: a numbered round is rendered by `okstra convergence reverify-prompt`,
|
|
292
|
+
the critic gap round (`critic-verify`) by `okstra convergence
|
|
293
|
+
critic-verify-prompt`. Either way a hand-written instruction is refused.
|
|
284
294
|
"""
|
|
285
295
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
286
296
|
errors: list[str] = _validate_model_header(normalized, expected_model)
|
|
@@ -314,7 +324,18 @@ def validate_reverify_prompt(
|
|
|
314
324
|
and boundary_position > first_heading.start()
|
|
315
325
|
):
|
|
316
326
|
errors.append("phase boundary block must precede reverify instructions")
|
|
317
|
-
|
|
327
|
+
instructions = normalized[_task_instructions_offset(normalized):]
|
|
328
|
+
if dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND:
|
|
329
|
+
if CRITIC_VERIFY_RENDERED_BY_LINE not in instructions:
|
|
330
|
+
errors.append(
|
|
331
|
+
"critic-verify instruction is not the output of `okstra convergence "
|
|
332
|
+
"critic-verify-prompt` (missing the `**Rendered by:**` line) — render "
|
|
333
|
+
"it with `okstra convergence critic-verify-prompt --run-manifest "
|
|
334
|
+
"<run-manifest> --gaps <coverage-batch.json> --worker <worker-id>` and "
|
|
335
|
+
"pass that output verbatim as --instruction; hand-written gap "
|
|
336
|
+
"verification instructions are refused"
|
|
337
|
+
)
|
|
338
|
+
elif RENDERED_BY_LINE not in instructions:
|
|
318
339
|
# 손으로 쓴 지시문이 한 라운드를 버렸다(2026-09-09: `- Verdict:` 형식과 축약된
|
|
319
340
|
# 근거). 렌더러의 서명 줄이 없으면 그 지시문은 렌더러 출력이 아니다.
|
|
320
341
|
errors.append(
|
|
@@ -78,6 +78,19 @@ WORKER_PREAMBLE_FILENAME_BY_AUDIENCE = {
|
|
|
78
78
|
"report-writer": "report-writer-prompt-preamble.md",
|
|
79
79
|
}
|
|
80
80
|
WORKER_ERROR_CONTRACT_FILENAME = "worker-error-contract.md"
|
|
81
|
+
# coverage-critic gap 검증 라운드의 dispatch kind. 번호 라운드(`reverify-r<N>`)와
|
|
82
|
+
# 같은 검증 계약(audience `reverification-worker`, 렌더러 서명, 출력 계약)을
|
|
83
|
+
# 따르되 라운드 원장 밖이라 번호가 없다. 실측(2026-09-09 dev-10642): 이 kind 가
|
|
84
|
+
# 없어 gap 검증 프롬프트를 만들 수 없었고 gap 3건 전부 `gapsUnverified` 로 남았다.
|
|
85
|
+
CRITIC_VERIFY_DISPATCH_KIND = "critic-verify"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_verification_dispatch_kind(dispatch_kind: str) -> bool:
|
|
89
|
+
"""번호 reverify 라운드와 critic gap 검증 — 검증 프롬프트 계약을 받는 kind."""
|
|
90
|
+
return (
|
|
91
|
+
dispatch_kind.startswith("reverify-r")
|
|
92
|
+
or dispatch_kind == CRITIC_VERIFY_DISPATCH_KIND
|
|
93
|
+
)
|
|
81
94
|
|
|
82
95
|
|
|
83
96
|
@dataclass(frozen=True)
|
|
@@ -114,7 +127,7 @@ def resolve_prompt_plan(
|
|
|
114
127
|
if worker_id != "translator" or dispatch_kind != "translator":
|
|
115
128
|
raise ValueError("translator worker and dispatch kind must match")
|
|
116
129
|
return _plan("translator", duty_audience="translator")
|
|
117
|
-
if dispatch_kind
|
|
130
|
+
if is_verification_dispatch_kind(dispatch_kind):
|
|
118
131
|
return _plan("reverify")
|
|
119
132
|
# A critic pass keeps the full analysis contract — worker anchor headers, the
|
|
120
133
|
# audit sidecar, the packet boundary — but is exempt from the equality group.
|
|
@@ -198,7 +198,7 @@ That is the entire interactive flow. The wizard handles:
|
|
|
198
198
|
- base-ref pick + git rev-parse validation (skipped when reusing an active worktree),
|
|
199
199
|
- `implementation`-only sub-flow: approved-plan path (frontmatter `approved: true` check) + stage pick (`auto` = the earliest incomplete stage whose dependencies are satisfied, or a specific stage number). Implementer slots use role-count / role-model like every other role (`executor` is only a compatibility alias for `implementer`). When an approved plan is selected and a `## PLAN DECISION` sidecar carrying `Status: approved`, exported from the report — matching the plan on source-report·seq — is detected in that run's sibling `user-responses/`, the approve-confirm step expands to 3 options (`yes_apply` recommended: approve + apply the option as exported / `yes` approve only / `no` abort) — `yes_apply` validates the option against the plan's `optionCandidates` before applying it via the existing approval·option path,
|
|
200
200
|
- `release-handoff`-only sub-flow: after the approved plan auto-resolves, a `handoff_stage_pick` multi-select — choose an eligible stage bundle (stage-group) or the whole task (when an accepted whole-task verification report exists); the result goes out as render-args' `stages` key (csv, empty when whole-task),
|
|
201
|
-
- launch selection after identity/worktree steps: one screen per static role, in profile order. A role that can run several instances (`max > 1`) is a checkbox step `role-models:<role>` (`multi: true`) — the number of models checked is the number of instances, there is no separate count question; the label states the profile range and recommended count, every executable candidate is listed on that one screen (defaults first, the recommended set flagged), and when the list exceeds the host's native checkbox limit the interaction plan is `numbered-multi` — render the whole list, never a shortlist or pages. An optional role (`min = 0`, e.g. critic) carries a `추가 안 함` row. A fixed single role (`min = max = 1`, e.g. report-writer) is a single pick `role-model:<role>:1`. current-session lead is this session and is listed on the confirmation summary, not as a wizard step. The wizard does not fork on defaults-vs-customize, does not show a provider roster multi-pick, and does not offer a separate implementer-provider pick. Dynamic verifiers are not chosen at launch. `--workers` is compatibility-only, not a launch picker. Repeated `--role-count` / `--role-model` tokens on `renderArgv` are intentional,
|
|
201
|
+
- launch selection after identity/worktree steps: one screen per static role, in profile order. A role that can run several instances (`max > 1`) is a checkbox step `role-models:<role>` (`multi: true`) — the number of models checked is the number of instances, there is no separate count question; the label states the profile range and recommended count, every executable candidate is listed on that one screen (defaults first, the recommended set flagged), and when the list exceeds the host's native checkbox limit the runtime either splits it into several checkbox questions on one `pick_group` screen (interaction plan `native-group`; the question steps are `role-models:<role>#1`, `#2`, … and the answer is one JSON object keyed by them, each value a CSV) when the host's native question group holds every option, or returns the interaction plan `numbered-multi` — render the whole list, never a shortlist or pages. An optional role (`min = 0`, e.g. critic) carries a `추가 안 함` row. A fixed single role (`min = max = 1`, e.g. report-writer) is a single pick `role-model:<role>:1`. current-session lead is this session and is listed on the confirmation summary, not as a wizard step. The wizard does not fork on defaults-vs-customize, does not show a provider roster multi-pick, and does not offer a separate implementer-provider pick. Dynamic verifiers are not chosen at launch. `--workers` is compatibility-only, not a launch picker. Repeated `--role-count` / `--role-model` tokens on `renderArgv` are intentional,
|
|
202
202
|
- **resume-clarification (in-session equivalent)** — there is no separate mode or flag matching the shell's `okstra.sh --resume-clarification`; two steps of the standard flow carry out its substance. (1) `reuse_previous` (yes/no to reuse the previous run's settings — in `requirements-discovery` / `error-analysis` / `implementation-planning`, only when prior run-inputs exist): YES prefills role-count·role-model·directive·related-tasks at once. (2) `clarification_pick`: if the **task-type's own** previous `final-report` exists it is auto-recommended as the carry-in input (falling back to the newest by mtime across all phases when absent), and the same run's `user-responses/` sidecar (answers the user filled in) is attached alongside. The chosen path is passed to prepare as `--clarification-response` — the user makes the sidecar via the report's `Export user response`, places it in `runs/<task-type>/user-responses/`, and re-runs the same phase,
|
|
203
203
|
- **re-verification scope (`reverify_scope_pick`, `implementation-planning` clarification re-runs only)** — asked right before `confirm` when the re-run is narrowable **or** an answered `C-NNN` traces to no stage. When every answered id traces to a stage: 3 options — `auto` (recommended — leave it to the lead's `okstra incremental-scope` decision) / `full` (re-verify every stage) / Enter directly (a stage-number CSV, validated against the prior report's Stage Map). When an id is unlinked, `auto` is omitted and the user names stages or picks `full`; that unlinked id does not freeze the run at full. The answer goes out as `--reverify-scope` and reaches the lead prompt as the `REVERIFY_SCOPE_MODE` / `REVERIFY_SCOPE_STAGES` tokens; it shapes that CLI's inputs rather than replacing the decision. The confirmation block's `reverify-scope` line names unlinked ids as needing stage numbers, not as a forced full re-run,
|
|
204
204
|
- `release-handoff` PR template override + persist scope,
|