okstra 0.209.0 → 0.209.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture.md +1 -1
- package/docs/cli.md +9 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/duties/business-flow-investigator.json +1 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +4 -4
- package/runtime/prompts/wizard/prompts.ko.json +15 -14
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +15 -4
- package/runtime/python/okstra_ctl/adapters/runtime/assembly.py +7 -3
- package/runtime/python/okstra_ctl/adapters/runtime/cmux.py +8 -4
- package/runtime/python/okstra_ctl/agent/activity.py +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +7 -0
- package/runtime/python/okstra_ctl/business_flow/engine.py +2 -2
- package/runtime/python/okstra_ctl/business_flow/source.py +35 -2
- package/runtime/python/okstra_ctl/cmux.py +93 -25
- package/runtime/python/okstra_ctl/convergence.py +29 -10
- package/runtime/python/okstra_ctl/dispatch_checkpoints.py +93 -3
- package/runtime/python/okstra_ctl/dispatch_core.py +18 -7
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +16 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +70 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +21 -16
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +11 -9
- package/runtime/python/okstra_ctl/phases/implementation_planning/wizard.py +79 -17
- package/runtime/python/okstra_ctl/plan_items.py +4 -2
- package/runtime/python/okstra_ctl/render.py +4 -0
- package/runtime/python/okstra_ctl/report_finalize.py +19 -7
- package/runtime/python/okstra_ctl/run.py +15 -1
- package/runtime/python/okstra_ctl/team.py +6 -4
- package/runtime/python/okstra_ctl/user_response.py +22 -0
- package/runtime/python/okstra_ctl/wizard/cli.py +0 -2
- package/runtime/python/okstra_ctl/wizard/engine.py +33 -46
- package/runtime/python/okstra_ctl/wizard/registry.py +2 -2
- package/runtime/python/okstra_ctl/wizard/roles.py +2 -8
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +7 -5
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +1 -1
- package/runtime/python/okstra_ctl/worker_audit_ledger.py +35 -24
- package/runtime/python/okstra_ctl/worker_prompt_body.py +6 -1
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +13 -0
- package/runtime/python/okstra_ctl/write_policy.py +16 -0
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/validators/validate-run.py +9 -2
|
@@ -718,6 +718,21 @@ _PREP_OWNERSHIP_NOTE = (
|
|
|
718
718
|
"writer did not produce those fields.\n"
|
|
719
719
|
)
|
|
720
720
|
|
|
721
|
+
# 이 네 필드는 report assembly 가 선택 방향 스냅샷으로 덮어쓰고 검증기가 다른 값을
|
|
722
|
+
# 거절한다(validation._direction_realization_errors). 계획자가 바꿀 수 없는 값에
|
|
723
|
+
# planner-fixable DISAGREE 를 내면 self-fix 로 풀리지 않는 차단이 된다.
|
|
724
|
+
_DIR_SNAPSHOT_OWNED_NOTE = (
|
|
725
|
+
"\nSnapshot-owned fields: `coreMechanism`, `architectureBoundaries`, "
|
|
726
|
+
"`planningInvariants`, and `userConstraints` above are verbatim copies of "
|
|
727
|
+
"the selected-direction snapshot. Report assembly overwrites them from "
|
|
728
|
+
"that snapshot and validation rejects any other value, so the planner "
|
|
729
|
+
"cannot change them. Judge whether the writer-owned realization fields "
|
|
730
|
+
"honour them. When a later user decision (an answered clarification) "
|
|
731
|
+
"departs from them, judge whether the writer-owned fields and the plan "
|
|
732
|
+
"narrative's `supersessionLedger` carry that decision; do not return a "
|
|
733
|
+
"planner-fixable DISAGREE that asks to edit these four fields.\n"
|
|
734
|
+
)
|
|
735
|
+
|
|
721
736
|
|
|
722
737
|
def _step_coordinate(payload: object, field: str) -> int | None:
|
|
723
738
|
if not isinstance(payload, Mapping):
|
|
@@ -784,7 +799,9 @@ def _render_analyser_split(verdicts: object) -> str:
|
|
|
784
799
|
continue
|
|
785
800
|
kind = str(verdict.get("breakageKind") or "").strip()
|
|
786
801
|
label = f"{token}({kind})" if kind else token
|
|
787
|
-
|
|
802
|
+
basis = _prior_vote(verdict).get("basis")
|
|
803
|
+
suffix = f" — `{scalar(basis)}`" if basis else ""
|
|
804
|
+
rows.append(f"- `{worker}`: `{label}`{suffix}\n")
|
|
788
805
|
if not rows:
|
|
789
806
|
return ""
|
|
790
807
|
return "Analyser split:\n" + "".join(rows)
|
|
@@ -944,6 +961,25 @@ def _render_prior_round(entry: object) -> str:
|
|
|
944
961
|
return "".join(rows)
|
|
945
962
|
|
|
946
963
|
|
|
964
|
+
# 검증자의 읽기 범위(`**Read scope:**`)는 `## Inputs` 에 열거된 경로만 허용한다.
|
|
965
|
+
# 이 절이 없으면 P-Req 의 originalRequirementId 를 패킷의 요구 문장으로 되짚을
|
|
966
|
+
# 수 없어 범위를 지킨 검증자는 verification-error 를 낸다.
|
|
967
|
+
_INPUT_FIELDS = (
|
|
968
|
+
("Primary analysis packet", "analysisPacketPath"),
|
|
969
|
+
("Plan narrative", "reportNarrativePath"),
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _render_inputs(run_manifest: Path) -> str:
|
|
974
|
+
payload = validated_run_authority(run_manifest).payload
|
|
975
|
+
rows = [
|
|
976
|
+
f"- {label}: `{payload[field].strip()}`\n"
|
|
977
|
+
for label, field in _INPUT_FIELDS
|
|
978
|
+
if isinstance(payload.get(field), str) and payload[field].strip()
|
|
979
|
+
]
|
|
980
|
+
return "\n## Inputs\n\n" + "".join(rows) if rows else ""
|
|
981
|
+
|
|
982
|
+
|
|
947
983
|
def _prompt(args: argparse.Namespace) -> str:
|
|
948
984
|
envelope = _load_json_object(
|
|
949
985
|
_prepared_items_path(args.run_manifest, require_regular=True)
|
|
@@ -963,7 +999,11 @@ def _prompt(args: argparse.Namespace) -> str:
|
|
|
963
999
|
if isinstance(envelope.get("priorRounds"), Mapping)
|
|
964
1000
|
else {}
|
|
965
1001
|
)
|
|
966
|
-
rows = [
|
|
1002
|
+
rows = [
|
|
1003
|
+
"# Plan verification queue\n",
|
|
1004
|
+
line("Item count", len(items)),
|
|
1005
|
+
_render_inputs(args.run_manifest),
|
|
1006
|
+
]
|
|
967
1007
|
for index, item in enumerate(items, 1):
|
|
968
1008
|
if not isinstance(item, Mapping):
|
|
969
1009
|
continue
|
|
@@ -982,6 +1022,8 @@ def _prompt(args: argparse.Namespace) -> str:
|
|
|
982
1022
|
rows.append(_render_prior_steps(item, all_items))
|
|
983
1023
|
if item_id.startswith("P-Prep-"):
|
|
984
1024
|
rows.append(_PREP_OWNERSHIP_NOTE)
|
|
1025
|
+
if item_id == "P-Dir-1":
|
|
1026
|
+
rows.append(_DIR_SNAPSHOT_OWNED_NOTE)
|
|
985
1027
|
split = _render_analyser_split(
|
|
986
1028
|
(envelope.get("tieSplits") or {}).get(item_id)
|
|
987
1029
|
if isinstance(envelope.get("tieSplits"), Mapping)
|
|
@@ -2270,6 +2312,10 @@ def _narrow_dispatch_queue(
|
|
|
2270
2312
|
f"queue does not contain — pass the artifact this round dispatched, "
|
|
2271
2313
|
f"not another round's"
|
|
2272
2314
|
)
|
|
2315
|
+
if getattr(args, "append", False) and _queue_holds_open_round_votes(
|
|
2316
|
+
verification, assigned - narrowed, args.round_number
|
|
2317
|
+
):
|
|
2318
|
+
return narrowed
|
|
2273
2319
|
verification["dispatchQueue"] = [
|
|
2274
2320
|
item_id
|
|
2275
2321
|
for item_id in (queue if isinstance(queue, list) else sorted(assigned))
|
|
@@ -2278,6 +2324,28 @@ def _narrow_dispatch_queue(
|
|
|
2278
2324
|
return narrowed
|
|
2279
2325
|
|
|
2280
2326
|
|
|
2327
|
+
def _queue_holds_open_round_votes(
|
|
2328
|
+
verification: Mapping[str, Any],
|
|
2329
|
+
outside: set[object],
|
|
2330
|
+
round_number: int,
|
|
2331
|
+
) -> bool:
|
|
2332
|
+
"""critic 의 `--append` 가 아직 열린 분석자 라운드에 얹히는지.
|
|
2333
|
+
|
|
2334
|
+
그 라운드의 큐를 tie 부분집합으로 덮으면 이어지는 `complete-round` 가
|
|
2335
|
+
분석자 표가 있는 나머지 항목을 배정 밖으로 보고 거절한다.
|
|
2336
|
+
"""
|
|
2337
|
+
items = verification.get("planItems")
|
|
2338
|
+
return any(
|
|
2339
|
+
isinstance(item, Mapping)
|
|
2340
|
+
and item.get("id") in outside
|
|
2341
|
+
and any(
|
|
2342
|
+
isinstance(vote, Mapping) and vote.get("round") == round_number
|
|
2343
|
+
for vote in item.get("verdicts") or []
|
|
2344
|
+
)
|
|
2345
|
+
for item in (items if isinstance(items, list) else [])
|
|
2346
|
+
)
|
|
2347
|
+
|
|
2348
|
+
|
|
2281
2349
|
def _incoming_verdict_rows(
|
|
2282
2350
|
args: argparse.Namespace,
|
|
2283
2351
|
known: set[object],
|
|
@@ -275,6 +275,8 @@ The hexagonal rule that an extracted point must declare `interfaceKind: "port"`
|
|
|
275
275
|
|
|
276
276
|
`P-Dir-1` carries the same YAGNI judgement as the legacy option item, but its comparison source is the selected-direction snapshot rather than a trade-off matrix. A new abstraction, configuration knob, widened interface, file, or stage with no original-requirement link is a hidden direction change and receives `DISAGREE(e)` on `P-Dir-1`.
|
|
277
277
|
|
|
278
|
+
Four `P-Dir-1` payload fields — `coreMechanism`, `architectureBoundaries`, `planningInvariants`, `userConstraints` — are verbatim copies of the selected-direction snapshot: report assembly overwrites them from the snapshot (`scripts/okstra_ctl/report_assembly.py`), and `scripts/okstra_ctl/phases/implementation_planning/validation.py` `_direction_realization_errors` rejects any other value. The planner cannot change them, so a mismatch between one of them and a later user decision (an answered clarification) is not a planner-fixable defect in those fields. Judge it against the writer-owned realization fields and the narrative's `supersessionLedger`: DISAGREE when they fail to carry the decision, and never ask for an edit to the four snapshot fields. The rendered `P-Dir-1` block restates this rule (`_DIR_SNAPSHOT_OWNED_NOTE` in `scripts/okstra_ctl/phases/implementation_planning/authoring.py`).
|
|
279
|
+
|
|
278
280
|
`P-Opt-<N>` carries the **YAGNI judgement** and is majority-gated for the same reason as `P-Var-*`: whether an abstraction serves the stated requirement or only a forecast is a judgement about the design, not a contradiction between two spelled-out references. Raise it as `DISAGREE(e)` — an option that carries an abstraction, parameter, or configuration knob no Requirement Coverage row demands contradicts the trade-off matrix that scored it, because the complexity the matrix priced is not the complexity the option actually buys. DISAGREE on a `P-Opt-*` item under this rule means one of:
|
|
279
281
|
|
|
280
282
|
- **an abstraction nobody asked for** — a helper module, strategy / factory, indirection layer, or interface whose only justification in the plan is a caller no requirement names. A second implementation already on the table is `P-Var-*` territory and is the opposite defect: do not raise both on the same behavior;
|
|
@@ -320,7 +322,7 @@ Plan-body verification only supports **lightweight mode** (defined in `prompts/l
|
|
|
320
322
|
|
|
321
323
|
**Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_parse_config` rejects a `verificationMode` outside `_VERIFICATION_MODES`, and the plan-body round is seeded with `lightweight` rather than the manifest value.
|
|
322
324
|
|
|
323
|
-
Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement
|
|
325
|
+
Exception for `P-Req-*`: verifiers still MUST NOT re-open the original task brief for this round, but they MUST compare the requirement with the cited Option / Stage / Step in the draft plan. A legacy `Requirement Coverage` row embeds that requirement in its own `requirement` field. A selected-direction row carries only `originalRequirementId` and its `*Refs`, so the requirement is the line for that id in the primary analysis packet the prompt lists under `## Inputs`. A row is not sound merely because it says `covered`; the cited plan item must actually satisfy the requirement. A `documented-deviation` never earns automatic `AGREE`: verify all four parts independently — the original requirement, the concrete alternative in `coveredBy`, every `decisionRefs` target, and the `approvalDisposition`. `accepted` requires a referenced user-confirmed clarification; `blocked C-NNN` requires that same-report clarification to be open and block approval.
|
|
324
326
|
|
|
325
327
|
For selected-direction `P-Req-*` rows, `status: externally-tracked` explicitly assigns external responsibility through `crossProjectDependencyRefs`. Verify that each reference resolves to a unique, complete `crossProjectDependencies` row and that its required work and verification signal satisfy the requirement. External-only rows need no local stage/step/file references; mixed rows retain their real local references. Do not classify absence of local references as kind `f` when the external mapping is valid. This is planned responsibility, not proof of implementation or deployment. Dangling or incomplete references are rejected by `implementation_direction._coverage_reference_errors`; semantic adequacy remains the verifier judgement.
|
|
326
328
|
|
|
@@ -670,25 +672,25 @@ Required prompt anchor headers are identical to finding convergence (see `prompt
|
|
|
670
672
|
The `prompts/lead/convergence.md` §"Required reverify output contract"
|
|
671
673
|
applies unchanged: append it verbatim after the response format below.
|
|
672
674
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
matches — a bare path or a reworded label counts as zero. They are in the
|
|
680
|
-
template below; keep them when you fill it in.
|
|
675
|
+
`okstra plan-items prompt` renders the `## Inputs` section itself: a
|
|
676
|
+
`- Primary analysis packet:` line from the run manifest's `analysisPacketPath`
|
|
677
|
+
and a `- Plan narrative:` line from its `reportNarrativePath`. The worker's
|
|
678
|
+
`**Read scope:**` header admits only paths the prompt enumerates, so these lines
|
|
679
|
+
are what let a verifier read the requirement a selected-direction `P-Req-*` row
|
|
680
|
+
names by `originalRequirementId`. The lead does not add them by hand.
|
|
681
681
|
|
|
682
682
|
Carrying the packet does not license re-analysis. It is there so the verifier
|
|
683
683
|
can resolve a plan item back to the requirement it claims to satisfy; the
|
|
684
684
|
posture in §"Adversarial plan-body posture" still applies, and this round does
|
|
685
685
|
not revisit the requirements themselves.
|
|
686
686
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
687
|
+
**Enforced:** only by the renderer — `_render_inputs` in
|
|
688
|
+
`scripts/okstra_ctl/phases/implementation_planning/authoring.py`, asserted by
|
|
689
|
+
`_assert_input_paths` in `tests/run/test_plan_items.py`. No dispatch check counts
|
|
690
|
+
the packet line for this round: the `Primary analysis packet` count in
|
|
691
|
+
`scripts/okstra_ctl/worker_prompt_contract.py` `_validate_prompt_for_plan` runs
|
|
692
|
+
only for the `analysis` audience, and a plan-verify dispatch is a
|
|
693
|
+
reverification.
|
|
692
694
|
|
|
693
695
|
````
|
|
694
696
|
Perform plan-body verification for <task-key> (round 1).
|
|
@@ -698,6 +700,7 @@ Perform plan-body verification for <task-key> (round 1).
|
|
|
698
700
|
## Inputs
|
|
699
701
|
|
|
700
702
|
- Primary analysis packet: `<path ending in analysis-packet.md>`
|
|
703
|
+
- Plan narrative: `<run manifest reportNarrativePath>`
|
|
701
704
|
|
|
702
705
|
## Instructions
|
|
703
706
|
|
|
@@ -757,8 +760,10 @@ Every verdict requires a non-empty explanation.
|
|
|
757
760
|
|
|
758
761
|
**Enforced:** `scripts/okstra_ctl/convergence_engine.py` `_parse_vote` rejects a vote whose `explanation` is empty and whose verdict is outside `_VERDICTS`; `classify_collaborative_round` drops `verification-error` votes from both numerator and denominator.
|
|
759
762
|
|
|
760
|
-
For `P-Req-*` items, compare
|
|
761
|
-
|
|
763
|
+
For `P-Req-*` items, compare the requirement against the cited plan item(s):
|
|
764
|
+
the row's own `requirement` text when it has one, otherwise the primary analysis
|
|
765
|
+
packet's line for the row's `originalRequirementId`. Do not open the original
|
|
766
|
+
brief, but do reject
|
|
762
767
|
coverage rows that cite no concrete option/stage/step or cite a plan item that
|
|
763
768
|
does not satisfy the row's own requirement. For `documented-deviation`, do not
|
|
764
769
|
auto-AGREE based on the status token: separately verify the requirement, the
|
|
@@ -67,6 +67,7 @@ from okstra_ctl.design_surfaces import DesignSurfaceError # noqa: E402
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
from okstra_ctl.plan_items import ( # noqa: E402
|
|
70
|
+
content_hash,
|
|
70
71
|
expected_plan_item_ids,
|
|
71
72
|
extract_plan_items,
|
|
72
73
|
)
|
|
@@ -501,14 +502,14 @@ def _plan_item_decision_authority(item: dict, pbv: dict) -> str | None:
|
|
|
501
502
|
non_result = any(
|
|
502
503
|
row.get("verdict") not in {"AGREE", "SUPPLEMENT", "DISAGREE"} for row in votes
|
|
503
504
|
)
|
|
505
|
+
disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
|
|
504
506
|
if (
|
|
505
507
|
classification not in {"majority-disagree", "needs-reverify", "all-non-result"}
|
|
506
|
-
and not non_result
|
|
508
|
+
and not (non_result and disagrees)
|
|
507
509
|
):
|
|
508
510
|
return None
|
|
509
511
|
if _stage_scope_bucket(item, pbv) != "in-scope" or item.get("block") == "record":
|
|
510
512
|
return None
|
|
511
|
-
disagrees = [row for row in votes if row.get("verdict") == "DISAGREE"]
|
|
512
513
|
verified = item.get("contentHash")
|
|
513
514
|
if (
|
|
514
515
|
not self_fix_rounds(pbv)
|
|
@@ -1572,10 +1573,7 @@ def _validate_verdicts_match_current_subjects(
|
|
|
1572
1573
|
if not isinstance(round_count, int) or round_count < 1:
|
|
1573
1574
|
return
|
|
1574
1575
|
try:
|
|
1575
|
-
current = {
|
|
1576
|
-
str(item["id"]): str(item.get("subject") or "")
|
|
1577
|
-
for item in extract_plan_items(ip)
|
|
1578
|
-
}
|
|
1576
|
+
current = {str(item["id"]): item for item in extract_plan_items(ip)}
|
|
1579
1577
|
except Exception: # noqa: BLE001
|
|
1580
1578
|
# Extraction failure is already reported by the completeness check;
|
|
1581
1579
|
# do not double-report it here as a spurious subject mismatch.
|
|
@@ -1587,10 +1585,14 @@ def _validate_verdicts_match_current_subjects(
|
|
|
1587
1585
|
continue
|
|
1588
1586
|
item_id = str(item.get("id") or "").strip()
|
|
1589
1587
|
recorded = str(item.get("subject") or "").strip()
|
|
1590
|
-
|
|
1591
|
-
if
|
|
1588
|
+
extracted = current.get(item_id)
|
|
1589
|
+
if extracted is None or not recorded:
|
|
1590
|
+
continue
|
|
1591
|
+
if item.get("verifiedContentHash") == content_hash(extracted):
|
|
1592
|
+
# seed 는 subject 를 갱신하지 않는다. 표가 지금 이 위치의 본문에
|
|
1593
|
+
# 찍혔으면 subject 문구 차이는 위치 이동이 아니다.
|
|
1592
1594
|
continue
|
|
1593
|
-
if recorded !=
|
|
1595
|
+
if recorded != str(extracted.get("subject") or "").strip():
|
|
1594
1596
|
drifted.append(item_id)
|
|
1595
1597
|
if drifted:
|
|
1596
1598
|
failures.append(
|
|
@@ -15,8 +15,13 @@ from okstra_ctl.implementation_direction import (
|
|
|
15
15
|
)
|
|
16
16
|
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
|
|
17
17
|
from okstra_ctl.paths import task_dir, task_runs_dir
|
|
18
|
-
from okstra_ctl.
|
|
19
|
-
|
|
18
|
+
from okstra_ctl.user_response import (
|
|
19
|
+
UserResponseError,
|
|
20
|
+
pending_direction_candidates,
|
|
21
|
+
record_direction_selection,
|
|
22
|
+
)
|
|
23
|
+
from okstra_ctl.wizard.ids import _ABORT_OPTION, S_SELECTED_DIRECTION_PICK
|
|
24
|
+
from okstra_ctl.wizard.state import Option, Prompt, WizardError, WizardState
|
|
20
25
|
from okstra_ctl.wizard.prompts import _opt, _p
|
|
21
26
|
from okstra_ctl.wizard.sources import _project_relative_path
|
|
22
27
|
|
|
@@ -27,6 +32,7 @@ from okstra_ctl.wizard.sources import _project_relative_path
|
|
|
27
32
|
_SELECTION_REPORT_RE = re.compile(
|
|
28
33
|
r"^final-report-implementation-option-selection-(?P<seq>\d{3,})\.data\.json$"
|
|
29
34
|
)
|
|
35
|
+
_DIRECTION_SEPARATOR = "#"
|
|
30
36
|
|
|
31
37
|
|
|
32
38
|
def _selected_direction_candidates(state: WizardState) -> list[str]:
|
|
@@ -97,18 +103,67 @@ def _planning_rerun_selected(state: WizardState) -> bool:
|
|
|
97
103
|
)
|
|
98
104
|
|
|
99
105
|
|
|
106
|
+
def _task_key(state: WizardState) -> str:
|
|
107
|
+
return f"{state.project_id}:{state.task_group}:{state.task_id}"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _pending_directions(
|
|
111
|
+
state: WizardState, rel_path: str
|
|
112
|
+
) -> tuple[list[dict[str, str]], str]:
|
|
113
|
+
try:
|
|
114
|
+
return pending_direction_candidates(
|
|
115
|
+
Path(state.project_root) / rel_path, _task_key(state)
|
|
116
|
+
)
|
|
117
|
+
except UserResponseError:
|
|
118
|
+
return [], ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _selected_direction_options(state: WizardState) -> list[Option]:
|
|
122
|
+
"""방향을 아직 고르지 않은 리포트는 후보마다 한 줄로 펼친다.
|
|
123
|
+
|
|
124
|
+
리포트 경로만 고르게 하면 실제 결정(어느 방향인가)은 답변 사이드카에 있어야
|
|
125
|
+
해서, 사이드카가 없는 리포트는 고르는 순간 거절됐다(실측 2026-10-04,
|
|
126
|
+
dev-11118: 후보 IO-001 하나를 고르자 `sidecar file not found`).
|
|
127
|
+
"""
|
|
128
|
+
t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
|
|
129
|
+
options: list[Option] = []
|
|
130
|
+
recommended_taken = False
|
|
131
|
+
for path in _selected_direction_candidates(state):
|
|
132
|
+
candidates, recommended_id = _pending_directions(state, path)
|
|
133
|
+
if not candidates:
|
|
134
|
+
options.append(_opt(path, _selection_option_label(state, path, t)))
|
|
135
|
+
continue
|
|
136
|
+
match = _SELECTION_REPORT_RE.fullmatch(Path(path).name)
|
|
137
|
+
seq = match.group("seq") if match else ""
|
|
138
|
+
for row in candidates:
|
|
139
|
+
if row["ineligibility"]:
|
|
140
|
+
continue
|
|
141
|
+
recommended = not recommended_taken and row["id"] == recommended_id
|
|
142
|
+
recommended_taken = recommended_taken or recommended
|
|
143
|
+
options.append(_opt(
|
|
144
|
+
f"{path}{_DIRECTION_SEPARATOR}{row['id']}",
|
|
145
|
+
t["labels"]["pending_option"].format(
|
|
146
|
+
id=row["id"], name=row["name"], seq=seq
|
|
147
|
+
),
|
|
148
|
+
row["goal"],
|
|
149
|
+
recommended=recommended,
|
|
150
|
+
))
|
|
151
|
+
return sorted(options, key=lambda option: not option.recommended)
|
|
152
|
+
|
|
153
|
+
|
|
100
154
|
def _build_selected_direction_pick(state: WizardState) -> Prompt:
|
|
101
|
-
|
|
155
|
+
options = _selected_direction_options(state)
|
|
102
156
|
t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
|
|
103
|
-
if not
|
|
157
|
+
if not options:
|
|
104
158
|
raise WizardError(t["errors"]["none"])
|
|
159
|
+
# 중단 줄이 있어야 후보가 하나뿐일 때도 사용자의 결정으로 남는다 — 옵션
|
|
160
|
+
# 1개짜리 화면은 자동 제출되고, 이 단계의 제출은 사용자 답변 파일을 쓴다.
|
|
161
|
+
options.append(_opt(_ABORT_OPTION, t["labels"]["abort"]))
|
|
105
162
|
return Prompt(
|
|
106
163
|
step=S_SELECTED_DIRECTION_PICK,
|
|
107
164
|
kind="pick",
|
|
108
165
|
label=t["label"],
|
|
109
|
-
options=
|
|
110
|
-
_opt(path, _selection_option_label(state, path, t)) for path in candidates
|
|
111
|
-
],
|
|
166
|
+
options=options,
|
|
112
167
|
echo_template=t["echo_template"],
|
|
113
168
|
)
|
|
114
169
|
|
|
@@ -144,17 +199,24 @@ def _selection_option_label(state: WizardState, rel_path: str, t: dict) -> str:
|
|
|
144
199
|
|
|
145
200
|
def _submit_selected_direction_pick(state: WizardState, value: str) -> Optional[str]:
|
|
146
201
|
t = _p(state.workspace_root, S_SELECTED_DIRECTION_PICK)
|
|
147
|
-
|
|
148
|
-
|
|
202
|
+
if value == _ABORT_OPTION:
|
|
203
|
+
state.aborted = True
|
|
204
|
+
return t["echo_variants"]["abort"]
|
|
205
|
+
if value not in {option.value for option in _selected_direction_options(state)}:
|
|
149
206
|
raise WizardError(t["errors"]["unknown"].format(value=value))
|
|
207
|
+
path, _, option_id = value.partition(_DIRECTION_SEPARATOR)
|
|
208
|
+
report = Path(state.project_root) / path
|
|
209
|
+
recorded = ""
|
|
150
210
|
try:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
211
|
+
if option_id:
|
|
212
|
+
recorded = t["echo_variants"]["recorded_direction"].format(
|
|
213
|
+
id=option_id,
|
|
214
|
+
sidecar=record_direction_selection(report, _task_key(state), option_id),
|
|
215
|
+
)
|
|
216
|
+
resolve_selected_direction(report, expected_task_key=_task_key(state))
|
|
217
|
+
except (DirectionSelectionError, UserResponseError) as exc:
|
|
156
218
|
raise WizardError(str(exc)) from exc
|
|
157
|
-
state.selected_direction_path =
|
|
219
|
+
state.selected_direction_path = path
|
|
158
220
|
# 두 입력은 상호 배타다 — 방향이 정해진 순간 이 런은 새 계획이고,
|
|
159
221
|
# clarification 자리에 남은 값은 render-bundle 이 거절할 이유일 뿐이다.
|
|
160
222
|
# 거절을 여기서 앞당기는 대신 값을 비우고, 무엇이 비워졌는지 echo 로
|
|
@@ -162,5 +224,5 @@ def _submit_selected_direction_pick(state: WizardState, value: str) -> Optional[
|
|
|
162
224
|
# `--selected-direction` 경로로 계획 런에 첨부된다(`run.py` 참조).
|
|
163
225
|
if state.clarification_response_path:
|
|
164
226
|
state.clarification_response_path = ""
|
|
165
|
-
return t["echo_variants"]["cleared_clarification"].format(value=
|
|
166
|
-
return f"selected-direction: {
|
|
227
|
+
return t["echo_variants"]["cleared_clarification"].format(value=path) + recorded
|
|
228
|
+
return f"selected-direction: {path}{recorded}"
|
|
@@ -751,8 +751,10 @@ ENVIRONMENT_CORRECTION_PREAMBLE = (
|
|
|
751
751
|
|
|
752
752
|
CRITIC_TIE_PREAMBLE = (
|
|
753
753
|
"You are the critic tie-break. Only the items below are in dispute. "
|
|
754
|
-
"Each item
|
|
755
|
-
"
|
|
754
|
+
"Each item's plan-body verifiers split on it; its `Analyser split` block lists "
|
|
755
|
+
"every analyser vote with the basis that analyser gave, and a "
|
|
756
|
+
"verification-error vote there counted for neither side. Decide the item: "
|
|
757
|
+
"AGREE or DISAGREE(<kind>). Your verdict "
|
|
756
758
|
"settles the split, including a prior DISAGREE(a) or DISAGREE(f). "
|
|
757
759
|
"Explain whether the earlier dissent is supported or mistaken; a corrected "
|
|
758
760
|
"dissent remains in the audit history without overriding your decision. "
|
|
@@ -32,6 +32,7 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
|
|
|
32
32
|
# render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
|
|
33
33
|
# 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
|
|
34
34
|
from . import fix_cycles
|
|
35
|
+
from .cmux import LEAD_LOCATION_KEY
|
|
35
36
|
from . import next_phase
|
|
36
37
|
from .analysis_inputs import ANALYSIS_TASK_TYPES
|
|
37
38
|
from .application.resolve_assignment import (
|
|
@@ -1930,6 +1931,9 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1930
1931
|
payload["analysisScopeConfirmation"] = scope_confirmation
|
|
1931
1932
|
if ctx.get("FIX_CYCLE_ID"):
|
|
1932
1933
|
payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
|
|
1934
|
+
cmux_lead = json.loads(ctx.get("CMUX_LEAD_JSON") or "{}")
|
|
1935
|
+
if cmux_lead:
|
|
1936
|
+
payload[LEAD_LOCATION_KEY] = cmux_lead
|
|
1933
1937
|
if (
|
|
1934
1938
|
CURRENT_REPORT_SCHEMA_VERSION == "3.0"
|
|
1935
1939
|
or ctx.get("TASK_TYPE") == "implementation-planning"
|
|
@@ -727,13 +727,7 @@ def run_finalize(
|
|
|
727
727
|
if step["name"] == "business-flow":
|
|
728
728
|
payload["businessProcess"] = step["result"]
|
|
729
729
|
# 선택 단계라 exitCode 는 0 이다. 실패를 중첩 JSON 에만 두면 리드가 못 본다.
|
|
730
|
-
failed = [
|
|
731
|
-
{"id": row.get("id", ""), "mode": row.get("mode", ""), "error": row.get("error", "")}
|
|
732
|
-
for row in step["result"].get("executions") or []
|
|
733
|
-
if isinstance(row, dict) and row.get("status") == "failed"
|
|
734
|
-
]
|
|
735
|
-
if step["result"].get("error"):
|
|
736
|
-
failed.append({"id": "", "mode": "", "error": str(step["result"]["error"])})
|
|
730
|
+
failed = business_flow_warnings(step["result"])
|
|
737
731
|
if failed:
|
|
738
732
|
payload["businessFlowWarnings"] = failed
|
|
739
733
|
census = _census_summary(ctx)
|
|
@@ -742,6 +736,24 @@ def run_finalize(
|
|
|
742
736
|
return payload
|
|
743
737
|
|
|
744
738
|
|
|
739
|
+
def business_flow_warnings(result: dict[str, Any]) -> list[dict[str, str]]:
|
|
740
|
+
# sidecar 는 task link 이력 전체를 담는다. 같은 mode 가 뒤에서 성공했으면 그 실패는 끝난 일이다.
|
|
741
|
+
rows = [row for row in result.get("executions") or [] if isinstance(row, dict)]
|
|
742
|
+
failed = [
|
|
743
|
+
{"id": row.get("id", ""), "mode": row.get("mode", ""), "error": row.get("error", "")}
|
|
744
|
+
for index, row in enumerate(rows)
|
|
745
|
+
if row.get("status") == "failed"
|
|
746
|
+
and not any(
|
|
747
|
+
later.get("mode") == row.get("mode")
|
|
748
|
+
and later.get("status") in {"complete", "partial"}
|
|
749
|
+
for later in rows[index + 1 :]
|
|
750
|
+
)
|
|
751
|
+
]
|
|
752
|
+
if result.get("error"):
|
|
753
|
+
failed.append({"id": "", "mode": "", "error": str(result["error"])})
|
|
754
|
+
return failed
|
|
755
|
+
|
|
756
|
+
|
|
745
757
|
def _census_summary(ctx: FinalizeContext) -> dict[str, Any] | None:
|
|
746
758
|
"""Unverdicted census cells per worker, for the lead's completion line."""
|
|
747
759
|
try:
|
|
@@ -3059,6 +3059,7 @@ def _write_instruction_set_sources(
|
|
|
3059
3059
|
json.loads(ctx.get("RELATED_TASKS_JSON", "[]")),
|
|
3060
3060
|
),
|
|
3061
3061
|
coverage_census_text=coverage_census_text,
|
|
3062
|
+
has_selected_direction=(instruction_set / "selected-direction.json").is_file(),
|
|
3062
3063
|
)
|
|
3063
3064
|
if inp.task_type == "technical-verification":
|
|
3064
3065
|
packet += materialize_technical_input_packet(
|
|
@@ -3967,7 +3968,7 @@ def _resolve_terminal_backend(project_root: Path, inp: PrepareInputs) -> str:
|
|
|
3967
3968
|
return detected
|
|
3968
3969
|
if _prior_run_backend(project_root, inp) != BACKEND_CMUX_PANE:
|
|
3969
3970
|
return detected
|
|
3970
|
-
observed, remedy = cmux.unreachable_advice(cmux.unreachable_reason())
|
|
3971
|
+
observed, remedy = cmux.unreachable_advice(cmux.unreachable_reason(None))
|
|
3971
3972
|
raise PrepareError(" ".join(part for part in (
|
|
3972
3973
|
f"this task's previous {inp.task_type} run put its workers on cmux "
|
|
3973
3974
|
f"panes, and now {observed}.",
|
|
@@ -3985,6 +3986,18 @@ def lead_pane_title(task_group: str, task_id: str) -> str:
|
|
|
3985
3986
|
return f"{task_group}/{task_id}"
|
|
3986
3987
|
|
|
3987
3988
|
|
|
3989
|
+
def _cmux_lead_json(terminal_backend: str) -> str:
|
|
3990
|
+
"""prepare 를 실행한 터미널의 cmux 위치. cmux 백엔드가 아니면 빈 객체.
|
|
3991
|
+
|
|
3992
|
+
리드 프로세스의 환경은 이 터미널의 것과 다를 수 있다(`cmux.LEAD_LOCATION_KEY`
|
|
3993
|
+
참조) — 그래서 리드가 뜨기 전인 지금 값을 남긴다.
|
|
3994
|
+
"""
|
|
3995
|
+
location = cmux.lead_location_from_env()
|
|
3996
|
+
if terminal_backend != BACKEND_CMUX_PANE or location is None:
|
|
3997
|
+
return "{}"
|
|
3998
|
+
return json.dumps(cmux.lead_location_payload(location))
|
|
3999
|
+
|
|
4000
|
+
|
|
3988
4001
|
def _title_lead_pane(inp: PrepareInputs) -> None:
|
|
3989
4002
|
"""리드 pane 제목을 `<task-group>/<task-id>` 로 바꾼다 (cmux 백엔드 전용).
|
|
3990
4003
|
|
|
@@ -4204,6 +4217,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4204
4217
|
|
|
4205
4218
|
ctx.update({
|
|
4206
4219
|
"TERMINAL_BACKEND": terminal_backend,
|
|
4220
|
+
"CMUX_LEAD_JSON": _cmux_lead_json(terminal_backend),
|
|
4207
4221
|
"EXECUTOR_WORKTREE_PATH": worktree.path,
|
|
4208
4222
|
"EXECUTOR_WORKTREE_BRANCH": worktree.branch,
|
|
4209
4223
|
"EXECUTOR_WORKTREE_BASE_REF": worktree.base_ref,
|
|
@@ -366,7 +366,7 @@ def _close_panes(manifest: Mapping[str, Any], panes: list[dict[str, str]]) -> No
|
|
|
366
366
|
from .adapters.runtime.assembly import port_for, runtime_chain
|
|
367
367
|
from .domain.worker_runtime import RuntimeHandle, SURFACE_CMUX_PANE
|
|
368
368
|
|
|
369
|
-
chain = runtime_chain(_manifest_backend(manifest))
|
|
369
|
+
chain = runtime_chain(_manifest_backend(manifest), manifest=manifest)
|
|
370
370
|
# cli-wrapper chains have no cmux port; leftover paneIds must not KeyError.
|
|
371
371
|
if panes and any(port.surface == SURFACE_CMUX_PANE for port in chain):
|
|
372
372
|
closer = port_for(chain, SURFACE_CMUX_PANE)
|
|
@@ -466,11 +466,13 @@ def _reclaimable_panes(
|
|
|
466
466
|
continue
|
|
467
467
|
_append_pane(panes, seen, str(record.get("paneId", "")), "worker")
|
|
468
468
|
if _is_cmux_run(manifest):
|
|
469
|
-
return _still_open_surfaces(panes)
|
|
469
|
+
return _still_open_surfaces(panes, cmux.recorded_lead(manifest))
|
|
470
470
|
return panes
|
|
471
471
|
|
|
472
472
|
|
|
473
|
-
def _still_open_surfaces(
|
|
473
|
+
def _still_open_surfaces(
|
|
474
|
+
panes: list[dict[str, str]], recorded: cmux.LeadLocation | None
|
|
475
|
+
) -> list[dict[str, str]]:
|
|
474
476
|
"""The recorded surfaces cmux still shows.
|
|
475
477
|
|
|
476
478
|
Nothing prunes `workerDispatches` — a repeat of one `dispatchId` replaces
|
|
@@ -484,7 +486,7 @@ def _still_open_surfaces(panes: list[dict[str, str]]) -> list[dict[str, str]]:
|
|
|
484
486
|
already went away is a silent no-op, and skipping a live one strands it on
|
|
485
487
|
the user's screen for the rest of the session.
|
|
486
488
|
"""
|
|
487
|
-
workspace = cmux.resolve_lead_workspace()
|
|
489
|
+
workspace = cmux.resolve_lead_workspace(recorded)
|
|
488
490
|
if not workspace:
|
|
489
491
|
return panes
|
|
490
492
|
try:
|
|
@@ -1602,6 +1602,28 @@ def set_direction_selection(
|
|
|
1602
1602
|
_write_transaction(path, payload)
|
|
1603
1603
|
|
|
1604
1604
|
|
|
1605
|
+
def pending_direction_candidates(
|
|
1606
|
+
report_path: Path, task_key: str
|
|
1607
|
+
) -> tuple[list[dict[str, str]], str]:
|
|
1608
|
+
"""아직 방향을 고르지 않은 후보비교 리포트의 (후보, 추천 id). 고른 뒤면 빈 목록."""
|
|
1609
|
+
context = _validate_owned_report_context(report_path, expected_task_key=task_key)
|
|
1610
|
+
if not _direction_selection_required(context):
|
|
1611
|
+
return [], ""
|
|
1612
|
+
return _direction_candidates(context)
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def record_direction_selection(report_path: Path, task_key: str, option_id: str) -> Path:
|
|
1616
|
+
"""사용자가 고른 방향을 `okstra user-response direction` 과 같은 트랜잭션으로 기록한다."""
|
|
1617
|
+
context = _validate_owned_report_context(report_path, expected_task_key=task_key)
|
|
1618
|
+
candidates, _ = _direction_candidates(context)
|
|
1619
|
+
ids = [row["id"] for row in candidates]
|
|
1620
|
+
if option_id not in ids:
|
|
1621
|
+
raise UserResponseError(f"direction option is not a ranked candidate: {option_id}")
|
|
1622
|
+
transaction_id = begin_response(report_path, task_key)
|
|
1623
|
+
set_direction_selection(transaction_id, ids.index(option_id) + 1, None, None)
|
|
1624
|
+
return finalize_response(transaction_id)
|
|
1625
|
+
|
|
1626
|
+
|
|
1605
1627
|
def set_legacy_report_authoring(
|
|
1606
1628
|
transaction_id: str, status: str, reason_file: Path
|
|
1607
1629
|
) -> None:
|
|
@@ -11,7 +11,6 @@ from .state import WizardError
|
|
|
11
11
|
from .statefile import load_state_file, save_state_file
|
|
12
12
|
from .confirmation import confirmation_block
|
|
13
13
|
from .engine import (
|
|
14
|
-
_auto_start_new_task_when_it_is_the_only_choice,
|
|
15
14
|
init_state,
|
|
16
15
|
next_prompt,
|
|
17
16
|
prompt_payload,
|
|
@@ -87,7 +86,6 @@ def main(argv: list[str]) -> int:
|
|
|
87
86
|
host_entry_mode=args.entry_mode,
|
|
88
87
|
available_functions=args.available_function,
|
|
89
88
|
)
|
|
90
|
-
_auto_start_new_task_when_it_is_the_only_choice(state)
|
|
91
89
|
save_state_file(state_path, state)
|
|
92
90
|
first = next_prompt(state)
|
|
93
91
|
print(json.dumps({"ok": True, "next": prompt_payload(state, first)},
|