okstra 0.206.1 → 0.207.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/README.md +1 -1
- package/dist/cli-registry.mjs +7 -1
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/architecture/storage-model.md +1 -0
- package/docs/architecture.md +28 -4
- package/docs/cli.md +13 -11
- package/docs/project-structure-overview.md +4 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/operations/code-review.json +1 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -3
- package/runtime/bin/okstra-compact-reminder.sh +1 -1
- package/runtime/prompts/duties/direction-selection-worker.json +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +4 -3
- package/runtime/prompts/lead/convergence.md +41 -9
- package/runtime/prompts/lead/okstra-lead-contract.md +31 -19
- package/runtime/prompts/lead/report-writer.md +8 -6
- package/runtime/prompts/profiles/_clarification-recommendation.md +4 -4
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +5 -5
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -2
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +1 -1
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +17 -26
- package/runtime/python/okstra_ctl/agent/prompt_cli/batch.py +183 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +60 -10
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +21 -4
- package/runtime/python/okstra_ctl/approval_decisions.py +32 -2
- package/runtime/python/okstra_ctl/assignment_resolver.py +8 -0
- package/runtime/python/okstra_ctl/blocking_checks.py +7 -0
- package/runtime/python/okstra_ctl/code_review_target.py +92 -6
- package/runtime/python/okstra_ctl/dispatch_checkpoints.py +121 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +54 -32
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -5
- package/runtime/python/okstra_ctl/domain/provider.py +5 -0
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +21 -2
- package/runtime/python/okstra_ctl/domain/write_policy.py +2 -1
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +19 -8
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +5 -0
- package/runtime/python/okstra_ctl/lead_progress.py +33 -1
- package/runtime/python/okstra_ctl/manager_view.py +26 -19
- package/runtime/python/okstra_ctl/model_io/lines.py +21 -4
- package/runtime/python/okstra_ctl/models.py +4 -1
- package/runtime/python/okstra_ctl/operation_invocation.py +11 -2
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +14 -3
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/process_group.py +118 -0
- package/runtime/python/okstra_ctl/render.py +6 -2
- package/runtime/python/okstra_ctl/report_assembly.py +17 -2
- package/runtime/python/okstra_ctl/report_finalize.py +106 -2
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/run_artifact_prune.py +200 -0
- package/runtime/python/okstra_ctl/team.py +108 -9
- package/runtime/python/okstra_ctl/wizard/steps_options.py +8 -0
- package/runtime/python/okstra_ctl/worker_dispatch.py +44 -3
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +19 -0
- package/runtime/python/okstra_ctl/worker_runner.py +21 -3
- package/runtime/python/okstra_ctl/write_policy.py +57 -7
- package/runtime/python/okstra_project/dirs.py +14 -0
- package/runtime/python/okstra_project/resolver.py +2 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +2 -1
- package/runtime/skills/okstra-code-review/SKILL.md +70 -32
- package/runtime/skills/okstra-code-review/references/review-calibration.md +26 -6
- package/runtime/skills/okstra-run/SKILL.md +2 -2
- package/runtime/templates/manager/view.template.html +18 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""워커를 띄우고 거두는 명령이 대신 기록하는 리드 체크포인트.
|
|
2
|
+
|
|
3
|
+
dispatch 를 정하고 결과를 확정하는 곳은 `team dispatch` / `team await`(pane)와
|
|
4
|
+
`worker-dispatch`(CLI, 블로킹)이다. 그래서 `phase-3-team-create`,
|
|
5
|
+
`phase-4-dispatch`, `phase-5-collect`, `phase-6-synthesis` 가 말하는 사실을 이
|
|
6
|
+
명령들이 갖고 있다. 리드가 같은 사실을 `okstra lead-progress append` 로 다시
|
|
7
|
+
적으면 체크포인트마다 전체 컨텍스트를 한 턴씩 쓰는데, 여기서 기록하면 턴이 들지 않는다.
|
|
8
|
+
|
|
9
|
+
워커별 체크포인트는 `initial` dispatch 만 센다. reverify·critic·plan-verify 는
|
|
10
|
+
리드가 따로 알리는 이후 라운드이고, 여기서 기록하면 activity-contract run 이
|
|
11
|
+
존재하지 않는 `worker-dispatched` / `worker-completed` 행을 요구받게 된다.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Mapping, Sequence
|
|
17
|
+
|
|
18
|
+
from .dispatch_state import (
|
|
19
|
+
TERMINAL_WORKER_STATUSES,
|
|
20
|
+
dispatch_worker_key,
|
|
21
|
+
has_recorded_team_create,
|
|
22
|
+
worker_dispatch_records,
|
|
23
|
+
)
|
|
24
|
+
from .lead_progress import record_checkpoint
|
|
25
|
+
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
26
|
+
|
|
27
|
+
_INITIAL = "initial"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def record_dispatch_checkpoints(
|
|
31
|
+
project_root: Path,
|
|
32
|
+
run_manifest: str | Path,
|
|
33
|
+
team_state: Mapping[str, Any],
|
|
34
|
+
jobs: Sequence[Any],
|
|
35
|
+
*,
|
|
36
|
+
source: str,
|
|
37
|
+
) -> list[str]:
|
|
38
|
+
"""곧 띄울 배치의 체크포인트. 배치가 시작되기 전의 team-state 로 판정한다.
|
|
39
|
+
|
|
40
|
+
`phase-3-team-create` 는 이 dispatch 가 implicit-team 마커를 쓸 때만 기록한다
|
|
41
|
+
(`dispatch_state.record_dispatch_facts`). 리드가 미리 적은 마커(concurrent-run
|
|
42
|
+
`skipped` 결정)는 리드가 알린다. `phase-6-synthesis` 는 run 의 첫 report-writer
|
|
43
|
+
dispatch 에서만 기록해서, 교정 라운드가 Phase 6 을 다시 열지 않게 한다.
|
|
44
|
+
"""
|
|
45
|
+
if not jobs:
|
|
46
|
+
return []
|
|
47
|
+
lines: list[str | None] = []
|
|
48
|
+
if not has_recorded_team_create(team_state):
|
|
49
|
+
lines.append(record_checkpoint(
|
|
50
|
+
project_root, run_manifest, "phase-3-team-create", source=source,
|
|
51
|
+
))
|
|
52
|
+
for job in jobs:
|
|
53
|
+
if job.dispatch_kind == _INITIAL:
|
|
54
|
+
lines.append(record_checkpoint(
|
|
55
|
+
project_root, run_manifest, "phase-4-dispatch", source=source,
|
|
56
|
+
worker=job.worker_id,
|
|
57
|
+
fields=(("model", job.model_execution_value),),
|
|
58
|
+
))
|
|
59
|
+
dispatches_writer = any(job.worker_id == REPORT_WRITER_WORKER_ID for job in jobs)
|
|
60
|
+
writer_dispatched_before = bool(
|
|
61
|
+
worker_dispatch_records(team_state, REPORT_WRITER_WORKER_ID)
|
|
62
|
+
)
|
|
63
|
+
if dispatches_writer and not writer_dispatched_before:
|
|
64
|
+
lines.append(record_checkpoint(
|
|
65
|
+
project_root, run_manifest, "phase-6-synthesis", source=source,
|
|
66
|
+
))
|
|
67
|
+
return [line for line in lines if line]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def settled_initial_dispatches(
|
|
71
|
+
team_state: Mapping[str, Any],
|
|
72
|
+
) -> dict[str, Mapping[str, Any]]:
|
|
73
|
+
"""최신 attempt 가 종결 상태에 이른 `initial` dispatch 들.
|
|
74
|
+
|
|
75
|
+
재시도해도 바뀌지 않는 prompt 경로를 키로 쓴다. 실패 후 재시도 중인 attempt 는
|
|
76
|
+
재시도까지 끝나야 거둔 결과로 친다.
|
|
77
|
+
"""
|
|
78
|
+
latest: dict[str, Mapping[str, Any]] = {}
|
|
79
|
+
for record in worker_dispatch_records(team_state):
|
|
80
|
+
if record.get("kind") != _INITIAL:
|
|
81
|
+
continue
|
|
82
|
+
key = str(record.get("promptPath") or "")
|
|
83
|
+
current = latest.get(key)
|
|
84
|
+
if current is None or _attempt(record) >= _attempt(current):
|
|
85
|
+
latest[key] = record
|
|
86
|
+
return {
|
|
87
|
+
key: record
|
|
88
|
+
for key, record in latest.items()
|
|
89
|
+
if record.get("status") in TERMINAL_WORKER_STATUSES
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def record_collect_checkpoints(
|
|
94
|
+
project_root: Path,
|
|
95
|
+
run_manifest: str | Path,
|
|
96
|
+
before: Mapping[str, Mapping[str, Any]],
|
|
97
|
+
after: Mapping[str, Mapping[str, Any]],
|
|
98
|
+
*,
|
|
99
|
+
source: str,
|
|
100
|
+
) -> list[str]:
|
|
101
|
+
"""이 명령이 확정한 dispatch 마다 `phase-5-collect` 를 기록한다.
|
|
102
|
+
|
|
103
|
+
확정이 곧 체크포인트가 알리는 결과 검증이다: 필요한 산출물이 있고, 변경 감사를
|
|
104
|
+
통과했고, 결과가 dispatch 에 연결됐거나(`dispatch_core._finish_attempt`),
|
|
105
|
+
attempt 가 `error` / `timeout` 으로 닫혔다.
|
|
106
|
+
"""
|
|
107
|
+
lines = []
|
|
108
|
+
for key, record in after.items():
|
|
109
|
+
previous = before.get(key)
|
|
110
|
+
if previous is not None and _attempt(previous) == _attempt(record):
|
|
111
|
+
continue
|
|
112
|
+
lines.append(record_checkpoint(
|
|
113
|
+
project_root, run_manifest, "phase-5-collect", source=source,
|
|
114
|
+
worker=dispatch_worker_key(record),
|
|
115
|
+
fields=(("status", str(record.get("status"))),),
|
|
116
|
+
))
|
|
117
|
+
return [line for line in lines if line]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _attempt(record: Mapping[str, Any]) -> int:
|
|
121
|
+
return int(record.get("attempt", 1))
|
|
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field, replace
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
import os
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from typing import Any, Mapping, Sequence
|
|
12
|
+
from typing import Any, Callable, Mapping, Sequence
|
|
13
13
|
|
|
14
14
|
from .dispatch_state import abandon_agent_dispatch_attempt
|
|
15
15
|
from .agent.invocation import invocation_input_digest
|
|
@@ -33,7 +33,7 @@ from .dispatch_state import (
|
|
|
33
33
|
link_agent_dispatch_result as _link_agent_dispatch_result,
|
|
34
34
|
missing_completion_paths as _missing_completion_paths,
|
|
35
35
|
unusable_result_defect,
|
|
36
|
-
|
|
36
|
+
dispatch_worker_key,
|
|
37
37
|
_plan_verify_result_aliases,
|
|
38
38
|
mutate_team_state as _mutate_team_state,
|
|
39
39
|
require_string as _require_string,
|
|
@@ -408,7 +408,17 @@ def build_dispatch_plan(
|
|
|
408
408
|
)
|
|
409
409
|
|
|
410
410
|
|
|
411
|
-
|
|
411
|
+
BeforeStart = Callable[["DispatchPlan"], None]
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def dispatch_plan(
|
|
415
|
+
plan: DispatchPlan, *, wait: bool = True, before_start: BeforeStart | None = None,
|
|
416
|
+
) -> int:
|
|
417
|
+
"""`before_start` 는 복구·검증을 통과한 plan 으로, 워커를 띄우기 직전에 한 번 불린다.
|
|
418
|
+
|
|
419
|
+
거부된 배치와 증거 보완으로 빠진 작업은 여기까지 오지 않으므로, 띄울 워커에
|
|
420
|
+
대한 사실(체크포인트)은 이 시점에 기록해야 실제로 뜬 워커만 남는다.
|
|
421
|
+
"""
|
|
412
422
|
plan = _recover_declaration_failures(plan)
|
|
413
423
|
if not plan.jobs:
|
|
414
424
|
return 0
|
|
@@ -416,21 +426,19 @@ def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
|
416
426
|
_validate_report_writer_isolation(plan.jobs)
|
|
417
427
|
_validate_implementation_phase_order(plan.jobs)
|
|
418
428
|
_revalidate_plan_role_executions(plan)
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
429
|
+
pane_backends = sorted(
|
|
430
|
+
{job.backend for job in plan.jobs if job.backend == BACKEND_CMUX_PANE}
|
|
431
|
+
)
|
|
432
|
+
if wait and pane_backends:
|
|
433
|
+
raise DispatchError(
|
|
434
|
+
f"wait=True dispatch does not support {'/'.join(pane_backends)} "
|
|
435
|
+
"workers: the per-job blocking loop would serialize panes "
|
|
436
|
+
"instead of running them concurrently; dispatch panes with "
|
|
437
|
+
"wait=False"
|
|
426
438
|
)
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
"workers: the per-job blocking loop would serialize panes "
|
|
431
|
-
"instead of running them concurrently; dispatch panes with "
|
|
432
|
-
"wait=False"
|
|
433
|
-
)
|
|
439
|
+
if before_start is not None:
|
|
440
|
+
before_start(plan)
|
|
441
|
+
if wait:
|
|
434
442
|
_record_dispatch_facts(plan.team_state_path, _dispatch_mode(plan.jobs))
|
|
435
443
|
for job in plan.jobs:
|
|
436
444
|
result = _dispatch_job_with_retry(plan, job)
|
|
@@ -509,7 +517,9 @@ def _round_artifact_paths(plan: DispatchPlan) -> tuple[Path, ...]:
|
|
|
509
517
|
)
|
|
510
518
|
|
|
511
519
|
|
|
512
|
-
def dispatch_cli_wrapper_plan(
|
|
520
|
+
def dispatch_cli_wrapper_plan(
|
|
521
|
+
plan: DispatchPlan, *, before_start: BeforeStart | None = None,
|
|
522
|
+
) -> int:
|
|
513
523
|
"""Start one dependency-free CLI batch before collecting any worker."""
|
|
514
524
|
if any(job.backend != BACKEND_CLI_WRAPPER for job in plan.jobs):
|
|
515
525
|
raise DispatchError("concurrent CLI dispatch requires cli-wrapper jobs only")
|
|
@@ -520,6 +530,8 @@ def dispatch_cli_wrapper_plan(plan: DispatchPlan) -> int:
|
|
|
520
530
|
_validate_report_writer_isolation(plan.jobs)
|
|
521
531
|
_validate_implementation_phase_order(plan.jobs)
|
|
522
532
|
_revalidate_plan_role_executions(plan)
|
|
533
|
+
if before_start is not None:
|
|
534
|
+
before_start(plan)
|
|
523
535
|
_record_dispatch_facts(plan.team_state_path, _dispatch_mode(plan.jobs))
|
|
524
536
|
final_codes = _dispatch_cli_wrapper_batch(plan, plan.jobs)
|
|
525
537
|
return next((final_codes[job.worker_id] for job in plan.jobs
|
|
@@ -1855,6 +1867,7 @@ def _canonical_write_contract(
|
|
|
1855
1867
|
try:
|
|
1856
1868
|
return build_invocation_write_contract(
|
|
1857
1869
|
role=execution.role,
|
|
1870
|
+
task_type=str(plan.manifest.get("taskType") or ""),
|
|
1858
1871
|
project_root=plan.project_root,
|
|
1859
1872
|
worktree=worktree,
|
|
1860
1873
|
artifact_paths=artifacts,
|
|
@@ -2269,14 +2282,23 @@ def _mutation_failure_reason(mutation, outcome: WorkerOutcome | None = None) ->
|
|
|
2269
2282
|
failure = _wrapper_failure_text(outcome)
|
|
2270
2283
|
if failure:
|
|
2271
2284
|
reason = f"{reason}: {failure}"
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2285
|
+
# 산출물 위반은 소스 변경 목록에 없다 — 그 목록만 보이면 정책 안에 있던
|
|
2286
|
+
# 소스 파일을 범인으로 읽는다 (실측 2026-09-26, dev-11054 `qa/baseline/`).
|
|
2287
|
+
for label, paths in (
|
|
2288
|
+
("outside artifact policy", mutation.violating_artifact_paths),
|
|
2289
|
+
("changed", mutation.changed_paths),
|
|
2290
|
+
):
|
|
2291
|
+
if paths:
|
|
2292
|
+
reason += f" [{label}: {_capped_path_list(paths)}]"
|
|
2293
|
+
return reason
|
|
2294
|
+
|
|
2295
|
+
|
|
2296
|
+
def _capped_path_list(paths: Sequence[str]) -> str:
|
|
2275
2297
|
shown = ", ".join(paths[:_MUTATION_REASON_PATH_LIMIT])
|
|
2276
2298
|
remaining = len(paths) - _MUTATION_REASON_PATH_LIMIT
|
|
2277
2299
|
if remaining > 0:
|
|
2278
2300
|
shown += f", +{remaining} more"
|
|
2279
|
-
return
|
|
2301
|
+
return shown
|
|
2280
2302
|
|
|
2281
2303
|
|
|
2282
2304
|
def _finish_attempt(
|
|
@@ -2751,7 +2773,7 @@ def _record_wrapper_failure(
|
|
|
2751
2773
|
out_path=out_path,
|
|
2752
2774
|
task_key=_string_value(plan.manifest.get("taskKey")),
|
|
2753
2775
|
phase=_workflow_phase(plan.manifest),
|
|
2754
|
-
agent=_error_log_agent(job
|
|
2776
|
+
agent=_error_log_agent(job),
|
|
2755
2777
|
agent_role=(
|
|
2756
2778
|
"report-writer"
|
|
2757
2779
|
if job.worker_id == REPORT_WRITER_WORKER_ID
|
|
@@ -2790,16 +2812,16 @@ def _job_stored_identity(job: WorkerJob, attempt: int) -> dict[str, Any]:
|
|
|
2790
2812
|
}
|
|
2791
2813
|
|
|
2792
2814
|
|
|
2793
|
-
def _error_log_agent(
|
|
2794
|
-
"""The error log's `--agent` enum value for a
|
|
2815
|
+
def _error_log_agent(job: WorkerJob) -> str:
|
|
2816
|
+
"""The error log's `--agent` enum value for a job.
|
|
2795
2817
|
|
|
2796
|
-
The
|
|
2797
|
-
|
|
2798
|
-
|
|
2818
|
+
The enum is keyed by provider (`codex-worker`), not by worker id: a role
|
|
2819
|
+
worker id such as `codex-verifier` produced `codex-verifier-worker`, which
|
|
2820
|
+
the allow-list rejected, so no wrapper failure reached the log.
|
|
2799
2821
|
"""
|
|
2800
|
-
if worker_id == REPORT_WRITER_WORKER_ID:
|
|
2822
|
+
if job.worker_id == REPORT_WRITER_WORKER_ID:
|
|
2801
2823
|
return REPORT_WRITER_WORKER_ID
|
|
2802
|
-
return f"{
|
|
2824
|
+
return f"{job.provider}-worker"
|
|
2803
2825
|
|
|
2804
2826
|
|
|
2805
2827
|
def _workflow_phase(manifest: Mapping[str, Any]) -> str:
|
|
@@ -3349,7 +3371,7 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
|
|
|
3349
3371
|
result_path = Path(_string_value(record.get("resultPath")))
|
|
3350
3372
|
worker_result = Path(_string_value(record.get("workerResultPath")))
|
|
3351
3373
|
missing: list[Path] = []
|
|
3352
|
-
worker_id =
|
|
3374
|
+
worker_id = dispatch_worker_key(record)
|
|
3353
3375
|
dispatch_kind = _string_value(record.get("dispatchKind"))
|
|
3354
3376
|
for path in _record_completion_paths(record):
|
|
3355
3377
|
if path.is_file():
|
|
@@ -3366,7 +3388,7 @@ def _record_missing_completion_paths(record: Mapping[str, Any]) -> tuple[Path, .
|
|
|
3366
3388
|
|
|
3367
3389
|
def _record_artifact_defects(record: Mapping[str, Any]) -> tuple[str, ...]:
|
|
3368
3390
|
defect = unusable_result_defect(
|
|
3369
|
-
|
|
3391
|
+
dispatch_worker_key(record),
|
|
3370
3392
|
Path(_string_value(record.get("resultPath"))),
|
|
3371
3393
|
_string_value(record.get("dispatchKind")),
|
|
3372
3394
|
)
|
|
@@ -365,6 +365,12 @@ def resolve_required_path(
|
|
|
365
365
|
def worktree_path(
|
|
366
366
|
manifest: Mapping[str, Any], active_context: Mapping[str, Any]
|
|
367
367
|
) -> str:
|
|
368
|
+
# Must match the prompt's `**Worktree:**` anchor
|
|
369
|
+
# (`worker_prompt_headers._final_verification_target_headers`).
|
|
370
|
+
if manifest.get("taskType") == "final-verification":
|
|
371
|
+
target = active_context.get("verificationTarget")
|
|
372
|
+
if isinstance(target, Mapping) and target.get("worktreePath"):
|
|
373
|
+
return str(target["worktreePath"])
|
|
368
374
|
worktree = active_context.get("executorWorktree")
|
|
369
375
|
if not isinstance(worktree, Mapping):
|
|
370
376
|
return ""
|
|
@@ -862,6 +868,7 @@ def _agent_write_contract(
|
|
|
862
868
|
try:
|
|
863
869
|
return build_invocation_write_contract(
|
|
864
870
|
role=execution.role,
|
|
871
|
+
task_type=str(authority.get("taskType") or ""),
|
|
865
872
|
project_root=project_root,
|
|
866
873
|
worktree=worktree,
|
|
867
874
|
artifact_paths=artifacts,
|
|
@@ -1626,12 +1633,12 @@ def record_dispatch_facts(team_state_path: Path, dispatch_mode: str) -> None:
|
|
|
1626
1633
|
with _team_state_lock(team_state_path):
|
|
1627
1634
|
payload = load_json_object(team_state_path, "team-state")
|
|
1628
1635
|
payload["dispatchMode"] = dispatch_mode
|
|
1629
|
-
if not
|
|
1636
|
+
if not has_recorded_team_create(payload):
|
|
1630
1637
|
payload["teamCreate"] = {"attempted": False, "status": "implicit"}
|
|
1631
1638
|
write_json(team_state_path, payload)
|
|
1632
1639
|
|
|
1633
1640
|
|
|
1634
|
-
def
|
|
1641
|
+
def has_recorded_team_create(team_state: Mapping[str, Any]) -> bool:
|
|
1635
1642
|
existing = team_state.get("teamCreate")
|
|
1636
1643
|
return (isinstance(existing, dict)
|
|
1637
1644
|
and bool(str(existing.get("status") or "").strip()))
|
|
@@ -1771,7 +1778,7 @@ def worker_dispatch_records(
|
|
|
1771
1778
|
집계, v2 명부 행 `scope` 는 `promptPath` 가 비어 0회).
|
|
1772
1779
|
|
|
1773
1780
|
v1 행은 `workerId`, v2 행은 `assignmentRef` 마지막 마디로 워커에 묶인다
|
|
1774
|
-
(`
|
|
1781
|
+
(`dispatch_worker_key`). 리스트가 아니면 빈 결과 — `worker_session_ids`
|
|
1775
1782
|
와 같은 이유로 호출자를 깨지 않는다.
|
|
1776
1783
|
"""
|
|
1777
1784
|
dispatches = team_state.get("workerDispatches")
|
|
@@ -1779,13 +1786,13 @@ def worker_dispatch_records(
|
|
|
1779
1786
|
for record in dispatches if isinstance(dispatches, list) else []:
|
|
1780
1787
|
if not isinstance(record, Mapping):
|
|
1781
1788
|
continue
|
|
1782
|
-
if worker_id is not None and
|
|
1789
|
+
if worker_id is not None and dispatch_worker_key(record) != worker_id:
|
|
1783
1790
|
continue
|
|
1784
1791
|
records.append(record)
|
|
1785
1792
|
return records
|
|
1786
1793
|
|
|
1787
1794
|
|
|
1788
|
-
def
|
|
1795
|
+
def dispatch_worker_key(record: Mapping[str, Any]) -> str:
|
|
1789
1796
|
"""The `workers[]` row a dispatch record belongs to.
|
|
1790
1797
|
|
|
1791
1798
|
A v1 record names it as `workerId`. A v2 record carries execution identity
|
|
@@ -65,6 +65,9 @@ class ModelSpec:
|
|
|
65
65
|
supported_roles: frozenset[str] | None
|
|
66
66
|
selectable: bool
|
|
67
67
|
pricing: Optional[tuple[float, float, float]]
|
|
68
|
+
# 이 모델을 실행할 때마다 공급자 CLI 에 덧붙는 인자. 사용자 CLI 설정에 맡기면
|
|
69
|
+
# 기계마다 달라지는 값(예: reasoning effort)을 카탈로그가 고정한다.
|
|
70
|
+
cli_args: tuple[str, ...]
|
|
68
71
|
|
|
69
72
|
def __init__(
|
|
70
73
|
self,
|
|
@@ -79,6 +82,7 @@ class ModelSpec:
|
|
|
79
82
|
pricing: Optional[tuple[float, float, float]] = None,
|
|
80
83
|
*,
|
|
81
84
|
in_picker: bool | None = None,
|
|
85
|
+
cli_args: tuple[str, ...] = (),
|
|
82
86
|
) -> None:
|
|
83
87
|
"""Accept the former display/execution form while adapters migrate."""
|
|
84
88
|
is_legacy = execution_value is _MISSING or not isinstance(execution_value, str)
|
|
@@ -102,6 +106,7 @@ class ModelSpec:
|
|
|
102
106
|
object.__setattr__(self, "supported_roles", supported_roles)
|
|
103
107
|
object.__setattr__(self, "selectable", selectable if in_picker is None else in_picker)
|
|
104
108
|
object.__setattr__(self, "pricing", resolved_pricing)
|
|
109
|
+
object.__setattr__(self, "cli_args", cli_args)
|
|
105
110
|
|
|
106
111
|
@property
|
|
107
112
|
def display(self) -> str:
|
|
@@ -93,13 +93,18 @@ class SplitText:
|
|
|
93
93
|
가야 하므로 종결 텍스트로 돌려주고, 진행은 기록과 화면에만 남는다.
|
|
94
94
|
"""
|
|
95
95
|
|
|
96
|
+
_answer: list[str] = field(default_factory=list, repr=False, compare=False)
|
|
97
|
+
|
|
96
98
|
def merges_stderr(self) -> bool:
|
|
97
99
|
return False
|
|
98
100
|
|
|
99
101
|
def sinks(self, writer: TranscriptWriter) -> tuple[SinkSpec, ...]:
|
|
100
102
|
def result(line: str) -> str | None:
|
|
101
103
|
writer.write(WORKER, line)
|
|
102
|
-
|
|
104
|
+
# 러너는 마지막으로 돌려받은 값만 호출자에게 넘기므로, 그 줄이 아니라
|
|
105
|
+
# 지금까지의 답 전체를 돌려준다.
|
|
106
|
+
self._answer.append(strip_terminal_colors(line))
|
|
107
|
+
return "\n".join(self._answer).strip("\n") or None
|
|
103
108
|
|
|
104
109
|
def progress(line: str) -> str | None:
|
|
105
110
|
writer.write(WORKER, line)
|
|
@@ -140,6 +145,9 @@ class JsonEvents:
|
|
|
140
145
|
_saw_conclusion: list[bool] = field(
|
|
141
146
|
default_factory=list, repr=False, compare=False
|
|
142
147
|
)
|
|
148
|
+
# 마지막 도구 호출 뒤에 온 산문. Result 없는 어휘에서는 이것이 최종 답이다.
|
|
149
|
+
_trailing_text: list[str] = field(default_factory=list, repr=False, compare=False)
|
|
150
|
+
_saw_result: list[bool] = field(default_factory=list, repr=False, compare=False)
|
|
143
151
|
|
|
144
152
|
def merges_stderr(self) -> bool:
|
|
145
153
|
return True
|
|
@@ -164,11 +172,17 @@ class JsonEvents:
|
|
|
164
172
|
if not isinstance(event, dict):
|
|
165
173
|
return None
|
|
166
174
|
self.observe(event)
|
|
167
|
-
|
|
175
|
+
concludes = self.is_conclusion is not None and self.is_conclusion(event)
|
|
176
|
+
if concludes:
|
|
168
177
|
self._saw_conclusion.append(True)
|
|
169
178
|
closing: str | None = None
|
|
170
179
|
for entry in self.normalise(event):
|
|
171
180
|
closing = self._write_entry(writer, entry) or closing
|
|
181
|
+
if concludes and closing is None and not self._saw_result:
|
|
182
|
+
# 종결 표지가 본문을 싣지 않는 어휘다. 돌려줄 것이
|
|
183
|
+
# 없으면 quiet 호출자의 stdout 이 비고 답은 로그에만 남는다.
|
|
184
|
+
joiner = "" if self.join_adjacent_text else "\n"
|
|
185
|
+
closing = joiner.join(self._trailing_text).strip() or None
|
|
172
186
|
return closing
|
|
173
187
|
|
|
174
188
|
return (("stdout", emit),)
|
|
@@ -179,6 +193,10 @@ class JsonEvents:
|
|
|
179
193
|
def _write_entry(
|
|
180
194
|
self, writer: TranscriptWriter, entry: StreamEvent
|
|
181
195
|
) -> str | None:
|
|
196
|
+
if isinstance(entry, Text):
|
|
197
|
+
self._trailing_text.append(entry.body)
|
|
198
|
+
else:
|
|
199
|
+
self._trailing_text.clear()
|
|
182
200
|
if isinstance(entry, Text) and self.join_adjacent_text:
|
|
183
201
|
self._pending_text.append(entry.body)
|
|
184
202
|
self._publish_complete_lines(writer)
|
|
@@ -197,6 +215,7 @@ class JsonEvents:
|
|
|
197
215
|
# Result 이벤트는 어휘와 무관한 종결이다. 빈 결과 텍스트도 종결이므로
|
|
198
216
|
# closing_text 의 진릿값과 따로 기록한다.
|
|
199
217
|
self._saw_conclusion.append(True)
|
|
218
|
+
self._saw_result.append(True)
|
|
200
219
|
return closing
|
|
201
220
|
|
|
202
221
|
def _publish_complete_lines(self, writer: TranscriptWriter) -> None:
|
|
@@ -125,10 +125,11 @@ def validate_write_policy_payload(payload: Mapping[str, Any]) -> None:
|
|
|
125
125
|
source = _mapping(payload.get("sourcePolicy"), "sourcePolicy")
|
|
126
126
|
git = _mapping(payload.get("gitPolicy"), "gitPolicy")
|
|
127
127
|
auxiliary = _mapping(payload.get("auxiliaryPolicy"), "auxiliaryPolicy")
|
|
128
|
-
if set(artifact) != {"allowedRoot", "allowedPaths"}:
|
|
128
|
+
if set(artifact) - {"preservedPaths"} != {"allowedRoot", "allowedPaths"}:
|
|
129
129
|
raise WritePolicyError("artifactPolicy is incomplete")
|
|
130
130
|
_absolute_text(artifact.get("allowedRoot"), "artifactPolicy.allowedRoot")
|
|
131
131
|
_relative_paths(artifact.get("allowedPaths", ()))
|
|
132
|
+
_relative_paths(artifact.get("preservedPaths", ()))
|
|
132
133
|
if source.get("mode") not in {"source-readonly", "project-mutation"}:
|
|
133
134
|
raise WritePolicyError("sourcePolicy mode is invalid")
|
|
134
135
|
if set(source) - {"plannedPathsDeclared"} != {
|
|
@@ -123,6 +123,7 @@ class MutationAuditResult:
|
|
|
123
123
|
declared_out_of_plan_paths: tuple[str, ...] = ()
|
|
124
124
|
unobserved_artifact_paths: tuple[str, ...] = ()
|
|
125
125
|
external_workspace_changes: tuple[str, ...] = ()
|
|
126
|
+
violating_artifact_paths: tuple[str, ...] = ()
|
|
126
127
|
|
|
127
128
|
def change_summary(self) -> dict[str, Any]:
|
|
128
129
|
return {
|
|
@@ -140,6 +141,7 @@ class MutationAuditResult:
|
|
|
140
141
|
"declaredOutOfPlanPaths": list(self.declared_out_of_plan_paths),
|
|
141
142
|
"unobservedArtifactPaths": list(self.unobserved_artifact_paths),
|
|
142
143
|
"externalWorkspaceChanges": list(self.external_workspace_changes),
|
|
144
|
+
"violatingArtifactPaths": list(self.violating_artifact_paths),
|
|
143
145
|
"beforeDigest": self.before_digest,
|
|
144
146
|
"afterDigest": self.after_digest,
|
|
145
147
|
}
|
|
@@ -242,10 +244,11 @@ class ExecutionMutationAudit:
|
|
|
242
244
|
rows,
|
|
243
245
|
source_changes,
|
|
244
246
|
)
|
|
245
|
-
|
|
247
|
+
violating_artifacts, untracked_artifact_changes, switched, external = (
|
|
246
248
|
_artifact_policy_failures(before, rows, artifact_changed, after=after)
|
|
247
249
|
)
|
|
248
|
-
|
|
250
|
+
if violating_artifacts:
|
|
251
|
+
violations.append("artifact-root change exceeds batch policy union")
|
|
249
252
|
worker_artifact_changes = {
|
|
250
253
|
path for path in artifact_changed
|
|
251
254
|
if path not in untracked_artifact_changes and path not in switched and path not in external
|
|
@@ -287,6 +290,7 @@ class ExecutionMutationAudit:
|
|
|
287
290
|
declared_out_of_plan_paths=tuple(out_of_plan_edits),
|
|
288
291
|
unobserved_artifact_paths=tuple(sorted(unobserved)),
|
|
289
292
|
external_workspace_changes=tuple(sorted(external)),
|
|
293
|
+
violating_artifact_paths=tuple(sorted(violating_artifacts)),
|
|
290
294
|
)
|
|
291
295
|
|
|
292
296
|
|
|
@@ -784,8 +788,8 @@ def _artifact_policy_failures(
|
|
|
784
788
|
changed: set[str],
|
|
785
789
|
*,
|
|
786
790
|
after: MutationSnapshot | None = None,
|
|
787
|
-
) -> tuple[
|
|
788
|
-
"""
|
|
791
|
+
) -> tuple[set[str], set[str], set[str], set[str]]:
|
|
792
|
+
"""위반 경로·비추적 파일·브랜치 전환·외부 작업공간 관측을 분리한다.
|
|
789
793
|
|
|
790
794
|
작업트리와 기본 체크아웃이 다르면 기본 체크아웃 소스의 변경 주체는
|
|
791
795
|
전후 비교로 판단할 수 없다. 배정된 산출물 밖의 변경은 관측으로 남기고,
|
|
@@ -797,10 +801,18 @@ def _artifact_policy_failures(
|
|
|
797
801
|
for value in snapshot.orchestrator_paths
|
|
798
802
|
if _is_relative_to(Path(value), snapshot.artifact_root)
|
|
799
803
|
}
|
|
804
|
+
preserved = {
|
|
805
|
+
path
|
|
806
|
+
for policy in policies
|
|
807
|
+
for path in policy.artifact_policy.get("preservedPaths", ())
|
|
808
|
+
}
|
|
800
809
|
unauthorized = {
|
|
801
810
|
path for path in changed
|
|
802
|
-
if
|
|
803
|
-
|
|
811
|
+
if path in preserved
|
|
812
|
+
or (
|
|
813
|
+
not any(_is_within(path, item) for item in allowed)
|
|
814
|
+
and not any(_is_within(path, item) for item in orchestrator)
|
|
815
|
+
)
|
|
804
816
|
}
|
|
805
817
|
if snapshot.artifact_root == snapshot.root:
|
|
806
818
|
unauthorized = {path for path in unauthorized if _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)}
|
|
@@ -826,8 +838,7 @@ def _artifact_policy_failures(
|
|
|
826
838
|
and not _is_within(path, _OKSTRA_ARTIFACT_SUBTREE)
|
|
827
839
|
}
|
|
828
840
|
violating = unauthorized - untracked_outside - switched - external
|
|
829
|
-
|
|
830
|
-
return failures, untracked_outside, switched, external
|
|
841
|
+
return violating, untracked_outside, switched, external
|
|
831
842
|
|
|
832
843
|
|
|
833
844
|
def _branch_switch_paths(
|
|
@@ -44,8 +44,10 @@ from .worker_prompt_headers import source_worktree_headers, worker_prompt_header
|
|
|
44
44
|
from .worker_prompt_policy import (
|
|
45
45
|
APPROVED_PLAN_HEADER,
|
|
46
46
|
IMPLEMENTATION_STAGE_HEADER,
|
|
47
|
+
STAGE_QA_OWNER_HEADER,
|
|
47
48
|
PromptPlan,
|
|
48
49
|
resolve_prompt_plan_for_manifest,
|
|
50
|
+
stage_qa_owner,
|
|
49
51
|
)
|
|
50
52
|
|
|
51
53
|
|
|
@@ -1469,6 +1471,9 @@ def _implementation_anchor_lines(
|
|
|
1469
1471
|
lines.append(
|
|
1470
1472
|
f"{IMPLEMENTATION_STAGE_HEADER} {_required_stage_number(context)}"
|
|
1471
1473
|
)
|
|
1474
|
+
owner = stage_qa_owner(context.manifest)
|
|
1475
|
+
if plan.audience == "implementation-verifier" and owner:
|
|
1476
|
+
lines.append(f"{STAGE_QA_OWNER_HEADER} {owner}")
|
|
1472
1477
|
return lines
|
|
1473
1478
|
|
|
1474
1479
|
|
|
@@ -220,6 +220,38 @@ def record_progress(
|
|
|
220
220
|
return event, warning
|
|
221
221
|
|
|
222
222
|
|
|
223
|
+
def record_checkpoint(
|
|
224
|
+
project_root: Path,
|
|
225
|
+
run_manifest: str | Path,
|
|
226
|
+
phase: str,
|
|
227
|
+
*,
|
|
228
|
+
source: str,
|
|
229
|
+
worker: str | None = None,
|
|
230
|
+
fields: Sequence[tuple[str, str]] = (),
|
|
231
|
+
) -> str | None:
|
|
232
|
+
"""단계를 수행하는 명령이 체크포인트를 기록하고 그 줄을 돌려준다.
|
|
233
|
+
|
|
234
|
+
기록이 실패해도 그 기록이 붙은 dispatch·await·reclaim·finalize 는 실패시키지
|
|
235
|
+
않는다. 장부 한 줄 때문에 작업 자체를 잃는 쪽이 더 나쁘다. 실패는 stderr 에
|
|
236
|
+
출력하고, 이후 검증기가 체크포인트 누락으로 보고한다.
|
|
237
|
+
"""
|
|
238
|
+
root = project_root.resolve()
|
|
239
|
+
try:
|
|
240
|
+
event, warning = record_progress(
|
|
241
|
+
root,
|
|
242
|
+
_resolve_project_path(root, run_manifest),
|
|
243
|
+
phase,
|
|
244
|
+
worker=worker,
|
|
245
|
+
fields=fields,
|
|
246
|
+
)
|
|
247
|
+
except (ProgressRecordError, OSError) as exc:
|
|
248
|
+
print(f"{source}: could not record `{phase}`: {exc}", file=sys.stderr)
|
|
249
|
+
return None
|
|
250
|
+
if warning:
|
|
251
|
+
print(f"{source}: {warning}", file=sys.stderr)
|
|
252
|
+
return str(event.details["line"])
|
|
253
|
+
|
|
254
|
+
|
|
223
255
|
def _append(args: argparse.Namespace) -> int:
|
|
224
256
|
fields = [_parse_field(raw) for raw in args.field]
|
|
225
257
|
event, warning = record_progress(
|
|
@@ -243,7 +275,7 @@ def _append(args: argparse.Namespace) -> int:
|
|
|
243
275
|
|
|
244
276
|
_CLI_EPILOG = r"""Usage:
|
|
245
277
|
okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-1-intake
|
|
246
|
-
okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-4-dispatch --worker codex-worker --field model=gpt-6-sol
|
|
278
|
+
okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-4-dispatch --worker codex-worker --field model=gpt-6.1-sol
|
|
247
279
|
okstra lead-progress append --project-root <dir> --run-manifest <path> --phase phase-5-collect --worker codex-worker --field status=completed
|
|
248
280
|
|
|
249
281
|
`progressLine` in the output is the line to emit to the user, verbatim.
|