okstra 0.223.0 → 0.224.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/dist/commands/pr/pr.d.mts +2 -0
- package/dist/commands/pr/pr.mjs +12 -3
- package/dist/commands/pr/pr.mjs.map +1 -1
- package/docs/cli.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/coding-preflight/clean-code.md +8 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
- package/runtime/python/okstra_ctl/phases/implementation/spec.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +98 -33
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +22 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
- package/runtime/python/okstra_ctl/phases/release_handoff/spec.md +1 -1
- package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
- package/runtime/python/okstra_ctl/plan_items.py +12 -9
- package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
- package/runtime/python/okstra_ctl/report_finalize.py +8 -3
- package/runtime/python/okstra_ctl/run.py +3 -0
- package/runtime/python/okstra_ctl/stage_fix_carry.py +118 -16
- package/runtime/python/okstra_ctl/stage_rebase.py +3 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
- package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
- package/runtime/python/okstra_ctl/wizard/registry.py +2 -0
- package/runtime/python/okstra_ctl/wizard/state.py +5 -0
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
- package/runtime/python/okstra_ctl/wizard/steps_options.py +11 -0
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
- package/runtime/python/okstra_ctl/worktree/rebase.py +11 -1
- package/runtime/schemas/final-report-v2.0.schema.json +3 -1
- package/runtime/schemas/final-report-v3.0.schema.json +3 -1
- package/runtime/skills/okstra-pr-gen/SKILL.md +2 -2
- package/runtime/templates/pr/drafting-rules.md +11 -4
|
@@ -410,11 +410,6 @@ def _is_plan_wide(item_id: str) -> bool:
|
|
|
410
410
|
|
|
411
411
|
|
|
412
412
|
def content_hash(item: Mapping[str, Any]) -> str:
|
|
413
|
-
"""이 항목 본문의 지문. 문구가 같으면 라운드가 달라도 같은 판정이다.
|
|
414
|
-
|
|
415
|
-
라운드 번호만 보면 고치지 않은 항목까지 재검증해야 한다. subject·payload·
|
|
416
|
-
block·stageScope 가 같으면 그 항목이 가리키는 텍스트는 재작성 전과 같다.
|
|
417
|
-
"""
|
|
418
413
|
payload = {
|
|
419
414
|
"block": item.get("block"),
|
|
420
415
|
"payload": item.get("payload"),
|
|
@@ -828,6 +823,14 @@ PLAN_VERIFY_RESPONSE_FORMAT = (
|
|
|
828
823
|
"**Note**: <the falsification candidate considered and why it is "
|
|
829
824
|
"excluded; required even for AGREE>\n"
|
|
830
825
|
"**Explanation**: <2-3 sentences>\n"
|
|
826
|
+
'**Dependencies**: {"complete": false, "itemIds": [], "paths": []}\n'
|
|
827
|
+
"Keep the detailed Note and Explanation above. Set complete=true only when "
|
|
828
|
+
"you can enumerate ALL direct and transitive evidence dependencies: other "
|
|
829
|
+
"P-* items in itemIds and whole local files in paths (project-relative). "
|
|
830
|
+
"Include source files behind citations, not just the citing document. "
|
|
831
|
+
"If the dependency boundary, dynamic state, an external source, or any "
|
|
832
|
+
"reference cannot be resolved, keep complete=false. Missing or uncertain "
|
|
833
|
+
"dependencies cause re-verification; they never suppress your verdict.\n"
|
|
831
834
|
)
|
|
832
835
|
|
|
833
836
|
|
|
@@ -851,7 +854,7 @@ def with_response_format(queue_markdown: str) -> str:
|
|
|
851
854
|
return queue_markdown.rstrip("\n") + "\n" + PLAN_VERIFY_RESPONSE_FORMAT
|
|
852
855
|
|
|
853
856
|
|
|
854
|
-
def
|
|
857
|
+
def _item_cites_build_command(item: Mapping[str, Any]) -> bool:
|
|
855
858
|
"""이 항목이 계획 워크트리에서 실행 불가한 빌드/테스트 명령을 인용하는가."""
|
|
856
859
|
payload = item.get("payload") if isinstance(item.get("payload"), Mapping) else item
|
|
857
860
|
if not isinstance(payload, Mapping):
|
|
@@ -914,13 +917,13 @@ def _worker_is_blanket(
|
|
|
914
917
|
if not votes or any(not _error_verdict(token) for _item_id, token in votes):
|
|
915
918
|
return False
|
|
916
919
|
return any(
|
|
917
|
-
not
|
|
920
|
+
not _item_cites_build_command(_with_payload(items_by_id[item_id], payloads))
|
|
918
921
|
for item_id, _token in votes
|
|
919
922
|
if item_id in items_by_id
|
|
920
923
|
)
|
|
921
924
|
|
|
922
925
|
|
|
923
|
-
def
|
|
926
|
+
def _blanket_unverifiable_workers(
|
|
924
927
|
items: Sequence[Mapping[str, Any]],
|
|
925
928
|
payloads: Mapping[str, Mapping[str, Any]] | None = None,
|
|
926
929
|
) -> list[str]:
|
|
@@ -1028,7 +1031,7 @@ def next_dispatch(
|
|
|
1028
1031
|
str(item["id"]) for item in items
|
|
1029
1032
|
if isinstance(item.get("id"), str) and item["id"]
|
|
1030
1033
|
)
|
|
1031
|
-
blanket = tuple(
|
|
1034
|
+
blanket = tuple(_blanket_unverifiable_workers(items, payloads))
|
|
1032
1035
|
if blanket:
|
|
1033
1036
|
return NextDispatch(
|
|
1034
1037
|
kind="worker-correction",
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Lossless plan context with conservative dependency-based selection."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .analysis_packet import _heading_lines
|
|
11
|
+
from .plan_item_evidence import _dependency_closure, review_fingerprint
|
|
12
|
+
from .plan_items import content_hash
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _packet_excerpt(text: str) -> str:
|
|
16
|
+
lines = text.splitlines(keepends=True)
|
|
17
|
+
headings = _heading_lines([line.rstrip("\r\n") for line in lines])
|
|
18
|
+
omitted: set[int] = set()
|
|
19
|
+
for index, (start, heading) in enumerate(headings):
|
|
20
|
+
stop = headings[index + 1][0] - 1 if index + 1 < len(headings) else len(lines)
|
|
21
|
+
section = "".join(lines[start - 1:stop])
|
|
22
|
+
generated_census = (
|
|
23
|
+
heading == "## Coverage Census"
|
|
24
|
+
and "The fixed worklist for this run. okstra built it from the phase " in section
|
|
25
|
+
and "| Cell | Scope | Subject | Question |" in section
|
|
26
|
+
)
|
|
27
|
+
if heading != "## Section Index" and not generated_census:
|
|
28
|
+
continue
|
|
29
|
+
omitted.update(range(start - 1, stop))
|
|
30
|
+
excerpt = "".join(line for index, line in enumerate(lines) if index not in omitted)
|
|
31
|
+
if re.search(r"(?im)^###\s+(?:[FC]-|P-)", excerpt):
|
|
32
|
+
return "".join("> " + line for line in excerpt.splitlines(keepends=True))
|
|
33
|
+
return excerpt
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _supporting_ids(queue: Sequence[str], inputs: Mapping[str, Any]) -> set[str]:
|
|
37
|
+
items = inputs["items"]
|
|
38
|
+
selected: set[str] = set()
|
|
39
|
+
for key in queue:
|
|
40
|
+
row = {"id": key, "verdicts": inputs.get("dependencies", {}).get(key),
|
|
41
|
+
"contentHash": content_hash(items[key])}
|
|
42
|
+
previous = inputs.get("priorReviewHashes", {}).get(key)
|
|
43
|
+
if not previous or previous != review_fingerprint(row, inputs):
|
|
44
|
+
return set(items)
|
|
45
|
+
closure = _dependency_closure(row, inputs)
|
|
46
|
+
if closure is None:
|
|
47
|
+
return set(items)
|
|
48
|
+
selected.update(closure[0])
|
|
49
|
+
return selected
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_review_context(
|
|
53
|
+
queue: Sequence[str], inputs: Mapping[str, Any], manifest: Mapping[str, Any], root: Path,
|
|
54
|
+
) -> str:
|
|
55
|
+
if not inputs.get("complete") or "planContext" not in inputs:
|
|
56
|
+
return ""
|
|
57
|
+
packet = str(manifest["analysisPacketPath"])
|
|
58
|
+
supporting = _supporting_ids(queue, inputs) - set(queue)
|
|
59
|
+
context = {
|
|
60
|
+
"planMetadata": inputs["planContext"],
|
|
61
|
+
"availableEvidencePaths": sorted(name for name in inputs["files"] if name != packet),
|
|
62
|
+
"supportingItems": [
|
|
63
|
+
item
|
|
64
|
+
for key, item in inputs["items"].items() if key in supporting
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
return (
|
|
68
|
+
"\n## Review context\n\n"
|
|
69
|
+
"Use the inline packet and plan context below first. The plan items assigned below "
|
|
70
|
+
"carry their complete payloads; supporting items are context, not extra verdicts. "
|
|
71
|
+
"Do not reread the full narrative or packet unless a reference is unresolved, "
|
|
72
|
+
"context is missing, or a cross-item contradiction needs investigation. Read the "
|
|
73
|
+
"original paths in Inputs in that case and declare all additional dependencies. "
|
|
74
|
+
"Never infer that an omitted item or an unread source is absent. "
|
|
75
|
+
"Only the packet's navigation index and generated analysis Coverage Census are "
|
|
76
|
+
"omitted; task requirements, directives, carry-in, and unknown sections are retained.\n\n"
|
|
77
|
+
f"### Primary packet excerpt: `{packet}`\n\n"
|
|
78
|
+
+ _packet_excerpt((root / packet).read_text(encoding="utf-8"))
|
|
79
|
+
+ "\n### Plan context (lossless JSON)\n\n"
|
|
80
|
+
+ json.dumps(context, ensure_ascii=False, separators=(",", ":"))
|
|
81
|
+
+ "\n"
|
|
82
|
+
)
|
|
@@ -1154,7 +1154,9 @@ def _context_from_args(args: argparse.Namespace) -> FinalizeContext:
|
|
|
1154
1154
|
team_state_path = resolve_project_path(
|
|
1155
1155
|
project_root, require_string(manifest, "teamStatePath")
|
|
1156
1156
|
)
|
|
1157
|
-
report_path = resolve_project_path(
|
|
1157
|
+
report_path = resolve_project_path(
|
|
1158
|
+
project_root, args.report or require_string(manifest, "expectedReportRecordPath")
|
|
1159
|
+
)
|
|
1158
1160
|
return FinalizeContext.from_manifest(
|
|
1159
1161
|
project_root=project_root,
|
|
1160
1162
|
workspace_root=Path(args.workspace_root).resolve(),
|
|
@@ -1213,8 +1215,11 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
1213
1215
|
parser.add_argument("--run-manifest", required=True)
|
|
1214
1216
|
parser.add_argument(
|
|
1215
1217
|
"--report",
|
|
1216
|
-
|
|
1217
|
-
help=
|
|
1218
|
+
default="",
|
|
1219
|
+
help=(
|
|
1220
|
+
"final-report record (.data.json); defaults to the run manifest's "
|
|
1221
|
+
"expectedReportRecordPath. A leftover markdown path still locates the sibling record"
|
|
1222
|
+
),
|
|
1218
1223
|
)
|
|
1219
1224
|
# `okstra report-finalize` 래퍼(src/lib/python-command.mts)가 넣고, 사용자가
|
|
1220
1225
|
# 직접 주면 거절한다. usage 에 필수로 찍히면 그 거절과 모순되므로 감춘다.
|
|
@@ -4274,6 +4274,9 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4274
4274
|
)
|
|
4275
4275
|
carry = derive_stage_fix_carry(
|
|
4276
4276
|
Path(ctx["RUN_DIR"]), ctx.get("EXECUTOR_WORKTREE_PATH", ""),
|
|
4277
|
+
plan_run_root=Path(inp.approved_plan_path).resolve().parents[1],
|
|
4278
|
+
stage=stage_run_claim.stage,
|
|
4279
|
+
project_root=Path(ctx["PROJECT_ROOT"]),
|
|
4277
4280
|
)
|
|
4278
4281
|
ctx["FIX_RUN_CONTEXT"] = carry.as_prompt_markdown() if carry else ""
|
|
4279
4282
|
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
"""Derive the fix-run carry for an implementation stage
|
|
2
|
-
|
|
1
|
+
"""Derive the fix-run carry for an implementation stage that is re-entered to
|
|
2
|
+
fix a defect: either a verifier FAIL in the stage's previous run, or acceptance
|
|
3
|
+
blockers a final-verification found after the stage was settled and reopened.
|
|
3
4
|
|
|
4
|
-
Pure read-side: scans the
|
|
5
|
-
files and the stage worktree HEAD.
|
|
5
|
+
Pure read-side: scans consumers.jsonl, the reopen evidence report, the stage run
|
|
6
|
+
directory's assembled final-report data.json files and the stage worktree HEAD.
|
|
6
7
|
Returns None when the upcoming run is not a fix run, so callers can treat
|
|
7
8
|
"no carry" and "first run on this stage" identically.
|
|
8
9
|
"""
|
|
@@ -14,10 +15,15 @@ import subprocess
|
|
|
14
15
|
from dataclasses import dataclass
|
|
15
16
|
from pathlib import Path
|
|
16
17
|
|
|
18
|
+
from .consumers import read_consumers
|
|
17
19
|
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
18
20
|
from .phases.implementation.validation import lead_fixed_verifiers
|
|
19
21
|
|
|
20
22
|
_REPORT_RE = re.compile(r"final-report-implementation-(\d+)\.data\.json$")
|
|
23
|
+
_SEQ_RE = re.compile(r"-(\d+)\.data\.json$")
|
|
24
|
+
|
|
25
|
+
SOURCE_VERIFIER = "verifier"
|
|
26
|
+
SOURCE_FINAL_VERIFICATION = "final-verification"
|
|
21
27
|
|
|
22
28
|
|
|
23
29
|
@dataclass(frozen=True)
|
|
@@ -28,16 +34,30 @@ class StageFixCarry:
|
|
|
28
34
|
failed_verifiers: list[str]
|
|
29
35
|
blocking_findings: list[str]
|
|
30
36
|
routing_recommendation: str
|
|
37
|
+
source: str = SOURCE_VERIFIER
|
|
38
|
+
reopen_reason: str = ""
|
|
39
|
+
verification_scope: str = ""
|
|
31
40
|
|
|
32
41
|
def as_prompt_markdown(self) -> str:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
42
|
+
if self.source == SOURCE_FINAL_VERIFICATION:
|
|
43
|
+
lines = [
|
|
44
|
+
"## Fix-Run Carry (this run fixes final-verification acceptance blockers)",
|
|
45
|
+
"",
|
|
46
|
+
f"- Final-verification report (data.json SSOT): `{self.prev_report_path}`",
|
|
47
|
+
f"- Reopened stage head (fix-diff base): `{self.prev_head_commit}`",
|
|
48
|
+
f"- Reopen reason: {self.reopen_reason}",
|
|
49
|
+
f"- Verification scope: {self.verification_scope or 'unrecorded'}",
|
|
50
|
+
"- Carried acceptance blockers:",
|
|
51
|
+
]
|
|
52
|
+
else:
|
|
53
|
+
lines = [
|
|
54
|
+
"## Fix-Run Carry (this run fixes a prior FAIL)",
|
|
55
|
+
"",
|
|
56
|
+
f"- Previous report (data.json SSOT): `{self.prev_report_path}`",
|
|
57
|
+
f"- Previous run HEAD (fix-diff base): `{self.prev_head_commit}`",
|
|
58
|
+
f"- Failed verifiers: {', '.join(self.failed_verifiers)}",
|
|
59
|
+
"- Carried blocking findings:",
|
|
60
|
+
]
|
|
41
61
|
lines.extend(f" - {finding}" for finding in self.blocking_findings)
|
|
42
62
|
if self.routing_recommendation:
|
|
43
63
|
lines.append(
|
|
@@ -49,16 +69,39 @@ class StageFixCarry:
|
|
|
49
69
|
"(`_implementation-verifier.md` § Fix-run incremental scope, "
|
|
50
70
|
"`report-writer.md` § Fix-run incremental authoring).",
|
|
51
71
|
"This block is inlined into the executor and verifier prompts by "
|
|
52
|
-
"`initial_prompt_materialization`; do not transcribe it by hand.
|
|
53
|
-
"Lead duties: pass the previous data.json path above as read context "
|
|
54
|
-
"to the report-writer dispatch so it updates only changed narrative blocks.",
|
|
72
|
+
"`initial_prompt_materialization`; do not transcribe it by hand.",
|
|
55
73
|
])
|
|
74
|
+
if self.source == SOURCE_FINAL_VERIFICATION:
|
|
75
|
+
# 블로커 행에는 stage 필드가 없다(AcceptanceBlockerRow) — whole-task 검증이면 소유 판단을 실행자에게 넘긴다.
|
|
76
|
+
if self.verification_scope != "single-stage":
|
|
77
|
+
lines.append(
|
|
78
|
+
"Blocker rows name no stage: fix the blockers this stage owns, "
|
|
79
|
+
"as the reopen reason states, and cite the others as out of scope."
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
lines.append(
|
|
83
|
+
"Lead duties: pass the previous data.json path above as read context "
|
|
84
|
+
"to the report-writer dispatch so it updates only changed narrative blocks."
|
|
85
|
+
)
|
|
56
86
|
return "\n".join(lines)
|
|
57
87
|
|
|
58
88
|
|
|
59
89
|
def derive_stage_fix_carry(
|
|
60
|
-
stage_run_dir: Path, worktree_path: str,
|
|
90
|
+
stage_run_dir: Path, worktree_path: str, *,
|
|
91
|
+
plan_run_root: Path | None = None,
|
|
92
|
+
stage: int | None = None,
|
|
93
|
+
project_root: Path | None = None,
|
|
61
94
|
) -> StageFixCarry | None:
|
|
95
|
+
"""A stage reopened over a final-verification report carries that report's
|
|
96
|
+
blockers; otherwise the latest implementation report's verifier FAILs.
|
|
97
|
+
|
|
98
|
+
The two never merge: a reopen withdraws a `done` row, and that `done` already
|
|
99
|
+
settled every verdict of the implementation report before it.
|
|
100
|
+
"""
|
|
101
|
+
if plan_run_root is not None and stage is not None and project_root is not None:
|
|
102
|
+
carry = _reopen_carry(plan_run_root, stage, project_root, worktree_path)
|
|
103
|
+
if carry is not None:
|
|
104
|
+
return carry
|
|
62
105
|
report = _latest_report(Path(stage_run_dir))
|
|
63
106
|
if report is None:
|
|
64
107
|
return None
|
|
@@ -91,6 +134,65 @@ def derive_stage_fix_carry(
|
|
|
91
134
|
)
|
|
92
135
|
|
|
93
136
|
|
|
137
|
+
def _reopen_carry(
|
|
138
|
+
plan_run_root: Path, stage: int, project_root: Path, worktree_path: str,
|
|
139
|
+
) -> StageFixCarry | None:
|
|
140
|
+
# 이 run 의 claim 이 이미 `started` 행을 붙였으므로 마지막 종결 행(done/failed)을 본다.
|
|
141
|
+
settled = [
|
|
142
|
+
row for row in read_consumers(Path(plan_run_root))
|
|
143
|
+
if row.get("stage") == stage and row.get("status") in ("done", "failed")
|
|
144
|
+
]
|
|
145
|
+
if not settled:
|
|
146
|
+
return None
|
|
147
|
+
row = settled[-1]
|
|
148
|
+
evidence = str(row.get("evidence_path") or "")
|
|
149
|
+
if row.get("reopened_by") != "stage-reopen" or not evidence:
|
|
150
|
+
return None
|
|
151
|
+
report = Path(evidence)
|
|
152
|
+
if not report.is_absolute():
|
|
153
|
+
report = Path(project_root) / report
|
|
154
|
+
try:
|
|
155
|
+
data = load_owned_object(report, artifact="final-verification final report")
|
|
156
|
+
except (OSError, JsonBoundaryError):
|
|
157
|
+
return None
|
|
158
|
+
if (data.get("header") or {}).get("taskType") != "final-verification":
|
|
159
|
+
return None
|
|
160
|
+
fv = data.get("finalVerification")
|
|
161
|
+
if not isinstance(fv, dict):
|
|
162
|
+
return None
|
|
163
|
+
blockers = [
|
|
164
|
+
_blocker_line(item) for item in fv.get("acceptanceBlockers") or []
|
|
165
|
+
if isinstance(item, dict)
|
|
166
|
+
]
|
|
167
|
+
if not blockers:
|
|
168
|
+
return None
|
|
169
|
+
# fix-diff 기준은 final-verification 이 판정한 head — reopen 이 철회한 done 행의 head 다.
|
|
170
|
+
head = str(row.get("head_commit") or "") or _git_head(worktree_path)
|
|
171
|
+
if not head:
|
|
172
|
+
return None
|
|
173
|
+
seq = _SEQ_RE.search(report.name)
|
|
174
|
+
return StageFixCarry(
|
|
175
|
+
prev_report_path=str(report),
|
|
176
|
+
prev_report_seq=seq.group(1) if seq else "",
|
|
177
|
+
prev_head_commit=head,
|
|
178
|
+
failed_verifiers=[],
|
|
179
|
+
blocking_findings=blockers,
|
|
180
|
+
routing_recommendation=_routing_line(fv.get("routingRecommendation")),
|
|
181
|
+
source=SOURCE_FINAL_VERIFICATION,
|
|
182
|
+
reopen_reason=str(row.get("reason") or ""),
|
|
183
|
+
verification_scope=str(data.get("verificationScope") or ""),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _blocker_line(row: dict) -> str:
|
|
188
|
+
return (
|
|
189
|
+
f"{row.get('id') or '<no id>'} ({row.get('severity') or 'unrated'}, "
|
|
190
|
+
f"follow-up phase: {row.get('followUpPhase') or 'unrecorded'}): "
|
|
191
|
+
f"{str(row.get('statement') or '').strip()} — evidence: "
|
|
192
|
+
f"{str(row.get('evidence') or '').strip()}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
94
196
|
def _blocking_findings(data: dict, failed: list[dict]) -> list[str]:
|
|
95
197
|
"""이전 run 을 FAIL 로 만든 finding 목록.
|
|
96
198
|
|
|
@@ -118,6 +118,9 @@ def synchronize_stage_stack(
|
|
|
118
118
|
stage = by_number[number]
|
|
119
119
|
if not row or number not in done or stage.get("cancelled"):
|
|
120
120
|
continue
|
|
121
|
+
# A reopened predecessor has no done row; its dependent waits for the next one.
|
|
122
|
+
if any(dep not in done for dep in stage.get("depends_on") or []):
|
|
123
|
+
continue
|
|
121
124
|
active = statuses.get(number) == "started" or row.get("status") == "active"
|
|
122
125
|
if _synchronize_recorded_stage(
|
|
123
126
|
project_root,
|
|
@@ -17,6 +17,7 @@ refusing them costs a dispatch cycle and yields no information. A bare
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
|
+
import json
|
|
20
21
|
from dataclasses import dataclass
|
|
21
22
|
|
|
22
23
|
VERDICT_TOKENS = frozenset({
|
|
@@ -64,7 +65,7 @@ _ITEM_RE = re.compile(r"^###[ \t]+(?P<id>[^\s:]+)[ \t]*:?.*$", re.MULTILINE)
|
|
|
64
65
|
# `_field_match` enforces that an opening `**` is closed exactly once and that
|
|
65
66
|
# a label without bold carries a bullet.
|
|
66
67
|
_FIELD_KEYS = ("Verdict", "Fixability", "Note", "Explanation", "Prior dissent",
|
|
67
|
-
"Basis", "Your evidence")
|
|
68
|
+
"Basis", "Your evidence", "Dependencies")
|
|
68
69
|
_CANONICAL_KEYS = {key.lower(): key for key in _FIELD_KEYS}
|
|
69
70
|
_FIELD_RE = re.compile(
|
|
70
71
|
r"^(?P<bullet>[-*+][ \t]+)?(?P<open>\*\*)?"
|
|
@@ -234,6 +235,7 @@ class VerdictBlock:
|
|
|
234
235
|
note: str
|
|
235
236
|
explanation: str
|
|
236
237
|
prior_dissent: str
|
|
238
|
+
dependencies: dict | None = None
|
|
237
239
|
|
|
238
240
|
|
|
239
241
|
def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
|
|
@@ -269,6 +271,19 @@ def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
|
|
|
269
271
|
f"`{fixability or '(missing)'}` — expected one of "
|
|
270
272
|
f"{sorted(FIXABILITY_VALUES)}"
|
|
271
273
|
)
|
|
274
|
+
dependencies = None
|
|
275
|
+
if fields.get("Dependencies"):
|
|
276
|
+
try:
|
|
277
|
+
candidate = json.loads(fields["Dependencies"])
|
|
278
|
+
if (isinstance(candidate, dict) and isinstance(candidate.get("complete"), bool)
|
|
279
|
+
and all(isinstance(candidate.get(key), list)
|
|
280
|
+
and all(isinstance(value, str) and value for value in candidate[key])
|
|
281
|
+
for key in ("itemIds", "paths"))):
|
|
282
|
+
dependencies = {key: candidate[key] for key in ("complete", "itemIds", "paths")}
|
|
283
|
+
else:
|
|
284
|
+
dependencies = {"complete": False, "itemIds": [], "paths": []}
|
|
285
|
+
except (ValueError, TypeError):
|
|
286
|
+
dependencies = {"complete": False, "itemIds": [], "paths": []}
|
|
272
287
|
return VerdictBlock(
|
|
273
288
|
item_id=item_id,
|
|
274
289
|
verdict=token,
|
|
@@ -277,4 +292,5 @@ def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
|
|
|
277
292
|
note=fields.get("Note", ""),
|
|
278
293
|
explanation=fields.get("Explanation", ""),
|
|
279
294
|
prior_dissent=fields.get("Prior dissent", ""),
|
|
295
|
+
dependencies=dependencies,
|
|
280
296
|
)
|
|
@@ -123,20 +123,41 @@ def _apply_answer(state: WizardState, prompt: Prompt, value: str) -> str:
|
|
|
123
123
|
return echo or ""
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
def _automatic_answers(state: WizardState, prompt: Prompt) -> list[tuple[Prompt, str]]:
|
|
127
|
+
"""Answers the wizard takes without showing the screen.
|
|
128
|
+
|
|
129
|
+
A one-line pick takes its line. A pick whose builder set `auto_value` takes it
|
|
130
|
+
unless the user came back to that step through Edit; a group takes each such
|
|
131
|
+
member and shows the rest.
|
|
132
|
+
"""
|
|
133
|
+
if _is_lone_choice_pick(prompt):
|
|
134
|
+
return [(prompt, prompt.options[0].value)]
|
|
135
|
+
members = prompt.questions if prompt.kind == "pick_group" else [prompt]
|
|
136
|
+
return [
|
|
137
|
+
(member, member.auto_value)
|
|
138
|
+
for member in members
|
|
139
|
+
if member.kind == "pick"
|
|
140
|
+
and member.auto_value
|
|
141
|
+
and member.step not in state.manual_steps
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
126
145
|
def next_prompt(state: WizardState) -> Prompt:
|
|
127
|
-
#
|
|
128
|
-
#
|
|
146
|
+
# 자동으로 답한 화면 자리에 다음 화면이 온다. 그것도 자동일 수 있어 반복하되,
|
|
147
|
+
# 같은 step 이 다시 오면(반복 가능한 step) 묻는다.
|
|
129
148
|
taken: set[str] = set()
|
|
130
149
|
while True:
|
|
131
150
|
prompt = _next_prompt_screen(state)
|
|
132
|
-
if prompt.step in taken
|
|
151
|
+
answers = [] if prompt.step in taken else _automatic_answers(state, prompt)
|
|
152
|
+
if not answers:
|
|
133
153
|
return _native_picker_screen(state, prompt)
|
|
134
154
|
taken.add(prompt.step)
|
|
135
155
|
attempt = copy.deepcopy(state)
|
|
136
156
|
try:
|
|
137
|
-
|
|
157
|
+
for member, value in answers:
|
|
158
|
+
_apply_answer(attempt, member, value)
|
|
138
159
|
except WizardError:
|
|
139
|
-
#
|
|
160
|
+
# 자동 답이 거절되면 사용자에게 보여 거절 사유를 받게 한다.
|
|
140
161
|
return _native_picker_screen(state, prompt)
|
|
141
162
|
state.__dict__.update(attempt.__dict__)
|
|
142
163
|
|
|
@@ -243,6 +243,8 @@ def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
|
|
|
243
243
|
_reset_from(state, value)
|
|
244
244
|
else:
|
|
245
245
|
raise WizardError(f"unknown step: {value!r}")
|
|
246
|
+
if value not in state.manual_steps:
|
|
247
|
+
state.manual_steps.append(value)
|
|
246
248
|
state.edit_target = value
|
|
247
249
|
state.confirmed = None
|
|
248
250
|
state.edit_target = ""
|
|
@@ -143,6 +143,8 @@ class WizardState:
|
|
|
143
143
|
|
|
144
144
|
# bookkeeping
|
|
145
145
|
answered: list[str] = field(default_factory=list)
|
|
146
|
+
# Steps the user came back to through Edit; their auto_value no longer applies.
|
|
147
|
+
manual_steps: list[str] = field(default_factory=list)
|
|
146
148
|
|
|
147
149
|
def to_json(self) -> dict[str, Any]:
|
|
148
150
|
payload = asdict(self)
|
|
@@ -181,6 +183,9 @@ class Prompt:
|
|
|
181
183
|
multi: bool = False # only meaningful when kind == "pick"
|
|
182
184
|
# only meaningful when kind == "pick_group": one entry per AskUserQuestion tab
|
|
183
185
|
questions: list["Prompt"] = field(default_factory=list)
|
|
186
|
+
# The answer the wizard takes without asking, because it comes from a recorded
|
|
187
|
+
# source; the confirm screen shows it and its Edit menu asks for it again.
|
|
188
|
+
auto_value: str = ""
|
|
184
189
|
|
|
185
190
|
def __post_init__(self) -> None:
|
|
186
191
|
"""자유 입력 선택지는 언제나 마지막이다.
|