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
|
@@ -119,6 +119,7 @@ from okstra_ctl.run import (
|
|
|
119
119
|
_extract_frontmatter_block,
|
|
120
120
|
_load_final_report_data_if_present,
|
|
121
121
|
_assignment_resolution_message,
|
|
122
|
+
_record_approved_flag,
|
|
122
123
|
_reject_blocking_plan_body_gate,
|
|
123
124
|
_set_data_json_approved_true_if_present,
|
|
124
125
|
_model_default_scopes,
|
|
@@ -711,7 +712,7 @@ def _planning_rerun_selected(state: WizardState) -> bool:
|
|
|
711
712
|
path.is_file()
|
|
712
713
|
and not path.is_symlink()
|
|
713
714
|
and re.fullmatch(
|
|
714
|
-
r"final-report-implementation-planning-\d{3,}\.md", path.name
|
|
715
|
+
r"final-report-implementation-planning-\d{3,}\.(?:md|data\.json)", path.name
|
|
715
716
|
)
|
|
716
717
|
is not None
|
|
717
718
|
and path.parent == reports
|
|
@@ -746,32 +747,23 @@ def _submit_selected_direction_pick(
|
|
|
746
747
|
return f"selected-direction: {value}"
|
|
747
748
|
|
|
748
749
|
|
|
749
|
-
def _data_json_approved_state(plan_path: Path) -> Optional[bool]:
|
|
750
|
-
"""`approved` flag of the sibling final-report data.json (the SSOT).
|
|
751
|
-
|
|
752
|
-
Returns the bool when present, or None when there is no data.json or the
|
|
753
|
-
flag is missing / non-bool (legacy report — markdown frontmatter governs)."""
|
|
754
|
-
loaded = _load_final_report_data_if_present(plan_path)
|
|
755
|
-
if loaded is None:
|
|
756
|
-
return None
|
|
757
|
-
frontmatter = loaded[1].get("frontmatter")
|
|
758
|
-
if not isinstance(frontmatter, dict) or "approved" not in frontmatter:
|
|
759
|
-
return None
|
|
760
|
-
value = frontmatter.get("approved")
|
|
761
|
-
return value if isinstance(value, bool) else None
|
|
762
|
-
|
|
763
|
-
|
|
764
750
|
def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bool]:
|
|
765
751
|
"""Resolve the plan and classify it as fully-approved vs approvable.
|
|
766
752
|
|
|
767
753
|
Returns ``(resolved_path, already_fully_approved)``. Raises WizardError ONLY
|
|
768
|
-
for failures that approval cannot fix: missing
|
|
769
|
-
|
|
770
|
-
`Blocks=approval` rows. A plan that is merely
|
|
771
|
-
|
|
754
|
+
for failures that approval cannot fix: missing `approved` on the report
|
|
755
|
+
record (or schema-v1 frontmatter), a blocking plan-body gate, an unparseable
|
|
756
|
+
§1, or unresolved `Blocks=approval` rows. A plan that is merely
|
|
757
|
+
not-yet-approved (record `approved: false`, gate ok, no blockers) returns
|
|
772
758
|
``already_fully_approved=False`` — the approve-confirm step offers to flip it.
|
|
773
759
|
"""
|
|
774
|
-
|
|
760
|
+
from okstra_ctl.final_report_paths import require_approved_plan_record
|
|
761
|
+
|
|
762
|
+
resolved = _require_file(path_str, project_root, "approved plan")
|
|
763
|
+
try:
|
|
764
|
+
p = require_approved_plan_record(resolved)
|
|
765
|
+
except ValueError as exc:
|
|
766
|
+
raise WizardError(str(exc)) from exc
|
|
775
767
|
loaded = _load_final_report_data_if_present(p)
|
|
776
768
|
if loaded is not None:
|
|
777
769
|
planning = loaded[1].get("implementationPlanning")
|
|
@@ -784,23 +776,9 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
|
|
|
784
776
|
"direction-invalidated planning reports are not approvable; "
|
|
785
777
|
"re-enter implementation-option-selection"
|
|
786
778
|
)
|
|
787
|
-
body = p.read_text(encoding="utf-8", errors="replace")
|
|
788
|
-
frontmatter = _extract_frontmatter_block(body)
|
|
789
|
-
if frontmatter is None:
|
|
790
|
-
raise WizardError(
|
|
791
|
-
f"approved plan has no YAML frontmatter block: {p}\n"
|
|
792
|
-
" expected the report to begin with `---\\n...\\n---\\n`."
|
|
793
|
-
)
|
|
794
|
-
m = APPROVED_FRONTMATTER_PATTERN.search(frontmatter)
|
|
795
|
-
if not m:
|
|
796
|
-
raise WizardError(
|
|
797
|
-
f"approved plan frontmatter has no `approved:` field: {p}\n"
|
|
798
|
-
" expected `approved: true` / `approved: false`. Re-render the "
|
|
799
|
-
"report if the field is missing."
|
|
800
|
-
)
|
|
801
779
|
# A blocking gate or an open Blocks=approval row makes the plan UN-approvable
|
|
802
780
|
# — these raise regardless of the current flag value.
|
|
803
|
-
_reject_blocking_plan_body_gate(p,
|
|
781
|
+
_reject_blocking_plan_body_gate(p, "", action="approved plan validation")
|
|
804
782
|
scan = scan_approval_gate(p)
|
|
805
783
|
if scan.unreadable_reason:
|
|
806
784
|
raise WizardError(
|
|
@@ -819,28 +797,19 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
|
|
|
819
797
|
lines.append(f" - {b.row_id} (Status={b.raw_status})")
|
|
820
798
|
lines.append(f" file: {p}")
|
|
821
799
|
raise WizardError("\n".join(lines))
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
800
|
+
try:
|
|
801
|
+
record_approved = _record_approved_flag(p)
|
|
802
|
+
except PrepareError as exc:
|
|
803
|
+
raise WizardError(str(exc)) from exc
|
|
804
|
+
return p, record_approved is True
|
|
826
805
|
|
|
827
806
|
|
|
828
807
|
def _approve_plan_in_place(plan_path: Path) -> None:
|
|
829
|
-
"""Flip the
|
|
830
|
-
|
|
831
|
-
data.json present → `_set_data_json_approved_true_if_present` sets
|
|
832
|
-
`frontmatter.approved=true` there and re-renders the markdown from it (so
|
|
833
|
-
both agree). data.json absent (legacy) → flip the markdown frontmatter line."""
|
|
834
|
-
rendered = _set_data_json_approved_true_if_present(plan_path)
|
|
835
|
-
if rendered:
|
|
836
|
-
return
|
|
837
|
-
body = plan_path.read_text(encoding="utf-8", errors="replace")
|
|
838
|
-
flipped = APPROVED_FRONTMATTER_PATTERN.sub("approved: true", body, count=1)
|
|
839
|
-
if flipped == body:
|
|
808
|
+
"""Flip the report record `frontmatter.approved` to true and re-render."""
|
|
809
|
+
if not _set_data_json_approved_true_if_present(plan_path):
|
|
840
810
|
raise WizardError(
|
|
841
|
-
f"approve-plan: could not
|
|
811
|
+
f"approve-plan: report record could not be updated: {plan_path}"
|
|
842
812
|
)
|
|
843
|
-
plan_path.write_text(flipped, encoding="utf-8")
|
|
844
813
|
|
|
845
814
|
|
|
846
815
|
def _find_html_approval_sidecar(
|
|
@@ -864,7 +833,7 @@ def _find_html_approval_sidecar(
|
|
|
864
833
|
responses_dir = plan_path.parent.parent / "user-responses"
|
|
865
834
|
if not responses_dir.is_dir():
|
|
866
835
|
return None
|
|
867
|
-
m = re.search(r"-(\d+)\.md$", plan_path.name)
|
|
836
|
+
m = re.search(r"-(\d+)\.(?:md|data\.json)$", plan_path.name)
|
|
868
837
|
plan_seq = m.group(1) if m else ""
|
|
869
838
|
best: Optional[tuple[float, Path, PlanDecisionRecord]] = None
|
|
870
839
|
for f in sorted(responses_dir.glob("user-response-*.md")):
|
|
@@ -875,7 +844,17 @@ def _find_html_approval_sidecar(
|
|
|
875
844
|
rec = parse_plan_decision(text)
|
|
876
845
|
if rec is None or not rec.approved or rec.seq != plan_seq:
|
|
877
846
|
continue
|
|
878
|
-
|
|
847
|
+
from okstra_ctl.final_report_paths import (
|
|
848
|
+
final_report_markdown_path,
|
|
849
|
+
is_report_record_path,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
expected_name = (
|
|
853
|
+
final_report_markdown_path(plan_path).name
|
|
854
|
+
if is_report_record_path(plan_path)
|
|
855
|
+
else plan_path.name
|
|
856
|
+
)
|
|
857
|
+
if Path(rec.source_report).name != expected_name:
|
|
879
858
|
continue
|
|
880
859
|
mtime = f.stat().st_mtime
|
|
881
860
|
if best is None or mtime > best[0]:
|
|
@@ -909,10 +888,12 @@ def _validate_sidecar_option(plan_path: Path, option_name: str, errors_t: dict)
|
|
|
909
888
|
|
|
910
889
|
def _plan_short_label(candidate: str) -> str:
|
|
911
890
|
"""plan 파일명에서 사용자용 짧은 식별자를 뽑는다.
|
|
912
|
-
final-report-implementation-planning-002.
|
|
891
|
+
final-report-implementation-planning-002.data.json → implementation-planning-002"""
|
|
913
892
|
if not candidate:
|
|
914
893
|
return ""
|
|
915
|
-
|
|
894
|
+
name = Path(candidate).name
|
|
895
|
+
stem = name[: -len(".data.json")] if name.endswith(".data.json") else Path(name).stem
|
|
896
|
+
return stem.removeprefix("final-report-")
|
|
916
897
|
|
|
917
898
|
|
|
918
899
|
def _stage_plan_for_confirmation(
|
|
@@ -2782,7 +2763,7 @@ def _latest_revision_requested_analysis_report(
|
|
|
2782
2763
|
return None
|
|
2783
2764
|
expected_task_key = f"{state.project_id}:{state.task_group}:{state.task_id}"
|
|
2784
2765
|
candidates: list[tuple[int, Path]] = []
|
|
2785
|
-
for report in reports.glob("final-report-*.
|
|
2766
|
+
for report in reports.glob("final-report-*.data.json"):
|
|
2786
2767
|
candidate = _analysis_revision_candidate(
|
|
2787
2768
|
state,
|
|
2788
2769
|
report,
|
|
@@ -3498,14 +3479,11 @@ def _ensure_design_prep_queue(state: WizardState) -> None:
|
|
|
3498
3479
|
return
|
|
3499
3480
|
report_path = Path(state.approved_plan_path)
|
|
3500
3481
|
if not re.fullmatch(
|
|
3501
|
-
r"final-report-implementation-planning-\d+\.
|
|
3482
|
+
r"final-report-implementation-planning-\d+\.data\.json",
|
|
3502
3483
|
report_path.name,
|
|
3503
3484
|
):
|
|
3504
3485
|
return
|
|
3505
|
-
|
|
3506
|
-
report_path.name.removesuffix(".md") + ".data.json"
|
|
3507
|
-
)
|
|
3508
|
-
if not data_path.is_file():
|
|
3486
|
+
if not report_path.is_file():
|
|
3509
3487
|
return
|
|
3510
3488
|
try:
|
|
3511
3489
|
items = load_design_prep_items(report_path)
|
|
@@ -3976,14 +3954,14 @@ def _suggest_latest_final_report(state: WizardState) -> str:
|
|
|
3976
3954
|
best = _newest_contained_final_report(
|
|
3977
3955
|
runs_base,
|
|
3978
3956
|
task_root,
|
|
3979
|
-
f"{seg}/reports/final-report-*.
|
|
3957
|
+
f"{seg}/reports/final-report-*.data.json",
|
|
3980
3958
|
Path(state.project_root),
|
|
3981
3959
|
)
|
|
3982
3960
|
if best is None:
|
|
3983
3961
|
best = _newest_contained_final_report(
|
|
3984
3962
|
runs_base,
|
|
3985
3963
|
task_root,
|
|
3986
|
-
"*/reports/final-report-*.
|
|
3964
|
+
"*/reports/final-report-*.data.json",
|
|
3987
3965
|
Path(state.project_root),
|
|
3988
3966
|
)
|
|
3989
3967
|
if best is None:
|
|
@@ -107,18 +107,17 @@ def report_writer_prompt_body(
|
|
|
107
107
|
mcp_pointer_line(),
|
|
108
108
|
"",
|
|
109
109
|
"## Output Contract",
|
|
110
|
-
"You are the author of
|
|
111
|
-
"- The
|
|
112
|
-
"- The rendered Markdown sibling produced through okstra render-final-report.",
|
|
110
|
+
"You are the author of TWO files:",
|
|
111
|
+
"- The report record (data.json) at Result Path.",
|
|
113
112
|
"- The worker-result pointer at Worker Result Path.",
|
|
114
113
|
(
|
|
115
|
-
"Keep the pointer to
|
|
116
|
-
"
|
|
114
|
+
"Keep the pointer to two entries: the data.json path and the "
|
|
115
|
+
"Convergence state input path."
|
|
117
116
|
),
|
|
118
117
|
"Maintain the separate audit sidecar at Audit sidecar path.",
|
|
119
118
|
(
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
"Do not invoke okstra render-final-report; the full reading copy "
|
|
120
|
+
"is on-demand."
|
|
122
121
|
),
|
|
123
122
|
"Do not return the report inline.",
|
|
124
123
|
"Copy Report Language verbatim into data.json.meta.reportLanguage.",
|
|
@@ -15,21 +15,19 @@ import os
|
|
|
15
15
|
import selectors
|
|
16
16
|
import signal
|
|
17
17
|
import subprocess
|
|
18
|
-
import sys
|
|
19
18
|
import time
|
|
20
|
-
from functools import partial
|
|
21
19
|
from pathlib import Path
|
|
22
20
|
from typing import Any, Callable, Mapping
|
|
23
21
|
|
|
24
22
|
from .domain.provider import ServedModelAttestation, ServedModelNormalizer
|
|
25
23
|
from .domain.worker_exec import (
|
|
26
24
|
SERVED_MODEL_MISMATCH_EXIT_CODE,
|
|
27
|
-
STREAM_JSON,
|
|
28
25
|
ExecCommand,
|
|
29
26
|
ExecutionStrategy,
|
|
30
27
|
WorkerExecRequest,
|
|
31
28
|
)
|
|
32
|
-
from .domain.
|
|
29
|
+
from .domain.worker_presentation import JsonEvents, Presentation
|
|
30
|
+
from .session_transcript import SessionTranscript
|
|
33
31
|
|
|
34
32
|
LIVE = "live"
|
|
35
33
|
QUIET = "quiet"
|
|
@@ -45,16 +43,6 @@ _READ_SIZE = 8192
|
|
|
45
43
|
_WRITE_SIZE = 8192
|
|
46
44
|
_NO_STATUS_EXTRA: Mapping[str, Any] = {}
|
|
47
45
|
|
|
48
|
-
# How many progress lines reach the log copy before it starts eliding. Progress
|
|
49
|
-
# is where a text CLI's bulk is — a dispatch that only reads one file already
|
|
50
|
-
# puts kilobytes of tool echo on stderr, and observed sidecars reach 8MB and
|
|
51
|
-
# dominate a project's `.okstra/` bytes. The cap is run-wide rather than
|
|
52
|
-
# per-block because block boundaries are a provider's own vocabulary and this
|
|
53
|
-
# runner has none; the cost is that a very long run keeps its opening rather
|
|
54
|
-
# than a sample throughout, which the elision notices make visible.
|
|
55
|
-
_LOG_PROGRESS_LINE_CAP = 5000
|
|
56
|
-
_ELISION_NOTICE_EVERY = 500
|
|
57
|
-
|
|
58
46
|
# The signals that end this process without raising anything Python can catch on
|
|
59
47
|
# the way out. SIGINT is absent on purpose: it arrives as KeyboardInterrupt and
|
|
60
48
|
# the exception path already closes the sidecar.
|
|
@@ -89,6 +77,9 @@ def run_worker(
|
|
|
89
77
|
idle_timeout_seconds=request.idle_timeout_seconds,
|
|
90
78
|
on_spawn=guard.watch,
|
|
91
79
|
)
|
|
80
|
+
at_exit = getattr(command.presentation, "served_model_at_exit", None)
|
|
81
|
+
if raw_model is None and at_exit is not None and request.session_id:
|
|
82
|
+
raw_model = at_exit(request.session_id, command.cwd)
|
|
92
83
|
except BaseException as exc:
|
|
93
84
|
# Whatever ended this run — an OS error, a Ctrl-C, a bug in this file —
|
|
94
85
|
# the sidecar has to stop saying `started`. Nothing downstream rewrites
|
|
@@ -179,33 +170,62 @@ def _launch(
|
|
|
179
170
|
idle_timeout_seconds: int,
|
|
180
171
|
on_spawn: Callable[[subprocess.Popen[bytes]], None],
|
|
181
172
|
) -> tuple[int, bool, int, str | None]:
|
|
182
|
-
|
|
183
|
-
|
|
173
|
+
live = presentation == LIVE
|
|
174
|
+
transcript = SessionTranscript(log_path, live=live)
|
|
175
|
+
observation = _ServedModelObservation()
|
|
176
|
+
strategy = _with_served_model_observation(command.presentation, observation)
|
|
177
|
+
try:
|
|
184
178
|
# The strategy decided where this provider runs — some CLIs work in the
|
|
185
179
|
# stage tree, others in the project root and reach the tree by flag.
|
|
186
180
|
process = subprocess.Popen(
|
|
187
181
|
list(command.argv),
|
|
188
182
|
cwd=str(command.cwd),
|
|
189
|
-
|
|
183
|
+
# 보낼 것이 없으면 DEVNULL 이다. `None` 은 부모의 stdin 을 물려주는
|
|
184
|
+
# 것이라, stdin 을 읽는 CLI 가 터미널에서 실행됐을 때 EOF 를 못 받고
|
|
185
|
+
# 영원히 기다린다 — 워커는 대화형이 아니므로 물려줄 이유가 없다.
|
|
186
|
+
stdin=(
|
|
187
|
+
subprocess.PIPE
|
|
188
|
+
if command.stdin_text is not None
|
|
189
|
+
else subprocess.DEVNULL
|
|
190
|
+
),
|
|
190
191
|
stdout=subprocess.PIPE,
|
|
191
|
-
stderr=_stderr_target(
|
|
192
|
+
stderr=_stderr_target(strategy),
|
|
192
193
|
start_new_session=True,
|
|
193
194
|
env=_child_env(),
|
|
194
195
|
)
|
|
195
196
|
on_spawn(process)
|
|
196
|
-
observation = _ServedModelObservation()
|
|
197
197
|
exit_code, timed_out, idle_seconds = _pump(
|
|
198
198
|
process,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
normalise=command.normalise,
|
|
199
|
+
transcript,
|
|
200
|
+
strategy=strategy,
|
|
202
201
|
presentation=presentation,
|
|
203
202
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
204
203
|
stdin_text=command.stdin_text,
|
|
205
|
-
observe_served_model=command.observe_served_model,
|
|
206
|
-
model_observation=observation,
|
|
207
204
|
)
|
|
208
205
|
return exit_code, timed_out, idle_seconds, observation.raw_model
|
|
206
|
+
finally:
|
|
207
|
+
transcript.close()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _with_served_model_observation(
|
|
211
|
+
presentation: Presentation,
|
|
212
|
+
observation: _ServedModelObservation,
|
|
213
|
+
) -> Presentation:
|
|
214
|
+
"""JSON 경로의 서빙 모델 관측을 러너가 모아 둔다.
|
|
215
|
+
|
|
216
|
+
해석 전략은 이벤트를 보고 모델 문자열만 돌려준다. 그 값을 사이드카에
|
|
217
|
+
적는 일은 러너의 것이라, 여기서 한 번 감싼다.
|
|
218
|
+
"""
|
|
219
|
+
if not isinstance(presentation, JsonEvents):
|
|
220
|
+
return presentation
|
|
221
|
+
original = presentation.observe
|
|
222
|
+
|
|
223
|
+
def observe(event: Mapping[str, Any]) -> str | None:
|
|
224
|
+
observed = original(event)
|
|
225
|
+
observation.record(observed)
|
|
226
|
+
return observed
|
|
227
|
+
|
|
228
|
+
return JsonEvents(normalise=presentation.normalise, observe=observe)
|
|
209
229
|
|
|
210
230
|
|
|
211
231
|
class _ServedModelObservation:
|
|
@@ -311,39 +331,27 @@ def _started_status(status_extra: Mapping[str, Any], log_path: Path) -> dict[str
|
|
|
311
331
|
}
|
|
312
332
|
|
|
313
333
|
|
|
314
|
-
def _stderr_target(
|
|
315
|
-
"""
|
|
334
|
+
def _stderr_target(presentation: Presentation) -> int:
|
|
335
|
+
"""stderr 를 stdout 에 합칠지는 전략이 정한다.
|
|
316
336
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
splits meaning across them — the result on stdout, progress on stderr — and
|
|
320
|
-
merging destroys the only way to tell the answer from the noise.
|
|
337
|
+
한 스트림에 진행과 결과가 함께 오는 CLI 는 합쳐 읽어야 순서가 보존되고,
|
|
338
|
+
둘을 갈라 내는 CLI 는 갈라 읽어야 답과 진행이 섞이지 않는다.
|
|
321
339
|
"""
|
|
322
|
-
return subprocess.STDOUT if
|
|
340
|
+
return subprocess.STDOUT if presentation.merges_stderr() else subprocess.PIPE
|
|
323
341
|
|
|
324
342
|
|
|
325
343
|
def _pump(
|
|
326
344
|
process: subprocess.Popen[bytes],
|
|
327
|
-
|
|
345
|
+
transcript: SessionTranscript,
|
|
328
346
|
*,
|
|
329
|
-
|
|
330
|
-
normalise: Normalise,
|
|
347
|
+
strategy: Presentation,
|
|
331
348
|
presentation: str,
|
|
332
349
|
idle_timeout_seconds: int,
|
|
333
350
|
stdin_text: str | None = None,
|
|
334
|
-
observe_served_model: Callable[[Mapping[str, Any]], str | None],
|
|
335
|
-
model_observation: _ServedModelObservation,
|
|
336
351
|
) -> tuple[int, bool, int]:
|
|
337
352
|
selector = selectors.DefaultSelector()
|
|
338
|
-
readers
|
|
339
|
-
selector,
|
|
340
|
-
process,
|
|
341
|
-
log_file,
|
|
342
|
-
stream_format=stream_format,
|
|
343
|
-
normalise=normalise,
|
|
344
|
-
observe_served_model=observe_served_model,
|
|
345
|
-
model_observation=model_observation,
|
|
346
|
-
presentation=presentation,
|
|
353
|
+
readers = _register_streams(
|
|
354
|
+
selector, process, strategy, transcript, presentation == LIVE
|
|
347
355
|
)
|
|
348
356
|
outgoing = _register_prompt(selector, process, stdin_text)
|
|
349
357
|
closing_text: str | None = None
|
|
@@ -379,23 +387,19 @@ def _pump(
|
|
|
379
387
|
|
|
380
388
|
for reader in readers.values():
|
|
381
389
|
closing_text = reader.flush() or closing_text
|
|
382
|
-
finalize_log()
|
|
383
390
|
|
|
384
391
|
exit_code = process.wait()
|
|
385
|
-
if closing_text is None:
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
)
|
|
397
|
-
elif presentation == QUIET:
|
|
398
|
-
print(closing_text, flush=True)
|
|
392
|
+
if closing_text is None and isinstance(strategy, JsonEvents):
|
|
393
|
+
# JSON 스트림은 결과 이벤트로 끝나야 한다. 텍스트를 흘리는 CLI 에는
|
|
394
|
+
# 놓칠 결과 이벤트가 없다.
|
|
395
|
+
transcript.note("no result event in the CLI's output")
|
|
396
|
+
elif closing_text is not None:
|
|
397
|
+
# Result 는 format_* 가 줄을 만들지 않는다. 아카이브 결론은 러너가 적는다.
|
|
398
|
+
# 상한 밖에 둔다 — 잘린 결론은 사후 분석 전체를 잃게 한다.
|
|
399
|
+
if isinstance(strategy, JsonEvents):
|
|
400
|
+
transcript.write("worker", closing_text, capped=False)
|
|
401
|
+
if presentation == QUIET:
|
|
402
|
+
print(closing_text, flush=True)
|
|
399
403
|
return (_TIMEOUT_EXIT_CODE if timed_out else exit_code), timed_out, idle_seconds
|
|
400
404
|
|
|
401
405
|
|
|
@@ -432,55 +436,23 @@ def _stop_reading(selector: selectors.BaseSelector) -> None:
|
|
|
432
436
|
selector.unregister(key.fileobj)
|
|
433
437
|
|
|
434
438
|
|
|
435
|
-
def
|
|
439
|
+
def _register_streams(
|
|
436
440
|
selector: selectors.BaseSelector,
|
|
437
441
|
process: subprocess.Popen[bytes],
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
model_observation: _ServedModelObservation,
|
|
444
|
-
presentation: str,
|
|
445
|
-
) -> tuple[dict[int, _LineReader], Callable[[], None]]:
|
|
446
|
-
"""Register every stream this child speaks, each with its own destination.
|
|
447
|
-
|
|
448
|
-
Returns the readers plus the finalizer a capped sink needs to report what it
|
|
449
|
-
dropped once the streams are done. A JSON stream caps nothing.
|
|
450
|
-
"""
|
|
442
|
+
presentation: Presentation,
|
|
443
|
+
transcript: SessionTranscript,
|
|
444
|
+
live: bool,
|
|
445
|
+
) -> dict[int, _LineReader]:
|
|
446
|
+
"""이 자식이 말하는 스트림마다 전략이 지정한 싱크를 붙인다."""
|
|
451
447
|
assert process.stdout is not None
|
|
452
|
-
|
|
453
|
-
sinks = [
|
|
454
|
-
(
|
|
455
|
-
process.stdout,
|
|
456
|
-
partial(
|
|
457
|
-
_emit_event,
|
|
458
|
-
log_file,
|
|
459
|
-
presentation,
|
|
460
|
-
normalise,
|
|
461
|
-
observe_served_model,
|
|
462
|
-
model_observation,
|
|
463
|
-
),
|
|
464
|
-
)
|
|
465
|
-
]
|
|
466
|
-
finalize_log: Callable[[], None] = _nothing_to_finalize
|
|
467
|
-
else:
|
|
468
|
-
assert process.stderr is not None
|
|
469
|
-
progress = _ProgressSink(log_file, presentation)
|
|
470
|
-
sinks = [
|
|
471
|
-
(process.stdout, partial(_emit_result, log_file)),
|
|
472
|
-
(process.stderr, progress),
|
|
473
|
-
]
|
|
474
|
-
finalize_log = progress.report_elided
|
|
448
|
+
streams = {"stdout": process.stdout, "stderr": process.stderr}
|
|
475
449
|
readers: dict[int, _LineReader] = {}
|
|
476
|
-
for
|
|
450
|
+
for channel, sink in presentation.sinks(transcript, live):
|
|
451
|
+
stream = streams[channel]
|
|
452
|
+
assert stream is not None, channel
|
|
477
453
|
selector.register(stream, selectors.EVENT_READ)
|
|
478
|
-
readers[stream.fileno()] = _LineReader(
|
|
479
|
-
return readers
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
def _nothing_to_finalize() -> None:
|
|
483
|
-
"""A JSON stream elides nothing, so it has nothing to report at the end."""
|
|
454
|
+
readers[stream.fileno()] = _LineReader(sink)
|
|
455
|
+
return readers
|
|
484
456
|
|
|
485
457
|
|
|
486
458
|
class _LineReader:
|
|
@@ -557,109 +529,6 @@ def _close_prompt(selector: selectors.BaseSelector, key: selectors.SelectorKey)
|
|
|
557
529
|
key.fileobj.close()
|
|
558
530
|
|
|
559
531
|
|
|
560
|
-
def _emit_result(log_file, line: str) -> None:
|
|
561
|
-
"""A text CLI's stdout is its answer, so the caller gets it in either mode.
|
|
562
|
-
|
|
563
|
-
``quiet`` withholds progress, not the result — and unlike a JSON stream
|
|
564
|
-
there is no result event to hold back and print at the end, so the answer
|
|
565
|
-
passes through as it arrives.
|
|
566
|
-
"""
|
|
567
|
-
_write_log(log_file, [line])
|
|
568
|
-
print(line, flush=True)
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
class _ProgressSink:
|
|
572
|
-
"""A text CLI's stderr: shown live, archived up to a cap.
|
|
573
|
-
|
|
574
|
-
Progress goes to this process's stderr rather than its stdout so the
|
|
575
|
-
caller's stdout stays the result alone. A pane shows both, and a pane is
|
|
576
|
-
exactly where ``live`` lands.
|
|
577
|
-
|
|
578
|
-
Only the log copy is capped, never the screen and never the result stream.
|
|
579
|
-
That asymmetry is the point: a truncated tool echo costs detail, while a
|
|
580
|
-
truncated answer costs the whole post-mortem — and the caller's streams are
|
|
581
|
-
read by a person or a subagent that was promised the CLI's output verbatim.
|
|
582
|
-
"""
|
|
583
|
-
|
|
584
|
-
def __init__(self, log_file, presentation: str) -> None:
|
|
585
|
-
self._log_file = log_file
|
|
586
|
-
self._presentation = presentation
|
|
587
|
-
self._archived = 0
|
|
588
|
-
self._elided = 0
|
|
589
|
-
|
|
590
|
-
def __call__(self, line: str) -> None:
|
|
591
|
-
if self._presentation == LIVE:
|
|
592
|
-
print(line, file=sys.stderr, flush=True)
|
|
593
|
-
if self._archived < _LOG_PROGRESS_LINE_CAP:
|
|
594
|
-
self._archived += 1
|
|
595
|
-
_write_log(self._log_file, [line])
|
|
596
|
-
return
|
|
597
|
-
self._elided += 1
|
|
598
|
-
# Periodic rather than only at the end: a reader tailing the log has to
|
|
599
|
-
# see that the run is still producing progress, not a file that stopped.
|
|
600
|
-
if self._elided % _ELISION_NOTICE_EVERY == 0:
|
|
601
|
-
self._note_elision()
|
|
602
|
-
|
|
603
|
-
def report_elided(self) -> None:
|
|
604
|
-
"""Close the archive with the exact total, if the last notice missed some."""
|
|
605
|
-
if self._elided % _ELISION_NOTICE_EVERY:
|
|
606
|
-
self._note_elision()
|
|
607
|
-
|
|
608
|
-
def _note_elision(self) -> None:
|
|
609
|
-
_write_log(
|
|
610
|
-
self._log_file,
|
|
611
|
-
[f" [okstra log-cap] {self._elided} progress line(s) elided"],
|
|
612
|
-
)
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
def _emit_event(
|
|
616
|
-
log_file,
|
|
617
|
-
presentation: str,
|
|
618
|
-
normalise: Normalise,
|
|
619
|
-
observe_served_model: Callable[[Mapping[str, Any]], str | None],
|
|
620
|
-
model_observation: _ServedModelObservation,
|
|
621
|
-
line: str,
|
|
622
|
-
) -> str | None:
|
|
623
|
-
"""Write one line of a JSON stream out; return closing text if this is it.
|
|
624
|
-
|
|
625
|
-
The provider's own wire shape gets no further than ``normalise`` — what
|
|
626
|
-
reaches the projections is the normalised vocabulary, which is the only
|
|
627
|
-
thing this runner and the formatter are allowed to know.
|
|
628
|
-
"""
|
|
629
|
-
stripped = line.strip()
|
|
630
|
-
if not stripped:
|
|
631
|
-
return None
|
|
632
|
-
try:
|
|
633
|
-
event = json.loads(stripped)
|
|
634
|
-
except ValueError:
|
|
635
|
-
# Not an event. The CLI's stderr is folded into this stream, so this is
|
|
636
|
-
# where its own error text arrives — the caller has to see it, and the
|
|
637
|
-
# archive alone does not show it to anyone. stderr rather than stdout so
|
|
638
|
-
# it reaches the caller without posing as progress, in either mode.
|
|
639
|
-
_write_log(log_file, [stripped])
|
|
640
|
-
print(stripped, file=sys.stderr, flush=True)
|
|
641
|
-
return None
|
|
642
|
-
if not isinstance(event, dict):
|
|
643
|
-
return None
|
|
644
|
-
model_observation.record(observe_served_model(event))
|
|
645
|
-
|
|
646
|
-
closing: str | None = None
|
|
647
|
-
for entry in normalise(event):
|
|
648
|
-
_write_log(log_file, format_log(entry))
|
|
649
|
-
if presentation == LIVE:
|
|
650
|
-
for row in format_live(entry):
|
|
651
|
-
print(row, flush=True)
|
|
652
|
-
text = final_text(entry)
|
|
653
|
-
if text is not None:
|
|
654
|
-
closing = text
|
|
655
|
-
if closing is not None:
|
|
656
|
-
# Whatever the presentation, the archive ends with the worker's own
|
|
657
|
-
# conclusion — otherwise it stops at the last tool call and the reader
|
|
658
|
-
# never learns what the run decided.
|
|
659
|
-
_write_log(log_file, closing.splitlines())
|
|
660
|
-
return closing
|
|
661
|
-
|
|
662
|
-
|
|
663
532
|
def _attestation_payload(attestation: ServedModelAttestation) -> dict[str, Any]:
|
|
664
533
|
return {
|
|
665
534
|
"observedModel": attestation.observed_model,
|
|
@@ -669,12 +538,6 @@ def _attestation_payload(attestation: ServedModelAttestation) -> dict[str, Any]:
|
|
|
669
538
|
}
|
|
670
539
|
|
|
671
540
|
|
|
672
|
-
def _write_log(log_file, lines) -> None:
|
|
673
|
-
for line in lines:
|
|
674
|
-
log_file.write(line + "\n")
|
|
675
|
-
log_file.flush()
|
|
676
|
-
|
|
677
|
-
|
|
678
541
|
def _terminate(process: subprocess.Popen[bytes]) -> None:
|
|
679
542
|
"""SIGTERM the worker's process group, then SIGKILL whatever survives.
|
|
680
543
|
|
|
@@ -102,7 +102,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
102
102
|
" - bite-sized stepwise execution order for the selected direction or legacy recommended option (each step ~2-5 min, exact file paths and commands, TDD ordering when applicable, no placeholders)\n"
|
|
103
103
|
" - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
|
|
104
104
|
" - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `5.5.x` — the unified table is the single home)\n"
|
|
105
|
-
" -
|
|
105
|
+
" - report record `frontmatter.approved: false` awaiting `--approve` or the in-session wizard\n"
|
|
106
106
|
" - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)\n"
|
|
107
107
|
" - one endStateCoverage row per brief end-state id, each mapped to the R-NNN row that carries it"
|
|
108
108
|
),
|
|
@@ -40,6 +40,29 @@ def log_path_for_prompt(prompt_path: Path) -> Path:
|
|
|
40
40
|
return Path(f"{prompt_path}.log")
|
|
41
41
|
|
|
42
42
|
|
|
43
|
+
def mutation_snapshot_path_for_prompt(prompt_path: Path) -> Path:
|
|
44
|
+
"""Where okstra writes this prompt's pre-dispatch mutation snapshot."""
|
|
45
|
+
return prompt_path.with_suffix(prompt_path.suffix + ".mutation-audit.json")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def prompt_derived_paths(prompt_path: Path) -> tuple[Path, ...]:
|
|
49
|
+
"""The three files okstra writes beside a prompt: status, log, snapshot.
|
|
50
|
+
|
|
51
|
+
They are written together and must be authorised together. Listing them by
|
|
52
|
+
hand let the two sides drift: dispatch added the snapshot to its write
|
|
53
|
+
policy while `agent-prompt materialize` kept its own five-path list, so the
|
|
54
|
+
two policies hashed differently and `record_invocation_attempt` refused
|
|
55
|
+
every dynamically materialized invocation as `invocationRef drift` — which
|
|
56
|
+
took Phase 5.5 reverify with it. One function, so a fourth derived file
|
|
57
|
+
cannot reach one caller and miss the other.
|
|
58
|
+
"""
|
|
59
|
+
return (
|
|
60
|
+
status_path_for_prompt(prompt_path),
|
|
61
|
+
log_path_for_prompt(prompt_path),
|
|
62
|
+
mutation_snapshot_path_for_prompt(prompt_path),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
43
66
|
def read_wrapper_status(path: Path) -> WrapperStatus | None:
|
|
44
67
|
try:
|
|
45
68
|
raw = json.loads(path.read_text(encoding="utf-8"))
|