okstra 0.144.0 → 0.146.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 (71) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +22 -4
  3. package/docs/cli.md +53 -6
  4. package/docs/project-structure-overview.md +19 -9
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +5 -6
  8. package/runtime/bin/okstra-trace-cleanup.sh +28 -2
  9. package/runtime/prompts/lead/adapters/claude-code.md +3 -3
  10. package/runtime/prompts/lead/convergence.md +30 -5
  11. package/runtime/prompts/lead/okstra-lead-contract.md +9 -3
  12. package/runtime/prompts/lead/report-writer.md +20 -14
  13. package/runtime/prompts/lead/team-contract.md +3 -3
  14. package/runtime/prompts/profiles/_common-contract.md +1 -1
  15. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  16. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  17. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  18. package/runtime/prompts/profiles/project-analysis.md +24 -0
  19. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  20. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  21. package/runtime/python/okstra_ctl/analysis_packet.py +4 -10
  22. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  23. package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
  24. package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
  25. package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
  26. package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
  27. package/runtime/python/okstra_ctl/path_hints.py +6 -0
  28. package/runtime/python/okstra_ctl/paths.py +7 -44
  29. package/runtime/python/okstra_ctl/render.py +79 -4
  30. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  31. package/runtime/python/okstra_ctl/report_views.py +134 -3
  32. package/runtime/python/okstra_ctl/run.py +118 -0
  33. package/runtime/python/okstra_ctl/run_context.py +34 -2
  34. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  35. package/runtime/python/okstra_ctl/user_response.py +309 -3
  36. package/runtime/python/okstra_ctl/wizard.py +579 -32
  37. package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
  38. package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
  39. package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
  40. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  41. package/runtime/python/okstra_ctl/worker_state.py +65 -0
  42. package/runtime/python/okstra_ctl/workflow.py +22 -0
  43. package/runtime/python/okstra_token_usage/antigravity.py +3 -0
  44. package/runtime/python/okstra_token_usage/codex.py +54 -23
  45. package/runtime/python/okstra_token_usage/collect.py +141 -33
  46. package/runtime/python/okstra_token_usage/paths.py +27 -0
  47. package/runtime/python/okstra_vendor/__init__.py +15 -2
  48. package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
  49. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  50. package/runtime/skills/okstra-run/SKILL.md +27 -5
  51. package/runtime/skills/okstra-setup/references/project-config.md +13 -4
  52. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  53. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  54. package/runtime/templates/reports/final-report.template.md +220 -0
  55. package/runtime/templates/reports/i18n/en.json +8 -0
  56. package/runtime/templates/reports/i18n/ko.json +8 -0
  57. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  58. package/runtime/templates/reports/report.js +84 -5
  59. package/runtime/templates/reports/user-response.template.md +19 -1
  60. package/runtime/validators/lib/fixtures.sh +1 -1
  61. package/runtime/validators/validate-report-views.py +61 -7
  62. package/runtime/validators/validate-run.py +94 -1
  63. package/runtime/validators/validate_analysis_report.py +895 -0
  64. package/src/cli-registry.mjs +7 -10
  65. package/src/commands/execute/render-bundle.mjs +3 -0
  66. package/src/commands/execute/worker-state.mjs +29 -0
  67. package/src/commands/inspect/worker-liveness.mjs +5 -3
  68. package/src/commands/lifecycle/preflight.mjs +13 -3
  69. package/src/lib/runtime-readiness.mjs +90 -0
  70. package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
  71. package/src/commands/execute/phase-cleanup.mjs +0 -38
@@ -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,11 +151,16 @@ _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__"
163
+ HOST_PICK_OPTION_CAP = 4
139
164
 
140
165
  CLAUDE_MODEL_OPTIONS = ["default", *picker_options("claude")]
141
166
  CODEX_MODEL_OPTIONS = ["default", *picker_options("codex")]
@@ -263,6 +288,12 @@ S_BRIEF_KEEP = "brief_keep"
263
288
  S_BRIEF_PATH_PICK = "brief_path_pick"
264
289
  S_BRIEF_PATH = "brief_path"
265
290
  S_BRIEF_CARRY = "brief_carry"
291
+ S_FEATURE_EVIDENCE_PICK = "feature_evidence_pick"
292
+ S_FEATURE_EVIDENCE = "feature_evidence"
293
+ S_PROJECT_EVIDENCE_PICK = "project_evidence_pick"
294
+ S_PROJECT_EVIDENCE = "project_evidence"
295
+ S_ANALYSIS_TARGET_PICK = "analysis_target_pick"
296
+ S_ANALYSIS_TARGET = "analysis_target"
266
297
  S_BASE_REF_PICK = "base_ref_pick"
267
298
  S_BASE_REF_TEXT = "base_ref_text"
268
299
  S_APPROVED_PLAN_PICK = "approved_plan_pick"
@@ -355,6 +386,14 @@ class WizardState:
355
386
  brief_path: str = ""
