okstra 0.143.0 → 0.145.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 (46) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +18 -2
  3. package/docs/cli.md +39 -2
  4. package/docs/project-structure-overview.md +19 -6
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/coding-preflight/overview.md +1 -1
  8. package/runtime/prompts/lead/convergence.md +11 -3
  9. package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
  10. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  11. package/runtime/prompts/profiles/_common-contract.md +1 -1
  12. package/runtime/prompts/profiles/_implementation-verifier.md +48 -2
  13. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  14. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  15. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  16. package/runtime/prompts/profiles/project-analysis.md +24 -0
  17. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  18. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  20. package/runtime/python/okstra_ctl/mutation_probe.py +1263 -0
  21. package/runtime/python/okstra_ctl/render.py +77 -4
  22. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  23. package/runtime/python/okstra_ctl/report_views.py +134 -3
  24. package/runtime/python/okstra_ctl/run.py +118 -0
  25. package/runtime/python/okstra_ctl/run_context.py +34 -2
  26. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  27. package/runtime/python/okstra_ctl/self_mock_signals.py +183 -0
  28. package/runtime/python/okstra_ctl/user_response.py +309 -3
  29. package/runtime/python/okstra_ctl/wizard.py +545 -32
  30. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  31. package/runtime/python/okstra_ctl/workflow.py +22 -0
  32. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  33. package/runtime/skills/okstra-run/SKILL.md +13 -1
  34. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  35. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  36. package/runtime/templates/reports/final-report.template.md +220 -0
  37. package/runtime/templates/reports/i18n/en.json +8 -0
  38. package/runtime/templates/reports/i18n/ko.json +8 -0
  39. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  40. package/runtime/templates/reports/report.js +84 -5
  41. package/runtime/templates/reports/user-response.template.md +19 -1
  42. package/runtime/validators/detect_self_mock.py +220 -0
  43. package/runtime/validators/validate-report-views.py +61 -7
  44. package/runtime/validators/validate-run.py +518 -0
  45. package/runtime/validators/validate_analysis_report.py +864 -0
  46. package/src/commands/execute/render-bundle.mjs +3 -0
@@ -31,13 +31,26 @@ from typing import Any, Callable, Optional
31
31
 
32
32
  from okstra_ctl.ids import slugify_task_segment
33
33
  from okstra_ctl.brief_frontmatter import read_brief_frontmatter
34
+ from okstra_ctl.analysis_inputs import (
35
+ ANALYSIS_TASK_TYPES,
36
+ AnalysisInputError,
37
+ AnalysisReportCandidate,
38
+ _resolved_within,
39
+ list_evidence_candidates,
40
+ load_analysis_report_candidate,
41
+ resolve_analysis_target,
42
+ resolve_evidence_inputs,
43
+ )
34
44
  from okstra_ctl.models import (
35
45
  PROVIDER_MAPPINGS,
36
46
  UnknownModelError,
37
47
  picker_options,
38
48
  resolve_model_metadata,
39
49
  )
