okstra 0.178.0 → 0.179.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 +2 -2
- package/dist/commands/execute/plan-verify.mjs +1 -1
- package/dist/commands/execute/worktree-status.mjs +8 -2
- package/dist/commands/execute/worktree-status.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +1 -1
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/dist/commands/report/render-final-report.mjs +3 -3
- package/docs/architecture/storage-model.md +3 -3
- package/docs/architecture.md +10 -9
- package/docs/cli.md +11 -13
- package/docs/for-ai/skills/okstra-inspect.md +3 -3
- package/docs/for-ai/skills/okstra-schedule-gen.md +2 -2
- package/docs/for-ai/skills/okstra-user-response.md +2 -2
- package/docs/project-structure-overview.md +10 -11
- package/docs/task-process/implementation-planning.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +11 -12
- package/runtime/bin/lib/okstra/globals.sh +2 -2
- package/runtime/bin/lib/okstra/interactive.sh +1 -1
- package/runtime/bin/lib/okstra/usage.sh +11 -9
- package/runtime/bin/lib/okstra-ctl/cmd-rerun.sh +1 -1
- package/runtime/bin/okstra-central.sh +2 -2
- package/runtime/bin/okstra-render-final-report.py +1 -1
- package/runtime/bin/okstra-token-usage.py +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +6 -1
- package/runtime/prompts/lead/context-loader.md +3 -2
- package/runtime/prompts/lead/convergence.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
- package/runtime/prompts/lead/plan-body-verification.md +3 -3
- package/runtime/prompts/lead/report-writer.md +21 -20
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +5 -4
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -0
- package/runtime/prompts/profiles/_implementation-executor.md +2 -1
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +9 -5
- package/runtime/prompts/profiles/implementation.md +4 -4
- package/runtime/prompts/profiles/improvement-discovery.md +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -4
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +71 -9
- package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +5 -4
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +55 -3
- package/runtime/python/okstra_ctl/analysis_inputs.py +5 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +21 -0
- package/runtime/python/okstra_ctl/backfill.py +12 -5
- package/runtime/python/okstra_ctl/consumers.py +70 -3
- package/runtime/python/okstra_ctl/convergence_engine.py +43 -17
- package/runtime/python/okstra_ctl/dispatch_core.py +47 -11
- package/runtime/python/okstra_ctl/dispatch_state.py +20 -19
- package/runtime/python/okstra_ctl/domain/worker_exec.py +13 -34
- package/runtime/python/okstra_ctl/domain/worker_presentation.py +128 -0
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +5 -0
- package/runtime/python/okstra_ctl/final_report_paths.py +77 -1
- package/runtime/python/okstra_ctl/handoff.py +1 -2
- package/runtime/python/okstra_ctl/implementation_outcome.py +1 -1
- package/runtime/python/okstra_ctl/index.py +4 -4
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +26 -12
- package/runtime/python/okstra_ctl/listing.py +4 -2
- package/runtime/python/okstra_ctl/manager_launch.py +1 -1
- package/runtime/python/okstra_ctl/manager_sync.py +1 -1
- package/runtime/python/okstra_ctl/path_hints.py +2 -2
- package/runtime/python/okstra_ctl/paths.py +13 -10
- package/runtime/python/okstra_ctl/plan_run_root.py +9 -5
- package/runtime/python/okstra_ctl/recap.py +3 -2
- package/runtime/python/okstra_ctl/reconcile.py +3 -1
- package/runtime/python/okstra_ctl/render.py +22 -22
- package/runtime/python/okstra_ctl/report_finalize.py +4 -4
- package/runtime/python/okstra_ctl/rollup.py +1 -1
- package/runtime/python/okstra_ctl/run.py +139 -284
- package/runtime/python/okstra_ctl/run_audit.py +5 -5
- package/runtime/python/okstra_ctl/run_index_row.py +2 -2
- package/runtime/python/okstra_ctl/session_transcript.py +89 -0
- package/runtime/python/okstra_ctl/stage_ledger.py +72 -0
- package/runtime/python/okstra_ctl/stage_map.py +28 -29
- package/runtime/python/okstra_ctl/stage_targets.py +61 -0
- package/runtime/python/okstra_ctl/user_response.py +97 -12
- package/runtime/python/okstra_ctl/wizard.py +43 -65
- package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -7
- package/runtime/python/okstra_ctl/worker_runner.py +76 -213
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_ctl/wrapper_status.py +23 -0
- package/runtime/python/okstra_ctl/write_policy.py +45 -6
- package/runtime/python/okstra_project/state.py +2 -2
- package/runtime/python/okstra_token_usage/__init__.py +1 -1
- package/runtime/python/okstra_token_usage/cli.py +3 -3
- package/runtime/python/okstra_token_usage/report.py +7 -24
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v2.0.schema.json +11 -1
- package/runtime/skills/okstra-inspect/facets/history.md +3 -3
- package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
- package/runtime/skills/okstra-inspect/facets/report.md +5 -5
- package/runtime/skills/okstra-inspect/facets/status.md +2 -2
- package/runtime/skills/okstra-pr-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-schedule-gen/SKILL.md +1 -1
- package/runtime/skills/okstra-user-response/SKILL.md +3 -3
- package/runtime/templates/project-docs/task-index.template.md +1 -1
- package/runtime/templates/report-writer-prompt-preamble.md +1 -1
- package/runtime/validators/forbidden_actions.py +76 -5
- package/runtime/validators/lib/fixtures.sh +14 -10
- package/runtime/validators/lib/runners.sh +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +3 -0
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +95 -37
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
5
6
|
import subprocess
|
|
6
7
|
import time
|
|
7
8
|
from dataclasses import dataclass, field, replace
|
|
@@ -110,6 +111,8 @@ from .worker_prompt_headers import (
|
|
|
110
111
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
111
112
|
from .wrapper_status import (
|
|
112
113
|
log_path_for_prompt,
|
|
114
|
+
mutation_snapshot_path_for_prompt,
|
|
115
|
+
prompt_derived_paths,
|
|
113
116
|
read_wrapper_status,
|
|
114
117
|
status_path_for_prompt,
|
|
115
118
|
)
|
|
@@ -1462,7 +1465,7 @@ def _mutation_snapshot(
|
|
|
1462
1465
|
|
|
1463
1466
|
|
|
1464
1467
|
def _mutation_snapshot_path(job: WorkerJob) -> Path:
|
|
1465
|
-
return
|
|
1468
|
+
return mutation_snapshot_path_for_prompt(job.prompt_path)
|
|
1466
1469
|
|
|
1467
1470
|
|
|
1468
1471
|
def _record_execution_attempt(plan: DispatchPlan, job: WorkerJob) -> None:
|
|
@@ -1517,6 +1520,11 @@ def _canonical_write_contract(
|
|
|
1517
1520
|
raise DispatchError("worker write policy has no runner write capability")
|
|
1518
1521
|
artifacts = _worker_artifact_paths(plan, job)
|
|
1519
1522
|
worktree = Path(job.worktree_path) if job.worktree_path else None
|
|
1523
|
+
planned = (
|
|
1524
|
+
planned_paths_from_run_manifest(plan.project_root, plan.manifest)
|
|
1525
|
+
if execution.role == "implementer"
|
|
1526
|
+
else ((), True)
|
|
1527
|
+
)
|
|
1520
1528
|
try:
|
|
1521
1529
|
return build_invocation_write_contract(
|
|
1522
1530
|
role=execution.role,
|
|
@@ -1524,11 +1532,8 @@ def _canonical_write_contract(
|
|
|
1524
1532
|
worktree=worktree,
|
|
1525
1533
|
artifact_paths=artifacts,
|
|
1526
1534
|
maximum_precision=capability.max_boundary_precision,
|
|
1527
|
-
planned_paths=
|
|
1528
|
-
|
|
1529
|
-
if execution.role == "implementer"
|
|
1530
|
-
else ()
|
|
1531
|
-
),
|
|
1535
|
+
planned_paths=planned[0],
|
|
1536
|
+
planned_paths_declared=planned[1],
|
|
1532
1537
|
auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
1533
1538
|
validated_auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
1534
1539
|
)
|
|
@@ -1545,14 +1550,12 @@ def _worker_artifact_paths(plan: DispatchPlan, job: WorkerJob) -> tuple[Path, ..
|
|
|
1545
1550
|
job.worker_result_path,
|
|
1546
1551
|
*job.completion_paths,
|
|
1547
1552
|
Path(audit_sidecar_rel(str(job.worker_result_path))),
|
|
1548
|
-
status_path_for_prompt(job.prompt_path),
|
|
1549
|
-
log_path_for_prompt(job.prompt_path),
|
|
1550
1553
|
# The prompt's three derived files are written together and belong in
|
|
1551
1554
|
# one list. The audit snapshot used to be listed only for the job whose
|
|
1552
1555
|
# snapshot it was, so a sibling's snapshot — written by okstra as that
|
|
1553
1556
|
# sibling started — landed inside this worker's window as an
|
|
1554
1557
|
# unauthorized artifact-root change.
|
|
1555
|
-
|
|
1558
|
+
*prompt_derived_paths(job.prompt_path),
|
|
1556
1559
|
}
|
|
1557
1560
|
error_logs = active_context.get("errorLogs")
|
|
1558
1561
|
if isinstance(error_logs, Mapping):
|
|
@@ -1914,8 +1917,41 @@ def _audit_attempt(
|
|
|
1914
1917
|
)
|
|
1915
1918
|
|
|
1916
1919
|
|
|
1920
|
+
def _declared_out_of_plan_paths(result_path: Path) -> tuple[str, ...]:
|
|
1921
|
+
"""워커가 자기 결과의 `Out-of-plan edits` 블록에 선언한 경로.
|
|
1922
|
+
|
|
1923
|
+
감사는 워커가 끝나는 시점에 돈다. 그때 디스크에 있는 것은 워커 결과
|
|
1924
|
+
마크다운뿐이고, `implementation.outOfPlanEdits` 는 리드가 나중에 쓰는 최종
|
|
1925
|
+
리포트의 필드다. JSON 형태만 읽었기 때문에 executor 의 선언이 한 번도 보이지
|
|
1926
|
+
않았고, 계약대로 선언한 편집까지 미허가 변경으로 집계됐다. 블록의 형태는 이
|
|
1927
|
+
판독기를 위해 `_implementation-executor.md` 가 고정한다 — `- ` 줄마다 첫 백틱
|
|
1928
|
+
토큰이 경로다.
|
|
1929
|
+
"""
|
|
1930
|
+
if not result_path.is_file() or result_path.suffix != ".md":
|
|
1931
|
+
return ()
|
|
1932
|
+
try:
|
|
1933
|
+
text = result_path.read_text(encoding="utf-8")
|
|
1934
|
+
except (OSError, UnicodeError):
|
|
1935
|
+
return ()
|
|
1936
|
+
paths: list[str] = []
|
|
1937
|
+
inside = False
|
|
1938
|
+
for line in text.splitlines():
|
|
1939
|
+
stripped = line.strip()
|
|
1940
|
+
if stripped.startswith("#"):
|
|
1941
|
+
inside = stripped.lstrip("#").strip().lower() == "out-of-plan edits"
|
|
1942
|
+
continue
|
|
1943
|
+
if not inside or not stripped.startswith("- "):
|
|
1944
|
+
continue
|
|
1945
|
+
quoted = re.findall(r"`([^`\n]+)`", stripped)
|
|
1946
|
+
if quoted and quoted[0].strip():
|
|
1947
|
+
paths.append(quoted[0].strip())
|
|
1948
|
+
return tuple(paths)
|
|
1949
|
+
|
|
1950
|
+
|
|
1917
1951
|
def _out_of_plan_edit_paths(result_path: Path) -> tuple[str, ...]:
|
|
1918
|
-
if
|
|
1952
|
+
if result_path.suffix != ".json":
|
|
1953
|
+
return _declared_out_of_plan_paths(result_path)
|
|
1954
|
+
if not result_path.is_file():
|
|
1919
1955
|
return ()
|
|
1920
1956
|
try:
|
|
1921
1957
|
payload = json.loads(result_path.read_text(encoding="utf-8"))
|
|
@@ -2917,7 +2953,7 @@ def _reject_stale_schema_excerpt(
|
|
|
2917
2953
|
)
|
|
2918
2954
|
if writer is None:
|
|
2919
2955
|
return
|
|
2920
|
-
expected = _string_value(manifest.get("
|
|
2956
|
+
expected = _string_value(manifest.get("expectedReportRecordPath"))
|
|
2921
2957
|
if not expected:
|
|
2922
2958
|
return
|
|
2923
2959
|
excerpt_path = bundle_excerpt_path(_resolve_project_path(project_root, expected))
|
|
@@ -46,10 +46,7 @@ from .execution_manifest import (
|
|
|
46
46
|
record_invocation_attempt,
|
|
47
47
|
)
|
|
48
48
|
from .execution_mutation_audit import ExecutionMutationAudit, MutationSnapshot
|
|
49
|
-
from .final_report_paths import
|
|
50
|
-
final_report_data_path,
|
|
51
|
-
final_report_markdown_path,
|
|
52
|
-
)
|
|
49
|
+
from .final_report_paths import final_report_data_path
|
|
53
50
|
from .worker_prompt_body import REPORT_WRITER_WORKER_ID
|
|
54
51
|
from .worker_prompt_contract import (
|
|
55
52
|
PromptRecord,
|
|
@@ -59,7 +56,11 @@ from .worker_prompt_contract import (
|
|
|
59
56
|
from .worker_runner import LIVE, QUIET
|
|
60
57
|
from .worker_request import verifier_extra_dirs
|
|
61
58
|
from .worker_artifact_paths import audit_sidecar_rel
|
|
62
|
-
from .wrapper_status import
|
|
59
|
+
from .wrapper_status import (
|
|
60
|
+
log_path_for_prompt,
|
|
61
|
+
prompt_derived_paths,
|
|
62
|
+
status_path_for_prompt,
|
|
63
|
+
)
|
|
63
64
|
from .write_policy import (
|
|
64
65
|
build_invocation_write_contract,
|
|
65
66
|
planned_paths_from_run_manifest,
|
|
@@ -812,6 +813,11 @@ def _agent_write_contract(
|
|
|
812
813
|
maximum_precision = capability.max_boundary_precision
|
|
813
814
|
else:
|
|
814
815
|
raise DispatchError("agent write policy has no runner capability")
|
|
816
|
+
planned = (
|
|
817
|
+
planned_paths_from_run_manifest(project_root, authority)
|
|
818
|
+
if execution.role == "implementer"
|
|
819
|
+
else ((), True)
|
|
820
|
+
)
|
|
815
821
|
try:
|
|
816
822
|
return build_invocation_write_contract(
|
|
817
823
|
role=execution.role,
|
|
@@ -819,11 +825,8 @@ def _agent_write_contract(
|
|
|
819
825
|
worktree=worktree,
|
|
820
826
|
artifact_paths=artifacts,
|
|
821
827
|
maximum_precision=maximum_precision,
|
|
822
|
-
planned_paths=
|
|
823
|
-
|
|
824
|
-
if execution.role == "implementer"
|
|
825
|
-
else ()
|
|
826
|
-
),
|
|
828
|
+
planned_paths=planned[0],
|
|
829
|
+
planned_paths_declared=planned[1],
|
|
827
830
|
auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
828
831
|
validated_auxiliary_roots=verifier_extra_dirs(execution.role),
|
|
829
832
|
)
|
|
@@ -927,8 +930,7 @@ def _prompt_write_paths(
|
|
|
927
930
|
artifacts = {
|
|
928
931
|
_project_or_absolute(project_root, value) for value in artifact_values
|
|
929
932
|
}
|
|
930
|
-
artifacts.
|
|
931
|
-
artifacts.add(log_path_for_prompt(prompt_path))
|
|
933
|
+
artifacts.update(prompt_derived_paths(prompt_path))
|
|
932
934
|
worktree_value = values.get("Worktree")
|
|
933
935
|
worktree = (
|
|
934
936
|
_project_or_absolute(project_root, worktree_value)
|
|
@@ -1467,7 +1469,7 @@ def dispatch_result_path(
|
|
|
1467
1469
|
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1468
1470
|
return worker_result_path
|
|
1469
1471
|
return final_report_data_path(
|
|
1470
|
-
resolve_required_path(project_root, manifest, "
|
|
1472
|
+
resolve_required_path(project_root, manifest, "expectedReportRecordPath")
|
|
1471
1473
|
)
|
|
1472
1474
|
|
|
1473
1475
|
|
|
@@ -1479,17 +1481,16 @@ def dispatch_completion_paths(
|
|
|
1479
1481
|
) -> tuple[Path, ...]:
|
|
1480
1482
|
"""Every artifact that must exist before this worker counts as done.
|
|
1481
1483
|
|
|
1482
|
-
The report writer's
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
the same answer.
|
|
1484
|
+
The report writer's two are the report record and the worker-result
|
|
1485
|
+
pointer. The full reading copy is rendered on demand and is not a
|
|
1486
|
+
completion artifact.
|
|
1486
1487
|
"""
|
|
1487
1488
|
if worker_id != REPORT_WRITER_WORKER_ID:
|
|
1488
1489
|
return (worker_result_path,)
|
|
1489
1490
|
data_json = final_report_data_path(
|
|
1490
|
-
resolve_required_path(project_root, manifest, "
|
|
1491
|
+
resolve_required_path(project_root, manifest, "expectedReportRecordPath")
|
|
1491
1492
|
)
|
|
1492
|
-
return (data_json,
|
|
1493
|
+
return (data_json, worker_result_path)
|
|
1493
1494
|
|
|
1494
1495
|
|
|
1495
1496
|
def validate_initial_prompts(
|
|
@@ -7,30 +7,16 @@ regardless of provider — see ``ExecutionPolicy``.
|
|
|
7
7
|
"""
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
from dataclasses import dataclass
|
|
10
|
+
from dataclasses import dataclass
|
|
11
11
|
from pathlib import Path
|
|
12
|
-
from
|
|
13
|
-
from typing import Any, Literal, Protocol, runtime_checkable
|
|
12
|
+
from typing import Literal, Protocol, runtime_checkable
|
|
14
13
|
|
|
15
|
-
from .
|
|
14
|
+
from .worker_presentation import Presentation
|
|
16
15
|
from ..write_policy import WriteEnforcement, WritePolicy
|
|
17
16
|
|
|
18
|
-
# What ``ExecCommand.stream_format`` may hold. The value decides whether the
|
|
19
|
-
# runner pipes the CLI's output through the stream formatter or forwards it
|
|
20
|
-
# unchanged, so it is part of the contract rather than a display hint.
|
|
21
|
-
STREAM_JSON = "stream-json"
|
|
22
|
-
TEXT = "text"
|
|
23
17
|
SERVED_MODEL_MISMATCH_EXIT_CODE = 78
|
|
24
18
|
|
|
25
19
|
|
|
26
|
-
def no_model_observation(_event: Mapping[str, Any]) -> str | None:
|
|
27
|
-
"""Truthful default for provider output that exposes no model identity."""
|
|
28
|
-
return None
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
|
|
32
|
-
|
|
33
|
-
|
|
34
20
|
@dataclass(frozen=True)
|
|
35
21
|
class ExecutionPolicy:
|
|
36
22
|
"""How a non-interactive worker is allowed to act.
|
|
@@ -75,31 +61,24 @@ class WorkerExecRequest:
|
|
|
75
61
|
|
|
76
62
|
@dataclass(frozen=True)
|
|
77
63
|
class ExecCommand:
|
|
78
|
-
"""
|
|
64
|
+
"""공급자 호출 하나, 완전히 해소된 상태.
|
|
79
65
|
|
|
80
|
-
``stdin_text``
|
|
81
|
-
rather than on stdin.
|
|
66
|
+
``stdin_text`` 는 프롬프트를 인자로 받는 CLI 에서 None 이다.
|
|
82
67
|
|
|
83
|
-
``cwd``
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
that had to re-derive it would be guessing at what the strategy already
|
|
87
|
-
decided.
|
|
68
|
+
``cwd`` 가 계약에 있는 이유는 argv 에서 유도할 수 없기 때문이다 — 어떤
|
|
69
|
+
CLI 는 작업 디렉터리를 플래그로 받고 어떤 CLI 는 프로세스의 것을 물려받는데,
|
|
70
|
+
두 무리가 같은 디렉터리를 고르지 않는다.
|
|
88
71
|
|
|
89
|
-
``
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
72
|
+
``presentation`` 은 이 CLI 의 출력을 무엇으로 볼 것인가를 정한다. 스트림
|
|
73
|
+
형식을 따로 선언하고 읽는 법을 나중에 붙이던 구조에서는, 형식만 선언하고
|
|
74
|
+
읽는 법이 어긋난 공급자의 화면이 런 내내 비어 있었다. 둘을 한 값으로 묶어
|
|
75
|
+
그 상태를 표현 불가능하게 만든다.
|
|
93
76
|
"""
|
|
94
77
|
|
|
95
78
|
argv: tuple[str, ...]
|
|
96
79
|
stdin_text: str | None
|
|
97
|
-
stream_format: str
|
|
98
80
|
cwd: Path
|
|
99
|
-
|
|
100
|
-
observe_served_model: ObserveServedModel = field(
|
|
101
|
-
default=no_model_observation
|
|
102
|
-
)
|
|
81
|
+
presentation: Presentation
|
|
103
82
|
|
|
104
83
|
|
|
105
84
|
@dataclass(frozen=True)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""워커 출력의 표현 전략.
|
|
2
|
+
|
|
3
|
+
공급자 CLI 는 저마다 다른 것을 뱉는다 — 사람이 읽는 텍스트를 흘리는 CLI 도
|
|
4
|
+
있고, 자기 어휘의 JSON 을 흘리는 CLI 도 있다. okstra 가 그 형식을 알아야만
|
|
5
|
+
화면이 나오는 구조에서는 공급자가 형식을 바꿀 때마다 화면이 조용히 빈다.
|
|
6
|
+
그래서 "해석하지 않는다" 를 1급 선택지로 둔다.
|
|
7
|
+
|
|
8
|
+
전략은 두 가지를 답한다. stderr 를 stdout 에 합칠 것인가, 그리고 각 스트림의
|
|
9
|
+
줄을 누가 받는가. 둘을 함께 두는 이유는 배치와 해석이 짝이기 때문이다 —
|
|
10
|
+
JSON 을 요청하면 한 스트림으로 합쳐 읽어야 하고, 결과와 진행을 나눠 내는
|
|
11
|
+
CLI 는 갈라 읽어야 한다.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Literal, Mapping, Protocol, runtime_checkable
|
|
19
|
+
|
|
20
|
+
from .worker_stream import (
|
|
21
|
+
Normalise,
|
|
22
|
+
final_text,
|
|
23
|
+
format_live,
|
|
24
|
+
format_log,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
Sink = Callable[[str], str | None]
|
|
28
|
+
Channel = Literal["stdout", "stderr"]
|
|
29
|
+
SinkSpec = tuple[Channel, Sink]
|
|
30
|
+
ObserveServedModel = Callable[[Mapping[str, Any]], str | None]
|
|
31
|
+
|
|
32
|
+
WORKER = "worker"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TranscriptWriter(Protocol):
|
|
36
|
+
"""세션 기록에 한 줄을 남긴다. 화면 출력도 이 구현이 함께 맡는다."""
|
|
37
|
+
|
|
38
|
+
def write(self, speaker: str, line: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@runtime_checkable
|
|
42
|
+
class Presentation(Protocol):
|
|
43
|
+
def merges_stderr(self) -> bool: ...
|
|
44
|
+
|
|
45
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class MergedText:
|
|
50
|
+
"""CLI 가 사람에게 보여주는 출력을 그대로 흘린다.
|
|
51
|
+
|
|
52
|
+
한 스트림에 진행과 결과가 함께 온다. 해석하지 않으므로 공급자가 형식을
|
|
53
|
+
바꿔도 화면이 깨지지 않는다.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
served_model_at_exit: Callable[[str, Path], str | None] | None = None
|
|
57
|
+
|
|
58
|
+
def merges_stderr(self) -> bool:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
|
|
62
|
+
def emit(line: str) -> str | None:
|
|
63
|
+
writer.write(WORKER, line)
|
|
64
|
+
return line
|
|
65
|
+
|
|
66
|
+
return (("stdout", emit),)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class SplitText:
|
|
71
|
+
"""결과와 진행을 다른 스트림으로 내는 CLI.
|
|
72
|
+
|
|
73
|
+
stdout 은 답이고 stderr 는 진행이다. 답은 어느 모드에서도 호출자에게
|
|
74
|
+
가야 하므로 종결 텍스트로 돌려주고, 진행은 기록과 화면에만 남는다.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def merges_stderr(self) -> bool:
|
|
78
|
+
return False
|
|
79
|
+
|
|
80
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
|
|
81
|
+
def result(line: str) -> str | None:
|
|
82
|
+
writer.write(WORKER, line)
|
|
83
|
+
return line
|
|
84
|
+
|
|
85
|
+
def progress(line: str) -> str | None:
|
|
86
|
+
writer.write(WORKER, line)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
return (("stdout", result), ("stderr", progress))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class JsonEvents:
|
|
94
|
+
"""공급자 어휘의 JSON 을 공통 이벤트로 옮겨 적는다.
|
|
95
|
+
|
|
96
|
+
사람이 읽는 출력에 진행이 없는 CLI 를 위한 경로다. 어휘를 아는 대가로
|
|
97
|
+
도구 호출을 구조로 보여줄 수 있다.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
normalise: Normalise
|
|
101
|
+
observe: ObserveServedModel
|
|
102
|
+
|
|
103
|
+
def merges_stderr(self) -> bool:
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
def sinks(self, writer: TranscriptWriter, live: bool) -> tuple[SinkSpec, ...]:
|
|
107
|
+
def emit(line: str) -> str | None:
|
|
108
|
+
stripped = line.strip()
|
|
109
|
+
if not stripped:
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
event = json.loads(stripped)
|
|
113
|
+
except ValueError:
|
|
114
|
+
# 이벤트가 아니다. CLI 자신의 오류 텍스트가 이 스트림으로
|
|
115
|
+
# 오므로 삼키면 아무도 못 본다.
|
|
116
|
+
writer.write(WORKER, stripped)
|
|
117
|
+
return None
|
|
118
|
+
if not isinstance(event, dict):
|
|
119
|
+
return None
|
|
120
|
+
self.observe(event)
|
|
121
|
+
closing: str | None = None
|
|
122
|
+
for entry in self.normalise(event):
|
|
123
|
+
for row in (format_live(entry) if live else format_log(entry)):
|
|
124
|
+
writer.write(WORKER, row)
|
|
125
|
+
closing = final_text(entry) or closing
|
|
126
|
+
return closing
|
|
127
|
+
|
|
128
|
+
return (("stdout", emit),)
|
|
@@ -571,6 +571,11 @@ def _git_policy_failures(
|
|
|
571
571
|
if not _is_ancestor(root, str(git.get("expectedBaseCommit")), str(after.git_projection.get("head"))):
|
|
572
572
|
failures.append("final HEAD is not a fast-forward descendant")
|
|
573
573
|
allowed = set(policy.source_policy.get("plannedPaths", ())) | set(out_of_plan_edits)
|
|
574
|
+
if policy.source_policy.get("plannedPathsDeclared") is False:
|
|
575
|
+
# The plan predates the declared path column, so `allowed` was derived
|
|
576
|
+
# from prose and is not a ledger anyone can be held to. Every other
|
|
577
|
+
# check above still applies; only the path comparison stands down.
|
|
578
|
+
return failures
|
|
574
579
|
if any(
|
|
575
580
|
not paths <= allowed
|
|
576
581
|
for paths in _commit_paths_by_commit(
|
|
@@ -14,9 +14,46 @@ def _stem(data_path: Path) -> str:
|
|
|
14
14
|
return name[: -len(DATA_JSON_SUFFIX)] if name.endswith(DATA_JSON_SUFFIX) else data_path.stem
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def require_approved_plan_record(path: Path) -> Path:
|
|
18
|
+
"""`--approved-plan` accepts the report record only.
|
|
19
|
+
|
|
20
|
+
A full reading copy (`.md`) is rejected with the sibling record path.
|
|
21
|
+
Schema-v1 plans have no record and cannot enter this gate.
|
|
22
|
+
"""
|
|
23
|
+
resolved = Path(path)
|
|
24
|
+
if is_full_reading_copy_path(resolved):
|
|
25
|
+
suggested = final_report_data_path(resolved)
|
|
26
|
+
raise ValueError(
|
|
27
|
+
f"--approved-plan must be the report record (.data.json), not the "
|
|
28
|
+
f"full reading copy: {path}\n"
|
|
29
|
+
" schema-v1 plans have no record and cannot be used here.\n"
|
|
30
|
+
f" for a schema-v2 plan use: {suggested}"
|
|
31
|
+
)
|
|
32
|
+
if not is_report_record_path(resolved):
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"--approved-plan must be a report record ending in .data.json: {path}"
|
|
35
|
+
)
|
|
36
|
+
if not resolved.is_file():
|
|
37
|
+
raise ValueError(f"approved plan record not found: {path}")
|
|
38
|
+
return resolved
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_report_record_path(path: Path) -> bool:
|
|
42
|
+
return path.name.endswith(DATA_JSON_SUFFIX)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_full_reading_copy_path(path: Path) -> bool:
|
|
46
|
+
return path.name.endswith(MARKDOWN_SUFFIX)
|
|
47
|
+
|
|
48
|
+
|
|
17
49
|
def final_report_data_path(report_path: Path) -> Path:
|
|
18
|
-
"""Return the
|
|
50
|
+
"""Return the report record path for a final-report pair.
|
|
51
|
+
|
|
52
|
+
Already a `.data.json` path is returned unchanged.
|
|
53
|
+
"""
|
|
19
54
|
name = report_path.name
|
|
55
|
+
if name.endswith(DATA_JSON_SUFFIX):
|
|
56
|
+
return report_path
|
|
20
57
|
if name.endswith(MARKDOWN_SUFFIX):
|
|
21
58
|
return report_path.with_name(name[: -len(MARKDOWN_SUFFIX)] + DATA_JSON_SUFFIX)
|
|
22
59
|
return report_path.with_suffix(DATA_JSON_SUFFIX)
|
|
@@ -30,6 +67,45 @@ def final_report_markdown_path(data_path: Path) -> Path:
|
|
|
30
67
|
return data_path.with_suffix(MARKDOWN_SUFFIX)
|
|
31
68
|
|
|
32
69
|
|
|
70
|
+
def report_record_rel_from_legacy_pointer(relative: str) -> str:
|
|
71
|
+
"""Turn a stored pointer into a report-record relative path.
|
|
72
|
+
|
|
73
|
+
New rows store the record. Rows written before that stored the full
|
|
74
|
+
reading copy (`.md`); the record is the sibling `.data.json`.
|
|
75
|
+
"""
|
|
76
|
+
relative = str(relative or "")
|
|
77
|
+
if not relative:
|
|
78
|
+
return ""
|
|
79
|
+
path = Path(relative)
|
|
80
|
+
if is_report_record_path(path):
|
|
81
|
+
return relative
|
|
82
|
+
return str(final_report_data_path(path))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def index_report_record_rel(row: dict) -> str:
|
|
86
|
+
"""Report-record pointer from a run-index row.
|
|
87
|
+
|
|
88
|
+
New rows use `finalReportRecordRel`. Older rows used `finalReportRel`
|
|
89
|
+
for the full reading copy.
|
|
90
|
+
"""
|
|
91
|
+
current = str(row.get("finalReportRecordRel") or "")
|
|
92
|
+
if current:
|
|
93
|
+
return current
|
|
94
|
+
return report_record_rel_from_legacy_pointer(str(row.get("finalReportRel") or ""))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def timeline_report_record_rel(run: dict) -> str:
|
|
98
|
+
"""Report-record pointer from a timeline / recap run entry.
|
|
99
|
+
|
|
100
|
+
New entries use `reportRecordPath`. Older entries used `reportPath`
|
|
101
|
+
for the full reading copy.
|
|
102
|
+
"""
|
|
103
|
+
current = str(run.get("reportRecordPath") or "")
|
|
104
|
+
if current:
|
|
105
|
+
return current
|
|
106
|
+
return report_record_rel_from_legacy_pointer(str(run.get("reportPath") or ""))
|
|
107
|
+
|
|
108
|
+
|
|
33
109
|
def translation_source_path(data_path: Path) -> Path:
|
|
34
110
|
"""Return the translator's work list for a final-report data.json path."""
|
|
35
111
|
return data_path.with_name(_stem(data_path) + TRANSLATION_SOURCE_SUFFIX)
|
|
@@ -81,8 +81,7 @@ def latest_whole_task_fv_release_ready(project_root, project_id: str,
|
|
|
81
81
|
if ((data.get("header") or {}).get("taskType") == "final-verification"
|
|
82
82
|
and data.get("verificationScope") == "whole-task"
|
|
83
83
|
and release_handoff_allowed(data)):
|
|
84
|
-
|
|
85
|
-
return str(md if md.is_file() else dj)
|
|
84
|
+
return str(dj)
|
|
86
85
|
return ""
|
|
87
86
|
|
|
88
87
|
|
|
@@ -212,7 +212,7 @@ def _update_task_catalog(project_root: Path, manifest: dict[str, Any]) -> bool:
|
|
|
212
212
|
entry["nextRecommendedPhase"] = next_phase.promote(
|
|
213
213
|
workflow.get("nextRecommendedPhase")
|
|
214
214
|
)
|
|
215
|
-
entry["
|
|
215
|
+
entry["latestReportRecordPath"] = manifest.get("latestReportRecordPath", "")
|
|
216
216
|
entry["latestRunStatus"] = manifest.get("latestRunStatus", entry.get("latestRunStatus", ""))
|
|
217
217
|
entry["currentStatus"] = manifest.get("currentStatus", entry.get("currentStatus", ""))
|
|
218
218
|
entry["phaseOutcome"] = outcome
|
|
@@ -33,7 +33,7 @@ def _replace_or_append_row(path: Path, run_id: str, row: dict) -> None:
|
|
|
33
33
|
def record_start(home: Path, *, project_id: str, project_root: str,
|
|
34
34
|
task_group: str, task_id: str, task_type: str,
|
|
35
35
|
run_seq: int, when: str, workers: list, lead_model: str,
|
|
36
|
-
run_dir_rel: str,
|
|
36
|
+
run_dir_rel: str, final_report_record_rel: str,
|
|
37
37
|
argv: list, cwd: str,
|
|
38
38
|
env_overrides: dict,
|
|
39
39
|
okstra_version: str = "",
|
|
@@ -55,7 +55,7 @@ def record_start(home: Path, *, project_id: str, project_root: str,
|
|
|
55
55
|
run_seq=run_seq, status=initial_status, started_at=when,
|
|
56
56
|
finished_at=when if is_terminal else None,
|
|
57
57
|
workers=workers, lead_model=lead_model, validation="not-run",
|
|
58
|
-
run_dir_rel=run_dir_rel,
|
|
58
|
+
run_dir_rel=run_dir_rel, final_report_record_rel=final_report_record_rel,
|
|
59
59
|
final_status_rel=final_status_rel,
|
|
60
60
|
execution_manifest_path=execution_manifest_path,
|
|
61
61
|
role_execution_refs=role_execution_refs)
|
|
@@ -115,7 +115,7 @@ def reserve_run_in_active(home: Path, *,
|
|
|
115
115
|
project_id: str, project_root: str,
|
|
116
116
|
task_group: str, task_id: str, task_type: str,
|
|
117
117
|
run_seq: int, when: str,
|
|
118
|
-
run_dir_rel: str,
|
|
118
|
+
run_dir_rel: str, final_report_record_rel: str,
|
|
119
119
|
final_status_rel: str = "") -> None:
|
|
120
120
|
"""예약 row 를 active.jsonl 에 즉시 기록한다. 중앙 락 보호 필수.
|
|
121
121
|
record_start 가 나중에 같은 (project, group, task, task_type, seq) 를 만나면
|
|
@@ -126,7 +126,7 @@ def reserve_run_in_active(home: Path, *,
|
|
|
126
126
|
task_group=task_group, task_id=task_id, task_type=task_type,
|
|
127
127
|
run_seq=run_seq, status="reserving", started_at=when,
|
|
128
128
|
finished_at=None, workers=[], lead_model="", validation="not-run",
|
|
129
|
-
run_dir_rel=run_dir_rel,
|
|
129
|
+
run_dir_rel=run_dir_rel, final_report_record_rel=final_report_record_rel,
|
|
130
130
|
final_status_rel=final_status_rel)
|
|
131
131
|
append_jsonl(home / "active.jsonl", row)
|
|
132
132
|
# project index 도 미리 한 줄 — record_start 가 나중에 update 하므로 동일 row 형태로.
|