356
387
  brief_path_pending_text: bool = False
357
388
 
389
+ # analysis evidence and target
390
+ project_evidence_path: str = ""
391
+ project_evidence_pending_text: bool = False
392
+ feature_evidence_path: str = ""
393
+ feature_evidence_pending_text: bool = False
394
+ analysis_target: str = ""
395
+ analysis_target_pending_text: bool = False
396
+
358
397
  # worktree
359
398
  reuse_worktree: Optional[bool] = None
360
399
  base_ref: str = ""
@@ -460,6 +499,8 @@ class Prompt:
460
499
  "echoTemplate": self.echo_template,
461
500
  "multi": self.multi,
462
501
  }
502
+ if self.kind == "pick" and len(self.options) > HOST_PICK_OPTION_CAP:
503
+ out["presentation"] = "numbered-text"
463
504
  if self.kind == "pick_group":
464
505
  out["questions"] = [
465
506
  {"step": q.step, "label": q.label,
@@ -474,6 +515,35 @@ class WizardError(Exception):
474
515
  """validation failure surfaced to user."""
475
516
 
476
517
 
518
+ def _normalize_numbered_pick_item(prompt: Prompt, answer: str) -> str:
519
+ candidate = (answer or "").strip()
520
+ exact_values = [option.value for option in prompt.options if option.value == candidate]
521
+ if exact_values:
522
+ return exact_values[0]
523
+
524
+ if candidate.isdecimal():
525
+ number = int(candidate)
526
+ if 1 <= number <= len(prompt.options):
527
+ return prompt.options[number - 1].value
528
+ raise WizardError(f"numbered-text answer is out of range: {candidate}")
529
+
530
+ label_values = [option.value for option in prompt.options if option.label == candidate]
531
+ if len(label_values) == 1:
532
+ return label_values[0]
533
+ if len(label_values) > 1:
534
+ raise WizardError(f"numbered-text answer label is ambiguous: {candidate}")
535
+ raise WizardError(f"numbered-text answer is not an option: {candidate}")
536
+
537
+
538
+ def _normalize_numbered_pick_answer(prompt: Prompt, answer: str) -> str:
539
+ if prompt.multi:
540
+ return ",".join(
541
+ _normalize_numbered_pick_item(prompt, item)
542
+ for item in (answer or "").split(",")
543
+ )
544
+ return _normalize_numbered_pick_item(prompt, answer)
545
+
546
+
477
547
  # ---- Validation helpers --------------------------------------------------
478
548
 
479
549
  _SLUG_OK = re.compile(r"[a-z0-9]")
@@ -1445,6 +1515,101 @@ def _recent_task_types(state: WizardState) -> list[str]:
1445
1515
  return out
1446
1516
 
1447
1517
 
1518
+ def _contained_final_report_data_path(report: Path, task_root: Path) -> Path:
1519
+ _resolved_within(report, task_root, "final report path")
1520
+ return _resolved_within(
1521
+ final_report_data_path(report),
1522
+ task_root,
1523
+ "final report data path",
1524
+ )
1525
+
1526
+
1527
+ def _newest_contained_final_report(
1528
+ runs_base: Path,
1529
+ task_root: Path,
1530
+ glob_pattern: str,
1531
+ project_root: Path,
1532
+ ) -> Path | None:
1533
+ candidates: list[Path] = []
1534
+ for report in runs_base.glob(glob_pattern):
1535
+ if not report.is_file():
1536
+ continue
1537
+ try:
1538
+ _contained_final_report_data_path(report, task_root)
1539
+ if report.parent.parent.name in ANALYSIS_TASK_TYPES:
1540
+ load_analysis_report_candidate(project_root, report)
1541
+ except AnalysisInputError:
1542
+ continue
1543
+ candidates.append(report)
1544
+
1545
+ def mtime_safe(report: Path) -> float:
1546
+ try:
1547
+ return report.stat().st_mtime
1548
+ except OSError:
1549
+ return -1.0
1550
+
1551
+ return max(candidates, key=mtime_safe) if candidates else None
1552
+
1553
+
1554
+ def _analysis_revision_candidate(
1555
+ state: WizardState,
1556
+ report: Path,
1557
+ task_root: Path,
1558
+ expected_task_key: str,
1559
+ ) -> tuple[int, Path] | None:
1560
+ try:
1561
+ _contained_final_report_data_path(report, task_root)
1562
+ candidate = load_analysis_report_candidate(
1563
+ Path(state.project_root), report
1564
+ )
1565
+ except AnalysisInputError:
1566
+ return None
1567
+ if (
1568
+ candidate.task_key != expected_task_key
1569
+ or candidate.task_type != state.task_type
1570
+ or candidate.review_status != "revision-requested"
1571
+ ):
1572
+ return None
1573
+ return int(candidate.run_seq), report
1574
+
1575
+
1576
+ def _latest_revision_requested_analysis_report(
1577
+ state: WizardState,
1578
+ ) -> Path | None:
1579
+ if state.task_type not in ANALYSIS_TASK_TYPES:
1580
+ return None
1581
+ reports = task_runs_dir(
1582
+ Path(state.project_root), state.task_group, state.task_id
1583
+ ) / state.task_type / "reports"
1584
+ try:
1585
+ task_root = _resolved_within(
1586
+ reports.parents[2],
1587
+ Path(state.project_root).resolve(),
1588
+ "analysis task root",
1589
+ )
1590
+ except AnalysisInputError:
1591
+ return None
1592
+ expected_task_key = f"{state.project_id}:{state.task_group}:{state.task_id}"
1593
+ candidates: list[tuple[int, Path]] = []
1594
+ for report in reports.glob("final-report-*.md"):
1595
+ candidate = _analysis_revision_candidate(
1596
+ state,
1597
+ report,
1598
+ task_root,
1599
+ expected_task_key,
1600
+ )
1601
+ if candidate is not None:
1602
+ candidates.append(candidate)
1603
+ if not candidates:
1604
+ return None
1605
+ return max(candidates, key=lambda candidate: candidate[0])[1]
1606
+
1607
+
1608
+ def _latest_revision_requested_analysis_type(state: WizardState) -> str:
1609
+ report = _latest_revision_requested_analysis_report(state)
1610
+ return state.task_type if report is not None else ""
1611
+
1612
+
1448
1613
  def _build_task_type(state: WizardState) -> Prompt:
1449
1614
  t = _p(state.workspace_root, "task_type")
1450
1615
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
@@ -1464,7 +1629,9 @@ def _build_task_type(state: WizardState) -> Prompt:
1464
1629
  description_by_type[task_type]))
