okstra 0.223.2 → 0.225.0
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/dist/commands/execute/render-bundle.mjs +7 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/docs/cli.md +10 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +4 -1
- package/runtime/bin/okstra-provider-exec.py +32 -16
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/coding-preflight/clean-code.md +8 -1
- package/runtime/prompts/duties/code-reviewer.json +5 -3
- package/runtime/prompts/wizard/prompts.ko.json +10 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +5 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +25 -0
- package/runtime/python/okstra_ctl/code_review_coverage.py +213 -0
- package/runtime/python/okstra_ctl/code_review_target.py +31 -2
- package/runtime/python/okstra_ctl/coverage_census.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +15 -0
- package/runtime/python/okstra_ctl/domain/worker_exec.py +6 -0
- package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +164 -33
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +27 -3
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/round_completion.py +177 -0
- package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
- package/runtime/python/okstra_ctl/plan_items.py +12 -9
- package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
- package/runtime/python/okstra_ctl/render.py +2 -0
- package/runtime/python/okstra_ctl/run.py +23 -2
- package/runtime/python/okstra_ctl/team.py +12 -18
- package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +2 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
- package/runtime/python/okstra_ctl/wizard/ids.py +3 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +13 -0
- package/runtime/python/okstra_ctl/wizard/render.py +1 -0
- package/runtime/python/okstra_ctl/wizard/state.py +8 -0
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
- package/runtime/python/okstra_ctl/wizard/steps_options.py +52 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
- package/runtime/python/okstra_ctl/worker_dispatch.py +9 -0
- package/runtime/python/okstra_ctl/worker_request.py +12 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
- package/runtime/schemas/final-report-v2.0.schema.json +3 -1
- package/runtime/schemas/final-report-v3.0.schema.json +3 -1
- package/runtime/skills/_fragments/bash-invocation-rule.md +1 -1
- package/runtime/skills/_fragments/host-question-interface.md +1 -0
- package/runtime/skills/_fragments/host-runtime-selection.md +1 -0
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -1
- package/runtime/skills/_fragments/wizard-preflight.md +1 -1
- package/runtime/skills/_fragments/wizard-render-bundle.md +4 -4
- package/runtime/skills/okstra-brief-gen/SKILL.md +45 -47
- package/runtime/skills/okstra-brief-gen/references/reporter-confirmations.md +3 -3
- package/runtime/skills/okstra-brief-gen/references/tracker-recursion.md +1 -1
- package/runtime/skills/okstra-chat/SKILL.md +1 -1
- package/runtime/skills/okstra-code-review/SKILL.md +26 -15
- package/runtime/skills/okstra-code-review/references/census-rules.md +38 -2
- package/runtime/skills/okstra-code-review/references/review-calibration.md +39 -15
- package/runtime/skills/okstra-container-build/SKILL.md +6 -2
- package/runtime/skills/okstra-discussion/SKILL.md +6 -6
- package/runtime/skills/okstra-inspect/SKILL.md +5 -3
- package/runtime/skills/okstra-manager/SKILL.md +8 -4
- package/runtime/skills/okstra-memory/SKILL.md +1 -1
- package/runtime/skills/okstra-pr-gen/SKILL.md +10 -6
- package/runtime/skills/okstra-rollup/SKILL.md +6 -2
- package/runtime/skills/okstra-run/SKILL.md +8 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +13 -6
- package/runtime/skills/okstra-setup/SKILL.md +8 -4
- package/runtime/skills/okstra-setup/references/project-config.md +3 -3
- package/runtime/skills/okstra-usage/SKILL.md +5 -1
- package/runtime/skills/okstra-user-response/SKILL.md +7 -3
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""채택된 계획 검증 배치의 결정적 후처리."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import subprocess
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING, Any
|
|
10
|
+
|
|
11
|
+
from okstra_ctl.convergence import validated_run_authority
|
|
12
|
+
from okstra_ctl.convergence_store import load_owned_json_object, write_json_atomic
|
|
13
|
+
from okstra_ctl.dispatch_state import dispatch_worker_key
|
|
14
|
+
from okstra_ctl.error_log_write import record_runtime_failure
|
|
15
|
+
from okstra_ctl.plan_items import PlanItemContractError, analyser_key, critic_is_rostered
|
|
16
|
+
from okstra_ctl.run_context import dir_flock
|
|
17
|
+
from okstra_ctl.worker_prompt_policy import (
|
|
18
|
+
is_plan_verify_dispatch_kind, verification_dispatch_round,
|
|
19
|
+
)
|
|
20
|
+
from .authoring import finish_verification_round, validate_current_review
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from okstra_ctl.dispatch_core import DispatchPlan
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _verifier_key(row: Mapping[str, Any]) -> str:
|
|
27
|
+
if row.get("assignmentRef") == "critic/scope":
|
|
28
|
+
return "critic"
|
|
29
|
+
return analyser_key(dispatch_worker_key(row))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _round_dispatches(plan: DispatchPlan, team: Mapping[str, Any]) -> tuple[int, list[dict]] | None:
|
|
33
|
+
accepted = {row.get("dispatchId") for row in team.get("agentResultLinks", [])
|
|
34
|
+
if isinstance(row, Mapping) and not row.get("supersededBy")}
|
|
35
|
+
native = [{**row, "kind": row.get("dispatchKind", ""),
|
|
36
|
+
"status": "completed" if row.get("status") == "dispatched" and row.get("dispatchId") in accepted else row.get("status"),
|
|
37
|
+
"workerResultPath": row.get("workerResultPath") or next(
|
|
38
|
+
(link.get("resultPath") for link in team.get("agentResultLinks", [])
|
|
39
|
+
if link.get("dispatchId") == row.get("dispatchId") and not link.get("supersededBy")), "")}
|
|
40
|
+
for row in team.get("agentDispatches", []) if isinstance(row, dict)]
|
|
41
|
+
tails = [rows[-1] for rows in (team.get("workerDispatches", []), native) if rows]
|
|
42
|
+
if not plan.jobs and tails and all(
|
|
43
|
+
not is_plan_verify_dispatch_kind(str(row.get("kind", ""))) for row in tails
|
|
44
|
+
):
|
|
45
|
+
return None
|
|
46
|
+
rows = [row for row in [*team.get("workerDispatches", []), *native] if isinstance(row, dict)
|
|
47
|
+
and is_plan_verify_dispatch_kind(str(row.get("kind", "")))]
|
|
48
|
+
targets = {verification_dispatch_round(job.dispatch_kind) for job in plan.jobs
|
|
49
|
+
if is_plan_verify_dispatch_kind(job.dispatch_kind)}
|
|
50
|
+
if plan.jobs and not targets:
|
|
51
|
+
return None
|
|
52
|
+
if not rows:
|
|
53
|
+
return None
|
|
54
|
+
if len(targets) > 1:
|
|
55
|
+
raise PlanItemContractError("one plan-verification batch cannot span multiple rounds")
|
|
56
|
+
number = next(iter(targets)) if targets else max(
|
|
57
|
+
verification_dispatch_round(row["kind"]) for row in rows
|
|
58
|
+
)
|
|
59
|
+
superseded = {row.get("dispatchId") for row in team.get("agentResultLinks", [])
|
|
60
|
+
if isinstance(row, Mapping) and row.get("supersededBy")}
|
|
61
|
+
latest: dict[str, dict] = {}
|
|
62
|
+
for row in rows:
|
|
63
|
+
if verification_dispatch_round(row["kind"]) != number or row.get("dispatchId") in superseded:
|
|
64
|
+
continue
|
|
65
|
+
key = _verifier_key(row)
|
|
66
|
+
previous = latest.get(key)
|
|
67
|
+
identity = row.get("invocationRef") or row.get("invocationId")
|
|
68
|
+
if previous and (not identity or identity != (previous.get("invocationRef") or previous.get("invocationId"))):
|
|
69
|
+
raise PlanItemContractError(f"ambiguous plan-verification invocations for {key}")
|
|
70
|
+
if previous is None or row.get("attempt", 1) > previous.get("attempt", 1):
|
|
71
|
+
latest[key] = row
|
|
72
|
+
return int(number), list(latest.values())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _accepted_results(plan: DispatchPlan, team: Mapping[str, Any], rows: list[dict]) -> list[str]:
|
|
76
|
+
authority = validated_run_authority(plan.manifest_path)
|
|
77
|
+
execution = authority.execution_manifest
|
|
78
|
+
results = []
|
|
79
|
+
for row in rows:
|
|
80
|
+
declared_path = plan.project_root / str(row.get("workerResultPath", ""))
|
|
81
|
+
path = declared_path.resolve(strict=True)
|
|
82
|
+
if declared_path.is_symlink():
|
|
83
|
+
raise PlanItemContractError("plan-verification result must not be a symlink")
|
|
84
|
+
if not path.is_relative_to(authority.run_dir / "worker-results") or not path.is_file():
|
|
85
|
+
raise PlanItemContractError("plan-verification result must stay inside this run's worker-results")
|
|
86
|
+
links = [link for link in team.get("agentResultLinks", []) if isinstance(link, Mapping)
|
|
87
|
+
and link.get("dispatchId") == row.get("dispatchId") and not link.get("supersededBy")]
|
|
88
|
+
if len(links) != 1 or (plan.project_root / str(links[0].get("resultPath", ""))).resolve() != path:
|
|
89
|
+
raise PlanItemContractError("plan-verification result has no unique accepted result link")
|
|
90
|
+
if execution is not None:
|
|
91
|
+
attempts = [attempt for attempt in execution.attempts
|
|
92
|
+
if attempt.invocation_ref == row.get("invocationRef")
|
|
93
|
+
and attempt.attempt == row.get("attempt")]
|
|
94
|
+
if len(attempts) != 1 or attempts[0].status != "ok":
|
|
95
|
+
raise PlanItemContractError("plan-verification attempt is not accepted")
|
|
96
|
+
if (plan.project_root / str(attempts[0].result_path or "")).resolve() != path:
|
|
97
|
+
raise PlanItemContractError("accepted attempt result path does not match the dispatch")
|
|
98
|
+
token = f'-plan-verify-r{verification_dispatch_round(row["kind"])}-'
|
|
99
|
+
if token not in path.name:
|
|
100
|
+
raise PlanItemContractError("plan-verification result filename has the wrong round")
|
|
101
|
+
worker = path.name.split(token, 1)[0]
|
|
102
|
+
if analyser_key(worker) != _verifier_key(row):
|
|
103
|
+
raise PlanItemContractError("plan-verification result has the wrong worker")
|
|
104
|
+
results.append(f"{worker}={path}")
|
|
105
|
+
return sorted(results)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _completion_receipt(plan: DispatchPlan, number: int, results: list[str]) -> dict[str, Any]:
|
|
109
|
+
review = validate_current_review(plan.manifest_path)
|
|
110
|
+
digest = hashlib.sha256()
|
|
111
|
+
for path in [review.items, review.narrative, *(Path(value.split("=", 1)[1]) for value in results)]:
|
|
112
|
+
digest.update(str(path).encode())
|
|
113
|
+
digest.update(path.read_bytes())
|
|
114
|
+
digest.update(json.dumps(results).encode())
|
|
115
|
+
fingerprint = digest.hexdigest()
|
|
116
|
+
receipt = review.state.with_name(f"plan-round-completion-r{number}-{review.state.name.removeprefix('plan-body-verification-')}")
|
|
117
|
+
state = load_owned_json_object(review.state)
|
|
118
|
+
if receipt.is_file():
|
|
119
|
+
recorded = load_owned_json_object(receipt)
|
|
120
|
+
if recorded.get("inputDigest") == fingerprint and recorded.get("stateDigest") == hashlib.sha256(review.state.read_bytes()).hexdigest():
|
|
121
|
+
return recorded
|
|
122
|
+
if any(row.get("round") == number for row in state.get("roundHistory", [])):
|
|
123
|
+
return {"status": "needs-lead", "round": number,
|
|
124
|
+
"reason": "round already closed; automatic completion will not replace its recorded inputs"}
|
|
125
|
+
prepared = load_owned_json_object(review.items)
|
|
126
|
+
if prepared.get("dispatchKind") == "reverify":
|
|
127
|
+
return {"status": "needs-lead", "round": number,
|
|
128
|
+
"reason": "self-fix re-verification needs the lead's cause groups and completion metadata"}
|
|
129
|
+
completed = finish_verification_round(review, results, number)
|
|
130
|
+
output = {**completed, "status": "completed", "round": number,
|
|
131
|
+
"inputDigest": fingerprint, "stateDigest": hashlib.sha256(review.state.read_bytes()).hexdigest(),
|
|
132
|
+
"receiptPath": str(receipt)}
|
|
133
|
+
write_json_atomic(receipt, output)
|
|
134
|
+
return output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def complete_ready_plan_round(plan: DispatchPlan) -> dict[str, Any] | None:
|
|
138
|
+
from okstra_ctl.dispatch_core import TERMINAL_DISPATCH_STATUSES
|
|
139
|
+
|
|
140
|
+
if plan.manifest.get("taskType") != "implementation-planning":
|
|
141
|
+
return None
|
|
142
|
+
number = 0
|
|
143
|
+
try:
|
|
144
|
+
with dir_flock(plan.team_state_path.parent, ".plan-round-completion.lock"):
|
|
145
|
+
team = load_owned_json_object(plan.team_state_path)
|
|
146
|
+
batch = _round_dispatches(plan, team)
|
|
147
|
+
if batch is None:
|
|
148
|
+
return None
|
|
149
|
+
number, rows = batch
|
|
150
|
+
review = validate_current_review(plan.manifest_path)
|
|
151
|
+
prepared = load_owned_json_object(review.items)
|
|
152
|
+
workers = {analyser_key(worker) for worker in prepared.get("reviewInputs", {}).get("workers", [])}
|
|
153
|
+
actual = {_verifier_key(row) for row in rows}
|
|
154
|
+
if prepared.get("dispatchKind") == "critic-tie":
|
|
155
|
+
if not critic_is_rostered(plan.manifest):
|
|
156
|
+
return {"status": "needs-lead", "round": number,
|
|
157
|
+
"reason": "the run has no rostered critic to settle this tie"}
|
|
158
|
+
if actual & workers and not actual - (workers | {"critic"}):
|
|
159
|
+
return {"status": "needs-lead", "round": number,
|
|
160
|
+
"reason": "critic shares an analyser round; preserve its queue through manual append"}
|
|
161
|
+
workers = {"critic"}
|
|
162
|
+
if actual - workers:
|
|
163
|
+
raise PlanItemContractError("plan-verification dispatch includes an unassigned verifier")
|
|
164
|
+
if not workers or actual != workers:
|
|
165
|
+
return {"status": "pending", "round": number, "reason": "waiting for the complete prepared verifier roster"}
|
|
166
|
+
if any(row.get("status") not in TERMINAL_DISPATCH_STATUSES for row in rows):
|
|
167
|
+
return {"status": "pending", "round": number, "reason": "waiting for every verifier to settle"}
|
|
168
|
+
if any(row.get("status") != "completed" for row in rows):
|
|
169
|
+
raise PlanItemContractError("one or more plan verifiers failed; preserve their results for lead recovery")
|
|
170
|
+
return _completion_receipt(plan, number, _accepted_results(plan, team, rows))
|
|
171
|
+
except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
|
172
|
+
output = {"status": "failed", "round": number, "reason": str(exc)}
|
|
173
|
+
logged = record_runtime_failure(plan.manifest_path, command="plan-round automatic completion",
|
|
174
|
+
exit_code=2, detail=str(exc))
|
|
175
|
+
if not logged["ok"]:
|
|
176
|
+
output["errorLogFailure"] = logged["reason"]
|
|
177
|
+
return output
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Conservative evidence snapshots for plan-verification verdict reuse."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from copy import deepcopy
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypedDict
|
|
12
|
+
|
|
13
|
+
from .plan_items import content_hash, is_critic_worker
|
|
14
|
+
from .paths import find_asset_root
|
|
15
|
+
from .report_narrative import writer_owned_data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FileSnapshot(TypedDict):
|
|
19
|
+
path: str
|
|
20
|
+
hash: str
|
|
21
|
+
mode: int | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ReviewInputs(TypedDict):
|
|
25
|
+
version: int
|
|
26
|
+
items: dict[str, dict[str, Any]]
|
|
27
|
+
files: dict[str, FileSnapshot | None]
|
|
28
|
+
dependencies: dict[str, list[dict[str, Any]]]
|
|
29
|
+
workers: list[str]
|
|
30
|
+
context: str
|
|
31
|
+
complete: bool
|
|
32
|
+
planContext: dict[str, Any]
|
|
33
|
+
priorReviewHashes: dict[str, str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _digest(value: object) -> str:
|
|
37
|
+
return hashlib.sha256(
|
|
38
|
+
json.dumps(value, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
39
|
+
).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _file_snapshot(root: Path, name: str) -> FileSnapshot | None:
|
|
43
|
+
path = root / name
|
|
44
|
+
try:
|
|
45
|
+
resolved = path.resolve(strict=False)
|
|
46
|
+
if not resolved.is_relative_to(root.resolve()):
|
|
47
|
+
return None
|
|
48
|
+
before = path.stat()
|
|
49
|
+
if stat.S_ISREG(before.st_mode):
|
|
50
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
51
|
+
after = path.stat()
|
|
52
|
+
changed = any(getattr(before, field) != getattr(after, field) for field in (
|
|
53
|
+
"st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns", "st_mode",
|
|
54
|
+
))
|
|
55
|
+
if changed or resolved != path.resolve(strict=False):
|
|
56
|
+
return None
|
|
57
|
+
return {"path": str(resolved), "hash": digest, "mode": stat.S_IMODE(before.st_mode)}
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
if not path.is_symlink():
|
|
60
|
+
return {"path": str(path.resolve(strict=False)), "hash": "absent", "mode": None}
|
|
61
|
+
except (OSError, RuntimeError):
|
|
62
|
+
return None
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _payload_paths(value: object) -> set[str]:
|
|
67
|
+
if isinstance(value, str):
|
|
68
|
+
return {value} if value and not any(c in value for c in "\n`*") else set()
|
|
69
|
+
if isinstance(value, list):
|
|
70
|
+
return set().union(*(_payload_paths(entry) for entry in value))
|
|
71
|
+
if isinstance(value, Mapping):
|
|
72
|
+
return _payload_paths(value.get("path"))
|
|
73
|
+
return set()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def capture_review_inputs(
|
|
77
|
+
items: Sequence[Mapping[str, Any]],
|
|
78
|
+
planning: Mapping[str, Any],
|
|
79
|
+
manifest: Mapping[str, Any],
|
|
80
|
+
root: Path,
|
|
81
|
+
previous_rows: Sequence[Mapping[str, Any]] = (),
|
|
82
|
+
) -> ReviewInputs:
|
|
83
|
+
paths: set[str] = set()
|
|
84
|
+
for item in items:
|
|
85
|
+
payload = item.get("payload") or {}
|
|
86
|
+
paths.update(
|
|
87
|
+
match for match in re.findall(r"`([^`\s]+)`", json.dumps(payload))
|
|
88
|
+
if "/" in match or "." in match
|
|
89
|
+
)
|
|
90
|
+
for field in ("files", "plannedPaths"):
|
|
91
|
+
paths.update(_payload_paths(payload.get(field)))
|
|
92
|
+
for row in previous_rows:
|
|
93
|
+
for vote in row.get("verdicts", []):
|
|
94
|
+
dependencies = vote.get("dependencies")
|
|
95
|
+
if isinstance(dependencies, Mapping) and isinstance(dependencies.get("paths"), list):
|
|
96
|
+
paths.update(p for p in dependencies.get("paths", []) if isinstance(p, str))
|
|
97
|
+
packet = manifest.get("analysisPacketPath")
|
|
98
|
+
if isinstance(packet, str) and packet:
|
|
99
|
+
paths.add(packet)
|
|
100
|
+
metadata = writer_owned_data({"implementationPlanning": planning})["implementationPlanning"]
|
|
101
|
+
for field in ("planBodyVerification", "optionCandidates", "stepwiseExecution",
|
|
102
|
+
"dependencyMigrationRisk", "validationChecklist", "rollbackStrategy",
|
|
103
|
+
"requirementCoverage", "variationPointAnalysis"):
|
|
104
|
+
metadata.pop(field, None)
|
|
105
|
+
metadata["stages"] = [
|
|
106
|
+
{key: value for key, value in stage.items() if key != "stepwiseExecution"}
|
|
107
|
+
for stage in metadata.get("stages", [])
|
|
108
|
+
]
|
|
109
|
+
contracts = find_asset_root(("agents", "common.json"))
|
|
110
|
+
contract_hashes = {
|
|
111
|
+
name: _file_snapshot(contracts, name) if contracts else None for name in (
|
|
112
|
+
"agents/common.json", "agents/roles/analyser.json",
|
|
113
|
+
"prompts/duties/reverification-worker.json",
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
package = Path(__file__).resolve().parent
|
|
117
|
+
contract_hashes.update({name: _file_snapshot(package, name) for name in (
|
|
118
|
+
"plan_items.py", "plan_item_evidence.py", "plan_review_context.py", "phases/implementation_planning/authoring.py",
|
|
119
|
+
"phases/implementation_planning/instructions/plan-body-verification.md",
|
|
120
|
+
)})
|
|
121
|
+
files = {name: _file_snapshot(root, name) for name in sorted(paths)}
|
|
122
|
+
return {
|
|
123
|
+
"version": 1,
|
|
124
|
+
"planContext": metadata,
|
|
125
|
+
"priorReviewHashes": {str(row["id"]): str(row.get("verifiedReviewHash", "")) for row in previous_rows},
|
|
126
|
+
"items": {str(item["id"]): dict(item) for item in items},
|
|
127
|
+
"files": files,
|
|
128
|
+
"dependencies": dependency_scopes(previous_rows),
|
|
129
|
+
"workers": [row["workerId"] for row in manifest.get("workerAssignments", [])
|
|
130
|
+
if row.get("workerId") and not is_critic_worker(row["workerId"])
|
|
131
|
+
and not row["workerId"].startswith("report-writer")],
|
|
132
|
+
"context": _digest([metadata, contract_hashes, manifest.get("workerAssignments"),
|
|
133
|
+
manifest.get("agentContract"), manifest.get("invocationAssignments"),
|
|
134
|
+
files.get(packet)]),
|
|
135
|
+
"complete": bool(packet and files.get(packet) and files[packet]["hash"] != "absent"
|
|
136
|
+
and all(value and value["hash"] != "absent" for value in contract_hashes.values())),
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def dependency_scopes(rows: Sequence[Mapping[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
|
141
|
+
return {
|
|
142
|
+
str(row["id"]): deepcopy(row["verdicts"]) for row in rows
|
|
143
|
+
if isinstance(row.get("id"), str) and isinstance(row.get("verdicts"), list)
|
|
144
|
+
and row["verdicts"]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _vote_dependencies(votes: object, workers: Sequence[str]) -> tuple[set[str], set[str]] | None:
|
|
149
|
+
if (not isinstance(votes, list) or not votes or not workers
|
|
150
|
+
or any(not isinstance(vote, Mapping) for vote in votes)
|
|
151
|
+
or set(workers) - {vote.get("worker") for vote in votes}):
|
|
152
|
+
return None
|
|
153
|
+
item_ids, paths = set(), set()
|
|
154
|
+
for vote in votes:
|
|
155
|
+
dependencies = vote.get("dependencies")
|
|
156
|
+
if (vote.get("verdict") not in {"AGREE", "DISAGREE", "SUPPLEMENT"}
|
|
157
|
+
or vote.get("claimKind") == "fact"
|
|
158
|
+
or not isinstance(dependencies, Mapping)
|
|
159
|
+
or dependencies.get("complete") is not True):
|
|
160
|
+
return None
|
|
161
|
+
for field in ("itemIds", "paths"):
|
|
162
|
+
values = dependencies.get(field)
|
|
163
|
+
if not isinstance(values, list) or any(not isinstance(v, str) or not v for v in values):
|
|
164
|
+
return None
|
|
165
|
+
item_ids.update(dependencies["itemIds"])
|
|
166
|
+
paths.update(dependencies["paths"])
|
|
167
|
+
return item_ids, paths
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _dependency_closure(row: Mapping[str, Any], inputs: Mapping[str, Any]) -> tuple[set[str], set[str]] | None:
|
|
171
|
+
items = inputs.get("items", {})
|
|
172
|
+
graph = {**inputs.get("dependencies", {}), str(row["id"]): row.get("verdicts")}
|
|
173
|
+
pending, item_ids, paths = [str(row["id"])], set(), set()
|
|
174
|
+
while pending:
|
|
175
|
+
key = pending.pop()
|
|
176
|
+
if key in item_ids:
|
|
177
|
+
continue
|
|
178
|
+
if key not in items:
|
|
179
|
+
return None
|
|
180
|
+
declared = _vote_dependencies(graph.get(key), inputs.get("workers", []))
|
|
181
|
+
if declared is None:
|
|
182
|
+
return None
|
|
183
|
+
if any(not inputs.get("files", {}).get(name)
|
|
184
|
+
or inputs["files"][name]["hash"] == "absent" for name in declared[1]):
|
|
185
|
+
return None
|
|
186
|
+
item_ids.add(key)
|
|
187
|
+
pending.extend(declared[0])
|
|
188
|
+
paths.update(declared[1])
|
|
189
|
+
payload = items[key].get("payload") or {}
|
|
190
|
+
if key.startswith(("P-Opt-", "P-Var-", "P-Dir-", "P-Dep-", "P-Prep-")):
|
|
191
|
+
pending.extend(items)
|
|
192
|
+
paths.update(inputs.get("files", {}))
|
|
193
|
+
elif key.startswith("P-Step-"):
|
|
194
|
+
pending.extend(
|
|
195
|
+
other_id for other_id, other in items.items()
|
|
196
|
+
if other_id.startswith("P-Step-") and other["payload"].get("stage") == payload.get("stage")
|
|
197
|
+
and other["payload"].get("step", 0) < payload.get("step", 0)
|
|
198
|
+
)
|
|
199
|
+
for field in ("files", "plannedPaths"):
|
|
200
|
+
paths.update(_payload_paths(payload.get(field)))
|
|
201
|
+
return item_ids, paths
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def review_fingerprint(row: Mapping[str, Any], inputs: Mapping[str, Any]) -> str | None:
|
|
205
|
+
items, files = inputs.get("items", {}), inputs.get("files", {})
|
|
206
|
+
item = items.get(row.get("id"))
|
|
207
|
+
if not inputs.get("complete") or not item or row.get("contentHash") != content_hash(item):
|
|
208
|
+
return None
|
|
209
|
+
closure = _dependency_closure(row, inputs)
|
|
210
|
+
if closure is None:
|
|
211
|
+
return None
|
|
212
|
+
item_ids, paths = closure
|
|
213
|
+
if any(name not in files or files[name] is None for name in paths):
|
|
214
|
+
return None
|
|
215
|
+
return _digest({
|
|
216
|
+
"context": inputs["context"],
|
|
217
|
+
"items": {key: content_hash(items[key]) for key in sorted(item_ids)},
|
|
218
|
+
"files": {name: files[name] for name in sorted(paths)},
|
|
219
|
+
"votes": row["verdicts"],
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def review_files_unchanged(inputs: Mapping[str, Any], root: Path) -> bool:
|
|
224
|
+
return all(
|
|
225
|
+
value == _file_snapshot(root, name)
|
|
226
|
+
for name, value in inputs.get("files", {}).items()
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def reusable_item_ids(
|
|
231
|
+
rows: Sequence[Mapping[str, Any]], inputs: Mapping[str, Any],
|
|
232
|
+
) -> set[str]:
|
|
233
|
+
return {
|
|
234
|
+
str(row["id"]) for row in rows
|
|
235
|
+
if row.get("verifiedReviewHash")
|
|
236
|
+
and row["verifiedReviewHash"] == review_fingerprint(row, inputs)
|
|
237
|
+
}
|
|
@@ -410,11 +410,6 @@ def _is_plan_wide(item_id: str) -> bool:
|
|
|
410
410
|
|
|
411
411
|
|
|
412
412
|
def content_hash(item: Mapping[str, Any]) -> str:
|
|
413
|
-
"""이 항목 본문의 지문. 문구가 같으면 라운드가 달라도 같은 판정이다.
|
|
414
|
-
|
|
415
|
-
라운드 번호만 보면 고치지 않은 항목까지 재검증해야 한다. subject·payload·
|
|
416
|
-
block·stageScope 가 같으면 그 항목이 가리키는 텍스트는 재작성 전과 같다.
|
|
417
|
-
"""
|
|
418
413
|
payload = {
|
|
419
414
|
"block": item.get("block"),
|
|
420
415
|
"payload": item.get("payload"),
|
|
@@ -828,6 +823,14 @@ PLAN_VERIFY_RESPONSE_FORMAT = (
|
|
|
828
823
|
"**Note**: <the falsification candidate considered and why it is "
|
|
829
824
|
"excluded; required even for AGREE>\n"
|
|
830
825
|
"**Explanation**: <2-3 sentences>\n"
|
|
826
|
+
'**Dependencies**: {"complete": false, "itemIds": [], "paths": []}\n'
|
|
827
|
+
"Keep the detailed Note and Explanation above. Set complete=true only when "
|
|
828
|
+
"you can enumerate ALL direct and transitive evidence dependencies: other "
|
|
829
|
+
"P-* items in itemIds and whole local files in paths (project-relative). "
|
|
830
|
+
"Include source files behind citations, not just the citing document. "
|
|
831
|
+
"If the dependency boundary, dynamic state, an external source, or any "
|
|
832
|
+
"reference cannot be resolved, keep complete=false. Missing or uncertain "
|
|
833
|
+
"dependencies cause re-verification; they never suppress your verdict.\n"
|
|
831
834
|
)
|
|
832
835
|
|
|
833
836
|
|
|
@@ -851,7 +854,7 @@ def with_response_format(queue_markdown: str) -> str:
|
|
|
851
854
|
return queue_markdown.rstrip("\n") + "\n" + PLAN_VERIFY_RESPONSE_FORMAT
|
|
852
855
|
|
|
853
856
|
|
|
854
|
-
def
|
|
857
|
+
def _item_cites_build_command(item: Mapping[str, Any]) -> bool:
|
|
855
858
|
"""이 항목이 계획 워크트리에서 실행 불가한 빌드/테스트 명령을 인용하는가."""
|
|
856
859
|
payload = item.get("payload") if isinstance(item.get("payload"), Mapping) else item
|
|
857
860
|
if not isinstance(payload, Mapping):
|
|
@@ -914,13 +917,13 @@ def _worker_is_blanket(
|
|
|
914
917
|
if not votes or any(not _error_verdict(token) for _item_id, token in votes):
|
|
915
918
|
return False
|
|
916
919
|
return any(
|
|
917
|
-
not
|
|
920
|
+
not _item_cites_build_command(_with_payload(items_by_id[item_id], payloads))
|
|
918
921
|
for item_id, _token in votes
|
|
919
922
|
if item_id in items_by_id
|
|
920
923
|
)
|
|
921
924
|
|
|
922
925
|
|
|
923
|
-
def
|
|
926
|
+
def _blanket_unverifiable_workers(
|
|
924
927
|
items: Sequence[Mapping[str, Any]],
|
|
925
928
|
payloads: Mapping[str, Mapping[str, Any]] | None = None,
|
|
926
929
|
) -> list[str]:
|
|
@@ -1028,7 +1031,7 @@ def next_dispatch(
|
|
|
1028
1031
|
str(item["id"]) for item in items
|
|
1029
1032
|
if isinstance(item.get("id"), str) and item["id"]
|
|
1030
1033
|
)
|
|
1031
|
-
blanket = tuple(
|
|
1034
|
+
blanket = tuple(_blanket_unverifiable_workers(items, payloads))
|
|
1032
1035
|
if blanket:
|
|
1033
1036
|
return NextDispatch(
|
|
1034
1037
|
kind="worker-correction",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Lossless plan context with conservative dependency-based selection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .analysis_packet import _heading_lines
|
|
11
|
+
from .plan_item_evidence import _dependency_closure, review_fingerprint
|
|
12
|
+
from .plan_items import content_hash
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _packet_excerpt(text: str) -> str:
|
|
16
|
+
lines = text.splitlines(keepends=True)
|
|
17
|
+
headings = _heading_lines([line.rstrip("\r\n") for line in lines])
|
|
18
|
+
omitted: set[int] = set()
|
|
19
|
+
for index, (start, heading) in enumerate(headings):
|
|
20
|
+
stop = headings[index + 1][0] - 1 if index + 1 < len(headings) else len(lines)
|
|
21
|
+
section = "".join(lines[start - 1:stop])
|
|
22
|
+
generated_census = (
|
|
23
|
+
heading == "## Coverage Census"
|
|
24
|
+
and "The fixed worklist for this run. okstra built it from the phase " in section
|
|
25
|
+
and "| Cell | Scope | Subject | Question |" in section
|
|
26
|
+
)
|
|
27
|
+
if heading != "## Section Index" and not generated_census:
|
|
28
|
+
continue
|
|
29
|
+
omitted.update(range(start - 1, stop))
|
|
30
|
+
excerpt = "".join(line for index, line in enumerate(lines) if index not in omitted)
|
|
31
|
+
if re.search(r"(?im)^###\s+(?:[FC]-|P-)", excerpt):
|
|
32
|
+
return "".join("> " + line for line in excerpt.splitlines(keepends=True))
|
|
33
|
+
return excerpt
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _supporting_ids(queue: Sequence[str], inputs: Mapping[str, Any]) -> set[str]:
|
|
37
|
+
items = inputs["items"]
|
|
38
|
+
selected: set[str] = set()
|
|
39
|
+
for key in queue:
|
|
40
|
+
row = {"id": key, "verdicts": inputs.get("dependencies", {}).get(key),
|
|
41
|
+
"contentHash": content_hash(items[key])}
|
|
42
|
+
previous = inputs.get("priorReviewHashes", {}).get(key)
|
|
43
|
+
if not previous or previous != review_fingerprint(row, inputs):
|
|
44
|
+
return set(items)
|
|
45
|
+
closure = _dependency_closure(row, inputs)
|
|
46
|
+
if closure is None:
|
|
47
|
+
return set(items)
|
|
48
|
+
selected.update(closure[0])
|
|
49
|
+
return selected
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_review_context(
|
|
53
|
+
queue: Sequence[str], inputs: Mapping[str, Any], manifest: Mapping[str, Any], root: Path,
|
|
54
|
+
) -> str:
|
|
55
|
+
if not inputs.get("complete") or "planContext" not in inputs:
|
|
56
|
+
return ""
|
|
57
|
+
packet = str(manifest["analysisPacketPath"])
|
|
58
|
+
supporting = _supporting_ids(queue, inputs) - set(queue)
|
|
59
|
+
context = {
|
|
60
|
+
"planMetadata": inputs["planContext"],
|
|
61
|
+
"availableEvidencePaths": sorted(name for name in inputs["files"] if name != packet),
|
|
62
|
+
"supportingItems": [
|
|
63
|
+
item
|
|
64
|
+
for key, item in inputs["items"].items() if key in supporting
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
"\n## Review context\n\n"
|
|
69
|
+
"Use the inline packet and plan context below first. The plan items assigned below "
|
|
70
|
+
"carry their complete payloads; supporting items are context, not extra verdicts. "
|
|
71
|
+
"Do not reread the full narrative or packet unless a reference is unresolved, "
|
|
72
|
+
"context is missing, or a cross-item contradiction needs investigation. Read the "
|
|
73
|
+
"original paths in Inputs in that case and declare all additional dependencies. "
|
|
74
|
+
"Never infer that an omitted item or an unread source is absent. "
|
|
75
|
+
"Only the packet's navigation index and generated analysis Coverage Census are "
|
|
76
|
+
"omitted; task requirements, directives, carry-in, and unknown sections are retained.\n\n"
|
|
77
|
+
f"### Primary packet excerpt: `{packet}`\n\n"
|
|
78
|
+
+ _packet_excerpt((root / packet).read_text(encoding="utf-8"))
|
|
79
|
+
+ "\n### Plan context (lossless JSON)\n\n"
|
|
80
|
+
+ json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
|
81
|
+
+ "\n"
|
|
82
|
+
)
|
|
@@ -1761,6 +1761,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1761
1761
|
Path(ctx.get("PROJECT_ROOT", "")), task_manifest, {}
|
|
1762
1762
|
),
|
|
1763
1763
|
"leadModel": ctx.get("LEAD_MODEL", ""),
|
|
1764
|
+
# `okstra-provider-exec.py` 가 워커를 띄울 때마다 읽는다.
|
|
1765
|
+
"reasoningEffort": ctx.get("REASONING_EFFORT", ""),
|
|
1764
1766
|
# Every other path in this manifest is project-relative; this is the
|
|
1765
1767
|
# anchor they resolve against. Convergence reads it to place run
|
|
1766
1768
|
# artifacts, so a manifest without it cannot seed a round.
|
|
@@ -130,6 +130,7 @@ from .domain.host import (
|
|
|
130
130
|
ProviderUnavailable,
|
|
131
131
|
)
|
|
132
132
|
from .domain.provider import ServedModelAttestation
|
|
133
|
+
from .domain.worker_exec import REASONING_EFFORTS
|
|
133
134
|
from .execution_identity import (
|
|
134
135
|
ExecutionManifest,
|
|
135
136
|
ParticipantAssignment,
|
|
@@ -634,6 +635,9 @@ class PrepareInputs:
|
|
|
634
635
|
# discussion 전용: 링크·짧은 설명·이 task 의 key. brief 자리는 prepare 가
|
|
635
636
|
# 이 값으로 만든 입력 문서가 채운다.
|
|
636
637
|
discussion_start: str = ""
|
|
638
|
+
# 이 run 이 띄우는 모든 워커 CLI 의 추론 강도(`REASONING_EFFORTS`). 빈 값이면
|
|
639
|
+
# 각 CLI 가 자기 설정을 따른다.
|
|
640
|
+
reasoning_effort: str = ""
|
|
637
641
|
|
|
638
642
|
|
|
639
643
|
@dataclass
|
|
@@ -4257,6 +4261,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4257
4261
|
"OKSTRA_PLAN_VERIFICATION": (
|
|
4258
4262
|
"false" if not inp.plan_verification_enabled else ""
|
|
4259
4263
|
),
|
|
4264
|
+
"REASONING_EFFORT": inp.reasoning_effort,
|
|
4260
4265
|
"ANALYSIS_SOURCE_COMMIT": analysis_source_commit,
|
|
4261
4266
|
"ANALYSIS_TARGET_JSON": json.dumps(resolved_target, ensure_ascii=False),
|
|
4262
4267
|
"EVIDENCE_INPUTS_JSON": json.dumps(
|
|
@@ -4687,6 +4692,10 @@ def build_prepare_argument_parser():
|
|
|
4687
4692
|
),
|
|
4688
4693
|
)
|
|
4689
4694
|
p.add_argument("--render-only", action="store_true", dest="render_only")
|
|
4695
|
+
p.add_argument(
|
|
4696
|
+
"--summary-only", action="store_true",
|
|
4697
|
+
help="Omit the lead prompt body from render-only output; still prepare all artifacts.",
|
|
4698
|
+
)
|
|
4690
4699
|
p.add_argument(
|
|
4691
4700
|
"--terminal-backend",
|
|
4692
4701
|
dest="terminal_backend",
|
|
@@ -4698,6 +4707,16 @@ def build_prepare_argument_parser():
|
|
|
4698
4707
|
"않고 멈춘다."
|
|
4699
4708
|
),
|
|
4700
4709
|
)
|
|
4710
|
+
p.add_argument(
|
|
4711
|
+
"--reasoning-effort",
|
|
4712
|
+
default="",
|
|
4713
|
+
choices=["", *REASONING_EFFORTS],
|
|
4714
|
+
dest="reasoning_effort",
|
|
4715
|
+
help=(
|
|
4716
|
+
"Reasoning effort every worker CLI in this run is started with. "
|
|
4717
|
+
"Empty leaves each CLI on its own configuration."
|
|
4718
|
+
),
|
|
4719
|
+
)
|
|
4701
4720
|
p.add_argument(
|
|
4702
4721
|
"--no-plan-verification",
|
|
4703
4722
|
action="store_false",
|
|
@@ -4897,6 +4916,7 @@ def main(argv: list[str]) -> int:
|
|
|
4897
4916
|
plan_verification_enabled=args.plan_verification_enabled,
|
|
4898
4917
|
fix_cycle=args.fix_cycle,
|
|
4899
4918
|
discussion_start=args.discussion_start,
|
|
4919
|
+
reasoning_effort=args.reasoning_effort,
|
|
4900
4920
|
)
|
|
4901
4921
|
try:
|
|
4902
4922
|
out = prepare_task_bundle(inputs)
|
|
@@ -4919,8 +4939,9 @@ def main(argv: list[str]) -> int:
|
|
|
4919
4939
|
stages_csv = ",".join(str(s) for s in cr["active_stages"])
|
|
4920
4940
|
print(f"okstra concurrent-run stages: {stages_csv}")
|
|
4921
4941
|
if inputs.render_only:
|
|
4922
|
-
|
|
4923
|
-
|
|
4942
|
+
if not args.summary_only:
|
|
4943
|
+
print()
|
|
4944
|
+
print(out.prompt_text, end="")
|
|
4924
4945
|
else:
|
|
4925
4946
|
print(f"okstra current run dir: {ctx['RUN_DIR']}")
|
|
4926
4947
|
print(f"final report path: {ctx['FINAL_REPORT_PATH']}")
|