okstra 0.144.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 (40) 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 +16 -4
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/convergence.md +11 -3
  8. package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
  9. package/runtime/prompts/profiles/_common-contract.md +1 -1
  10. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  11. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  12. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  13. package/runtime/prompts/profiles/project-analysis.md +24 -0
  14. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  15. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  16. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  17. package/runtime/python/okstra_ctl/render.py +77 -4
  18. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  19. package/runtime/python/okstra_ctl/report_views.py +134 -3
  20. package/runtime/python/okstra_ctl/run.py +118 -0
  21. package/runtime/python/okstra_ctl/run_context.py +34 -2
  22. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  23. package/runtime/python/okstra_ctl/user_response.py +309 -3
  24. package/runtime/python/okstra_ctl/wizard.py +545 -32
  25. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  26. package/runtime/python/okstra_ctl/workflow.py +22 -0
  27. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  28. package/runtime/skills/okstra-run/SKILL.md +13 -1
  29. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  30. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  31. package/runtime/templates/reports/final-report.template.md +220 -0
  32. package/runtime/templates/reports/i18n/en.json +8 -0
  33. package/runtime/templates/reports/i18n/ko.json +8 -0
  34. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  35. package/runtime/templates/reports/report.js +84 -5
  36. package/runtime/templates/reports/user-response.template.md +19 -1
  37. package/runtime/validators/validate-report-views.py +61 -7
  38. package/runtime/validators/validate-run.py +42 -0
  39. package/runtime/validators/validate_analysis_report.py +864 -0
  40. package/src/commands/execute/render-bundle.mjs +3 -0
@@ -108,6 +108,11 @@ class RunMeta:
108
108
  source_report: str # relative path of the .md the HTML is derived from
109
109
 
110
110
 
111
+ @dataclass(frozen=True)
112
+ class AnalysisReviewContext:
113
+ selector_ids: tuple[str, ...]
114
+
115
+
111
116
  # task-type itself can contain hyphens (``implementation-planning``,
112
117
  # ``final-verification``, ``release-handoff``), so the filename segment
113
118
  # between ``final-report-`` and ``-<seq>.md`` is matched greedily; the
@@ -159,6 +164,7 @@ class ReportViewModel:
159
164
  sidecar_dir: str
160
165
  approval_ctx: PlanApprovalContext | None = None
161
166
  reader_ctx: ReaderDashboardContext | None = None
167
+ analysis_review_ctx: AnalysisReviewContext | None = None
162
168
 
163
169
 
164
170
  @dataclass(frozen=True)
@@ -179,6 +185,7 @@ def build_report_view_model(
179
185
  run_meta: RunMeta,
180
186
  approval_ctx: PlanApprovalContext | None = None,
181
187
  reader_ctx: ReaderDashboardContext | None = None,
188
+ analysis_review_ctx: AnalysisReviewContext | None = None,
182
189
  ) -> ReportViewModel:
183
190
  digest = source_digest(src_md)
184
191
  body_md = _strip_leading_frontmatter(src_md)
@@ -197,6 +204,7 @@ def build_report_view_model(
197
204
  sidecar_dir=f"runs/{run_meta.task_type}/user-responses/",
198
205
  approval_ctx=approval_ctx,
199
206
  reader_ctx=reader_ctx,
207
+ analysis_review_ctx=analysis_review_ctx,
200
208
  )
201
209
 
202
210
 
