okstra 0.172.0 → 0.174.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 +8 -6
- package/docs/architecture/storage-model.md +24 -3
- package/docs/architecture.md +21 -35
- package/docs/cli.md +39 -7
- package/docs/container.md +1 -1
- package/docs/contributor-change-matrix.md +1 -1
- package/docs/performance-improvement-plan-v2.md +6 -5
- package/docs/project-structure-overview.md +33 -25
- package/docs/task-process/README.md +6 -4
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +2 -2
- package/docs/task-process/implementation-option-selection.md +70 -0
- package/docs/task-process/implementation-planning.md +24 -16
- package/docs/task-process/requirements-discovery.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/agents/workers/report-writer-worker.md +30 -6
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +2 -1
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra-provider-exec.py +29 -12
- package/runtime/bin/okstra-trace-cleanup.sh +58 -129
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/direction-selection-worker.md +44 -0
- package/runtime/prompts/duties/planning-worker.md +12 -4
- package/runtime/prompts/lead/adapters/cmux.md +2 -0
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +5 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
- package/runtime/prompts/lead/plan-body-verification.md +23 -6
- package/runtime/prompts/lead/report-writer.md +33 -11
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
- package/runtime/prompts/profiles/_implementation-executor.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +4 -4
- package/runtime/prompts/profiles/final-verification.md +3 -3
- package/runtime/prompts/profiles/forbidden-actions.json +7 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
- package/runtime/prompts/profiles/implementation-planning.md +61 -46
- package/runtime/prompts/profiles/implementation.md +4 -2
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +3 -3
- package/runtime/prompts/wizard/prompts.ko.json +9 -1
- package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
- package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
- package/runtime/python/okstra_ctl/conformance.py +68 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
- package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
- package/runtime/python/okstra_ctl/doctor.py +2 -2
- package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
- package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
- package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
- package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
- package/runtime/python/okstra_ctl/implementation_options.py +479 -0
- package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
- package/runtime/python/okstra_ctl/plan_items.py +51 -3
- package/runtime/python/okstra_ctl/render.py +1 -0
- package/runtime/python/okstra_ctl/render_final_report.py +16 -19
- package/runtime/python/okstra_ctl/report_contract.py +45 -14
- package/runtime/python/okstra_ctl/report_finalize.py +68 -9
- package/runtime/python/okstra_ctl/report_html/render.py +4 -2
- package/runtime/python/okstra_ctl/report_html/router.py +4 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
- package/runtime/python/okstra_ctl/report_views.py +148 -12
- package/runtime/python/okstra_ctl/run.py +393 -4
- package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
- package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
- package/runtime/python/okstra_ctl/session.py +69 -12
- package/runtime/python/okstra_ctl/team.py +51 -25
- package/runtime/python/okstra_ctl/tmux.py +19 -149
- package/runtime/python/okstra_ctl/user_response.py +75 -0
- package/runtime/python/okstra_ctl/wizard.py +144 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
- package/runtime/python/okstra_ctl/worker_request.py +2 -0
- package/runtime/python/okstra_ctl/workflow.py +29 -7
- package/runtime/python/okstra_ctl/worktree.py +69 -3
- package/runtime/python/okstra_token_usage/cli.py +1 -1
- package/runtime/python/okstra_token_usage/collect.py +66 -6
- package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
- package/runtime/skills/okstra-setup/references/project-config.md +11 -0
- package/runtime/templates/reports/final-report-v2.template.md +4 -0
- package/runtime/templates/reports/final-verification-input.template.md +1 -1
- package/runtime/templates/reports/html/base.template.html +3 -2
- package/runtime/templates/reports/html/i18n/en.json +21 -1
- package/runtime/templates/reports/html/i18n/ko.json +21 -1
- package/runtime/templates/reports/html/macros/forms.html +21 -2
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
- package/runtime/templates/reports/i18n/en.json +13 -0
- package/runtime/templates/reports/implementation-input.template.md +4 -2
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
- package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
- package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
- package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
- package/runtime/templates/reports/report.js +111 -4
- package/runtime/templates/reports/settings.template.json +0 -24
- package/runtime/templates/reports/task-brief.template.md +9 -3
- package/runtime/templates/reports/user-response.template.md +25 -4
- package/runtime/templates/worker-prompt-preamble.md +8 -0
- package/runtime/validators/lib/fixtures.sh +49 -17
- package/runtime/validators/validate-implementation-plan-stages.py +169 -4
- package/runtime/validators/validate-report-views.py +2 -2
- package/runtime/validators/validate-run.py +149 -498
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/runtime/validators/validate_session_conformance.py +1 -1
- package/src/cli-registry.mjs +8 -1
- package/src/commands/execute/codex-run.mjs +1 -0
- package/src/commands/execute/render-bundle.mjs +1 -0
- package/src/commands/execute/team.mjs +3 -3
- package/src/commands/execute/worktree-status.mjs +109 -0
- package/src/commands/lifecycle/install.mjs +0 -2
- package/src/commands/report/finalize.mjs +13 -6
- package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
- package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
- package/runtime/templates/reports/final-report.template.md +0 -1258
|
@@ -82,6 +82,11 @@ from okstra_ctl.design_prep import (
|
|
|
82
82
|
resolve_design_prep,
|
|
83
83
|
write_design_prep_input,
|
|
84
84
|
)
|
|
85
|
+
from okstra_ctl.implementation_direction import (
|
|
86
|
+
DirectionSelectionError,
|
|
87
|
+
lexical_absolute_path,
|
|
88
|
+
validate_task_artifact_path,
|
|
89
|
+
)
|
|
85
90
|
from okstra_ctl.final_report_paths import final_report_data_path
|
|
86
91
|
from okstra_ctl.plan_run_root import list_implementation_planning_reports
|
|
87
92
|
from okstra_ctl.pr_template import PrTemplateError, resolve_pr_template_path
|
|
@@ -154,6 +159,7 @@ TASK_TYPES: list[tuple[str, str]] = [
|
|
|
154
159
|
("improvement-discovery", "Find improvement candidates within a codebase scope and lens whitelist"),
|
|
155
160
|
*zip(ANALYSIS_TASK_TYPES, _ANALYSIS_TASK_TYPE_DESCRIPTIONS),
|
|
156
161
|
("error-analysis", "Evidence-based root-cause analysis (no code changes)"),
|
|
162
|
+
("implementation-option-selection", "Compare implementation options (read-only)"),
|
|
157
163
|
("implementation-planning", "Plan options + request user approval"),
|
|
158
164
|
("implementation", "Execute approved plan (requires approved final-report)"),
|
|
159
165
|
("final-verification", "Acceptance + residual-risk review"),
|
|
@@ -331,6 +337,7 @@ S_ANALYSIS_TARGET_PICK = "analysis_target_pick"
|
|
|
331
337
|
S_ANALYSIS_TARGET = "analysis_target"
|
|
332
338
|
S_BASE_REF_PICK = "base_ref_pick"
|
|
333
339
|
S_BASE_REF_TEXT = "base_ref_text"
|
|
340
|
+
S_SELECTED_DIRECTION_PICK = "selected_direction_pick"
|
|
334
341
|
S_APPROVED_PLAN_PICK = "approved_plan_pick"
|
|
335
342
|
S_APPROVED_PLAN = "approved_plan"
|
|
336
343
|
S_APPROVE_PLAN_CONFIRM = "approve_plan_confirm"
|
|
@@ -495,6 +502,7 @@ class WizardState:
|
|
|
495
502
|
clarification_response_path: str = ""
|
|
496
503
|
clarification_pending_text: bool = False
|
|
497
504
|
last_final_report_cached: str = ""
|
|
505
|
+
selected_direction_path: str = ""
|
|
498
506
|
# "" | "auto" | "full" | "<stage csv>" — 사용자가 고른 이번 재실행의 재검증
|
|
499
507
|
# 범위. implementation-planning 재실행에서 좁힐 여지가 있을 때만 채워진다.
|
|
500
508
|
reverify_scope: str = ""
|
|
@@ -595,6 +603,105 @@ def _require_file(path_str: str, project_root: Path, label: str) -> Path:
|
|
|
595
603
|
return p
|
|
596
604
|
|
|
597
605
|
|
|
606
|
+
_SELECTION_REPORT_RE = re.compile(
|
|
607
|
+
r"^final-report-implementation-option-selection-(?P<seq>\d{3,})\.md$"
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _selected_direction_candidates(state: WizardState) -> list[str]:
|
|
612
|
+
if not state.project_root or not state.task_group or not state.task_id:
|
|
613
|
+
return []
|
|
614
|
+
project_root = Path(state.project_root).resolve()
|
|
615
|
+
task_root = lexical_absolute_path(
|
|
616
|
+
task_dir(project_root, state.task_group, state.task_id)
|
|
617
|
+
)
|
|
618
|
+
reports = (
|
|
619
|
+
task_runs_dir(project_root, state.task_group, state.task_id)
|
|
620
|
+
/ "implementation-option-selection"
|
|
621
|
+
/ "reports"
|
|
622
|
+
)
|
|
623
|
+
candidates: list[tuple[int, Path]] = []
|
|
624
|
+
for report in reports.glob("final-report-implementation-option-selection-*.md"):
|
|
625
|
+
match = _SELECTION_REPORT_RE.fullmatch(report.name)
|
|
626
|
+
if match is None:
|
|
627
|
+
continue
|
|
628
|
+
try:
|
|
629
|
+
validated = validate_task_artifact_path(
|
|
630
|
+
report, task_root, "selection report"
|
|
631
|
+
)
|
|
632
|
+
except DirectionSelectionError:
|
|
633
|
+
continue
|
|
634
|
+
candidates.append((int(match.group("seq")), validated))
|
|
635
|
+
return [
|
|
636
|
+
_project_relative_path(path, project_root)
|
|
637
|
+
for _, path in sorted(candidates, reverse=True)[:3]
|
|
638
|
+
]
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _planning_rerun_selected(state: WizardState) -> bool:
|
|
642
|
+
if (
|
|
643
|
+
not state.clarification_response_path
|
|
644
|
+
or not state.project_root
|
|
645
|
+
or not state.task_group
|
|
646
|
+
or not state.task_id
|
|
647
|
+
):
|
|
648
|
+
return False
|
|
649
|
+
project_root = Path(state.project_root).resolve()
|
|
650
|
+
raw_path = Path(state.clarification_response_path).expanduser()
|
|
651
|
+
path = lexical_absolute_path(
|
|
652
|
+
raw_path if raw_path.is_absolute() else project_root / raw_path
|
|
653
|
+
)
|
|
654
|
+
task_root = lexical_absolute_path(
|
|
655
|
+
task_dir(project_root, state.task_group, state.task_id)
|
|
656
|
+
)
|
|
657
|
+
reports = lexical_absolute_path(
|
|
658
|
+
task_runs_dir(project_root, state.task_group, state.task_id)
|
|
659
|
+
/ "implementation-planning"
|
|
660
|
+
/ "reports"
|
|
661
|
+
)
|
|
662
|
+
try:
|
|
663
|
+
validate_task_artifact_path(path, task_root, "planning report")
|
|
664
|
+
except DirectionSelectionError:
|
|
665
|
+
return False
|
|
666
|
+
return (
|
|
667
|
+
path.is_file()
|
|
668
|
+
and not path.is_symlink()
|
|
669
|
+
and re.fullmatch(
|
|
670
|
+
r"final-report-implementation-planning-\d{3,}\.md", path.name
|
|
671
|
+
)
|
|
672
|
+
is not None
|
|
673
|
+
and path.parent == reports
|
|
674
|
+
)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _build_selected_direction_pick(state: WizardState) -> Prompt:
|
|
678
|
+
candidates = _selected_direction_candidates(state)
|
|
679
|
+
t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
|
|
680
|
+
if not candidates:
|
|
681
|
+
raise WizardError(t["errors"]["none"])
|
|
682
|
+
return Prompt(
|
|
683
|
+
step=S_SELECTED_DIRECTION_PICK,
|
|
684
|
+
kind="pick",
|
|
685
|
+
label=t["label"],
|
|
686
|
+
options=[_opt(path, path) for path in candidates],
|
|
687
|
+
echo_template=t["echo_template"],
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _submit_selected_direction_pick(
|
|
692
|
+
state: WizardState, value: str
|
|
693
|
+
) -> Optional[str]:
|
|
694
|
+
candidates = _selected_direction_candidates(state)
|
|
695
|
+
if value not in candidates:
|
|
696
|
+
raise WizardError(
|
|
697
|
+
_p(state.workspace_root, S_SELECTED_DIRECTION_PICK)["errors"][
|
|
698
|
+
"unknown"
|
|
699
|
+
].format(value=value)
|
|
700
|
+
)
|
|
701
|
+
state.selected_direction_path = value
|
|
702
|
+
return f"selected-direction: {value}"
|
|
703
|
+
|
|
704
|
+
|
|
598
705
|
def _data_json_approved_state(plan_path: Path) -> Optional[bool]:
|
|
599
706
|
"""`approved` flag of the sibling final-report data.json (the SSOT).
|
|
600
707
|
|
|
@@ -621,6 +728,18 @@ def _classify_approved_plan(path_str: str, project_root: Path) -> tuple[Path, bo
|
|
|
621
728
|
``already_fully_approved=False`` — the approve-confirm step offers to flip it.
|
|
622
729
|
"""
|
|
623
730
|
p = _require_file(path_str, project_root, "approved plan")
|
|
731
|
+
loaded = _load_final_report_data_if_present(p)
|
|
732
|
+
if loaded is not None:
|
|
733
|
+
planning = loaded[1].get("implementationPlanning")
|
|
734
|
+
if (
|
|
735
|
+
isinstance(planning, dict)
|
|
736
|
+
and planning.get("planningContract") == "selected-direction"
|
|
737
|
+
and planning.get("outcome") == "direction-invalidated"
|
|
738
|
+
):
|
|
739
|
+
raise WizardError(
|
|
740
|
+
"direction-invalidated planning reports are not approvable; "
|
|
741
|
+
"re-enter implementation-option-selection"
|
|
742
|
+
)
|
|
624
743
|
body = p.read_text(encoding="utf-8", errors="replace")
|
|
625
744
|
frontmatter = _extract_frontmatter_block(body)
|
|
626
745
|
if frontmatter is None:
|
|
@@ -690,6 +809,14 @@ def _find_html_approval_sidecar(
|
|
|
690
809
|
승인이 아닌 판정(반려·재작업 요청)은 여기서 걸러진다 — 이 단계가 묻는 것은
|
|
691
810
|
"사용자가 이 plan 을 승인해 두었는가" 뿐이고, 반려 사유는 다음 planning
|
|
692
811
|
run 이 sidecar 를 통째로 읽어 처리한다."""
|
|
812
|
+
loaded = _load_final_report_data_if_present(plan_path)
|
|
813
|
+
if loaded is not None:
|
|
814
|
+
planning = loaded[1].get("implementationPlanning")
|
|
815
|
+
if (
|
|
816
|
+
isinstance(planning, dict)
|
|
817
|
+
and planning.get("planningContract") == "selected-direction"
|
|
818
|
+
):
|
|
819
|
+
return None
|
|
693
820
|
responses_dir = plan_path.parent.parent / "user-responses"
|
|
694
821
|
if not responses_dir.is_dir():
|
|
695
822
|
return None
|
|
@@ -4175,6 +4302,19 @@ STEPS: list[Step] = [
|
|
|
4175
4302
|
applies=lambda s: s.base_ref_pending_text,
|
|
4176
4303
|
build=_build_base_ref_text, submit=_submit_base_ref_text,
|
|
4177
4304
|
owns=("base_ref", "base_ref_pending_text")),
|
|
4305
|
+
Step(S_SELECTED_DIRECTION_PICK,
|
|
4306
|
+
applies=lambda s: (
|
|
4307
|
+
s.task_type == "implementation-planning"
|
|
4308
|
+
and not s.selected_direction_path
|
|
4309
|
+
and not _planning_rerun_selected(s)
|
|
4310
|
+
and _brief_resolved(s)
|
|
4311
|
+
and _base_ref_ready(s)
|
|
4312
|
+
and not s.base_ref_pending_text
|
|
4313
|
+
and S_SELECTED_DIRECTION_PICK not in s.answered
|
|
4314
|
+
),
|
|
4315
|
+
build=_build_selected_direction_pick,
|
|
4316
|
+
submit=_submit_selected_direction_pick,
|
|
4317
|
+
owns=("selected_direction_path",)),
|
|
4178
4318
|
Step(S_APPROVED_PLAN_PICK,
|
|
4179
4319
|
applies=lambda s: (s.task_type in _STAGE_SCOPED_TASK_TYPES
|
|
4180
4320
|
and not s.approved_plan_path
|
|
@@ -4556,6 +4696,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
4556
4696
|
"directive_pending_text": False,
|
|
4557
4697
|
"related_tasks_raw": "", "related_tasks_pending_text": False,
|
|
4558
4698
|
"clarification_response_path": "", "clarification_pending_text": False,
|
|
4699
|
+
"selected_direction_path": "",
|
|
4559
4700
|
"reverify_scope": "", "reverify_scope_pending_text": False,
|
|
4560
4701
|
"pr_template_path": "", "pr_template_pending_text": False,
|
|
4561
4702
|
"pr_template_scope": "",
|
|
@@ -4924,6 +5065,7 @@ def render_args(state: WizardState) -> dict[str, str]:
|
|
|
4924
5065
|
"report-writer-model": state.report_writer_model,
|
|
4925
5066
|
"related-tasks": state.related_tasks_raw,
|
|
4926
5067
|
"clarification-response": state.clarification_response_path,
|
|
5068
|
+
"selected-direction": state.selected_direction_path,
|
|
4927
5069
|
"reverify-scope": (
|
|
4928
5070
|
state.reverify_scope
|
|
4929
5071
|
if state.task_type == "implementation-planning" else ""
|
|
@@ -5080,6 +5222,8 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
5080
5222
|
reverify_line = _reverify_scope_line(state)
|
|
5081
5223
|
if reverify_line is not None:
|
|
5082
5224
|
lines.append(reverify_line)
|
|
5225
|
+
if state.selected_direction_path:
|
|
5226
|
+
lines.append(f" selected-direction: {state.selected_direction_path}")
|
|
5083
5227
|
if state.task_type == "release-handoff" and state.handoff_mode:
|
|
5084
5228
|
scope = (
|
|
5085
5229
|
_msg(state.workspace_root, "confirmation",
|
|
@@ -51,6 +51,7 @@ FINAL_VERIFICATION_HEADERS = (
|
|
|
51
51
|
SUPPORTED_TASK_TYPES = frozenset({
|
|
52
52
|
"requirements-discovery",
|
|
53
53
|
"error-analysis",
|
|
54
|
+
"implementation-option-selection",
|
|
54
55
|
"implementation-planning",
|
|
55
56
|
"improvement-discovery",
|
|
56
57
|
"implementation",
|
|
@@ -67,6 +68,7 @@ ANALYSIS_DUTY_BY_TASK_TYPE: dict[str, AgentAudience] = {
|
|
|
67
68
|
"requirements-discovery": "discovery-worker",
|
|
68
69
|
"improvement-discovery": "discovery-worker",
|
|
69
70
|
"error-analysis": "diagnosis-worker",
|
|
71
|
+
"implementation-option-selection": "direction-selection-worker",
|
|
70
72
|
"implementation-planning": "planning-worker",
|
|
71
73
|
}
|
|
72
74
|
WORKER_PREAMBLE_FILENAME_BY_AUDIENCE = {
|
|
@@ -35,6 +35,7 @@ def build_request(
|
|
|
35
35
|
worktree_path: Path | None,
|
|
36
36
|
role: str,
|
|
37
37
|
idle_timeout_seconds: int,
|
|
38
|
+
session_id: str = "",
|
|
38
39
|
) -> WorkerExecRequest:
|
|
39
40
|
"""One dispatch, with every value the strategies will not re-derive.
|
|
40
41
|
|
|
@@ -55,6 +56,7 @@ def build_request(
|
|
|
55
56
|
auto_approve=True, write_scope=write_scope(root, worktree, role)
|
|
56
57
|
),
|
|
57
58
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
59
|
+
session_id=session_id,
|
|
58
60
|
)
|
|
59
61
|
|
|
60
62
|
|
|
@@ -17,22 +17,34 @@ from okstra_ctl.analysis_inputs import ANALYSIS_TASK_TYPES
|
|
|
17
17
|
PHASE_SEQUENCE = [
|
|
18
18
|
"requirements-discovery",
|
|
19
19
|
"error-analysis",
|
|
20
|
+
"implementation-option-selection",
|
|
20
21
|
"implementation-planning",
|
|
21
22
|
"implementation",
|
|
22
23
|
"final-verification",
|
|
23
24
|
"release-handoff",
|
|
24
25
|
]
|
|
25
26
|
|
|
27
|
+
REQUIREMENTS_DISCOVERY_ROUTING_TARGETS = frozenset(
|
|
28
|
+
{"error-analysis", "implementation-option-selection"}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
ERROR_ANALYSIS_ROUTING_DIRECTIONS = {
|
|
32
|
+
"error-analysis": "continue-investigation",
|
|
33
|
+
"implementation-option-selection": "begin-option-selection",
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
DEFAULT_NEXT_PHASE = {
|
|
27
|
-
"requirements-discovery": "
|
|
37
|
+
"requirements-discovery": "implementation-option-selection",
|
|
28
38
|
"improvement-discovery": "pending-routing-decision",
|
|
29
39
|
**{task_type: "pending-routing-decision" for task_type in ANALYSIS_TASK_TYPES},
|
|
30
|
-
"error-analysis": "implementation-
|
|
40
|
+
"error-analysis": "implementation-option-selection",
|
|
41
|
+
"implementation-option-selection": "implementation-planning",
|
|
31
42
|
"implementation-planning": "implementation",
|
|
32
43
|
"implementation": "final-verification",
|
|
33
44
|
# final-verification 의 다음 단계는 verdict 에 따라 갈리므로 정적 매핑은
|
|
34
45
|
# `pending-release-handoff` 로 둔다 (accepted 일 때만 release-handoff 로
|
|
35
|
-
# 진입; 그 외에는 error-analysis / implementation-
|
|
46
|
+
# 진입; 그 외에는 error-analysis / implementation-option-selection /
|
|
47
|
+
# implementation-planning 으로 리라우팅).
|
|
36
48
|
"final-verification": "pending-release-handoff",
|
|
37
49
|
"release-handoff": "done-or-follow-up",
|
|
38
50
|
}
|
|
@@ -87,12 +99,22 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
87
99
|
" - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
|
|
88
100
|
),
|
|
89
101
|
},
|
|
102
|
+
"implementation-option-selection": {
|
|
103
|
+
"allowed": (
|
|
104
|
+
" - candidate-comparison evidence for up to three options per worker\n"
|
|
105
|
+
" - preselected-validation that re-evaluates the merged candidate set\n"
|
|
106
|
+
" - a ranked display of at most three options with requirement mappings\n"
|
|
107
|
+
" - an audit record for every rejected candidate\n"
|
|
108
|
+
" - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
|
|
109
|
+
),
|
|
110
|
+
},
|
|
90
111
|
"implementation-planning": {
|
|
91
112
|
"allowed": (
|
|
92
113
|
" - pre-planning context exploration notes (files/interfaces inspected, recent commits scanned, ambiguities flagged)\n"
|
|
93
|
-
" -
|
|
94
|
-
" -
|
|
95
|
-
" -
|
|
114
|
+
" - selected-direction planning: realize the one validated selected direction without reopening candidate comparison, with a File Structure list (Create/Modify/Delete with one-line responsibility per file), affected interfaces, and blast-radius estimate\n"
|
|
115
|
+
" - legacy candidate-comparison planning only: at least two implementation option candidates, each with a File Structure list (Create/Modify/Delete with one-line responsibility per file), affected interfaces, and blast-radius estimate\n"
|
|
116
|
+
" - legacy candidate-comparison planning only: a trade-off matrix across options (complexity, risk, reversibility, test cost, rollout cost) and recommended option with rationale tied to isolation / single-responsibility / YAGNI principles\n"
|
|
117
|
+
" - 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"
|
|
96
118
|
" - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
|
|
97
119
|
" - 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"
|
|
98
120
|
" - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
|
|
@@ -117,7 +139,7 @@ PHASE_RULES: dict[str, dict[str, str]] = {
|
|
|
117
139
|
"allowed": (
|
|
118
140
|
" - acceptance verdict with requirement coverage assessment\n"
|
|
119
141
|
" - residual risk and regression notes\n"
|
|
120
|
-
" - recommended follow-up routing (`error-analysis` / `implementation-planning` / `release-handoff`) for any defects detected"
|
|
142
|
+
" - recommended follow-up routing (`error-analysis` / `implementation-option-selection` / `implementation-planning` / `release-handoff`) for any defects detected"
|
|
121
143
|
),
|
|
122
144
|
},
|
|
123
145
|
"release-handoff": {
|
|
@@ -77,6 +77,27 @@ DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
|
|
|
77
77
|
)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
+
# Sync dirs materialised as a REAL directory whose children are symlinked one
|
|
81
|
+
# by one, instead of a single symlink standing in for the whole directory.
|
|
82
|
+
#
|
|
83
|
+
# Why the split exists: git does not follow a symlink, so a symlinked directory
|
|
84
|
+
# is one *file* to it. A project that ignores host config by its contents
|
|
85
|
+
# (`.claude/*`) matches every child but never the bare `.claude` path, so the
|
|
86
|
+
# symlink lands in `git status` as
|
|
87
|
+
# `?? .claude` while the same directory is invisible in the main checkout. Any
|
|
88
|
+
# plan step asserting a clean worktree then fails on okstra's own provisioning.
|
|
89
|
+
# Linking the children instead reproduces the main checkout's shape, so
|
|
90
|
+
# whatever the project's ignore rules do there, they do here too.
|
|
91
|
+
#
|
|
92
|
+
# Only `.claude` qualifies. The other sync dirs are shared okstra state that
|
|
93
|
+
# okstra WRITES into, and a directory symlink is what makes a newly created
|
|
94
|
+
# top-level entry land in the main checkout rather than diverging inside the
|
|
95
|
+
# worktree. `.claude` is host configuration okstra reads; its one okstra write
|
|
96
|
+
# is the fixed `settings.local.json` child seeded by
|
|
97
|
+
# `_seed_worktree_settings_symlink`.
|
|
98
|
+
CHILD_LINKED_SYNC_DIRS: tuple[str, ...] = (".claude",)
|
|
99
|
+
|
|
100
|
+
|
|
80
101
|
# Project-root-relative FILES (not dirs) symlinked from MAIN → task worktree
|
|
81
102
|
# at provision time. Same symlink semantics as `DEFAULT_WORKTREE_SYNC_DIRS`:
|
|
82
103
|
# every task sees the live shared file. The split exists because the original
|
|
@@ -522,16 +543,27 @@ def is_ancestor(cwd, commit: str, head: str) -> bool:
|
|
|
522
543
|
return _git(Path(cwd), "merge-base", "--is-ancestor", commit, head).returncode == 0
|
|
523
544
|
|
|
524
545
|
|
|
525
|
-
def
|
|
526
|
-
"""
|
|
546
|
+
def dirty_entries_excluding_okstra(cwd) -> list[str]:
|
|
547
|
+
"""`git status --short` rows for changes outside okstra-owned paths.
|
|
527
548
|
|
|
528
549
|
okstra-owned = `okstra_clean_gate_excludes` (e.g. `.okstra`, synced dirs)
|
|
529
550
|
plus `nested_worktree_excludes` (stage worktrees nested under `cwd`).
|
|
551
|
+
|
|
552
|
+
This is the clean-worktree question every okstra gate asks, and the one a
|
|
553
|
+
plan step must ask through `okstra worktree-status` rather than through a
|
|
554
|
+
bare `git status --porcelain`: okstra provisions `.okstra` plus the synced
|
|
555
|
+
entries into every task worktree, so a bare status is never empty there and
|
|
556
|
+
an assertion built on it fails on okstra's own scaffolding.
|
|
530
557
|
"""
|
|
531
558
|
owned = (*okstra_clean_gate_excludes(Path(cwd)), *nested_worktree_excludes(cwd))
|
|
532
559
|
excludes = [f":(exclude){p}" for p in owned]
|
|
533
560
|
out = _git(Path(cwd), "status", "--short", "--", ".", *excludes).stdout
|
|
534
|
-
return
|
|
561
|
+
return [line for line in out.splitlines() if line.strip()]
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def is_dirty_excluding_okstra(cwd) -> bool:
|
|
565
|
+
"""True iff the worktree has changes outside okstra-owned paths."""
|
|
566
|
+
return bool(dirty_entries_excluding_okstra(cwd))
|
|
535
567
|
|
|
536
568
|
|
|
537
569
|
class MergeError(RuntimeError):
|
|
@@ -580,6 +612,9 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
|
|
|
580
612
|
version-controlled files.
|
|
581
613
|
- Parent directories are created as needed for nested entries.
|
|
582
614
|
|
|
615
|
+
Entries listed in `CHILD_LINKED_SYNC_DIRS` are materialised as a real
|
|
616
|
+
directory of per-child symlinks instead (see that constant).
|
|
617
|
+
|
|
583
618
|
Returns a list of human-readable notes (one per linked entry) so the
|
|
584
619
|
caller can include them in the provisioning note.
|
|
585
620
|
"""
|
|
@@ -592,6 +627,10 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
|
|
|
592
627
|
if dst.exists() or dst.is_symlink():
|
|
593
628
|
continue
|
|
594
629
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
630
|
+
if rel in CHILD_LINKED_SYNC_DIRS and src.is_dir():
|
|
631
|
+
_link_dir_children(src, dst)
|
|
632
|
+
notes.append(rel)
|
|
633
|
+
continue
|
|
595
634
|
try:
|
|
596
635
|
os.symlink(src, dst)
|
|
597
636
|
except FileExistsError:
|
|
@@ -600,6 +639,33 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
|
|
|
600
639
|
return notes
|
|
601
640
|
|
|
602
641
|
|
|
642
|
+
def _link_dir_children(src: Path, dst: Path) -> None:
|
|
643
|
+
"""Create `dst` as a real directory whose entries symlink to `src`'s
|
|
644
|
+
children, so git sees the same directory shape it sees in the main
|
|
645
|
+
checkout (`CHILD_LINKED_SYNC_DIRS`).
|
|
646
|
+
|
|
647
|
+
Each link points at the MAIN checkout's child rather than its resolved
|
|
648
|
+
target, so a child that is itself a symlink (e.g. `.claude/settings.local.json`
|
|
649
|
+
→ `~/.okstra/templates/settings.local.json`) keeps following whatever the
|
|
650
|
+
main checkout currently points at. An unreadable source or a child that
|
|
651
|
+
cannot be linked degrades to "that child is absent in this worktree" —
|
|
652
|
+
provisioning is not worth failing over host config.
|
|
653
|
+
"""
|
|
654
|
+
dst.mkdir(parents=True, exist_ok=True)
|
|
655
|
+
try:
|
|
656
|
+
children = sorted(src.iterdir())
|
|
657
|
+
except OSError:
|
|
658
|
+
return
|
|
659
|
+
for child in children:
|
|
660
|
+
link = dst / child.name
|
|
661
|
+
if link.exists() or link.is_symlink():
|
|
662
|
+
continue
|
|
663
|
+
try:
|
|
664
|
+
os.symlink(child, link)
|
|
665
|
+
except OSError:
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
|
|
603
669
|
def _link_sync_files(source_root: Path, worktree_path: Path) -> list[str]:
|
|
604
670
|
"""File-level counterpart to `_link_sync_dirs` (FU-V2).
|
|
605
671
|
|
|
@@ -57,7 +57,7 @@ def main() -> int:
|
|
|
57
57
|
"from the freshly computed usageSummary, then re-render the "
|
|
58
58
|
"sibling final-report markdown via the renderer. The data.json "
|
|
59
59
|
"is the SSOT; the markdown is regenerated from it. "
|
|
60
|
-
"See schemas/final-report-
|
|
60
|
+
"See schemas/final-report-v2.0.schema.json for the data shape."
|
|
61
61
|
),
|
|
62
62
|
)
|
|
63
63
|
parser.add_argument(
|
|
@@ -21,6 +21,7 @@ from .antigravity import (
|
|
|
21
21
|
)
|
|
22
22
|
from .paths import claude_project_dir, utc_now
|
|
23
23
|
from .pricing import antigravity_cost_usd, provider_cost_usd
|
|
24
|
+
from okstra_ctl.dispatch_state import worker_session_ids
|
|
24
25
|
from okstra_ctl.models import provider_wrappers
|
|
25
26
|
from okstra_ctl.wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
26
27
|
|
|
@@ -864,22 +865,66 @@ def collect_claude_runtime_usage(
|
|
|
864
865
|
f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
|
|
865
866
|
)
|
|
866
867
|
|
|
867
|
-
# Workers —
|
|
868
|
-
#
|
|
869
|
-
# implementation `-executor`, report-writer `-impl` / `-2
|
|
868
|
+
# Workers — dispatch 가 기록한 세션 id 로 짚은 세션과 agentName prefix 로
|
|
869
|
+
# 찾은 세션의 합집합을 합산한다(재배치 `-002`, convergence `-reverify-r1`,
|
|
870
|
+
# implementation `-executor`, report-writer `-impl` / `-2` 등).
|
|
871
|
+
sessions_dir = claude_project_dir(cwd)
|
|
872
|
+
# sid 로 귀속한 세션 id 전체 — 아래 unattributed 폴드에서 빼는 데 쓴다.
|
|
873
|
+
attributed_sids: set[str] = set()
|
|
870
874
|
for worker in state.get("workers", []):
|
|
871
875
|
worker_id = worker.get("workerId")
|
|
872
876
|
agent = worker.get("agent")
|
|
873
877
|
prefixes = match_prefixes(worker_id) if worker_id else []
|
|
874
878
|
|
|
879
|
+
# pane 워커는 별도 `claude -p` 프로세스라 jsonl 에 agentName 도 teamName
|
|
880
|
+
# 도 안 남긴다 — 아래 prefix 경로로는 영원히 매칭되지 않는다. dispatch 가
|
|
881
|
+
# 발급해 적어 둔 이 id 가 그 세션을 짚는 유일한 결정적 단서다. 재시도는
|
|
882
|
+
# attempt 마다 새 세션이므로 기록된 id 를 전부 합산한다.
|
|
883
|
+
# 헬퍼에서 worker_id=None 은 "run 전체"라, 이름 없는 워커에 그대로 넘기면
|
|
884
|
+
# 그 워커가 run 의 모든 세션을 흡수한다.
|
|
885
|
+
dispatched_sids = worker_session_ids(state, worker_id) if worker_id else []
|
|
875
886
|
matched: list[tuple[str, Path, dict]] = []
|
|
887
|
+
matched_sids: set[str] = set()
|
|
888
|
+
for sid in dispatched_sids:
|
|
889
|
+
path = sessions_dir / f"{sid}.jsonl"
|
|
890
|
+
if not path.is_file():
|
|
891
|
+
continue
|
|
892
|
+
totals = claude_session_totals(path, since=run_since, until=run_until,
|
|
893
|
+
incremental=incremental)
|
|
894
|
+
# 창 밖 세션은 여기서 버린다 — agentName 발견 경로가 위에서 같은
|
|
895
|
+
# `startedAt` 검사로 거르는 것과 같은 이유다. 넣으면 0 토큰 totals 가
|
|
896
|
+
# usage_block 을 타고 "이 워커는 0 을 썼다"로 보고되어, unavailable 로
|
|
897
|
+
# 남아야 할 상태를 허위 0 이 덮는다.
|
|
898
|
+
if not totals.get("startedAt"):
|
|
899
|
+
continue
|
|
900
|
+
matched.append((sid, path, totals))
|
|
901
|
+
matched_sids.add(sid)
|
|
902
|
+
attributed_sids.add(sid)
|
|
903
|
+
|
|
904
|
+
# 조건부 폴백이 아니라 합집합이다. 한 워커의 attempt 1 이 pane(sid 로만
|
|
905
|
+
# 찾힌다), attempt 2 가 in-process 서브에이전트(agentName 으로만 찾힌다)일
|
|
906
|
+
# 수 있고, `if not matched:` 로 두면 sid 가 하나라도 맞는 순간 attempt 2 의
|
|
907
|
+
# 토큰이 통째로 누락된다. 양쪽에서 온 같은 세션은 sid 로 한 번만 센다.
|
|
876
908
|
for agent_name, entries in by_agent.items():
|
|
877
|
-
if agent_matches(agent_name, prefixes):
|
|
878
|
-
|
|
909
|
+
if not agent_matches(agent_name, prefixes):
|
|
910
|
+
continue
|
|
911
|
+
for entry in entries:
|
|
912
|
+
if entry[0] in matched_sids:
|
|
913
|
+
continue
|
|
914
|
+
# team-needle 경로는 창 검사 없이 by_agent 를 채운다. 같은 리드
|
|
915
|
+
# 세션의 다음 run 이 띄운 워커도 같은 needle 에 걸리는데, 창 밖이라
|
|
916
|
+
# startedAt 이 없어 아래 정렬에서 맨 앞에 서고 `sessionId` 를
|
|
917
|
+
# 차지한다 — 토큰은 0 이라 합계는 안 틀리고 리포트가 가리키는
|
|
918
|
+
# 세션만 남의 것이 된다.
|
|
919
|
+
if not entry[2].get("startedAt"):
|
|
920
|
+
continue
|
|
921
|
+
matched.append(entry)
|
|
922
|
+
matched_sids.add(entry[0])
|
|
879
923
|
|
|
880
924
|
if not matched:
|
|
881
925
|
worker["usage"] = na_block(
|
|
882
|
-
|
|
926
|
+
"no Claude session jsonl in the run window for dispatched "
|
|
927
|
+
f"sessionIds {dispatched_sids} or agentName prefixes {prefixes}"
|
|
883
928
|
)
|
|
884
929
|
continue
|
|
885
930
|
|
|
@@ -919,6 +964,21 @@ def collect_claude_runtime_usage(
|
|
|
919
964
|
# session the harness never tagged with `name`. Without this, the run-level
|
|
920
965
|
# Worker total reads 0 and the report validator hard-fails a legitimate run.
|
|
921
966
|
# Attribution is aggregate, not per-worker; usageSummary records it openly.
|
|
967
|
+
#
|
|
968
|
+
# 먼저 sid 로 귀속된 세션을 뺀다. 이 두 집합은 `agentName` 유무로 배타적이었지만
|
|
969
|
+
# (by_agent 는 있어야 들어가고 unattributed 는 없어야 들어간다) sid 경로는
|
|
970
|
+
# agentName 을 안 보므로 그 배타성이 더는 성립하지 않는다. team needle 에
|
|
971
|
+
# 걸리면서 agentName 이 없는 세션이 동시에 기록된 dispatch sid 이기도 하면,
|
|
972
|
+
# 빼지 않을 경우 그 토큰이 워커 usage 와 이 폴드 양쪽에 들어가 `_populate_usage_summary`
|
|
973
|
+
# 의 workerTotalTokens 를 부풀린다. 두 리스트는 인덱스가 대응하므로 함께 거른다.
|
|
974
|
+
kept = [
|
|
975
|
+
(sid, totals)
|
|
976
|
+
for sid, totals in zip(unattributed_sessions, unattributed_totals)
|
|
977
|
+
if sid not in attributed_sids
|
|
978
|
+
]
|
|
979
|
+
unattributed_sessions = [sid for sid, _ in kept]
|
|
980
|
+
unattributed_totals = [totals for _, totals in kept]
|
|
981
|
+
|
|
922
982
|
unattributed_usage = None
|
|
923
983
|
if unattributed_totals:
|
|
924
984
|
unattributed_usage = usage_block(
|