40
- from okstra_ctl.clarification_items import scan_approval_gate, user_response_sidecars
50
+ from okstra_ctl.clarification_items import (
51
+ scan_approval_gate,
52
+ user_response_sidecars,
53
+ )
41
54
  from okstra_ctl.design_prep import (
42
55
  DesignPrepError,
43
56
  load_design_prep_items,
@@ -100,9 +113,16 @@ from okstra_project.state import (
100
113
 
101
114
  # ---- Constants -----------------------------------------------------------
102
115
 
116
+ _ANALYSIS_TASK_TYPE_DESCRIPTIONS = (
117
+ "Map project structure and bounded codebase scope (read-only)",
118
+ "Analyse feature behavior and test coverage scope (read-only)",
119
+ "Assess change impact and preserved behavior (read-only)",
120
+ )
121
+
103
122
  TASK_TYPES: list[tuple[str, str]] = [
104
123
  ("requirements-discovery", "Classify request and route to next safe phase"),
105
124
  ("improvement-discovery", "Find improvement candidates within a codebase scope and lens whitelist"),
125
+ *zip(ANALYSIS_TASK_TYPES, _ANALYSIS_TASK_TYPE_DESCRIPTIONS),
106
126
  ("error-analysis", "Evidence-based root-cause analysis (no code changes)"),
107
127
  ("implementation-planning", "Plan options + request user approval"),
108
128
  ("implementation", "Execute approved plan (requires approved final-report)"),
@@ -131,8 +151,12 @@ _STAGE_SCOPED_TASK_TYPES = ("implementation", "final-verification")
131
151
  # downstream phase 는 task manifest 의 taskBriefPath 를 자동 carry-in 하며
132
152
  # 위저드에서 brief 를 묻지 않는다 (release-handoff 는 prepare 가 검증 보고서
133
153
  # 인용 input 문서를 자동 생성하므로 brief 자체가 없다).
134
- _BRIEF_ENTRY_TASK_TYPES = ("requirements-discovery", "improvement-discovery",
135
- "error-analysis")
154
+ _BRIEF_ENTRY_TASK_TYPES = (
155
+ "requirements-discovery",
156
+ "improvement-discovery",
157
+ *ANALYSIS_TASK_TYPES,
158
+ "error-analysis",
159
+ )
136
160
 
137
161
  CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
138
162
  BASE_REF_FREE_INPUT_TOKEN = "__free_input__"
@@ -263,6 +287,12 @@ S_BRIEF_KEEP = "brief_keep"
263
287
  S_BRIEF_PATH_PICK = "brief_path_pick"
264
288
  S_BRIEF_PATH = "brief_path"
265
289
  S_BRIEF_CARRY = "brief_carry"
290
+ S_FEATURE_EVIDENCE_PICK = "feature_evidence_pick"
291
+ S_FEATURE_EVIDENCE = "feature_evidence"
292
+ S_PROJECT_EVIDENCE_PICK = "project_evidence_pick"
293
+ S_PROJECT_EVIDENCE = "project_evidence"
294
+ S_ANALYSIS_TARGET_PICK = "analysis_target_pick"
295
+ S_ANALYSIS_TARGET = "analysis_target"
266
296
  S_BASE_REF_PICK = "base_ref_pick"
267
297
  S_BASE_REF_TEXT = "base_ref_text"
268
298
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
@@ -355,6 +385,14 @@ class WizardState:
355
385
  brief_path: str = ""
356
386
  brief_path_pending_text: bool = False
357
387
 
388
+ # analysis evidence and target
389
+ project_evidence_path: str = ""
390
+ project_evidence_pending_text: bool = False
391
+ feature_evidence_path: str = ""
392
+ feature_evidence_pending_text: bool = False
393
+ analysis_target: str = ""
394
+ analysis_target_pending_text: bool = False
395
+
358
396
  # worktree
359
397
  reuse_worktree: Optional[bool] = None
360
398
  base_ref: str = ""
@@ -1445,6 +1483,101 @@ def _recent_task_types(state: WizardState) -> list[str]:
1445
1483
  return out
1446
1484
 
1447
1485
 
1486
+ def _contained_final_report_data_path(report: Path, task_root: Path) -> Path:
1487
+ _resolved_within(report, task_root, "final report path")
1488
+ return _resolved_within(
1489
+ final_report_data_path(report),
1490
+ task_root,
1491
+ "final report data path",
1492
+ )
1493
+
1494
+
1495
+ def _newest_contained_final_report(
1496
+ runs_base: Path,
1497
+ task_root: Path,
1498
+ glob_pattern: str,
1499
+ project_root: Path,
1500
+ ) -> Path | None:
1501
+ candidates: list[Path] = []
1502
+ for report in runs_base.glob(glob_pattern):
1503
+ if not report.is_file():
1504
+ continue
1505
+ try:
1506
+ _contained_final_report_data_path(report, task_root)
1507
+ if report.parent.parent.name in ANALYSIS_TASK_TYPES:
1508
+ load_analysis_report_candidate(project_root, report)
1509
+ except AnalysisInputError:
1510
+ continue
1511
+ candidates.append(report)
1512
+
1513
+ def mtime_safe(report: Path) -> float:
1514
+ try:
1515
+ return report.stat().st_mtime
1516
+ except OSError:
1517
+ return -1.0
1518
+
1519
+ return max(candidates, key=mtime_safe) if candidates else None
1520
+
1521
+
1522
+ def _analysis_revision_candidate(
1523
+ state: WizardState,
1524
+ report: Path,
1525
+ task_root: Path,
1526
+ expected_task_key: str,
1527
+ ) -> tuple[int, Path] | None:
1528
+ try:
1529
+ _contained_final_report_data_path(report, task_root)
1530
+ candidate = load_analysis_report_candidate(
1531
+ Path(state.project_root), report
1532
+ )
1533
+ except AnalysisInputError:
1534
+ return None
1535
+ if (
1536
+ candidate.task_key != expected_task_key
1537
+ or candidate.task_type != state.task_type
1538
+ or candidate.review_status != "revision-requested"
1539
+ ):
1540
+ return None
1541
+ return int(candidate.run_seq), report
1542
+
1543
+
1544
+ def _latest_revision_requested_analysis_report(
1545
+ state: WizardState,
1546
+ ) -> Path | None:
1547
+ if state.task_type not in ANALYSIS_TASK_TYPES:
1548
+ return None
1549
+ reports = task_runs_dir(
1550
+ Path(state.project_root), state.task_group, state.task_id
1551
+ ) / state.task_type / "reports"
1552
+ try:
1553
+ task_root = _resolved_within(
1554
+ reports.parents[2],
1555
+ Path(state.project_root).resolve(),
1556
+ "analysis task root",
1557
+ )
1558
+ except AnalysisInputError:
1559
+ return None
1560
+ expected_task_key = f"{state.project_id}:{state.task_group}:{state.task_id}"
1561
+ candidates: list[tuple[int, Path]] = []
1562
+ for report in reports.glob("final-report-*.md"):
1563
+ candidate = _analysis_revision_candidate(
1564
+ state,
1565
+ report,
1566
+ task_root,
1567
+ expected_task_key,
1568
+ )
1569
+ if candidate is not None:
1570
+ candidates.append(candidate)
1571
+ if not candidates:
1572
+ return None
1573
+ return max(candidates, key=lambda candidate: candidate[0])[1]
1574
+
1575
+
1576
+ def _latest_revision_requested_analysis_type(state: WizardState) -> str:
1577
+ report = _latest_revision_requested_analysis_report(state)
1578
+ return state.task_type if report is not None else ""
1579
+
1580
+
1448
1581
  def _build_task_type(state: WizardState) -> Prompt:
1449
1582
  t = _p(state.workspace_root, "task_type")
1450
1583
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
@@ -1464,7 +1597,9 @@ def _build_task_type(state: WizardState) -> Prompt:
1464
1597
  description_by_type[task_type]))
1465
1598
 
1466
1599
  workflow = _existing_task_workflow(state)
1467
- recommended = state.task_type or workflow.get("nextRecommendedPhase") or ""
1600
+ revision_requested = _latest_revision_requested_analysis_type(state)
1601
+ recommended = (revision_requested or state.task_type
1602
+ or workflow.get("nextRecommendedPhase") or "")
1468
1603
  if not recommended and not workflow:
1469
1604
  recommended = TASK_TYPE_VALUES[0]
1470
1605
  add(recommended, recommended_suffix)
@@ -1677,6 +1812,225 @@ def _submit_brief_carry(state: WizardState, value: str) -> Optional[str]:
1677
1812
  )
1678
1813
 
1679
1814
 
1815
+ def _analysis_current_commit(state: WizardState) -> str:
1816
+ try:
1817
+ return subprocess.check_output(
1818
+ ["git", "-C", state.project_root, "rev-parse", "HEAD"],
1819
+ text=True,
1820
+ stderr=subprocess.DEVNULL,
1821
+ ).strip()
1822
+ except (OSError, subprocess.CalledProcessError) as exc:
1823
+ raise WizardError(
1824
+ f"cannot resolve current project commit for analysis evidence: {state.project_root}"
1825
+ ) from exc
1826
+
1827
+
1828
+ def _analysis_evidence_paths(state: WizardState) -> list[Path]:
1829
+ return [
1830
+ Path(path)
1831
+ for path in (state.feature_evidence_path, state.project_evidence_path)
1832
+ if path
1833
+ ]
1834
+
1835
+
1836
+ def _resolve_analysis_evidence(state: WizardState):
1837
+ try:
1838
+ return resolve_evidence_inputs(
1839
+ Path(state.project_root),
1840
+ state.task_type,
1841
+ _analysis_evidence_paths(state),
1842
+ _analysis_current_commit(state),
1843
+ )
1844
+ except AnalysisInputError as exc:
1845
+ raise WizardError(str(exc)) from exc
1846
+
1847
+
1848
+ def _accept_analysis_evidence(
1849
+ state: WizardState, raw_path: str, field_name: str
1850
+ ) -> AnalysisReportCandidate:
1851
+ report_path = _resolve_path(raw_path, Path(state.project_root))
1852
+ try:
1853
+ candidate = load_analysis_report_candidate(
1854
+ Path(state.project_root), report_path
1855
+ )
1856
+ except AnalysisInputError as exc:
1857
+ raise WizardError(str(exc)) from exc
1858
+ previous = getattr(state, field_name)
1859
+ setattr(state, field_name, str(candidate.report_path))
1860
+ try:
1861
+ _resolve_analysis_evidence(state)
1862
+ except WizardError:
1863
+ setattr(state, field_name, previous)
1864
+ raise
1865
+ return candidate
1866
+
1867
+
1868
+ def _evidence_option_label(candidate: AnalysisReportCandidate) -> str:
1869
+ return f"{candidate.task_key} · {candidate.task_type} · run {candidate.run_seq}"
1870
+
1871
+
1872
+ def _build_analysis_evidence_pick(
1873
+ state: WizardState, *, step: str, relation: str
1874
+ ) -> Prompt:
1875
+ t = _p(state.workspace_root, step)
1876
+ candidates = list_evidence_candidates(
1877
+ Path(state.project_root), state.task_type, relation
1878
+ )[:2]
1879
+ options = [
1880
+ _opt(str(candidate.report_path), _evidence_option_label(candidate))
1881
+ for candidate in candidates
1882
+ ]
1883
+ options.extend([
1884
+ _opt(PICK_SKIP, t["options"][PICK_SKIP]),
1885
+ _opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]),
1886
+ ])
1887
+ return Prompt(step=step, kind="pick", label=t["label"], options=options,
1888
+ echo_template=t["echo_template"])
1889
+
1890
+
1891
+ def _submit_analysis_evidence_pick(
1892
+ state: WizardState,
1893
+ value: str,
1894
+ *,
1895
+ step: str,
1896
+ field_name: str,
1897
+ pending_name: str,
1898
+ target_after_skip: bool = False,
1899
+ ) -> Optional[str]:
1900
+ t = _p(state.workspace_root, step)
1901
+ if value == PICK_SKIP:
1902
+ setattr(state, field_name, "")
1903
+ setattr(state, pending_name, False)
1904
+ if target_after_skip:
1905
+ state.analysis_target_pending_text = True
1906
+ return t["echo_variants"]["skip"]
1907
+ if value == PICK_TYPE_CUSTOM:
1908
+ setattr(state, pending_name, True)
1909
+ return None
1910
+ candidate = _accept_analysis_evidence(state, value, field_name)
1911
+ setattr(state, pending_name, False)
1912
+ return t["echo_variants"]["selected"].format(path=candidate.report_path)
1913
+
1914
+
1915
+ def _build_feature_evidence_pick(state: WizardState) -> Prompt:
1916
+ return _build_analysis_evidence_pick(
1917
+ state, step=S_FEATURE_EVIDENCE_PICK, relation="feature-baseline"
1918
+ )
1919
+
1920
+
1921
+ def _submit_feature_evidence_pick(state: WizardState, value: str) -> Optional[str]:
1922
+ return _submit_analysis_evidence_pick(
1923
+ state, value, step=S_FEATURE_EVIDENCE_PICK,
1924
+ field_name="feature_evidence_path",
1925
+ pending_name="feature_evidence_pending_text",
1926
+ )
1927
+
1928
+
1929
+ def _build_feature_evidence(state: WizardState) -> Prompt:
1930
+ t = _p(state.workspace_root, S_FEATURE_EVIDENCE)
1931
+ return Prompt(step=S_FEATURE_EVIDENCE, kind="text", label=t["label"],
1932
+ echo_template=t["echo_template"])
1933
+
1934
+
1935
+ def _submit_feature_evidence(state: WizardState, value: str) -> Optional[str]:
1936
+ candidate = _accept_analysis_evidence(state, value, "feature_evidence_path")
1937
+ state.feature_evidence_pending_text = False
1938
+ return f"feature-evidence: {candidate.report_path}"
1939
+
1940
+
1941
+ def _build_project_evidence_pick(state: WizardState) -> Prompt:
1942
+ return _build_analysis_evidence_pick(
1943
+ state, step=S_PROJECT_EVIDENCE_PICK, relation="project-context"
1944
+ )
1945
+
1946
+
1947
+ def _submit_project_evidence_pick(state: WizardState, value: str) -> Optional[str]:
1948
+ return _submit_analysis_evidence_pick(
1949
+ state, value, step=S_PROJECT_EVIDENCE_PICK,
1950
+ field_name="project_evidence_path",
1951
+ pending_name="project_evidence_pending_text",
1952
+ target_after_skip=state.task_type == "feature-analysis",
1953
+ )
1954
+
1955
+
1956
+ def _build_project_evidence(state: WizardState) -> Prompt:
1957
+ t = _p(state.workspace_root, S_PROJECT_EVIDENCE)
1958
+ return Prompt(step=S_PROJECT_EVIDENCE, kind="text", label=t["label"],
1959
+ echo_template=t["echo_template"])
1960
+
1961
+
1962
+ def _submit_project_evidence(state: WizardState, value: str) -> Optional[str]:
1963
+ candidate = _accept_analysis_evidence(state, value, "project_evidence_path")
1964
+ state.project_evidence_pending_text = False
1965
+ return f"project-evidence: {candidate.report_path}"
1966
+
1967
+
1968
+ def _feature_description(feature: dict[object, object]) -> str:
1969
+ name = str(feature.get("name") or "")
1970
+ summary = str(feature.get("summary") or "")
1971
+ entry_point = str(
1972
+ feature.get("entryPoint") or feature.get("entrypoint") or ""
1973
+ )
1974
+ return " · ".join(value for value in (name, summary, entry_point) if value)
1975
+
1976
+
1977
+ def _build_analysis_target_pick(state: WizardState) -> Prompt:
1978
+ t = _p(state.workspace_root, S_ANALYSIS_TARGET_PICK)
1979
+ try:
1980
+ candidate = load_analysis_report_candidate(
1981
+ Path(state.project_root), Path(state.project_evidence_path)
1982
+ )
1983
+ except AnalysisInputError as exc:
1984
+ raise WizardError(str(exc)) from exc
1985
+ options = [
1986
+ _opt(str(feature["id"]), str(feature["id"]), _feature_description(feature))
1987
+ for feature in candidate.feature_index
1988
+ if isinstance(feature, dict) and isinstance(feature.get("id"), str)
1989
+ ][:3]
1990
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
1991
+ return Prompt(step=S_ANALYSIS_TARGET_PICK, kind="pick", label=t["label"],
1992
+ options=options, echo_template=t["echo_template"])
1993
+
1994
+
1995
+ def _accept_analysis_target(state: WizardState, value: str) -> str:
1996
+ candidates: dict[Path, AnalysisReportCandidate] = {}
1997
+ for path in _analysis_evidence_paths(state):
1998
+ try:
1999
+ candidate = load_analysis_report_candidate(Path(state.project_root), path)
2000
+ except AnalysisInputError as exc:
2001
+ raise WizardError(str(exc)) from exc
2002
+ candidates[candidate.report_path] = candidate
2003
+ try:
2004
+ target = resolve_analysis_target(
2005
+ value, _resolve_analysis_evidence(state), candidates
2006
+ )
2007
+ except AnalysisInputError as exc:
2008
+ raise WizardError(str(exc)) from exc
2009
+ requested_value = target["requestedValue"]
2010
+ if not isinstance(requested_value, str):
2011
+ raise WizardError("analysis target resolver returned an invalid requestedValue")
2012
+ state.analysis_target = requested_value
2013
+ state.analysis_target_pending_text = False
2014
+ return requested_value
2015
+
2016
+
2017
+ def _submit_analysis_target_pick(state: WizardState, value: str) -> Optional[str]:
2018
+ if value == PICK_TYPE_CUSTOM:
2019
+ state.analysis_target_pending_text = True
2020
+ return None
2021
+ return f"analysis-target: {_accept_analysis_target(state, value)}"
2022
+
2023
+
2024
+ def _build_analysis_target(state: WizardState) -> Prompt:
2025
+ t = _p(state.workspace_root, S_ANALYSIS_TARGET)
2026
+ return Prompt(step=S_ANALYSIS_TARGET, kind="text", label=t["label"],
2027
+ echo_template=t["echo_template"])
2028
+
2029
+
2030
+ def _submit_analysis_target(state: WizardState, value: str) -> Optional[str]:
2031
+ return f"analysis-target: {_accept_analysis_target(state, value)}"
2032
+
2033
+
1680
2034
  def _build_base_ref_pick(state: WizardState) -> Prompt:
1681
2035
  t = _p(state.workspace_root, "base_ref_pick")
1682
2036
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
@@ -2383,24 +2737,34 @@ def _suggest_latest_final_report(state: WizardState) -> str:
2383
2737
  runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
2384
2738
  if not runs_base.is_dir():
2385
2739
  return ""
2740
+ try:
2741
+ task_root = _resolved_within(
2742
+ runs_base.parent,
2743
+ Path(state.project_root).resolve(),
2744
+ "task root",
2745
+ )
2746
+ except AnalysisInputError:
2747
+ return ""
2386
2748
 
2387
- def _mtime_safe(p: Path) -> float:
2388
- try:
2389
- return p.stat().st_mtime
2390
- except OSError:
2391
- return -1.0
2392
-
2393
- def _newest(glob_pattern: str) -> Optional[Path]:
2394
- cands = [p for p in runs_base.glob(glob_pattern) if p.is_file()]
2395
- return max(cands, key=_mtime_safe) if cands else None
2396
-
2397
- # 산출물 구조는 runs/<task-type>/reports/final-report-*.md (1단계).
2398
- best: Optional[Path] = None
2399
- if state.task_type:
2749
+ # A revision request outranks ordinary recency. Carry the newest requested
2750
+ # revision for the selected analysis type into that exact rerun.
2751
+ revision = _latest_revision_requested_analysis_report(state)
2752
+ best = revision
2753
+ if best is None and state.task_type:
2400
2754
  seg = slugify_task_segment(state.task_type)
2401
- best = _newest(f"{seg}/reports/final-report-*.md")
2755
+ best = _newest_contained_final_report(
2756
+ runs_base,
2757
+ task_root,
2758
+ f"{seg}/reports/final-report-*.md",
2759
+ Path(state.project_root),
2760
+ )
2402
2761
  if best is None:
2403
- best = _newest("*/reports/final-report-*.md")
2762
+ best = _newest_contained_final_report(
2763
+ runs_base,
2764
+ task_root,
2765
+ "*/reports/final-report-*.md",
2766
+ Path(state.project_root),
2767
+ )
2404
2768
  if best is None:
2405
2769
  return ""
2406
2770
  # The approved plan is already wired via --approved-plan. On the first run
@@ -2735,10 +3099,15 @@ def _submit_executor(state: WizardState, value: str) -> Optional[str]:
2735
3099
  # §1 clarification 을 갖는 비-implementation phase 로 한정한다(implementation 은
2736
3100
  # approved-plan/stage 가 매 run 고유라 재사용 대상이 아니다).
2737
3101
  _RESUME_REUSE_PHASES = (
2738
- "requirements-discovery", "error-analysis", "implementation-planning")
3102
+ "requirements-discovery", "error-analysis", "implementation-planning",
3103
+ *ANALYSIS_TASK_TYPES,
3104
+ )
2739
3105
 