@@ -258,6 +266,7 @@ def render_report_view_model(
258
266
  f"</header>\n"
259
267
  f"<main>{_reader_dashboard(model.reader_ctx)}{model.body_html}</main>\n"
260
268
  f"{_plan_approval_section(model.approval_ctx, run_meta) if model.approval_ctx else ''}"
269
+ f"{_analysis_review_section(model.analysis_review_ctx) if model.analysis_review_ctx else ''}"
261
270
  f"<footer class=\"report-footer\">\n"
262
271
  f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
263
272
  f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
@@ -288,6 +297,7 @@ def render_html(
288
297
  js: str,
289
298
  approval_ctx: PlanApprovalContext | None = None,
290
299
  reader_ctx: ReaderDashboardContext | None = None,
300
+ analysis_review_ctx: AnalysisReviewContext | None = None,
291
301
  ) -> str:
292
302
  """Return a single self-contained HTML document for ``src_md``.
293
303
 
@@ -300,6 +310,7 @@ def render_html(
300
310
  run_meta=run_meta,
301
311
  approval_ctx=approval_ctx,
302
312
  reader_ctx=reader_ctx,
313
+ analysis_review_ctx=analysis_review_ctx,
303
314
  )
304
315
  return render_report_view_model(model, css=css, js=js)
305
316
 
@@ -1021,12 +1032,56 @@ class UserResponseApproval:
1021
1032
  implementation_option: str = ""
1022
1033
 
1023
1034
 
1035
+ @dataclass(frozen=True)
1036
+ class UserResponseAnalysisReview:
1037
+ status: str
1038
+ affected_ids: tuple[str, ...] = ()
1039
+ reason: str = ""
1040
+ additional_evidence: str = ""
1041
+ requested_scope_change: str = ""
1042
+
1043
+
1044
+ _ANALYSIS_REVIEW_STATUSES = frozenset({
1045
+ "accepted",
1046
+ "revision-requested",
1047
+ "rejected",
1048
+ })
1049
+
1050
+
1051
+ def _quoted_sidecar_field(label: str, value: str) -> str:
1052
+ cleaned = value.strip()
1053
+ if not cleaned:
1054
+ return f"- {label}:\n"
1055
+ quoted = "".join(f" > {line}\n" for line in cleaned.split("\n"))
1056
+ return f"- {label}:\n{quoted}"
1057
+
1058
+
1059
+ def _serialize_analysis_review(review: UserResponseAnalysisReview) -> str:
1060
+ if review.status not in _ANALYSIS_REVIEW_STATUSES:
1061
+ raise ValueError(f"invalid ANALYSIS REVIEW status: {review.status}")
1062
+ if review.status in {"revision-requested", "rejected"} and (
1063
+ not review.affected_ids or not review.reason.strip()
1064
+ ):
1065
+ raise ValueError(
1066
+ f"ANALYSIS REVIEW {review.status} requires Affected-IDs and Reason"
1067
+ )
1068
+ return (
1069
+ "\n## ANALYSIS REVIEW\n"
1070
+ f"- Status: {review.status}\n"
1071
+ f"- Affected-IDs: {', '.join(review.affected_ids)}\n"
1072
+ f"{_quoted_sidecar_field('Reason', review.reason)}"
1073
+ f"{_quoted_sidecar_field('Additional-Evidence', review.additional_evidence)}"
1074
+ f"{_quoted_sidecar_field('Requested-Scope-Change', review.requested_scope_change)}"
1075
+ )
1076
+
1077
+
1024
1078
  def serialize_user_response(
1025
1079
  *,
1026
1080
  run_meta: RunMeta,
1027
1081
  entries: list[UserResponseEntry],
1028
1082
  created_at: str,
1029
1083
  approval: UserResponseApproval | None = None,
1084
+ analysis_review: UserResponseAnalysisReview | None = None,
1030
1085
  ) -> str:
1031
1086
  """Return the canonical markdown text the HTML 'Export user
1032
1087
  response' button must produce. Used by validators to confirm that
@@ -1046,6 +1101,7 @@ def serialize_user_response(
1046
1101
  "# User Response\n"
1047
1102
  )
1048
1103
  has_approval = approval is not None and approval.approved
1104
+ has_analysis_review = analysis_review is not None
1049
1105
  body_chunks: list[str] = []
1050
1106
  for e in entries:
1051
1107
  chunk = f"\n## {e.response_id}\n- Kind: {e.kind}\n"
@@ -1056,13 +1112,15 @@ def serialize_user_response(
1056
1112
  if e.rationale:
1057
1113
  chunk += f"- Rationale: {e.rationale.strip()}\n"
1058
1114
  body_chunks.append(chunk)
1059
- if not entries and not has_approval:
1115
+ if not entries and not has_approval and not has_analysis_review:
1060
1116
  body_chunks.append("\n_(No user responses recorded.)_\n")
1061
1117
  if has_approval:
1062
1118
  chunk = "\n## APPROVAL\n- Approved: true\n"
1063
1119
  if approval.implementation_option:
1064
1120
  chunk += f"- Implementation-Option: {approval.implementation_option.strip()}\n"
1065
1121
  body_chunks.append(chunk)
1122
+ if analysis_review is not None:
1123
+ body_chunks.append(_serialize_analysis_review(analysis_review))
1066
1124
  return head + "".join(body_chunks)
1067
1125
 
1068
1126
 
@@ -1107,6 +1165,39 @@ def _load_report_data(src_md_path: Path) -> dict | None:
1107
1165
  return data if isinstance(data, dict) else None
1108
1166
 
1109
1167
 
1168
+ _ANALYSIS_DATA_KEYS = (
1169
+ "analysisCommon",
1170
+ "projectAnalysis",
1171
+ "featureAnalysis",
1172
+ "changeImpactAnalysis",
1173
+ )
1174
+ _STRUCTURED_ANALYSIS_ID_RE = re.compile(r"^[A-Z]{2}-\d{3}$")
1175
+
1176
+
1177
+ def _collect_structured_analysis_ids(value: object, found: set[str]) -> None:
1178
+ if isinstance(value, dict):
1179
+ candidate = value.get("id")
1180
+ if isinstance(candidate, str) and _STRUCTURED_ANALYSIS_ID_RE.fullmatch(
1181
+ candidate
1182
+ ):
1183
+ found.add(candidate)
1184
+ for child in value.values():
1185
+ _collect_structured_analysis_ids(child, found)
1186
+ elif isinstance(value, list):
1187
+ for child in value:
1188
+ _collect_structured_analysis_ids(child, found)
1189
+
1190
+
1191
+ def analysis_review_context(src_md_path: Path) -> AnalysisReviewContext | None:
1192
+ data = _load_report_data(src_md_path)
1193
+ if data is None or not isinstance(data.get("analysisCommon"), dict):
1194
+ return None
1195
+ found: set[str] = set()
1196
+ for key in _ANALYSIS_DATA_KEYS:
1197
+ _collect_structured_analysis_ids(data.get(key), found)
1198
+ return AnalysisReviewContext(selector_ids=tuple(sorted(found)))
1199
+
1200
+
1110
1201
  def plan_approval_context(
1111
1202
  src_md_path: Path, src_text: str
1112
1203
  ) -> PlanApprovalContext | None:
@@ -1298,6 +1389,35 @@ def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
1298
1389
  )
1299
1390
 
1300
1391
 
1392
+ def _analysis_review_section(ctx: AnalysisReviewContext) -> str:
1393
+ options = "".join(
1394
+ f'<option value="{html.escape(analysis_id)}">'
1395
+ f"{html.escape(analysis_id)}</option>"
1396
+ for analysis_id in ctx.selector_ids
1397
+ )
1398
+ return (
1399
+ '<section id="analysis-review">\n'
1400
+ " <h2>Analysis Review</h2>\n"
1401
+ ' <fieldset><legend>Decision</legend>\n'
1402
+ ' <label><input type="radio" name="analysis-review-status" '
1403
+ 'value="accepted">Accept</label>\n'
1404
+ ' <label><input type="radio" name="analysis-review-status" '
1405
+ 'value="revision-requested">Request revision</label>\n'
1406
+ ' <label><input type="radio" name="analysis-review-status" '
1407
+ 'value="rejected">Reject</label>\n'
1408
+ " </fieldset>\n"
1409
+ ' <label>Affected IDs <select id="analysis-review-affected-ids" '
1410
+ f'multiple>{options}</select></label>\n'
1411
+ ' <label>Reason <textarea id="analysis-review-reason" rows="3">'
1412
+ "</textarea></label>\n"
1413
+ ' <label>Additional evidence <textarea id="analysis-review-evidence" '
1414
+ 'rows="3"></textarea></label>\n'
1415
+ ' <label>Requested scope change <textarea '
1416
+ 'id="analysis-review-scope-change" rows="3"></textarea></label>\n'
1417
+ "</section>\n"
1418
+ )
1419
+
1420
+
1301
1421
  def render_html_view(
1302
1422
  src_md_path: Path,
1303
1423
  *,
@@ -1327,9 +1447,15 @@ def render_html_view(
1327
1447
  "the HTML view."
1328
1448
  )
1329
1449
  approval_ctx = plan_approval_context(src_md_path, src_text)
1450
+ analysis_review_ctx = analysis_review_context(src_md_path)
1330
1451
  reader_ctx = reader_dashboard_context(src_md_path, src_text, approval_ctx)
1331
1452
  has_clarifications = report_has_clarification_items(src_text)
1332
- if not has_clarifications and approval_ctx is None and reader_ctx is None:
1453
+ if (
1454
+ not has_clarifications
1455
+ and approval_ctx is None
1456
+ and reader_ctx is None
1457
+ and analysis_review_ctx is None
1458
+ ):
1333
1459
  if html_path.is_file():
1334
1460
  html_path.unlink()
1335
1461
  return None
@@ -1340,9 +1466,14 @@ def render_html_view(
1340
1466
  js=js,
1341
1467
  approval_ctx=approval_ctx,
1342
1468
  reader_ctx=reader_ctx,
1469
+ analysis_review_ctx=analysis_review_ctx,
1343
1470
  )
1344
1471
  html_path.write_text(html_text, encoding="utf-8")
1345
- if has_clarifications or approval_ctx is not None:
1472
+ if (
1473
+ has_clarifications
1474
+ or approval_ctx is not None
1475
+ or analysis_review_ctx is not None
1476
+ ):
1346
1477
  # 사용자 확인(폼/승인)이 필요한 보고서 — Export 파일의 저장 위치를 미리
1347
1478
  # 만들어 사용자가 디렉토리를 만들 필요가 없게 한다 (reports/ 의 sibling).
1348
1479
  user_responses_dir_for_report(src_md_path).mkdir(parents=True, exist_ok=True)
@@ -31,6 +31,15 @@ from okstra_project import project_json_path, upsert_project_json
31
31
  from okstra_project.state import slugify
32
32
  from . import fix_cycles
33
33
  from .analysis_packet import build_analysis_packet
34
+ from .analysis_inputs import (
35
+ ANALYSIS_TASK_TYPES,
36
+ AnalysisInputError,
37
+ load_candidate_map,
38
+ parse_evidence_paths,
39
+ resolve_analysis_head,
40
+ resolve_analysis_target,
41
+ resolve_evidence_inputs,
42
+ )
34
43
  from .stage_fix_carry import derive_stage_fix_carry
35
44
  from .clarification_items import (
36
45
  attached_user_responses_section,
@@ -71,6 +80,7 @@ from okstra_project.dirs import okstra_home
71
80
 
72
81
  from .run_context import (
73
82
  compute_and_write_run_context,
83
+ refresh_run_context_snapshot,
74
84
  write_run_inputs,
75
85
  )
76
86
  from .seeding import (
@@ -311,6 +321,8 @@ class PrepareInputs:
311
321
  task_id: str
312
322
  task_type: str
313
323
  brief_path: Path # absolute, already resolved
324
+ analysis_target: str = ""
325
+ evidence_inputs_raw: str = ""
314
326
  directive: str = ""
315
327
  workers_override: str = ""
316
328
  lead_model: str = ""
@@ -758,6 +770,8 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
758
770
  ("--task-group", inp.task_group),
759
771
  ("--task-id", inp.task_id),
760
772
  ("--task-brief", str(inp.brief_path)),
773
+ ("--analysis-target", inp.analysis_target),
774
+ ("--evidence-inputs", inp.evidence_inputs_raw),
761
775
  ("--directive", inp.directive),
762
776
  ("--approved-plan", inp.approved_plan_path),
763
777
  ("--implementation-option", inp.implementation_option),
@@ -946,6 +960,7 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
946
960
  """Validate pure prepare inputs and return a final-verification stage map."""
947
961
  if not project_root.is_dir():
948
962
  raise PrepareError(f"project root not found: {project_root}")
963
+ _validate_analysis_prepare_inputs(project_root, inp)
949
964
  if inp.stages and inp.task_type != "release-handoff":
950
965
  raise PrepareError(
951
966
  "--stages is only meaningful with --task-type release-handoff; "
@@ -1028,6 +1043,41 @@ def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
1028
1043
  return _parse_stage_map_into_ctx(inp.approved_plan_path)
1029
1044
 
1030
1045
 
1046
+ def _validate_analysis_prepare_inputs(project_root: Path, inp: PrepareInputs) -> None:
1047
+ """Reject invalid analysis inputs before task worktree or run sequence creation."""
1048
+ is_analysis = inp.task_type in ANALYSIS_TASK_TYPES
1049
+ if not is_analysis:
1050
+ if inp.analysis_target:
1051
+ raise PrepareError(
1052
+ f"--analysis-target is only accepted for analysis task types; got {inp.task_type}"
1053
+ )
1054
+ if inp.evidence_inputs_raw:
1055
+ raise PrepareError(
1056
+ f"--evidence-inputs is only accepted for analysis task types; got {inp.task_type}"
1057
+ )
1058
+ return
1059
+ if inp.task_type == "project-analysis":
1060
+ if inp.analysis_target:
1061
+ raise PrepareError("project-analysis does not accept an analysis target")
1062
+ if inp.evidence_inputs_raw:
1063
+ raise PrepareError("project-analysis does not accept evidence inputs")
1064
+ return
1065
+ if inp.task_type == "feature-analysis" and not inp.analysis_target.strip():
1066
+ raise PrepareError("feature-analysis requires --analysis-target")
1067
+ if inp.task_type != "feature-analysis" and inp.analysis_target:
1068
+ raise PrepareError(f"{inp.task_type} does not accept an analysis target")
1069
+ paths = parse_evidence_paths(inp.evidence_inputs_raw, project_root)
1070
+ try:
1071
+ candidates = load_candidate_map(project_root, paths)
1072
+ evidence = resolve_evidence_inputs(
1073
+ project_root, inp.task_type, paths, "0" * 40,
1074
+ )
1075
+ if inp.task_type == "feature-analysis":
1076
+ resolve_analysis_target(inp.analysis_target, evidence, candidates)
1077
+ except AnalysisInputError as exc:
1078
+ raise PrepareError(str(exc)) from exc
1079
+
1080
+
1031
1081
  def _collect_handoff_source_report_rows(
1032
1082
  rows: list, nums: list,
1033
1083
  ) -> list:
@@ -1583,6 +1633,7 @@ def _write_instruction_set_sources(
1583
1633
  reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
1584
1634
  instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
1585
1635
  instruction_set.mkdir(parents=True, exist_ok=True)
1636
+ _write_analysis_evidence_artifact(ctx, instruction_set)
1586
1637
  _write_verification_target_artifact(inp, ctx, instruction_set)
1587
1638
  profile_rendered = profile_content
1588
1639
  if inp.task_type == "implementation":
@@ -1658,10 +1709,43 @@ def _write_instruction_set_sources(
1658
1709
  fix_history_text=fix_cycles.packet_summary(
1659
1710
  fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
1660
1711
  )
1712
+ if inp.task_type in ANALYSIS_TASK_TYPES:
1713
+ packet += (
1714
+ "\n## Analysis Evidence\n\n"
1715
+ f"- Analysis evidence: `{ctx['INSTRUCTION_SET_RELATIVE_PATH']}/analysis-evidence.md`\n"
1716
+ )
1661
1717
  (instruction_set / "analysis-packet.md").write_text(packet, encoding="utf-8")
1662
1718
  return instruction_set
1663
1719
 
1664
1720
 
1721
+ def _write_analysis_evidence_artifact(ctx: dict, instruction_set: Path) -> None:
1722
+ """Write selected evidence metadata without copying the original reports."""
1723
+ if ctx.get("TASK_TYPE") not in ANALYSIS_TASK_TYPES:
1724
+ return
1725
+ evidence = json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]"))
1726
+ lines = [
1727
+ "# Analysis Evidence",
1728
+ "",
1729
+ "Revalidate every cited finding against the current code before relying on it.",
1730
+ ]
1731
+ for item in evidence:
1732
+ lines.extend([
1733
+ "",
1734
+ f"## {item['taskType']} / run {item['runSeq']}",
1735
+ "",
1736
+ f"- Metadata: task key `{item['taskKey']}`, source commit `{item['sourceCommit']}`, relation `{item['relation']}`",
1737
+ f"- Original report: `{item['reportPath']}`",
1738
+ f"- Review status: `{item['reviewStatus']}`",
1739
+ f"- Freshness: `{item['freshness']}`",
1740
+ "- Current-code obligation: revalidate cited findings against the current code.",
1741
+ ])
1742
+ if not evidence:
1743
+ lines.extend(["", "- No evidence reports were selected."])
1744
+ (instruction_set / "analysis-evidence.md").write_text(
1745
+ "\n".join(lines) + "\n", encoding="utf-8",
1746
+ )
1747
+
1748
+
1665
1749
  def _render_lead_prompt_and_snapshot(
1666
1750
  inp: PrepareInputs,
1667
1751
  ctx: dict,
@@ -1744,6 +1828,10 @@ def _persist_run_inputs(
1744
1828
  "relatedTasks": inp.related_tasks_raw,
1745
1829
  "approvedPlanPath": approved_plan_path,
1746
1830
  "clarificationResponsePath": inp.clarification_response_path,
1831
+ "analysisTarget": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")).get(
1832
+ "requestedValue", ""
1833
+ ),
1834
+ "evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
1747
1835
  "renderOnly": inp.render_only,
1748
1836
  },
1749
1837
  )
@@ -2169,6 +2257,26 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2169
2257
  stage_run_claim = provisioned.stage_run_claim
2170
2258
  stage_arg = provisioned.stage_arg
2171
2259
 
2260
+ analysis_source_commit = ""
2261
+ resolved_evidence = ()
2262
+ resolved_target: dict[str, object] = {}
2263
+ if inp.task_type in ANALYSIS_TASK_TYPES:
2264
+ analysis_root = Path(worktree.path or project_root)
2265
+ evidence_paths = parse_evidence_paths(inp.evidence_inputs_raw, project_root)
2266
+ try:
2267
+ analysis_source_commit = resolve_analysis_head(analysis_root)
2268
+ resolved_evidence = resolve_evidence_inputs(
2269
+ project_root, inp.task_type, evidence_paths, analysis_source_commit,
2270
+ )
2271
+ if inp.task_type == "feature-analysis":
2272
+ resolved_target = resolve_analysis_target(
2273
+ inp.analysis_target,
2274
+ resolved_evidence,
2275
+ load_candidate_map(project_root, evidence_paths),
2276
+ )
2277
+ except AnalysisInputError as exc:
2278
+ raise PrepareError(str(exc)) from exc
2279
+
2172
2280
  ctx = compute_and_write_run_context(
2173
2281
  workspace_root=workspace_root, project_root=project_root,
2174
2282
  project_id=inp.project_id, task_group=inp.task_group, task_id=inp.task_id,
@@ -2189,6 +2297,11 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2189
2297
  "OKSTRA_PLAN_VERIFICATION": (
2190
2298
  "false" if not inp.plan_verification_enabled else ""
2191
2299
  ),
2300
+ "ANALYSIS_SOURCE_COMMIT": analysis_source_commit,
2301
+ "ANALYSIS_TARGET_JSON": json.dumps(resolved_target, ensure_ascii=False),
2302
+ "EVIDENCE_INPUTS_JSON": json.dumps(
2303
+ [item.to_dict() for item in resolved_evidence], ensure_ascii=False,
2304
+ ),
2192
2305
  })
2193
2306
 
2194
2307
  # implementation: override the task-worktree fields with the claimed
@@ -2254,6 +2367,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
2254
2367
  "RENDER_ONLY": "true" if inp.render_only else "false",
2255
2368
  "OKSTRA_VERSION": installed_version(),
2256
2369
  })
2370
+ refresh_run_context_snapshot(ctx)
2257
2371
  if lead_runtime == "codex":
2258
2372
  ctx["CLAUDE_RESUME_COMMAND_PATH"] = ""
2259
2373
  ctx["CLAUDE_RESUME_COMMAND_RELATIVE_PATH"] = ""
@@ -2328,6 +2442,8 @@ def main(argv: list[str]) -> int:
2328
2442
  ),
2329
2443
  )
2330
2444
  p.add_argument("--directive", default="")
2445
+ p.add_argument("--analysis-target", default="", dest="analysis_target")
2446
+ p.add_argument("--evidence-inputs", default="", dest="evidence_inputs_raw")
2331
2447
  p.add_argument(
2332
2448
  "--fix-cycle", default="", choices=["", "yes", "no"], dest="fix_cycle",
2333
2449
  help=(
@@ -2502,6 +2618,8 @@ def main(argv: list[str]) -> int:
2502
2618
  task_id=args.task_id,
2503
2619
  task_type=args.task_type,
2504
2620
  brief_path=brief_abs,
2621
+ analysis_target=args.analysis_target,
2622
+ evidence_inputs_raw=args.evidence_inputs_raw,
2505
2623
  directive=args.directive,
2506
2624
  workers_override=args.workers_override,
2507
2625
  lead_model=args.lead_model,
@@ -193,6 +193,20 @@ def compute_and_write_run_context(
193
193
  return ctx
194
194
 
195
195
 
196
+ def refresh_run_context_snapshot(ctx: dict) -> None:
197
+ """Rewrite a run context after prepare resolves run-scoped inputs."""
198
+ payload = compact_run_context(ctx)
199
+ payload["analysis"] = {
200
+ "sourceCommit": ctx.get("ANALYSIS_SOURCE_COMMIT", ""),
201
+ "target": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")),
202
+ "evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
203
+ }
204
+ payload["ANALYSIS_SOURCE_COMMIT"] = ctx.get("ANALYSIS_SOURCE_COMMIT", "")
205
+ payload["ANALYSIS_TARGET_JSON"] = ctx.get("ANALYSIS_TARGET_JSON", "{}")
206
+ payload["EVIDENCE_INPUTS_JSON"] = ctx.get("EVIDENCE_INPUTS_JSON", "[]")
207
+ _atomic_write_json(Path(ctx["RUN_CONTEXT_FILE"]), payload)
208
+
209
+
196
210
  def write_run_inputs(
197
211
  *,
198
212
  project_root: Path,
@@ -207,7 +221,7 @@ def write_run_inputs(
207
221
  inputs schema (모든 키 optional):
208
222
  taskBriefPath, directive, workers, leadModel, claudeModel, codexModel,
209
223
  antigravityModel, reportWriterModel, relatedTasks, approvedPlanPath,
210
- clarificationResponsePath, renderOnly
224
+ clarificationResponsePath, analysisTarget, evidenceInputs, renderOnly
211
225
  """
212
226
  run_manifests_dir = Path(run_manifests_dir)
213
227
  path = run_manifests_dir / _run_inputs_filename(task_type_segment, seq)
@@ -227,7 +241,25 @@ def read_run_context(run_manifests_dir: Path, task_type_segment: str,
227
241
  if not path.is_file():
228
242
  return None
229
243
  payload = json.loads(path.read_text(encoding="utf-8"))
230
- return hydrate_run_context(payload)
244
+ context = hydrate_run_context(payload)
245
+ analysis = payload.get("analysis")
246
+ if isinstance(analysis, dict):
247
+ target_json = payload.get("ANALYSIS_TARGET_JSON")
248
+ if not isinstance(target_json, str):
249
+ target_json = json.dumps(analysis.get("target", {}), ensure_ascii=False)
250
+ evidence_json = payload.get("EVIDENCE_INPUTS_JSON")
251
+ if not isinstance(evidence_json, str):
252
+ evidence_json = json.dumps(
253
+ analysis.get("evidenceInputs", []), ensure_ascii=False,
254
+ )
255
+ context.update({
256
+ "ANALYSIS_SOURCE_COMMIT": str(
257
+ payload.get("ANALYSIS_SOURCE_COMMIT", analysis.get("sourceCommit", ""))
258
+ ),
259
+ "ANALYSIS_TARGET_JSON": target_json,
260
+ "EVIDENCE_INPUTS_JSON": evidence_json,
261
+ })
262
+ return context
231
263
 
232
264
 
233
265
  def read_run_inputs(run_manifests_dir: Path, task_type_segment: str,
@@ -1,9 +1,9 @@
1
1
  """Build a task-type-scoped excerpt of the final-report schema.
2
2
 
3
3
  The full schema (``schemas/final-report-v1.0.schema.json``) carries the
4
- deliverable property blocks for ALL task-types (``errorAnalysis``,
5
- ``implementationPlanning``, ``releaseHandoff``, ``implementation``,
6
- ``finalVerification``) plus a
4
+ deliverable property blocks for ALL task-types (``errorAnalysis``, the three
5
+ read-only analysis blocks, ``implementationPlanning``, ``releaseHandoff``,
6
+ ``implementation``, and ``finalVerification``) plus a
7
7
  ``$defs`` library (~38% of the file) shared across them. A single run only
8
8
  authors ONE task-type's data.json, so the report-writer worker only needs
9
9
  the common structure + its own task-type's block + the ``$defs`` those
@@ -27,15 +27,21 @@ import re
27
27
  # task-type → the per-type deliverable property key it owns. task-types
28
28
  # absent from this map (requirements-discovery,
29
29
  # improvement-discovery) have no per-type block; their excerpt keeps only
30
- # the common properties.
30
+ # the non-analysis common properties.
31
31
  _TASK_TYPE_PROPERTY = {
32
32
  "error-analysis": "errorAnalysis",
33
+ "project-analysis": "projectAnalysis",
34
+ "feature-analysis": "featureAnalysis",
35
+ "change-impact-analysis": "changeImpactAnalysis",
33
36
  "implementation-planning": "implementationPlanning",
34
37
  "release-handoff": "releaseHandoff",
35
38
  "implementation": "implementation",
36
39
  "final-verification": "finalVerification",
37
40
  }
38
41
  _ALL_PER_TYPE_PROPERTIES = frozenset(_TASK_TYPE_PROPERTY.values())
42
+ _ANALYSIS_COMMON_TASK_TYPES = frozenset(
43
+ {"project-analysis", "feature-analysis", "change-impact-analysis"}
44
+ )
39
45
 
40
46
  _REF_RE = re.compile(r'"\$ref"\s*:\s*"#/\$defs/([^"]+)"')
41
47
 
@@ -100,6 +106,8 @@ def build_schema_excerpt(schema: dict, task_type: str, cut_from_version: str = "
100
106
  """
101
107
  keep_per_type = _TASK_TYPE_PROPERTY.get(task_type)
102
108
  drop_props = _ALL_PER_TYPE_PROPERTIES - ({keep_per_type} if keep_per_type else set())
109
+ if task_type not in _ANALYSIS_COMMON_TASK_TYPES:
110
+ drop_props = drop_props | {"analysisCommon"}
103
111
 
104
112
  props = {
105
113
  k: v for k, v in schema.get("properties", {}).items() if k not in drop_props