1465
1630
 
1466
1631
  workflow = _existing_task_workflow(state)
1467
- recommended = state.task_type or workflow.get("nextRecommendedPhase") or ""
1632
+ revision_requested = _latest_revision_requested_analysis_type(state)
1633
+ recommended = (revision_requested or state.task_type
1634
+ or workflow.get("nextRecommendedPhase") or "")
1468
1635
  if not recommended and not workflow:
1469
1636
  recommended = TASK_TYPE_VALUES[0]
1470
1637
  add(recommended, recommended_suffix)
@@ -1677,6 +1844,225 @@ def _submit_brief_carry(state: WizardState, value: str) -> Optional[str]:
1677
1844
  )
1678
1845
 
1679
1846
 
1847
+ def _analysis_current_commit(state: WizardState) -> str:
1848
+ try:
1849
+ return subprocess.check_output(
1850
+ ["git", "-C", state.project_root, "rev-parse", "HEAD"],
1851
+ text=True,
1852
+ stderr=subprocess.DEVNULL,
1853
+ ).strip()
1854
+ except (OSError, subprocess.CalledProcessError) as exc:
1855
+ raise WizardError(
1856
+ f"cannot resolve current project commit for analysis evidence: {state.project_root}"
1857
+ ) from exc
1858
+
1859
+
1860
+ def _analysis_evidence_paths(state: WizardState) -> list[Path]:
1861
+ return [
1862
+ Path(path)
1863
+ for path in (state.feature_evidence_path, state.project_evidence_path)
1864
+ if path
1865
+ ]
1866
+
1867
+
1868
+ def _resolve_analysis_evidence(state: WizardState):
1869
+ try:
1870
+ return resolve_evidence_inputs(
1871
+ Path(state.project_root),
1872
+ state.task_type,
1873
+ _analysis_evidence_paths(state),
1874
+ _analysis_current_commit(state),
1875
+ )
1876
+ except AnalysisInputError as exc:
1877
+ raise WizardError(str(exc)) from exc
1878
+
1879
+
1880
+ def _accept_analysis_evidence(
1881
+ state: WizardState, raw_path: str, field_name: str
1882
+ ) -> AnalysisReportCandidate:
1883
+ report_path = _resolve_path(raw_path, Path(state.project_root))
1884
+ try:
1885
+ candidate = load_analysis_report_candidate(
1886
+ Path(state.project_root), report_path
1887
+ )
1888
+ except AnalysisInputError as exc:
1889
+ raise WizardError(str(exc)) from exc
1890
+ previous = getattr(state, field_name)
1891
+ setattr(state, field_name, str(candidate.report_path))
1892
+ try:
1893
+ _resolve_analysis_evidence(state)
1894
+ except WizardError:
1895
+ setattr(state, field_name, previous)
1896
+ raise
1897
+ return candidate
1898
+
1899
+
1900
+ def _evidence_option_label(candidate: AnalysisReportCandidate) -> str:
1901
+ return f"{candidate.task_key} · {candidate.task_type} · run {candidate.run_seq}"
1902
+
1903
+
1904
+ def _build_analysis_evidence_pick(
1905
+ state: WizardState, *, step: str, relation: str
1906
+ ) -> Prompt:
1907
+ t = _p(state.workspace_root, step)
1908
+ candidates = list_evidence_candidates(
1909
+ Path(state.project_root), state.task_type, relation
1910
+ )[:2]
1911
+ options = [
1912
+ _opt(str(candidate.report_path), _evidence_option_label(candidate))
1913
+ for candidate in candidates
1914
+ ]
1915
+ options.extend([
1916
+ _opt(PICK_SKIP, t["options"][PICK_SKIP]),
1917
+ _opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]),
1918
+ ])
1919
+ return Prompt(step=step, kind="pick", label=t["label"], options=options,
1920
+ echo_template=t["echo_template"])
1921
+
1922
+
1923
+ def _submit_analysis_evidence_pick(
1924
+ state: WizardState,
1925
+ value: str,
1926
+ *,
1927
+ step: str,
1928
+ field_name: str,
1929
+ pending_name: str,
1930
+ target_after_skip: bool = False,
1931
+ ) -> Optional[str]:
1932
+ t = _p(state.workspace_root, step)
1933
+ if value == PICK_SKIP:
1934
+ setattr(state, field_name, "")
1935
+ setattr(state, pending_name, False)
1936
+ if target_after_skip:
1937
+ state.analysis_target_pending_text = True
1938
+ return t["echo_variants"]["skip"]
1939
+ if value == PICK_TYPE_CUSTOM:
1940
+ setattr(state, pending_name, True)
1941
+ return None
1942
+ candidate = _accept_analysis_evidence(state, value, field_name)
1943
+ setattr(state, pending_name, False)
1944
+ return t["echo_variants"]["selected"].format(path=candidate.report_path)
1945
+
1946
+
1947
+ def _build_feature_evidence_pick(state: WizardState) -> Prompt:
1948
+ return _build_analysis_evidence_pick(
1949
+ state, step=S_FEATURE_EVIDENCE_PICK, relation="feature-baseline"
1950
+ )
1951
+
1952
+
1953
+ def _submit_feature_evidence_pick(state: WizardState, value: str) -> Optional[str]:
1954
+ return _submit_analysis_evidence_pick(
1955
+ state, value, step=S_FEATURE_EVIDENCE_PICK,
1956
+ field_name="feature_evidence_path",
1957
+ pending_name="feature_evidence_pending_text",
1958
+ )
1959
+
1960
+
1961
+ def _build_feature_evidence(state: WizardState) -> Prompt:
1962
+ t = _p(state.workspace_root, S_FEATURE_EVIDENCE)
1963
+ return Prompt(step=S_FEATURE_EVIDENCE, kind="text", label=t["label"],
1964
+ echo_template=t["echo_template"])
1965
+
1966
+
1967
+ def _submit_feature_evidence(state: WizardState, value: str) -> Optional[str]:
1968
+ candidate = _accept_analysis_evidence(state, value, "feature_evidence_path")
1969
+ state.feature_evidence_pending_text = False
1970
+ return f"feature-evidence: {candidate.report_path}"
1971
+
1972
+
1973
+ def _build_project_evidence_pick(state: WizardState) -> Prompt:
1974
+ return _build_analysis_evidence_pick(
1975
+ state, step=S_PROJECT_EVIDENCE_PICK, relation="project-context"
1976
+ )
1977
+
1978
+
1979
+ def _submit_project_evidence_pick(state: WizardState, value: str) -> Optional[str]:
1980
+ return _submit_analysis_evidence_pick(
1981
+ state, value, step=S_PROJECT_EVIDENCE_PICK,
1982
+ field_name="project_evidence_path",
1983
+ pending_name="project_evidence_pending_text",
1984
+ target_after_skip=state.task_type == "feature-analysis",
1985
+ )
1986
+
1987
+
1988
+ def _build_project_evidence(state: WizardState) -> Prompt:
1989
+ t = _p(state.workspace_root, S_PROJECT_EVIDENCE)
1990
+ return Prompt(step=S_PROJECT_EVIDENCE, kind="text", label=t["label"],
1991
+ echo_template=t["echo_template"])
1992
+
1993
+
1994
+ def _submit_project_evidence(state: WizardState, value: str) -> Optional[str]:
1995
+ candidate = _accept_analysis_evidence(state, value, "project_evidence_path")
1996
+ state.project_evidence_pending_text = False
1997
+ return f"project-evidence: {candidate.report_path}"
1998
+
1999
+
2000
+ def _feature_description(feature: dict[object, object]) -> str:
2001
+ name = str(feature.get("name") or "")
2002
+ summary = str(feature.get("summary") or "")
2003
+ entry_point = str(
2004
+ feature.get("entryPoint") or feature.get("entrypoint") or ""
2005
+ )
2006
+ return " · ".join(value for value in (name, summary, entry_point) if value)
2007
+
2008
+
2009
+ def _build_analysis_target_pick(state: WizardState) -> Prompt:
2010
+ t = _p(state.workspace_root, S_ANALYSIS_TARGET_PICK)
2011
+ try:
2012
+ candidate = load_analysis_report_candidate(
2013
+ Path(state.project_root), Path(state.project_evidence_path)
2014
+ )
2015
+ except AnalysisInputError as exc:
2016
+ raise WizardError(str(exc)) from exc
2017
+ options = [
2018
+ _opt(str(feature["id"]), str(feature["id"]), _feature_description(feature))
2019
+ for feature in candidate.feature_index
2020
+ if isinstance(feature, dict) and isinstance(feature.get("id"), str)
2021
+ ][:3]
2022
+ options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
2023
+ return Prompt(step=S_ANALYSIS_TARGET_PICK, kind="pick", label=t["label"],
2024
+ options=options, echo_template=t["echo_template"])
2025
+
2026
+
2027
+ def _accept_analysis_target(state: WizardState, value: str) -> str:
2028
+ candidates: dict[Path, AnalysisReportCandidate] = {}
2029
+ for path in _analysis_evidence_paths(state):
2030
+ try:
2031
+ candidate = load_analysis_report_candidate(Path(state.project_root), path)
2032
+ except AnalysisInputError as exc:
2033
+ raise WizardError(str(exc)) from exc
2034
+ candidates[candidate.report_path] = candidate
2035
+ try:
2036
+ target = resolve_analysis_target(
2037
+ value, _resolve_analysis_evidence(state), candidates
2038
+ )
2039
+ except AnalysisInputError as exc:
2040
+ raise WizardError(str(exc)) from exc
2041
+ requested_value = target["requestedValue"]
2042
+ if not isinstance(requested_value, str):
2043
+ raise WizardError("analysis target resolver returned an invalid requestedValue")
2044
+ state.analysis_target = requested_value
2045
+ state.analysis_target_pending_text = False
2046
+ return requested_value
2047
+
2048
+
2049
+ def _submit_analysis_target_pick(state: WizardState, value: str) -> Optional[str]:
2050
+ if value == PICK_TYPE_CUSTOM:
2051
+ state.analysis_target_pending_text = True
2052
+ return None
2053
+ return f"analysis-target: {_accept_analysis_target(state, value)}"
2054
+
2055
+
2056
+ def _build_analysis_target(state: WizardState) -> Prompt:
2057
+ t = _p(state.workspace_root, S_ANALYSIS_TARGET)
2058
+ return Prompt(step=S_ANALYSIS_TARGET, kind="text", label=t["label"],
2059
+ echo_template=t["echo_template"])
2060
+
2061
+
2062
+ def _submit_analysis_target(state: WizardState, value: str) -> Optional[str]:
2063
+ return f"analysis-target: {_accept_analysis_target(state, value)}"
2064
+
2065
+
1680
2066
  def _build_base_ref_pick(state: WizardState) -> Prompt:
1681
2067
  t = _p(state.workspace_root, "base_ref_pick")
1682
2068
  recommended_suffix = t["options"].get("_RECOMMENDED_SUFFIX", "")
@@ -2383,24 +2769,34 @@ def _suggest_latest_final_report(state: WizardState) -> str:
2383
2769
  runs_base = task_runs_dir(state.project_root, state.task_group, state.task_id)
2384
2770
  if not runs_base.is_dir():
2385
2771
  return ""
2772
+ try:
2773
+ task_root = _resolved_within(
2774
+ runs_base.parent,
2775
+ Path(state.project_root).resolve(),
2776
+ "task root",
2777
+ )
2778
+ except AnalysisInputError:
2779
+ return ""
2386
2780
 
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:
2781
+ # A revision request outranks ordinary recency. Carry the newest requested
2782
+ # revision for the selected analysis type into that exact rerun.
2783
+ revision = _latest_revision_requested_analysis_report(state)
2784
+ best = revision
2785
+ if best is None and state.task_type:
2400
2786
  seg = slugify_task_segment(state.task_type)
2401
- best = _newest(f"{seg}/reports/final-report-*.md")
2787
+ best = _newest_contained_final_report(
2788
+ runs_base,
2789
+ task_root,
2790
+ f"{seg}/reports/final-report-*.md",
2791
+ Path(state.project_root),
2792
+ )
2402
2793
  if best is None:
2403
- best = _newest("*/reports/final-report-*.md")
2794
+ best = _newest_contained_final_report(
2795
+ runs_base,
2796
+ task_root,
2797
+ "*/reports/final-report-*.md",
2798
+ Path(state.project_root),
2799
+ )
2404
2800
  if best is None:
2405
2801
  return ""
2406
2802
  # The approved plan is already wired via --approved-plan. On the first run
@@ -2735,10 +3131,15 @@ def _submit_executor(state: WizardState, value: str) -> Optional[str]:
2735
3131
  # §1 clarification 을 갖는 비-implementation phase 로 한정한다(implementation 은
2736
3132
  # approved-plan/stage 가 매 run 고유라 재사용 대상이 아니다).
2737
3133
  _RESUME_REUSE_PHASES = (
2738
- "requirements-discovery", "error-analysis", "implementation-planning")
3134
+ "requirements-discovery", "error-analysis", "implementation-planning",
3135
+ *ANALYSIS_TASK_TYPES,
3136
+ )
2739
3137
 
2740
3138
  # YES 선택 시 prefill 로 충족되어 다시 묻지 않는 설정 단계들.
2741
3139
  _REUSE_FILLED_STEPS = (
3140
+ S_FEATURE_EVIDENCE_PICK, S_FEATURE_EVIDENCE,
3141
+ S_PROJECT_EVIDENCE_PICK, S_PROJECT_EVIDENCE,
3142
+ S_ANALYSIS_TARGET_PICK, S_ANALYSIS_TARGET,
2742
3143
  S_DEFAULTS_OR_CUSTOM, S_WORKERS_OVERRIDE, S_LEAD_MODEL, S_EXECUTOR_MODEL,
2743
3144
  S_CLAUDE_MODEL, S_CODEX_MODEL, S_ANTIGRAVITY_MODEL, S_REPORT_WRITER_MODEL,
2744
3145
  S_DIRECTIVE_PICK, S_DIRECTIVE, S_RELATED_TASKS_PICK, S_RELATED_TASKS,
@@ -2750,6 +3151,49 @@ def _has_prior_run_inputs(state: WizardState) -> bool:
2750
3151
  return bool(_latest_run_inputs(state, phase_segment=seg))
2751
3152
 
2752
3153
 
3154
+ def _analysis_inputs_after_reuse(state: WizardState) -> bool:
3155
+ return (
3156
+ state.reuse_previous is not None
3157
+ or state.task_type not in _RESUME_REUSE_PHASES
3158
+ or not _has_prior_run_inputs(state)
3159
+ )
3160
+
3161
+
3162
+ def _restore_analysis_evidence_inputs(
3163
+ state: WizardState, evidence_raw: object
3164
+ ) -> tuple[tuple[Any, ...], dict[Path, AnalysisReportCandidate]]:
3165
+ if state.task_type not in ANALYSIS_TASK_TYPES or not isinstance(evidence_raw, str):
3166
+ return (), {}
3167
+ report_paths = [
3168
+ _resolve_path(item.strip(), Path(state.project_root))
3169
+ for item in evidence_raw.split(",")
3170
+ if item.strip()
3171
+ ]
3172
+ try:
3173
+ candidates = [
3174
+ load_analysis_report_candidate(Path(state.project_root), path)
3175
+ for path in report_paths
3176
+ ]
3177
+ evidence_inputs = resolve_evidence_inputs(
3178
+ Path(state.project_root),
3179
+ state.task_type,
3180
+ [candidate.report_path for candidate in candidates],
3181
+ _analysis_current_commit(state),
3182
+ )
3183
+ except AnalysisInputError as exc:
3184
+ raise WizardError(str(exc)) from exc
3185
+ state.feature_evidence_path = ""
3186
+ state.project_evidence_path = ""
3187
+ for evidence in evidence_inputs:
3188
+ if evidence.relation == "feature-baseline":
3189
+ state.feature_evidence_path = str(evidence.report_path)
3190
+ elif evidence.relation == "project-context":
3191
+ state.project_evidence_path = str(evidence.report_path)
3192
+ return evidence_inputs, {
3193
+ candidate.report_path: candidate for candidate in candidates
3194
+ }
3195
+
3196
+
2753
3197
  def _safe_model(provider: str, raw: object) -> str:
2754
3198
  """run-inputs 의 모델 display 값을 wizard 필드로 환원. 알 수 없는 값은
2755
3199
  빈 문자열(phase 기본값)로 안전하게 떨어뜨린다."""
@@ -2795,6 +3239,25 @@ def _submit_reuse_previous(state: WizardState, value: str) -> Optional[str]:
2795
3239
  state.directive = directive if isinstance(directive, str) else ""
2796
3240
  related = inputs.get("relatedTasks")
2797
3241
  state.related_tasks_raw = related if isinstance(related, str) else ""
3242
+ evidence_inputs, candidates = _restore_analysis_evidence_inputs(
3243
+ state, inputs.get("evidenceInputs")
3244
+ )
3245
+ target = inputs.get("analysisTarget")
3246
+ if state.task_type == "feature-analysis":
3247
+ try:
3248
+ resolved_target = resolve_analysis_target(
3249
+ target if isinstance(target, str) else "",
3250
+ evidence_inputs,
3251
+ candidates,
3252
+ )
3253
+ except AnalysisInputError as exc:
3254
+ raise WizardError(str(exc)) from exc
3255
+ requested_value = resolved_target.get("requestedValue")
3256
+ if not isinstance(requested_value, str):
3257
+ raise WizardError("analysis target resolver returned an invalid requestedValue")
3258
+ state.analysis_target = requested_value
3259
+ else:
3260
+ state.analysis_target = target if isinstance(target, str) else ""
2798
3261
  # clarification 은 직전 run 의 입력이 아니라 "직전 run 의 산출물(가장 최근
2799
3262
  # final-report)" 을 재실행 입력으로 자동 선택한다 — resume-clarification 의 본질.
2800
3263
  state.clarification_response_path = _suggest_latest_final_report(state)
@@ -3304,6 +3767,63 @@ STEPS: list[Step] = [
3304
3767
  and s.task_id_pending_text),
3305
3768
  build=_build_task_id_text, submit=_submit_task_id_text,
3306
3769
  owns=("task_id", "task_id_pending_text")),
3770
+ Step(S_REUSE_PREVIOUS,
3771
+ applies=lambda s: (_identity_ready(s)
3772
+ and s.use_defaults is None
3773
+ and s.reuse_previous is None
3774
+ and s.task_type in _RESUME_REUSE_PHASES
3775
+ and _has_prior_run_inputs(s)),
3776
+ build=_build_reuse_previous, submit=_submit_reuse_previous,
3777
+ owns=("reuse_previous",)),
3778
+ Step(S_FEATURE_EVIDENCE_PICK,
3779
+ applies=lambda s: (s.task_type == "change-impact-analysis"
3780
+ and not s.feature_evidence_path
3781
+ and not s.feature_evidence_pending_text
3782
+ and S_FEATURE_EVIDENCE_PICK not in s.answered
3783
+ and _brief_resolved(s)
3784
+ and _analysis_inputs_after_reuse(s)),
3785
+ build=_build_feature_evidence_pick, submit=_submit_feature_evidence_pick,
3786
+ owns=("feature_evidence_path", "feature_evidence_pending_text")),
3787
+ Step(S_FEATURE_EVIDENCE,
3788
+ applies=lambda s: (s.task_type == "change-impact-analysis"
3789
+ and s.feature_evidence_pending_text
3790
+ and S_FEATURE_EVIDENCE not in s.answered),
3791
+ build=_build_feature_evidence, submit=_submit_feature_evidence,
3792
+ owns=("feature_evidence_path", "feature_evidence_pending_text")),
3793
+ Step(S_PROJECT_EVIDENCE_PICK,
3794
+ applies=lambda s: (s.task_type in ("feature-analysis", "change-impact-analysis")
3795
+ and not s.project_evidence_path
3796
+ and not s.project_evidence_pending_text
3797
+ and S_PROJECT_EVIDENCE_PICK not in s.answered
3798
+ and _brief_resolved(s)
3799
+ and _analysis_inputs_after_reuse(s)
3800
+ and (s.task_type != "change-impact-analysis"
3801
+ or S_FEATURE_EVIDENCE_PICK in s.answered)),
3802
+ build=_build_project_evidence_pick, submit=_submit_project_evidence_pick,
3803
+ owns=("project_evidence_path", "project_evidence_pending_text",
3804
+ "analysis_target", "analysis_target_pending_text")),
3805
+ Step(S_PROJECT_EVIDENCE,
3806
+ applies=lambda s: (s.task_type in ("feature-analysis", "change-impact-analysis")
3807
+ and s.project_evidence_pending_text
3808
+ and S_PROJECT_EVIDENCE not in s.answered),
3809
+ build=_build_project_evidence, submit=_submit_project_evidence,
3810
+ owns=("project_evidence_path", "project_evidence_pending_text",
3811
+ "analysis_target", "analysis_target_pending_text")),
3812
+ Step(S_ANALYSIS_TARGET_PICK,
3813
+ applies=lambda s: (s.task_type == "feature-analysis"
3814
+ and bool(s.project_evidence_path)
3815
+ and not s.analysis_target
3816
+ and not s.analysis_target_pending_text
3817
+ and S_ANALYSIS_TARGET_PICK not in s.answered),
3818
+ build=_build_analysis_target_pick, submit=_submit_analysis_target_pick,
3819
+ owns=("analysis_target", "analysis_target_pending_text")),
3820
+ Step(S_ANALYSIS_TARGET,
3821
+ applies=lambda s: (s.task_type == "feature-analysis"
3822
+ and not s.analysis_target
3823
+ and s.analysis_target_pending_text
3824
+ and S_ANALYSIS_TARGET not in s.answered),
3825
+ build=_build_analysis_target, submit=_submit_analysis_target,
3826
+ owns=("analysis_target", "analysis_target_pending_text")),
3307
3827
  Step(S_BASE_REF_PICK,
3308
3828
  applies=lambda s: (S_TASK_TYPE in s.answered
3309
3829
  and _base_ref_required(s)
@@ -3395,18 +3915,6 @@ STEPS: list[Step] = [
3395
3915
  applies=lambda s: (s.critic_pending_text and S_CRITIC_TEXT not in s.answered),
3396
3916
  build=_build_critic_text, submit=_submit_critic_text,
3397
3917
  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
3918
  Step(S_DEFAULTS_OR_CUSTOM,
3411
3919
  applies=lambda s: (_identity_ready(s)
3412
3920
  and s.use_defaults is None),
@@ -3609,6 +4117,14 @@ def _reset_from(state: WizardState, target_step: str) -> None:
3609
4117
  for fname in step.owns:
3610
4118
  _reset_field(state, fname)
3611
4119
  state.answered = [a for a in state.answered if a not in cleared_ids]
4120
+ direct_input_pending = {
4121
+ S_FEATURE_EVIDENCE: "feature_evidence_pending_text",
4122
+ S_PROJECT_EVIDENCE: "project_evidence_pending_text",
4123
+ S_ANALYSIS_TARGET: "analysis_target_pending_text",
4124
+ }
4125
+ pending_field = direct_input_pending.get(target_step)
4126
+ if pending_field is not None:
4127
+ setattr(state, pending_field, True)
3612
4128
 
3613
4129
 
3614
4130
  _FIELD_DEFAULTS: dict[str, Any] = {
@@ -3619,6 +4135,9 @@ _FIELD_DEFAULTS: dict[str, Any] = {
3619
4135
  "profile_workers": [], "profile_optional_workers": [],
3620
4136
  "keep_existing_brief": None,
3621
4137
  "brief_path": "", "brief_path_pending_text": False,
4138
+ "project_evidence_path": "", "project_evidence_pending_text": False,
4139
+ "feature_evidence_path": "", "feature_evidence_pending_text": False,
4140
+ "analysis_target": "", "analysis_target_pending_text": False,
3622
4141
  "reuse_worktree": None, "base_ref": "",
3623
4142
  "base_ref_pending_text": False, "approved_plan_path": "",
3624
4143
  "approved_plan_pending_text": False, "approve_plan_candidate": "",
@@ -3841,6 +4360,8 @@ def submit(state: WizardState, value: str) -> dict[str, Any]:
3841
4360
  return {"echo": "", "next": prompt_payload(state, prompt)}
3842
4361
  if prompt.kind == "pick_group":
3843
4362
  return _submit_group(state, prompt, value)
4363
+ if prompt.kind == "pick" and len(prompt.options) > HOST_PICK_OPTION_CAP:
4364
+ value = _normalize_numbered_pick_answer(prompt, value)
3844
4365
  step = STEP_BY_ID[prompt.step]
3845
4366
  echo = step.submit(state, value or "")
3846
4367
  if prompt.step not in state.answered:
@@ -3876,6 +4397,11 @@ def render_args(state: WizardState) -> dict[str, str]:
3876
4397
  if state.task_type == "release-handoff"
3877
4398
  else ""
3878
4399
  )
4400
+ evidence_paths = [
4401
+ path
4402
+ for path in (state.feature_evidence_path, state.project_evidence_path)
4403
+ if path
4404
+ ]
3879
4405
  return {
3880
4406
  "project-root": state.project_root,
3881
4407
  "project-id": state.project_id,
@@ -3883,6 +4409,8 @@ def render_args(state: WizardState) -> dict[str, str]:
3883
4409
  "task-id": state.task_id,
3884
4410
  "task-type": state.task_type,
3885
4411
  "task-brief": state.brief_path,
4412
+ "analysis-target": state.analysis_target,
4413
+ "evidence-inputs": ",".join(evidence_paths),
3886
4414
  "executor": state.executor,
3887
4415
  "critic": state.critic,
3888
4416
  "approved-plan": state.approved_plan_path,
@@ -3911,6 +4439,25 @@ def confirmation_block(state: WizardState) -> str:
3911
4439
  lines.append(f" task-type : {state.task_type}")
3912
4440
  lines.append(f" task-key : {state.task_group}/{state.task_id}")
3913
4441
  lines.append(f" brief : {state.brief_path or '(none)'}")
4442
+ if state.analysis_target:
4443
+ lines.append(f" analysis-target: {state.analysis_target}")
4444
+ if state.feature_evidence_path or state.project_evidence_path:
4445
+ evidence_status = {
4446
+ str(item.report_path): item.review_status
4447
+ for item in _resolve_analysis_evidence(state)
4448
+ }
4449
+ if state.feature_evidence_path:
4450
+ lines.append(
4451
+ " feature-evidence: "
4452
+ f"{state.feature_evidence_path} "
4453
+ f"({evidence_status.get(state.feature_evidence_path, 'unknown')})"
4454
+ )
4455
+ if state.project_evidence_path:
4456
+ lines.append(
4457
+ " project-evidence: "
4458
+ f"{state.project_evidence_path} "
4459
+ f"({evidence_status.get(state.project_evidence_path, 'unknown')})"
4460
+ )
3914
4461
  if state.task_type == "final-verification":
3915
4462
  lines.append(" base-ref : (selected stage worktree)")
3916
4463
  elif state.task_type == "implementation" and state.reuse_worktree: