okstra 0.195.1 → 0.195.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -0
- 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/prompts/wizard/prompts.ko.json +2 -27
- 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_html/render.py +10 -4
- package/runtime/python/okstra_ctl/report_narrative.py +4 -1
- package/runtime/python/okstra_ctl/report_projections.py +19 -1
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +20 -8
- package/runtime/python/okstra_ctl/wizard/engine.py +13 -4
- package/runtime/python/okstra_ctl/wizard/picker_navigation.py +14 -36
- package/runtime/python/okstra_ctl/wizard/roles.py +26 -82
- package/runtime/python/okstra_ctl/wizard/state.py +1 -5
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +0 -1
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +22 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +14 -1
- package/runtime/python/okstra_token_usage/collect.py +27 -6
- package/runtime/python/okstra_token_usage/pricing.py +39 -6
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/templates/reports/html/base.template.html +1 -1
package/docs/architecture.md
CHANGED
|
@@ -944,6 +944,7 @@ Tokens used in each run are collected from lead/worker session transcripts and w
|
|
|
944
944
|
- Antigravity CLI: the `usage` snapshot in the wrapper `<prompt>.status.json` — the runner records the last `usage` the `agy` stream reported (`result.usage` on a run that closed). The CLI writes no transcript under the home directory, and the worker `.log` is the stream rewritten as readable lines, not stream-json.
|
|
945
945
|
- CLI execution evidence and token attribution are independent. A wrapper `.status.json` proves `not-started`, `started`, `exited`, `timeout`, or `failed` and supplies the worker's collection window; only a matching transcript with a final token snapshot — or, for a provider without a home transcript, the `usage` snapshot the runner wrote into that `.status.json` — proves attributable usage. If a wrapper exited successfully but no attributable transcript exists, the worker remains `source: "unavailable"` with `cliExecutionStatus: "exited"` and a reason instead of becoming zero usage or being described as never invoked.
|
|
946
946
|
- Records billable-equivalent token math and USD cost estimates. It applies Anthropic billing ratios (`cache_creation_5m=1.25x`, `cache_creation_1h=2.0x`, `cache_read=0.1x`, `output=5x`). When the transcript provides separate `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` values, they are counted separately.
|
|
947
|
+
- CLI providers (codex, grok, kimi) report cache reads inside their input count (`cached_input_tokens ⊂ input_tokens`). Their usage blocks carry the same keys as a Claude block — `totalTokens` with the cached part removed, `cacheReadTokens`, and `billableEquivalentTokens` normalized by that model's own rate ratios (`provider_billable_equivalent`) — so the report's cache-read and billable columns are filled for every agent. Their cost stays in `cliEstimatedCostUsd` and on the report's separate CLI row.
|
|
947
948
|
- Pricing is centrally managed in `scripts/okstra_token_usage/pricing.py` (Claude and Gemini rate tables) and in each provider catalog's `ModelSpec(pricing=...)` (codex, grok, kimi — merged through `catalog_pricing`). Update it when model prices change. Model IDs that fail price matching are exposed to the user in `usageSummary.unmatchedModels`, preventing silent-zero incidents.
|
|
948
949
|
- **Cost is always the public list price, regardless of how the account is billed.** The report answers "how much was consumed", not "what the invoice says": a model served under a ChatGPT/Claude subscription is still priced at its API rate, so every selectable catalog row must carry a price. The one row without a price is `codex-auto-review`, for which no public rate exists.
|
|
949
950
|
- Project-wide historical usage is exposed through the read-only `okstra usage-report` command (`scripts/okstra_ctl/usage_report.py`) and the `okstra-usage` skill. It defaults to the whole current project's last 30 days and returns run coverage, raw and billable-equivalent tokens, known USD cost, CPU-sum milliseconds, and wall-clock milliseconds grouped by task type. Runs without usable Phase 7 usage are excluded from resource totals and reported through unavailable reason counts rather than treated as zero usage; unmatched model names remain visible when their tokens and time are included but their cost is not. Use `okstra-inspect` for one task's elapsed/context detail and `okstra-rollup` for task-group or project status/report digests.
|
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
|
|
|
@@ -621,14 +621,14 @@
|
|
|
621
621
|
"options": {
|
|
622
622
|
"model": "{model_ref} — {display}",
|
|
623
623
|
"skip": "추가 안 함{skip_warning}",
|
|
624
|
-
"__free_input__": "직접 선택 (실행 가능한 전체 후보 {total}개에서 고르기)",
|
|
625
624
|
"skip_warnings": {
|
|
626
625
|
"critic": " — 차단 kind 에서 분석자 표가 1대1 동수면 가를 주체가 없어, 그 항목마다 승인 결정과 Blocks=approval clarification 행이 열립니다 (자동 tie-break 대신 사용자 질문. phase 는 계속 진행됩니다)"
|
|
627
626
|
}
|
|
628
627
|
},
|
|
629
628
|
"labels": {
|
|
630
629
|
"range": "허용 {minimum}..{maximum}개, 권장 {recommended}개",
|
|
631
|
-
"exact": "정확히 {count}개"
|
|
630
|
+
"exact": "정확히 {count}개",
|
|
631
|
+
"single": "{role} 역할의 모델을 하나 고르세요 — 인스턴스는 1개입니다 ({range}). 1번 추천은 프로젝트 modelDefaults(없으면 카탈로그 기본값)이고, 이 역할을 빼려면 '추가 안 함' 입니다"
|
|
632
632
|
},
|
|
633
633
|
"errors": {
|
|
634
634
|
"count_out_of_range": "{range} 골라야 합니다 — 고른 수 {count}개",
|
|
@@ -636,31 +636,6 @@
|
|
|
636
636
|
"unknown_option": "목록에 없는 항목입니다: {values}"
|
|
637
637
|
},
|
|
638
638
|
"echo_variants": {
|
|
639
|
-
"custom": "role-models: (전체 후보에서 직접 선택)",
|
|
640
|
-
"skipped": "role-models: (추가 안 함)"
|
|
641
|
-
}
|
|
642
|
-
},
|
|
643
|
-
"role_models_custom": {
|
|
644
|
-
"label": "{role} 역할의 모델을 전체 후보에서 고르세요 — 고른 만큼 인스턴스를 띄웁니다 ({range})",
|
|
645
|
-
"echo_template": "role-models: {value}",
|
|
646
|
-
"options": {
|
|
647
|
-
"model": "{model_ref} — {display}",
|
|
648
|
-
"skip": "추가 안 함{skip_warning}",
|
|
649
|
-
"skip_warnings": {
|
|
650
|
-
"critic": " — 차단 kind 에서 분석자 표가 1대1 동수면 가를 주체가 없어, 그 항목마다 승인 결정과 Blocks=approval clarification 행이 열립니다 (자동 tie-break 대신 사용자 질문. phase 는 계속 진행됩니다)"
|
|
651
|
-
}
|
|
652
|
-
},
|
|
653
|
-
"labels": {
|
|
654
|
-
"range": "허용 {minimum}..{maximum}개, 권장 {recommended}개",
|
|
655
|
-
"exact": "정확히 {count}개"
|
|
656
|
-
},
|
|
657
|
-
"errors": {
|
|
658
|
-
"count_out_of_range": "{range} 골라야 합니다 — 고른 수 {count}개",
|
|
659
|
-
"min_one_required": "모델을 {range} 고르세요 (이 역할을 빼려면 '추가 안 함')",
|
|
660
|
-
"unknown_option": "목록에 없는 항목입니다: {values}"
|
|
661
|
-
},
|
|
662
|
-
"echo_variants": {
|
|
663
|
-
"custom": "role-models: (전체 후보에서 직접 선택)",
|
|
664
639
|
"skipped": "role-models: (추가 안 함)"
|
|
665
640
|
}
|
|
666
641
|
}
|
|
@@ -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
|
|