2740
3106
  # YES 선택 시 prefill 로 충족되어 다시 묻지 않는 설정 단계들.
2741
3107
  _REUSE_FILLED_STEPS = (
3108
+ S_FEATURE_EVIDENCE_PICK, S_FEATURE_EVIDENCE,
3109
+ S_PROJECT_EVIDENCE_PICK, S_PROJECT_EVIDENCE,
3110
+ S_ANALYSIS_TARGET_PICK, S_ANALYSIS_TARGET,
2742
3111
  S_DEFAULTS_OR_CUSTOM, S_WORKERS_OVERRIDE, S_LEAD_MODEL, S_EXECUTOR_MODEL,
2743
3112
  S_CLAUDE_MODEL, S_CODEX_MODEL, S_ANTIGRAVITY_MODEL, S_REPORT_WRITER_MODEL,
2744
3113
  S_DIRECTIVE_PICK, S_DIRECTIVE, S_RELATED_TASKS_PICK, S_RELATED_TASKS,
@@ -2750,6 +3119,49 @@ def _has_prior_run_inputs(state: WizardState) -> bool:
2750
3119
  return bool(_latest_run_inputs(state, phase_segment=seg))
2751
3120
 
2752
3121
 
3122
+ def _analysis_inputs_after_reuse(state: WizardState) -> bool:
3123
+ return (
3124
+ state.reuse_previous is not None
3125
+ or state.task_type not in _RESUME_REUSE_PHASES
3126
+ or not _has_prior_run_inputs(state)
3127
+ )
3128
+
3129
+
3130
+ def _restore_analysis_evidence_inputs(
3131
+ state: WizardState, evidence_raw: object
3132
+ ) -> tuple[tuple[Any, ...], dict[Path, AnalysisReportCandidate]]:
3133
+ if state.task_type not in ANALYSIS_TASK_TYPES or not isinstance(evidence_raw, str):
3134
+ return (), {}
3135
+ report_paths = [
3136
+ _resolve_path(item.strip(), Path(state.project_root))
3137
+ for item in evidence_raw.split(",")
3138
+ if item.strip()
3139
+ ]
3140
+ try:
3141
+ candidates = [
3142
+ load_analysis_report_candidate(Path(state.project_root), path)
3143
+ for path in report_paths
3144
+ ]
3145
+ evidence_inputs = resolve_evidence_inputs(
3146
+ Path(state.project_root),
3147
+ state.task_type,
3148
+ [candidate.report_path for candidate in candidates],
3149
+ _analysis_current_commit(state),
3150
+ )
3151
+ except AnalysisInputError as exc:
3152
+ raise WizardError(str(exc)) from exc
3153
+ state.feature_evidence_path = ""
3154
+ state.project_evidence_path = ""
3155
+ for evidence in evidence_inputs:
3156
+ if evidence.relation == "feature-baseline":
3157
+ state.feature_evidence_path = str(evidence.report_path)
3158
+ elif evidence.relation == "project-context":
3159
+ state.project_evidence_path = str(evidence.report_path)
3160
+ return evidence_inputs, {
3161
+ candidate.report_path: candidate for candidate in candidates
3162
+ }
3163
+
3164
+
2753
3165
  def _safe_model(provider: str, raw: object) -> str:
2754
3166
  """run-inputs 의 모델 display 값을 wizard 필드로 환원. 알 수 없는 값은
2755
3167
  빈 문자열(phase 기본값)로 안전하게 떨어뜨린다."""
@@ -2795,6 +3207,25 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
2795
3207
  state.directive = directive if isinstance(directive, str) else ""
2796
3208
  related = inputs.get("relatedTasks")
2797
3209
  state.related_tasks_raw = related if isinstance(related, str) else ""
3210
+ evidence_inputs, candidates = _restore_analysis_evidence_inputs(
3211
+ state, inputs.get("evidenceInputs")
3212
+ )
3213
+ target = inputs.get("analysisTarget")
3214
+ if state.task_type == "feature-analysis":
3215
+ try:
3216
+ resolved_target = resolve_analysis_target(
3217
+ target if isinstance(target, str) else "",
3218
+ evidence_inputs,
3219
+ candidates,
3220
+ )
3221
+ except AnalysisInputError as exc:
3222
+ raise WizardError(str(exc)) from exc
3223
+ requested_value = resolved_target.get("requestedValue")
3224
+ if not isinstance(requested_value, str):
3225
+ raise WizardError("analysis target resolver returned an invalid requestedValue")
3226
+ state.analysis_target = requested_value
3227
+ else:
3228
+ state.analysis_target = target if isinstance(target, str) else ""
2798
3229
  # clarification 은 직전 run 의 입력이 아니라 "직전 run 의 산출물(가장 최근
2799
3230
  # final-report)" 을 재실행 입력으로 자동 선택한다 — resume-clarification 의 본질.
2800
3231
  state.clarification_response_path = _suggest_latest_final_report(state)
@@ -3304,6 +3735,63 @@ STEPS: list[Step] = [
3304
3735
  and s.task_id_pending_text),
3305
3736
  build=_build_task_id_text, submit=_submit_task_id_text,
3306
3737
  owns=("task_id", "task_id_pending_text")),
