okstra 0.223.2 → 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.
Files changed (22) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/prompts/coding-preflight/clean-code.md +8 -1
  4. package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
  5. package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
  6. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
  7. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
  8. package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +98 -33
  9. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +22 -2
  10. package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
  11. package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
  12. package/runtime/python/okstra_ctl/plan_items.py +12 -9
  13. package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
  14. package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
  15. package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
  16. package/runtime/python/okstra_ctl/wizard/registry.py +2 -0
  17. package/runtime/python/okstra_ctl/wizard/state.py +5 -0
  18. package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
  19. package/runtime/python/okstra_ctl/wizard/steps_options.py +11 -0
  20. package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
  21. package/runtime/schemas/final-report-v2.0.schema.json +3 -1
  22. package/runtime/schemas/final-report-v3.0.schema.json +3 -1
@@ -0,0 +1,237 @@
1
+ """Conservative evidence snapshots for plan-verification verdict reuse."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ import stat
8
+ from collections.abc import Mapping, Sequence
9
+ from copy import deepcopy
10
+ from pathlib import Path
11
+ from typing import Any, TypedDict
12
+
13
+ from .plan_items import content_hash, is_critic_worker
14
+ from .paths import find_asset_root
15
+ from .report_narrative import writer_owned_data
16
+
17
+
18
+ class FileSnapshot(TypedDict):
19
+ path: str
20
+ hash: str
21
+ mode: int | None
22
+
23
+
24
+ class ReviewInputs(TypedDict):
25
+ version: int
26
+ items: dict[str, dict[str, Any]]
27
+ files: dict[str, FileSnapshot | None]
28
+ dependencies: dict[str, list[dict[str, Any]]]
29
+ workers: list[str]
30
+ context: str
31
+ complete: bool
32
+ planContext: dict[str, Any]
33
+ priorReviewHashes: dict[str, str]
34
+
35
+
36
+ def _digest(value: object) -> str:
37
+ return hashlib.sha256(
38
+ json.dumps(value, sort_keys=True, ensure_ascii=False).encode("utf-8")
39
+ ).hexdigest()
40
+
41
+
42
+ def _file_snapshot(root: Path, name: str) -> FileSnapshot | None:
43
+ path = root / name
44
+ try:
45
+ resolved = path.resolve(strict=False)
46
+ if not resolved.is_relative_to(root.resolve()):
47
+ return None
48
+ before = path.stat()
49
+ if stat.S_ISREG(before.st_mode):
50
+ digest = hashlib.sha256(path.read_bytes()).hexdigest()
51
+ after = path.stat()
52
+ changed = any(getattr(before, field) != getattr(after, field) for field in (
53
+ "st_dev", "st_ino", "st_size", "st_mtime_ns", "st_ctime_ns", "st_mode",
54
+ ))
55
+ if changed or resolved != path.resolve(strict=False):
56
+ return None
57
+ return {"path": str(resolved), "hash": digest, "mode": stat.S_IMODE(before.st_mode)}
58
+ except FileNotFoundError:
59
+ if not path.is_symlink():
60
+ return {"path": str(path.resolve(strict=False)), "hash": "absent", "mode": None}
61
+ except (OSError, RuntimeError):
62
+ return None
63
+ return None
64
+
65
+
66
+ def _payload_paths(value: object) -> set[str]:
67
+ if isinstance(value, str):
68
+ return {value} if value and not any(c in value for c in "\n`*") else set()
69
+ if isinstance(value, list):
70
+ return set().union(*(_payload_paths(entry) for entry in value))
71
+ if isinstance(value, Mapping):
72
+ return _payload_paths(value.get("path"))
73
+ return set()
74
+
75
+
76
+ def capture_review_inputs(
77
+ items: Sequence[Mapping[str, Any]],
78
+ planning: Mapping[str, Any],
79
+ manifest: Mapping[str, Any],
80
+ root: Path,
81
+ previous_rows: Sequence[Mapping[str, Any]] = (),
82
+ ) -> ReviewInputs:
83
+ paths: set[str] = set()
84
+ for item in items:
85
+ payload = item.get("payload") or {}
86
+ paths.update(
87
+ match for match in re.findall(r"`([^`\s]+)`", json.dumps(payload))
88
+ if "/" in match or "." in match
89
+ )
90
+ for field in ("files", "plannedPaths"):
91
+ paths.update(_payload_paths(payload.get(field)))
92
+ for row in previous_rows:
93
+ for vote in row.get("verdicts", []):
94
+ dependencies = vote.get("dependencies")
95
+ if isinstance(dependencies, Mapping) and isinstance(dependencies.get("paths"), list):
96
+ paths.update(p for p in dependencies.get("paths", []) if isinstance(p, str))
97
+ packet = manifest.get("analysisPacketPath")
98
+ if isinstance(packet, str) and packet:
99
+ paths.add(packet)
100
+ metadata = writer_owned_data({"implementationPlanning": planning})["implementationPlanning"]
101
+ for field in ("planBodyVerification", "optionCandidates", "stepwiseExecution",
102
+ "dependencyMigrationRisk", "validationChecklist", "rollbackStrategy",
103
+ "requirementCoverage", "variationPointAnalysis"):
104
+ metadata.pop(field, None)
105
+ metadata["stages"] = [
106
+ {key: value for key, value in stage.items() if key != "stepwiseExecution"}
107
+ for stage in metadata.get("stages", [])
108
+ ]
109
+ contracts = find_asset_root(("agents", "common.json"))
110
+ contract_hashes = {
111
+ name: _file_snapshot(contracts, name) if contracts else None for name in (
112
+ "agents/common.json", "agents/roles/analyser.json",
113
+ "prompts/duties/reverification-worker.json",
114
+ )
115
+ }
116
+ package = Path(__file__).resolve().parent
117
+ contract_hashes.update({name: _file_snapshot(package, name) for name in (
118
+ "plan_items.py", "plan_item_evidence.py", "plan_review_context.py", "phases/implementation_planning/authoring.py",
119
+ "phases/implementation_planning/instructions/plan-body-verification.md",
120
+ )})
121
+ files = {name: _file_snapshot(root, name) for name in sorted(paths)}
122
+ return {
123
+ "version": 1,
124
+ "planContext": metadata,
125
+ "priorReviewHashes": {str(row["id"]): str(row.get("verifiedReviewHash", "")) for row in previous_rows},
126
+ "items": {str(item["id"]): dict(item) for item in items},
127
+ "files": files,
128
+ "dependencies": dependency_scopes(previous_rows),
129
+ "workers": [row["workerId"] for row in manifest.get("workerAssignments", [])
130
+ if row.get("workerId") and not is_critic_worker(row["workerId"])
131
+ and not row["workerId"].startswith("report-writer")],
132
+ "context": _digest([metadata, contract_hashes, manifest.get("workerAssignments"),
133
+ manifest.get("agentContract"), manifest.get("invocationAssignments"),
134
+ files.get(packet)]),
135
+ "complete": bool(packet and files.get(packet) and files[packet]["hash"] != "absent"
136
+ and all(value and value["hash"] != "absent" for value in contract_hashes.values())),
137
+ }
138
+
139
+
140
+ def dependency_scopes(rows: Sequence[Mapping[str, Any]]) -> dict[str, list[dict[str, Any]]]:
141
+ return {
142
+ str(row["id"]): deepcopy(row["verdicts"]) for row in rows
143
+ if isinstance(row.get("id"), str) and isinstance(row.get("verdicts"), list)
144
+ and row["verdicts"]
145
+ }
146
+
147
+
148
+ def _vote_dependencies(votes: object, workers: Sequence[str]) -> tuple[set[str], set[str]] | None:
149
+ if (not isinstance(votes, list) or not votes or not workers
150
+ or any(not isinstance(vote, Mapping) for vote in votes)
151
+ or set(workers) - {vote.get("worker") for vote in votes}):
152
+ return None
153
+ item_ids, paths = set(), set()
154
+ for vote in votes:
155
+ dependencies = vote.get("dependencies")
156
+ if (vote.get("verdict") not in {"AGREE", "DISAGREE", "SUPPLEMENT"}
157
+ or vote.get("claimKind") == "fact"
158
+ or not isinstance(dependencies, Mapping)
159
+ or dependencies.get("complete") is not True):
160
+ return None
161
+ for field in ("itemIds", "paths"):
162
+ values = dependencies.get(field)
163
+ if not isinstance(values, list) or any(not isinstance(v, str) or not v for v in values):
164
+ return None
165
+ item_ids.update(dependencies["itemIds"])
166
+ paths.update(dependencies["paths"])
167
+ return item_ids, paths
168
+
169
+
170
+ def _dependency_closure(row: Mapping[str, Any], inputs: Mapping[str, Any]) -> tuple[set[str], set[str]] | None:
171
+ items = inputs.get("items", {})
172
+ graph = {**inputs.get("dependencies", {}), str(row["id"]): row.get("verdicts")}
173
+ pending, item_ids, paths = [str(row["id"])], set(), set()
174
+ while pending:
175
+ key = pending.pop()
176
+ if key in item_ids:
177
+ continue
178
+ if key not in items:
179
+ return None
180
+ declared = _vote_dependencies(graph.get(key), inputs.get("workers", []))
181
+ if declared is None:
182
+ return None
183
+ if any(not inputs.get("files", {}).get(name)
184
+ or inputs["files"][name]["hash"] == "absent" for name in declared[1]):
185
+ return None
186
+ item_ids.add(key)
187
+ pending.extend(declared[0])
188
+ paths.update(declared[1])
189
+ payload = items[key].get("payload") or {}
190
+ if key.startswith(("P-Opt-", "P-Var-", "P-Dir-", "P-Dep-", "P-Prep-")):
191
+ pending.extend(items)
192
+ paths.update(inputs.get("files", {}))
193
+ elif key.startswith("P-Step-"):
194
+ pending.extend(
195
+ other_id for other_id, other in items.items()
196
+ if other_id.startswith("P-Step-") and other["payload"].get("stage") == payload.get("stage")
197
+ and other["payload"].get("step", 0) < payload.get("step", 0)
198
+ )
199
+ for field in ("files", "plannedPaths"):
200
+ paths.update(_payload_paths(payload.get(field)))
201
+ return item_ids, paths
202
+
203
+
204
+ def review_fingerprint(row: Mapping[str, Any], inputs: Mapping[str, Any]) -> str | None:
205
+ items, files = inputs.get("items", {}), inputs.get("files", {})
206
+ item = items.get(row.get("id"))
207
+ if not inputs.get("complete") or not item or row.get("contentHash") != content_hash(item):
208
+ return None
209
+ closure = _dependency_closure(row, inputs)
210
+ if closure is None:
211
+ return None
212
+ item_ids, paths = closure
213
+ if any(name not in files or files[name] is None for name in paths):
214
+ return None
215
+ return _digest({
216
+ "context": inputs["context"],
217
+ "items": {key: content_hash(items[key]) for key in sorted(item_ids)},
218
+ "files": {name: files[name] for name in sorted(paths)},
219
+ "votes": row["verdicts"],
220
+ })
221
+
222
+
223
+ def review_files_unchanged(inputs: Mapping[str, Any], root: Path) -> bool:
224
+ return all(
225
+ value == _file_snapshot(root, name)
226
+ for name, value in inputs.get("files", {}).items()
227
+ )
228
+
229
+
230
+ def reusable_item_ids(
231
+ rows: Sequence[Mapping[str, Any]], inputs: Mapping[str, Any],
232
+ ) -> set[str]:
233
+ return {
234
+ str(row["id"]) for row in rows
235
+ if row.get("verifiedReviewHash")
236
+ and row["verifiedReviewHash"] == review_fingerprint(row, inputs)
237
+ }
@@ -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 item_cites_build_command(item: Mapping[str, Any]) -> bool:
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 item_cites_build_command(_with_payload(items_by_id[item_id], payloads))
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 blanket_unverifiable_workers(
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(blanket_unverifiable_workers(items, payloads))
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
+ )
@@ -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
- # 한 줄짜리 picker 를 건너뛰면 그 자리에 다음 화면이 온다. 그것도 한 줄짜리일
128
- # 수 있어 반복하되, 같은 step 이 다시 오면(반복 가능한 step) 묻는다.
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 or not _is_lone_choice_pick(prompt):
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
- _apply_answer(attempt, prompt, prompt.options[0].value)
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
  """자유 입력 선택지는 언제나 마지막이다.