okstra 0.127.0 → 0.129.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/docs/architecture/storage-model.md +23 -3
- package/docs/architecture.md +28 -1
- package/docs/cli.md +9 -0
- package/docs/project-structure-overview.md +17 -7
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/antigravity-worker.md +3 -3
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/codex-worker.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +5 -5
- package/runtime/prompts/lead/adapters/claude-code.md +2 -1
- package/runtime/prompts/lead/adapters/codex.md +1 -0
- package/runtime/prompts/lead/adapters/external.md +1 -0
- package/runtime/prompts/lead/convergence.md +45 -83
- package/runtime/prompts/lead/okstra-lead-contract.md +12 -8
- package/runtime/prompts/lead/report-writer.md +3 -2
- package/runtime/prompts/lead/team-contract.md +19 -9
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +10 -6
- package/runtime/prompts/profiles/implementation-planning.md +5 -0
- package/runtime/prompts/profiles/improvement-discovery.md +11 -1
- package/runtime/prompts/profiles/requirements-discovery.md +8 -1
- package/runtime/python/okstra_ctl/analysis_packet.py +14 -15
- package/runtime/python/okstra_ctl/codex_dispatch.py +51 -80
- package/runtime/python/okstra_ctl/context_cost.py +82 -7
- package/runtime/python/okstra_ctl/convergence.py +223 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +1152 -0
- package/runtime/python/okstra_ctl/convergence_migration.py +184 -0
- package/runtime/python/okstra_ctl/convergence_store.py +85 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +60 -1
- package/runtime/python/okstra_ctl/improvement_assignment.py +61 -0
- package/runtime/python/okstra_ctl/log_report.py +44 -5
- package/runtime/python/okstra_ctl/path_hints.py +28 -1
- package/runtime/python/okstra_ctl/paths.py +10 -1
- package/runtime/python/okstra_ctl/render.py +78 -11
- package/runtime/python/okstra_ctl/run.py +66 -2
- package/runtime/python/okstra_ctl/wizard.py +15 -4
- package/runtime/python/okstra_ctl/work_categories.py +37 -0
- package/runtime/python/okstra_ctl/worker_prompt_body.py +107 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +316 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +109 -10
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +158 -0
- package/runtime/templates/implementation-worker-preamble.md +51 -0
- package/runtime/templates/operating-standard.md +4 -4
- package/runtime/templates/report-writer-prompt-preamble.md +37 -0
- package/runtime/templates/worker-error-contract.md +46 -0
- package/runtime/templates/worker-prompt-preamble.md +28 -227
- package/runtime/validators/lib/fixtures.sh +27 -12
- package/runtime/validators/validate-run.py +228 -45
- package/src/cli-registry.mjs +7 -0
- package/src/commands/execute/convergence.mjs +34 -0
- package/src/commands/inspect/log-report.mjs +5 -3
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Resume and legacy-state migration decisions for convergence seeding."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any, Literal, Mapping
|
|
10
|
+
|
|
11
|
+
from .convergence_engine import (
|
|
12
|
+
ConvergenceContractError,
|
|
13
|
+
grouped_input_digest,
|
|
14
|
+
validate_final_state,
|
|
15
|
+
validate_working_state,
|
|
16
|
+
)
|
|
17
|
+
from .convergence_store import load_json_object
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
SeedAction = Literal[
|
|
21
|
+
"create-work",
|
|
22
|
+
"resume-work",
|
|
23
|
+
"reuse-final",
|
|
24
|
+
"restart-round0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SeedDecision:
|
|
30
|
+
action: SeedAction
|
|
31
|
+
archive_path: Path | None
|
|
32
|
+
legacy_digest: str | None
|
|
33
|
+
reason: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def decide_seed_action(
|
|
37
|
+
*,
|
|
38
|
+
grouped_input: Mapping[str, Any],
|
|
39
|
+
work_state_path: Path,
|
|
40
|
+
final_state_path: Path,
|
|
41
|
+
restart_from_round0: bool,
|
|
42
|
+
) -> SeedDecision:
|
|
43
|
+
"""Classify existing new-engine/legacy state without mutating files."""
|
|
44
|
+
task_key = grouped_input.get("taskKey")
|
|
45
|
+
digest = grouped_input_digest(grouped_input)
|
|
46
|
+
final_status, final_value = _inspect_final(final_state_path)
|
|
47
|
+
if final_status == "terminal":
|
|
48
|
+
return SeedDecision("reuse-final", None, None, "valid terminal final state")
|
|
49
|
+
|
|
50
|
+
work_status, work_value = _inspect_work(work_state_path)
|
|
51
|
+
invalid_final = final_status == "invalid"
|
|
52
|
+
if work_status == "valid":
|
|
53
|
+
assert work_value is not None
|
|
54
|
+
if work_value.get("taskKey") != task_key:
|
|
55
|
+
raise ConvergenceContractError(
|
|
56
|
+
"existing working state taskKey does not match grouped input"
|
|
57
|
+
)
|
|
58
|
+
if work_value.get("groupsDigest") != digest:
|
|
59
|
+
raise ConvergenceContractError(
|
|
60
|
+
"existing working state groupsDigest does not match grouped input"
|
|
61
|
+
)
|
|
62
|
+
if invalid_final:
|
|
63
|
+
archive_path, legacy_digest = _archive_identity(
|
|
64
|
+
final_state_path, final_state_path.parent / "migrations"
|
|
65
|
+
)
|
|
66
|
+
return SeedDecision(
|
|
67
|
+
"resume-work",
|
|
68
|
+
archive_path,
|
|
69
|
+
legacy_digest,
|
|
70
|
+
"matching working state with invalid legacy final",
|
|
71
|
+
)
|
|
72
|
+
return SeedDecision("resume-work", None, None, "matching working state")
|
|
73
|
+
|
|
74
|
+
if work_status == "invalid" and not restart_from_round0:
|
|
75
|
+
raise ConvergenceContractError(
|
|
76
|
+
"existing convergence working state is invalid; pass "
|
|
77
|
+
"--restart-from-round0 to archive it and restart"
|
|
78
|
+
)
|
|
79
|
+
if work_status == "invalid":
|
|
80
|
+
source = final_state_path if invalid_final else work_state_path
|
|
81
|
+
archive_path, legacy_digest = _archive_identity(
|
|
82
|
+
source, source.parent / "migrations"
|
|
83
|
+
)
|
|
84
|
+
return SeedDecision(
|
|
85
|
+
"restart-round0",
|
|
86
|
+
archive_path,
|
|
87
|
+
legacy_digest,
|
|
88
|
+
"explicit restart of invalid working state",
|
|
89
|
+
)
|
|
90
|
+
if invalid_final:
|
|
91
|
+
archive_path, legacy_digest = _archive_identity(
|
|
92
|
+
final_state_path, final_state_path.parent / "migrations"
|
|
93
|
+
)
|
|
94
|
+
return SeedDecision(
|
|
95
|
+
"restart-round0",
|
|
96
|
+
archive_path,
|
|
97
|
+
legacy_digest,
|
|
98
|
+
"invalid or partial legacy final state",
|
|
99
|
+
)
|
|
100
|
+
return SeedDecision("create-work", None, None, "no existing convergence state")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def archive_state_bytes(
|
|
104
|
+
*,
|
|
105
|
+
source_path: Path,
|
|
106
|
+
migration_dir: Path,
|
|
107
|
+
) -> tuple[Path, str]:
|
|
108
|
+
"""Archive source bytes under a digest-derived immutable filename."""
|
|
109
|
+
data = source_path.read_bytes()
|
|
110
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
111
|
+
archive_path = migration_dir / f"{source_path.name}.{digest[:12]}.json"
|
|
112
|
+
if archive_path.exists():
|
|
113
|
+
if archive_path.read_bytes() != data:
|
|
114
|
+
raise ConvergenceContractError(
|
|
115
|
+
f"migration archive path contains different bytes: {archive_path}"
|
|
116
|
+
)
|
|
117
|
+
return archive_path, digest
|
|
118
|
+
migration_dir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
temp_path: Path | None = None
|
|
120
|
+
try:
|
|
121
|
+
with tempfile.NamedTemporaryFile(
|
|
122
|
+
mode="wb",
|
|
123
|
+
dir=migration_dir,
|
|
124
|
+
prefix=f".{archive_path.name}.",
|
|
125
|
+
suffix=".tmp",
|
|
126
|
+
delete=False,
|
|
127
|
+
) as handle:
|
|
128
|
+
temp_path = Path(handle.name)
|
|
129
|
+
handle.write(data)
|
|
130
|
+
handle.flush()
|
|
131
|
+
os.fsync(handle.fileno())
|
|
132
|
+
os.replace(temp_path, archive_path)
|
|
133
|
+
temp_path = None
|
|
134
|
+
finally:
|
|
135
|
+
if temp_path is not None:
|
|
136
|
+
temp_path.unlink(missing_ok=True)
|
|
137
|
+
return archive_path, digest
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def migration_record(
|
|
141
|
+
source_path: Path,
|
|
142
|
+
archive_path: Path,
|
|
143
|
+
digest: str,
|
|
144
|
+
) -> dict[str, str]:
|
|
145
|
+
return {
|
|
146
|
+
"mode": "restart-round0",
|
|
147
|
+
"sourcePath": str(source_path),
|
|
148
|
+
"archivePath": str(archive_path),
|
|
149
|
+
"legacyDigest": digest,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _inspect_final(path: Path) -> tuple[str, dict[str, Any] | None]:
|
|
154
|
+
if not path.exists():
|
|
155
|
+
return "absent", None
|
|
156
|
+
try:
|
|
157
|
+
value = load_json_object(path)
|
|
158
|
+
except (OSError, ValueError):
|
|
159
|
+
return "invalid", None
|
|
160
|
+
if validate_final_state(value):
|
|
161
|
+
return "invalid", value
|
|
162
|
+
if value.get("finalState") not in {
|
|
163
|
+
"converged",
|
|
164
|
+
"max-rounds-reached",
|
|
165
|
+
"aborted-non-result",
|
|
166
|
+
}:
|
|
167
|
+
return "invalid", value
|
|
168
|
+
return "terminal", value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _inspect_work(path: Path) -> tuple[str, dict[str, Any] | None]:
|
|
172
|
+
if not path.exists():
|
|
173
|
+
return "absent", None
|
|
174
|
+
try:
|
|
175
|
+
value = load_json_object(path)
|
|
176
|
+
except (OSError, ValueError):
|
|
177
|
+
return "invalid", None
|
|
178
|
+
return ("valid", value) if not validate_working_state(value) else ("invalid", value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _archive_identity(source_path: Path, migration_dir: Path) -> tuple[Path, str]:
|
|
182
|
+
data = source_path.read_bytes()
|
|
183
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
184
|
+
return migration_dir / f"{source_path.name}.{digest[:12]}.json", digest
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Validated JSON loading and atomic persistence for convergence artifacts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import tempfile
|
|
9
|
+
from typing import Any, Mapping
|
|
10
|
+
|
|
11
|
+
from .convergence_engine import ConvergenceContractError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_json_object(path: Path) -> dict[str, Any]:
|
|
15
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
16
|
+
if not isinstance(value, dict):
|
|
17
|
+
raise ConvergenceContractError(f"JSON input must be an object: {path}")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def write_json_atomic(path: Path, payload: Mapping[str, Any]) -> None:
|
|
22
|
+
if not isinstance(payload, Mapping):
|
|
23
|
+
raise ConvergenceContractError("JSON output payload must be an object")
|
|
24
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
temp_path: Path | None = None
|
|
26
|
+
try:
|
|
27
|
+
with tempfile.NamedTemporaryFile(
|
|
28
|
+
mode="w",
|
|
29
|
+
encoding="utf-8",
|
|
30
|
+
dir=path.parent,
|
|
31
|
+
prefix=f".{path.name}.",
|
|
32
|
+
suffix=".tmp",
|
|
33
|
+
delete=False,
|
|
34
|
+
) as handle:
|
|
35
|
+
temp_path = Path(handle.name)
|
|
36
|
+
json.dump(
|
|
37
|
+
payload,
|
|
38
|
+
handle,
|
|
39
|
+
ensure_ascii=False,
|
|
40
|
+
sort_keys=True,
|
|
41
|
+
indent=2,
|
|
42
|
+
)
|
|
43
|
+
handle.write("\n")
|
|
44
|
+
handle.flush()
|
|
45
|
+
os.fsync(handle.fileno())
|
|
46
|
+
os.replace(temp_path, path)
|
|
47
|
+
temp_path = None
|
|
48
|
+
finally:
|
|
49
|
+
if temp_path is not None:
|
|
50
|
+
temp_path.unlink(missing_ok=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def write_final_state_atomic(
|
|
54
|
+
path: Path,
|
|
55
|
+
payload: Mapping[str, Any],
|
|
56
|
+
*,
|
|
57
|
+
migration: Mapping[str, Any] | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Replace a legacy final only when its byte archive proves preservation."""
|
|
60
|
+
if path.exists():
|
|
61
|
+
_validate_final_replacement(path, migration)
|
|
62
|
+
write_json_atomic(path, payload)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validate_final_replacement(
|
|
66
|
+
path: Path,
|
|
67
|
+
migration: Mapping[str, Any] | None,
|
|
68
|
+
) -> None:
|
|
69
|
+
if not isinstance(migration, Mapping) or migration.get("mode") != "restart-round0":
|
|
70
|
+
raise ConvergenceContractError(
|
|
71
|
+
"existing final state requires a restart-round0 migration archive"
|
|
72
|
+
)
|
|
73
|
+
if migration.get("sourcePath") != str(path):
|
|
74
|
+
raise ConvergenceContractError("migration archive sourcePath does not match final state")
|
|
75
|
+
archive_value = migration.get("archivePath")
|
|
76
|
+
digest = migration.get("legacyDigest")
|
|
77
|
+
if not isinstance(archive_value, str) or not isinstance(digest, str):
|
|
78
|
+
raise ConvergenceContractError("migration archive metadata is incomplete")
|
|
79
|
+
archive_path = Path(archive_value)
|
|
80
|
+
if not archive_path.is_file():
|
|
81
|
+
raise ConvergenceContractError(f"migration archive is missing: {archive_path}")
|
|
82
|
+
archived = archive_path.read_bytes()
|
|
83
|
+
current = path.read_bytes()
|
|
84
|
+
if hashlib.sha256(archived).hexdigest() != digest or archived != current:
|
|
85
|
+
raise ConvergenceContractError("migration archive bytes do not match legacy final")
|
|
@@ -18,6 +18,9 @@ from .final_report_paths import (
|
|
|
18
18
|
from .lead_events import LeadEvent, append_lead_event
|
|
19
19
|
from .path_hints import hydrate_active_run_context
|
|
20
20
|
from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
|
|
21
|
+
from .worker_prompt_body import analysis_prompt_body
|
|
22
|
+
from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
|
|
23
|
+
from .worker_prompt_policy import resolve_prompt_plan_for_manifest
|
|
21
24
|
from .wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
22
25
|
|
|
23
26
|
|
|
@@ -183,6 +186,7 @@ def build_dispatch_plan(
|
|
|
183
186
|
report_writer_model,
|
|
184
187
|
options,
|
|
185
188
|
)
|
|
189
|
+
_validate_initial_prompts(manifest, jobs)
|
|
186
190
|
return DispatchPlan(
|
|
187
191
|
project_root=project_root,
|
|
188
192
|
workspace_root=workspace_root.resolve(),
|
|
@@ -195,6 +199,20 @@ def build_dispatch_plan(
|
|
|
195
199
|
)
|
|
196
200
|
|
|
197
201
|
|
|
202
|
+
def _validate_initial_prompts(
|
|
203
|
+
manifest: Mapping[str, Any],
|
|
204
|
+
jobs: Sequence[WorkerJob],
|
|
205
|
+
) -> None:
|
|
206
|
+
records = [
|
|
207
|
+
PromptRecord(job.worker_id, job.dispatch_kind, job.prompt_path)
|
|
208
|
+
for job in jobs
|
|
209
|
+
]
|
|
210
|
+
errors = validate_initial_prompt_records(manifest=manifest, records=records)
|
|
211
|
+
if errors:
|
|
212
|
+
task_type = _require_string(manifest, "taskType")
|
|
213
|
+
raise DispatchError(f"{task_type} prompt contract: " + "; ".join(errors))
|
|
214
|
+
|
|
215
|
+
|
|
198
216
|
def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
199
217
|
if wait:
|
|
200
218
|
if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
|
|
@@ -314,6 +332,9 @@ def _job_from_roster_worker(
|
|
|
314
332
|
project_root=project_root,
|
|
315
333
|
manifest=manifest,
|
|
316
334
|
active_context=active_context,
|
|
335
|
+
dispatch_kind=options.dispatch_kind,
|
|
336
|
+
model=model,
|
|
337
|
+
role=_string_value(state.get("role")) or "worker",
|
|
317
338
|
)
|
|
318
339
|
return WorkerJob(
|
|
319
340
|
worker_id=worker_id,
|
|
@@ -778,6 +799,9 @@ def _materialize_prompt_if_missing(
|
|
|
778
799
|
project_root: Path,
|
|
779
800
|
manifest: Mapping[str, Any],
|
|
780
801
|
active_context: Mapping[str, Any],
|
|
802
|
+
dispatch_kind: str,
|
|
803
|
+
model: str,
|
|
804
|
+
role: str,
|
|
781
805
|
) -> None:
|
|
782
806
|
if prompt_path.is_file():
|
|
783
807
|
return
|
|
@@ -788,12 +812,47 @@ def _materialize_prompt_if_missing(
|
|
|
788
812
|
prompt_rel=prompt_rel,
|
|
789
813
|
result_rel=result_rel,
|
|
790
814
|
worker_id=worker_id,
|
|
815
|
+
dispatch_kind=dispatch_kind,
|
|
791
816
|
manifest=manifest,
|
|
792
817
|
active_context=active_context,
|
|
793
818
|
)
|
|
794
819
|
except WorkerPromptHeaderError as exc:
|
|
795
820
|
raise DispatchError(str(exc)) from exc
|
|
796
|
-
|
|
821
|
+
try:
|
|
822
|
+
plan = resolve_prompt_plan_for_manifest(
|
|
823
|
+
manifest=manifest,
|
|
824
|
+
worker_id=worker_id,
|
|
825
|
+
dispatch_kind=dispatch_kind,
|
|
826
|
+
)
|
|
827
|
+
except ValueError as exc:
|
|
828
|
+
raise DispatchError(str(exc)) from exc
|
|
829
|
+
lines = list(headers)
|
|
830
|
+
if plan.audience in {
|
|
831
|
+
"analysis",
|
|
832
|
+
"implementation-executor",
|
|
833
|
+
"implementation-verifier",
|
|
834
|
+
}:
|
|
835
|
+
if _string_value(manifest.get("taskType")) == "implementation":
|
|
836
|
+
worktree = _worktree_path(active_context)
|
|
837
|
+
if not worktree:
|
|
838
|
+
raise DispatchError(
|
|
839
|
+
"implementation prompt generation requires a worktree path"
|
|
840
|
+
)
|
|
841
|
+
lines.append(f"**Worktree:** {worktree}")
|
|
842
|
+
try:
|
|
843
|
+
lines.extend(
|
|
844
|
+
analysis_prompt_body(
|
|
845
|
+
manifest,
|
|
846
|
+
active_context,
|
|
847
|
+
worker_id,
|
|
848
|
+
model,
|
|
849
|
+
role,
|
|
850
|
+
plan,
|
|
851
|
+
)
|
|
852
|
+
)
|
|
853
|
+
except ValueError as exc:
|
|
854
|
+
raise DispatchError(str(exc)) from exc
|
|
855
|
+
prompt_path.write_text("\n".join([*lines, ""]), encoding="utf-8")
|
|
797
856
|
|
|
798
857
|
|
|
799
858
|
def _provider_for_worker(worker_id: str) -> str:
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Assign improvement-discovery primary passes from the resolved roster."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def assign_primary_lenses(
|
|
8
|
+
worker_ids: list[str],
|
|
9
|
+
priority_lenses: list[str],
|
|
10
|
+
) -> dict[str, str]:
|
|
11
|
+
"""Round-robin resolved worker instances over resolved priority lenses."""
|
|
12
|
+
if not worker_ids:
|
|
13
|
+
raise ValueError("worker IDs must not be empty")
|
|
14
|
+
if not priority_lenses:
|
|
15
|
+
raise ValueError("priority lenses must not be empty")
|
|
16
|
+
if len(set(worker_ids)) != len(worker_ids):
|
|
17
|
+
raise ValueError("worker IDs must be unique")
|
|
18
|
+
return {
|
|
19
|
+
worker_id: priority_lenses[index % len(priority_lenses)]
|
|
20
|
+
for index, worker_id in enumerate(worker_ids)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_primary_lens_assignments(
|
|
25
|
+
assignments: Mapping[str, str],
|
|
26
|
+
worker_ids: list[str],
|
|
27
|
+
priority_lenses: list[str],
|
|
28
|
+
) -> list[str]:
|
|
29
|
+
"""Require every selected analyser once at its roster-order lens."""
|
|
30
|
+
errors: list[str] = []
|
|
31
|
+
if not worker_ids:
|
|
32
|
+
return ["resolved analyser worker IDs must not be empty"]
|
|
33
|
+
if not priority_lenses:
|
|
34
|
+
return ["resolved priority lenses must not be empty"]
|
|
35
|
+
try:
|
|
36
|
+
expected = assign_primary_lenses(worker_ids, priority_lenses)
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
return [str(exc)]
|
|
39
|
+
if list(assignments) != worker_ids:
|
|
40
|
+
errors.append(
|
|
41
|
+
"assignment order must match resolved analyser worker order: "
|
|
42
|
+
+ ", ".join(worker_ids)
|
|
43
|
+
)
|
|
44
|
+
for worker_id in worker_ids:
|
|
45
|
+
if worker_id not in assignments:
|
|
46
|
+
errors.append(f"missing worker assignment: {worker_id}")
|
|
47
|
+
for worker_id, lens in assignments.items():
|
|
48
|
+
if worker_id not in expected:
|
|
49
|
+
errors.append(f"extra worker assignment: {worker_id}")
|
|
50
|
+
continue
|
|
51
|
+
if lens not in priority_lenses:
|
|
52
|
+
errors.append(
|
|
53
|
+
f"{worker_id}: primary lens {lens!r} is outside resolved priority lenses"
|
|
54
|
+
)
|
|
55
|
+
continue
|
|
56
|
+
if lens != expected[worker_id]:
|
|
57
|
+
errors.append(
|
|
58
|
+
f"{worker_id}: expected primary lens {expected[worker_id]!r}, "
|
|
59
|
+
f"got {lens!r}"
|
|
60
|
+
)
|
|
61
|
+
return errors
|
|
@@ -53,6 +53,34 @@ def _per_task(files: list[dict]) -> list[dict]:
|
|
|
53
53
|
return sorted(acc.values(), key=lambda a: a["totalBytes"], reverse=True)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _prompt_pair(log_path: Path, transcript_bytes: int) -> dict:
|
|
57
|
+
prompt_path = log_path.with_suffix(".md")
|
|
58
|
+
if not prompt_path.is_file():
|
|
59
|
+
return {
|
|
60
|
+
"transcriptPath": str(log_path),
|
|
61
|
+
"transcriptBytes": transcript_bytes,
|
|
62
|
+
"promptPath": "",
|
|
63
|
+
"promptBytes": 0,
|
|
64
|
+
"transcriptToPromptRatio": None,
|
|
65
|
+
}
|
|
66
|
+
try:
|
|
67
|
+
prompt_bytes = prompt_path.stat().st_size
|
|
68
|
+
except OSError:
|
|
69
|
+
prompt_bytes = 0
|
|
70
|
+
ratio = (
|
|
71
|
+
round(transcript_bytes / prompt_bytes, 2)
|
|
72
|
+
if prompt_bytes > 0
|
|
73
|
+
else None
|
|
74
|
+
)
|
|
75
|
+
return {
|
|
76
|
+
"transcriptPath": str(log_path),
|
|
77
|
+
"transcriptBytes": transcript_bytes,
|
|
78
|
+
"promptPath": str(prompt_path),
|
|
79
|
+
"promptBytes": prompt_bytes,
|
|
80
|
+
"transcriptToPromptRatio": ratio,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
56
84
|
def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
|
|
57
85
|
project_id = _project_id(project_root)
|
|
58
86
|
files: list[dict] = []
|
|
@@ -68,16 +96,27 @@ def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
|
|
|
68
96
|
continue
|
|
69
97
|
meta = _parse(parts, p.stem)
|
|
70
98
|
tk = f"{project_id}:{meta['taskGroup']}:{meta['taskId']}" if meta["taskGroup"] else ""
|
|
71
|
-
files.append({
|
|
72
|
-
|
|
99
|
+
files.append({
|
|
100
|
+
"path": str(p),
|
|
101
|
+
"sizeBytes": size,
|
|
102
|
+
"mtimeEpoch": mtime,
|
|
103
|
+
"taskKey": tk,
|
|
104
|
+
**meta,
|
|
105
|
+
**_prompt_pair(p, size),
|
|
106
|
+
})
|
|
73
107
|
files.sort(key=lambda f: f["sizeBytes"], reverse=True)
|
|
74
108
|
return {
|
|
75
109
|
"logsRoot": str(logs_root),
|
|
76
110
|
"topLargest": files[:top],
|
|
77
111
|
"perTask": _per_task(files),
|
|
78
|
-
"totals": {
|
|
79
|
-
|
|
80
|
-
|
|
112
|
+
"totals": {
|
|
113
|
+
"fileCount": len(files),
|
|
114
|
+
"totalBytes": sum(f["sizeBytes"] for f in files),
|
|
115
|
+
"taskCount": len({f["taskKey"] for f in files if f["taskKey"]}),
|
|
116
|
+
"promptBytes": sum(f["promptBytes"] for f in files),
|
|
117
|
+
"transcriptBytes": sum(f["transcriptBytes"] for f in files),
|
|
118
|
+
"pairedFileCount": sum(1 for f in files if f["promptPath"]),
|
|
119
|
+
},
|
|
81
120
|
}
|
|
82
121
|
|
|
83
122
|
|
|
@@ -59,6 +59,7 @@ def compact_active_run_context(
|
|
|
59
59
|
"inputs": _compact_active_inputs(payload),
|
|
60
60
|
"workers": _compact_active_workers(payload),
|
|
61
61
|
"executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
|
|
62
|
+
"verificationTarget": dict(_mapping(payload.get("verificationTarget"))),
|
|
62
63
|
"lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -97,8 +98,20 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
97
98
|
"errorLogs": _hydrate_active_error_logs(ctx),
|
|
98
99
|
"runtimeResources": {
|
|
99
100
|
"codingPreflightDir": ctx.get("OKSTRA_CODING_PREFLIGHT_DIR", ""),
|
|
101
|
+
"workerPreamblePathByAudience": {
|
|
102
|
+
"analysis": ctx.get("ANALYSIS_WORKER_PREAMBLE_PATH", ""),
|
|
103
|
+
"implementation-executor": ctx.get(
|
|
104
|
+
"IMPLEMENTATION_WORKER_PREAMBLE_PATH", ""
|
|
105
|
+
),
|
|
106
|
+
"implementation-verifier": ctx.get(
|
|
107
|
+
"IMPLEMENTATION_WORKER_PREAMBLE_PATH", ""
|
|
108
|
+
),
|
|
109
|
+
"report-writer": ctx.get("REPORT_WRITER_PREAMBLE_PATH", ""),
|
|
110
|
+
},
|
|
111
|
+
"workerErrorContractPath": ctx.get("WORKER_ERROR_CONTRACT_PATH", ""),
|
|
100
112
|
},
|
|
101
113
|
"executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
|
|
114
|
+
"verificationTarget": dict(_mapping(payload.get("verificationTarget"))),
|
|
102
115
|
"sourceArtifacts": _hydrate_active_source_artifacts(ctx),
|
|
103
116
|
"lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
|
|
104
117
|
}
|
|
@@ -190,6 +203,7 @@ def _hydrate_active_instruction_set(
|
|
|
190
203
|
clarification_response = ""
|
|
191
204
|
if inputs.get("hasClarificationResponse"):
|
|
192
205
|
clarification_response = f"{instruction_set}/clarification-response.md"
|
|
206
|
+
target = _mapping(payload.get("verificationTarget"))
|
|
193
207
|
return {
|
|
194
208
|
"path": instruction_set,
|
|
195
209
|
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
@@ -200,6 +214,8 @@ def _hydrate_active_instruction_set(
|
|
|
200
214
|
"clarificationResponsePath": clarification_response,
|
|
201
215
|
"finalReportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
|
|
202
216
|
"finalReportSchemaPath": ctx.get("FINAL_REPORT_SCHEMA_RELATIVE_PATH", ""),
|
|
217
|
+
"verificationTargetPath": str(target.get("path") or ""),
|
|
218
|
+
"verificationTargetDigest": str(target.get("digest") or ""),
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
|
|
@@ -451,7 +467,18 @@ def _runtime_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
|
451
467
|
runtime_home = Path(paths["runtime_home"])
|
|
452
468
|
lead = runtime_home / "prompts" / "lead"
|
|
453
469
|
return {
|
|
454
|
-
"
|
|
470
|
+
"ANALYSIS_WORKER_PREAMBLE_PATH": str(
|
|
471
|
+
runtime_home / "templates" / "worker-prompt-preamble.md"
|
|
472
|
+
),
|
|
473
|
+
"IMPLEMENTATION_WORKER_PREAMBLE_PATH": str(
|
|
474
|
+
runtime_home / "templates" / "implementation-worker-preamble.md"
|
|
475
|
+
),
|
|
476
|
+
"REPORT_WRITER_PREAMBLE_PATH": str(
|
|
477
|
+
runtime_home / "templates" / "report-writer-prompt-preamble.md"
|
|
478
|
+
),
|
|
479
|
+
"WORKER_ERROR_CONTRACT_PATH": str(
|
|
480
|
+
runtime_home / "templates" / "worker-error-contract.md"
|
|
481
|
+
),
|
|
455
482
|
"OKSTRA_LEAD_CONTRACT_PATH": str(lead / "okstra-lead-contract.md"),
|
|
456
483
|
"OKSTRA_CONTEXT_LOADER_PATH": str(lead / "context-loader.md"),
|
|
457
484
|
"OKSTRA_TEAM_CONTRACT_PATH": str(lead / "team-contract.md"),
|
|
@@ -265,9 +265,18 @@ def compute_run_paths(
|
|
|
265
265
|
"TASK_TYPE_SEGMENT": task_type_segment,
|
|
266
266
|
"OKSTRA_ROOT": str(okstra_root),
|
|
267
267
|
"OKSTRA_TASKS_ROOT": str(tasks_root),
|
|
268
|
-
"
|
|
268
|
+
"ANALYSIS_WORKER_PREAMBLE_PATH": str(
|
|
269
269
|
runtime_home / "templates" / "worker-prompt-preamble.md"
|
|
270
270
|
),
|
|
271
|
+
"IMPLEMENTATION_WORKER_PREAMBLE_PATH": str(
|
|
272
|
+
runtime_home / "templates" / "implementation-worker-preamble.md"
|
|
273
|
+
),
|
|
274
|
+
"REPORT_WRITER_PREAMBLE_PATH": str(
|
|
275
|
+
runtime_home / "templates" / "report-writer-prompt-preamble.md"
|
|
276
|
+
),
|
|
277
|
+
"WORKER_ERROR_CONTRACT_PATH": str(
|
|
278
|
+
runtime_home / "templates" / "worker-error-contract.md"
|
|
279
|
+
),
|
|
271
280
|
"OKSTRA_LEAD_CONTRACT_PATH": str(
|
|
272
281
|
runtime_home / "prompts" / "lead" / "okstra-lead-contract.md"
|
|
273
282
|
),
|