3738
+ Step(S_REUSE_PREVIOUS,
3739
+ applies=lambda s: (_identity_ready(s)
3740
+ and s.use_defaults is None
3741
+ and s.reuse_previous is None
3742
+ and s.task_type in _RESUME_REUSE_PHASES
3743
+ and _has_prior_run_inputs(s)),
3744
+ build=_build_reuse_previous, submit=_submit_reuse_previous,
3745
+ owns=("reuse_previous",)),
3746
+ Step(S_FEATURE_EVIDENCE_PICK,
3747
+ applies=lambda s: (s.task_type == "change-impact-analysis"
3748
+ and not s.feature_evidence_path
3749
+ and not s.feature_evidence_pending_text
3750
+ and S_FEATURE_EVIDENCE_PICK not in s.answered
3751
+ and _brief_resolved(s)
3752
+ and _analysis_inputs_after_reuse(s)),
3753
+ build=_build_feature_evidence_pick, submit=_submit_feature_evidence_pick,
3754
+ owns=("feature_evidence_path", "feature_evidence_pending_text")),
3755
+ Step(S_FEATURE_EVIDENCE,
3756
+ applies=lambda s: (s.task_type == "change-impact-analysis"
3757
+ and s.feature_evidence_pending_text
3758
+ and S_FEATURE_EVIDENCE not in s.answered),
3759
+ build=_build_feature_evidence, submit=_submit_feature_evidence,
3760
+ owns=("feature_evidence_path", "feature_evidence_pending_text")),
3761
+ Step(S_PROJECT_EVIDENCE_PICK,
3762
+ applies=lambda s: (s.task_type in ("feature-analysis", "change-impact-analysis")
3763
+ and not s.project_evidence_path
3764
+ and not s.project_evidence_pending_text
3765
+ and S_PROJECT_EVIDENCE_PICK not in s.answered
3766
+ and _brief_resolved(s)
3767
+ and _analysis_inputs_after_reuse(s)
3768
+ and (s.task_type != "change-impact-analysis"
3769
+ or S_FEATURE_EVIDENCE_PICK in s.answered)),
3770
+ build=_build_project_evidence_pick, submit=_submit_project_evidence_pick,
3771
+ owns=("project_evidence_path", "project_evidence_pending_text",
3772
+ "analysis_target", "analysis_target_pending_text")),
3773
+ Step(S_PROJECT_EVIDENCE,
3774
+ applies=lambda s: (s.task_type in ("feature-analysis", "change-impact-analysis")
3775
+ and s.project_evidence_pending_text
3776
+ and S_PROJECT_EVIDENCE not in s.answered),
3777
+ build=_build_project_evidence, submit=_submit_project_evidence,
3778
+ owns=("project_evidence_path", "project_evidence_pending_text",
3779
+ "analysis_target", "analysis_target_pending_text")),
3780
+ Step(S_ANALYSIS_TARGET_PICK,
3781
+ applies=lambda s: (s.task_type == "feature-analysis"
3782
+ and bool(s.project_evidence_path)
3783
+ and not s.analysis_target
3784
+ and not s.analysis_target_pending_text
3785
+ and S_ANALYSIS_TARGET_PICK not in s.answered),
3786
+ build=_build_analysis_target_pick, submit=_submit_analysis_target_pick,
3787
+ owns=("analysis_target", "analysis_target_pending_text")),
3788
+ Step(S_ANALYSIS_TARGET,
3789
+ applies=lambda s: (s.task_type == "feature-analysis"
3790
+ and not s.analysis_target
3791
+ and s.analysis_target_pending_text
3792
+ and S_ANALYSIS_TARGET not in s.answered),
3793
+ build=_build_analysis_target, submit=_submit_analysis_target,
3794
+ owns=("analysis_target", "analysis_target_pending_text")),
3307
3795
  Step(S_BASE_REF_PICK,
3308
3796
  applies=lambda s: (S_TASK_TYPE in s.answered
3309
3797
  and _base_ref_required(s)
@@ -3395,18 +3883,6 @@ STEPS: list[Step] = [
3395
3883
  applies=lambda s: (s.critic_pending_text and S_CRITIC_TEXT not in s.answered),
3396
3884
  build=_build_critic_text, submit=_submit_critic_text,
3397
3885
  owns=("critic", "critic_pending_text")),
3398
- # resume: identity 가 모두 정해진 뒤(직전 run 과 같은 task/phase 가 확정),
3399
- # 모델/워커/directive/related/clarification 설정 단계로 들어가기 직전에
3400
- # "직전 run 설정 그대로 재사용?" 를 한 번 묻는다. YES 면 그 단계들을 prefill 로
3401
- # 충족시켜 곧장 confirm 으로 직행하고, NO 면 현행대로 단계별 입력을 받는다.
3402
- Step(S_REUSE_PREVIOUS,
3403
- applies=lambda s: (_identity_ready(s)
3404
- and s.use_defaults is None
3405
- and s.reuse_previous is None
3406
- and s.task_type in _RESUME_REUSE_PHASES
3407
- and _has_prior_run_inputs(s)),
3408
- build=_build_reuse_previous, submit=_submit_reuse_previous,
3409
- owns=("reuse_previous",)),
3410
3886
  Step(S_DEFAULTS_OR_CUSTOM,
3411
3887
  applies=lambda s: (_identity_ready(s)
3412
3888
  and s.use_defaults is None),
@@ -3609,6 +4085,14 @@ def _reset_from(state: WizardState, target_step: str) -> None:
3609
4085
  for fname in step.owns:
3610
4086
  _reset_field(state, fname)
3611
4087
  state.answered = [a for a in state.answered if a not in cleared_ids]
4088
+ direct_input_pending = {
4089
+ S_FEATURE_EVIDENCE: "feature_evidence_pending_text",
4090
+ S_PROJECT_EVIDENCE: "project_evidence_pending_text",
4091
+ S_ANALYSIS_TARGET: "analysis_target_pending_text",
4092
+ }
4093
+ pending_field = direct_input_pending.get(target_step)
4094
+ if pending_field is not None:
4095
+ setattr(state, pending_field, True)
3612
4096
 
3613
4097
 
3614
4098
  _FIELD_DEFAULTS: dict[str, Any] = {
@@ -3619,6 +4103,9 @@ _FIELD_DEFAULTS: dict[str, Any] = {
3619
4103
  "profile_workers": [], "profile_optional_workers": [],
3620
4104
  "keep_existing_brief": None,
3621
4105
  "brief_path": "", "brief_path_pending_text": False,
4106
+ "project_evidence_path": "", "project_evidence_pending_text": False,
4107
+ "feature_evidence_path": "", "feature_evidence_pending_text": False,
4108
+ "analysis_target": "", "analysis_target_pending_text": False,
3622
4109
  "reuse_worktree": None, "base_ref": "",
3623
4110
  "base_ref_pending_text": False, "approved_plan_path": "",
3624
4111
  "approved_plan_pending_text": False, "approve_plan_candidate": "",
@@ -3876,6 +4363,11 @@ def render_args(state: WizardState) -> dict[str, str]:
3876
4363
  if state.task_type == "release-handoff"
3877
4364
  else ""
3878
4365
  )
4366
+ evidence_paths = [
4367
+ path
4368
+ for path in (state.feature_evidence_path, state.project_evidence_path)
4369
+ if path
4370
+ ]
3879
4371
  return {
3880
4372
  "project-root": state.project_root,
3881
4373
  "project-id": state.project_id,
@@ -3883,6 +4375,8 @@ def render_args(state: WizardState) -> dict[str, str]:
3883
4375
  "task-id": state.task_id,
3884
4376
  "task-type": state.task_type,
3885
4377
  "task-brief": state.brief_path,
4378
+ "analysis-target": state.analysis_target,
4379
+ "evidence-inputs": ",".join(evidence_paths),
3886
4380
  "executor": state.executor,
3887
4381
  "critic": state.critic,
3888
4382
  "approved-plan": state.approved_plan_path,
@@ -3911,6 +4405,25 @@ def confirmation_block(state: WizardState) -> str:
3911
4405
  lines.append(f" task-type : {state.task_type}")
3912
4406
  lines.append(f" task-key : {state.task_group}/{state.task_id}")
3913
4407
  lines.append(f" brief : {state.brief_path or '(none)'}")
4408
+ if state.analysis_target:
4409
+ lines.append(f" analysis-target: {state.analysis_target}")
4410
+ if state.feature_evidence_path or state.project_evidence_path:
4411
+ evidence_status = {
4412
+ str(item.report_path): item.review_status
4413
+ for item in _resolve_analysis_evidence(state)
4414
+ }
4415
+ if state.feature_evidence_path:
4416
+ lines.append(
4417
+ " feature-evidence: "
4418
+ f"{state.feature_evidence_path} "
4419
+ f"({evidence_status.get(state.feature_evidence_path, 'unknown')})"
4420
+ )
4421
+ if state.project_evidence_path:
4422
+ lines.append(
4423
+ " project-evidence: "
4424
+ f"{state.project_evidence_path} "
4425
+ f"({evidence_status.get(state.project_evidence_path, 'unknown')})"
4426
+ )
3914
4427
  if state.task_type == "final-verification":
3915
4428
  lines.append(" base-ref : (selected stage worktree)")
3916
4429
  elif state.task_type == "implementation" and state.reuse_worktree: