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,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
@@ -3,8 +3,8 @@
3
3
  The sidecar format is documented in ``templates/reports/user-response.template.md``
4
4
  and produced byte-identically by ``report_views.serialize_user_response`` (Python)
5
5
  and ``templates/reports/report.js`` (browser). This module owns the read side of
6
- the ``## APPROVAL`` block — the implementation wizard consumes it to offer
7
- "승인 + 옵션 적용" at the approve-confirm step.
6
+ the ``## APPROVAL`` block used by the implementation wizard and the optional
7
+ ``## ANALYSIS REVIEW`` block used by analysis reruns.
8
8
  """
9
9
  from __future__ import annotations
10
10
 
@@ -12,6 +12,7 @@ import argparse
12
12
  import datetime as dt
13
13
  import json
14
14
  import re
15
+ import stat
15
16
  import sys
16
17
  from dataclasses import dataclass
17
18
  from pathlib import Path
@@ -31,6 +32,19 @@ from okstra_ctl.clarification_items import (
31
32
 
32
33
  _APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
33
34
  _NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
35
+ _ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILINE)
36
+ _ANALYSIS_SIDECAR_HEADING_RE = re.compile(
37
+ r"^## (?P<filename>user-response-[^\n]+\.md)\s*$", re.MULTILINE
38
+ )
39
+ _YAML_FRONTMATTER_RE = re.compile(
40
+ r"\A---[ \t]*\r?\n(?P<body>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
41
+ re.DOTALL,
42
+ )
43
+ _FRONTMATTER_DELIMITER_RE = re.compile(r"^---[ \t]*$", re.MULTILINE)
44
+
45
+
46
+ class UserResponseError(ValueError):
47
+ """Raised when a user-response sidecar violates its supported format."""
34
48
 
35
49
 
36
50
  @dataclass(frozen=True)
@@ -42,6 +56,296 @@ class UserResponseApprovalRecord:
42
56
  seq: str
43
57
 
44
58
 
59
+ @dataclass(frozen=True)
60
+ class AnalysisReviewRecord:
61
+ """Validated user review of an analysis report."""
62
+
63
+ status: str
64
+ affected_ids: tuple[str, ...]
65
+ reason: str
66
+ additional_evidence: str
67
+ requested_scope_change: str
68
+ task_key: str
69
+ task_type: str
70
+ source_report: str
71
+ seq: str
72
+
73
+
74
+ _ANALYSIS_REVIEW_STATUSES = frozenset({
75
+ "accepted",
76
+ "revision-requested",
77
+ "rejected",
78
+ })
79
+ _ANALYSIS_REPORT_RE = re.compile(
80
+ r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
81
+ r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
82
+ )
83
+
84
+
85
+ def _quoted_review_value(block: str, key: str) -> str:
86
+ match = re.search(
87
+ rf"^- {re.escape(key)}:\s*\n((?:\s*>.*\n?)+)", block, re.MULTILINE
88
+ )
89
+ if not match:
90
+ return ""
91
+ return "\n".join(
92
+ re.sub(r"^\s*>\s?", "", line)
93
+ for line in match.group(1).splitlines()
94
+ ).strip()
95
+
96
+
97
+ def _sidecar_metadata_value(sidecar_text: str, key: str) -> str:
98
+ frontmatter = _YAML_FRONTMATTER_RE.match(sidecar_text)
99
+ if frontmatter is None:
100
+ return ""
101
+ match = re.search(
102
+ rf"^{re.escape(key)}:[ \t]*(\S.*?)[ \t]*$",
103
+ frontmatter.group("body"),
104
+ re.MULTILINE,
105
+ )
106
+ return match.group(1) if match else ""
107
+
108
+
109
+ def _nearest_frontmatter_value(
110
+ sidecar_text: str, key: str, before_offset: int
111
+ ) -> str:
112
+ delimiters = [
113
+ match
114
+ for match in _FRONTMATTER_DELIMITER_RE.finditer(sidecar_text)
115
+ if match.start() < before_offset
116
+ ]
117
+ for opening, closing in reversed(list(zip(delimiters, delimiters[1:]))):
118
+ body = sidecar_text[opening.end():closing.start()]
119
+ match = re.search(
120
+ rf"^{re.escape(key)}:[ \t]*(\S.*?)[ \t]*$",
121
+ body,
122
+ re.MULTILINE,
123
+ )
124
+ if match:
125
+ return match.group(1)
126
+ return _sidecar_metadata_value(sidecar_text, key)
127
+
128
+
129
+ def _parsed_created_at(value: str) -> dt.datetime | None:
130
+ if re.fullmatch(
131
+ r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z",
132
+ value,
133
+ ) is None:
134
+ return None
135
+ try:
136
+ parsed = dt.datetime.fromisoformat(value[:-1] + "+00:00")
137
+ except ValueError:
138
+ return None
139
+ if parsed.tzinfo is None:
140
+ return None
141
+ return parsed.astimezone(dt.timezone.utc)
142
+
143
+
144
+ def _nearest_sidecar_filename(sidecar_text: str, before_offset: int) -> str:
145
+ headings = [
146
+ match
147
+ for match in _ANALYSIS_SIDECAR_HEADING_RE.finditer(sidecar_text)
148
+ if match.start() < before_offset
149
+ ]
150
+ return headings[-1].group("filename") if headings else ""
151
+
152
+
153
+ def _authoritative_analysis_review_match(
154
+ sidecar_text: str, matches: list[re.Match[str]]
155
+ ) -> re.Match[str]:
156
+ candidates: list[tuple[dt.datetime, str, int, re.Match[str]]] = []
157
+ for match in matches:
158
+ created_at = _parsed_created_at(
159
+ _nearest_frontmatter_value(sidecar_text, "created-at", match.start())
160
+ )
161
+ if created_at is None:
162
+ continue
163
+ candidates.append((
164
+ created_at,
165
+ _nearest_sidecar_filename(sidecar_text, match.start()),
166
+ match.start(),
167
+ match,
168
+ ))
169
+ if not candidates:
170
+ raise UserResponseError(
171
+ "ANALYSIS REVIEW requires a valid canonical created-at"
172
+ )
173
+ return max(candidates, key=lambda candidate: candidate[:3])[3]
174
+
175
+
176
+ def parse_analysis_review(sidecar_text: str) -> AnalysisReviewRecord | None:
177
+ """Parse the bounded ``## ANALYSIS REVIEW`` block from a sidecar.
178
+
179
+ A sidecar without this optional block is not a review. Once the block is
180
+ present, status and the fields that make a rejection actionable are
181
+ validated fail-closed.
182
+ """
183
+ matches = list(_ANALYSIS_REVIEW_HEADING_RE.finditer(sidecar_text))
184
+ if not matches:
185
+ return None
186
+ match = _authoritative_analysis_review_match(sidecar_text, matches)
187
+ block = sidecar_text[match.end():]
188
+ next_heading = _NEXT_RESPONSE_HEADING_RE.search(block)
189
+ if next_heading:
190
+ block = block[:next_heading.start()]
191
+ status = _field(block, "Status")
192
+ if status not in _ANALYSIS_REVIEW_STATUSES:
193
+ raise UserResponseError("ANALYSIS REVIEW Status is invalid")
194
+ affected = _field(block, "Affected-IDs") or ""
195
+ affected_ids = tuple(item.strip() for item in affected.split(",") if item.strip())
196
+ reason = _quoted_review_value(block, "Reason")
197
+ if status in {"revision-requested", "rejected"} and (not affected_ids or not reason):
198
+ raise UserResponseError(
199
+ f"ANALYSIS REVIEW {status} requires Affected-IDs and Reason"
200
+ )
201
+ return AnalysisReviewRecord(
202
+ status=status,
203
+ affected_ids=affected_ids,
204
+ reason=reason,
205
+ additional_evidence=_quoted_review_value(block, "Additional-Evidence"),
206
+ requested_scope_change=_quoted_review_value(block, "Requested-Scope-Change"),
207
+ task_key=_nearest_frontmatter_value(
208
+ sidecar_text, "task-key", match.start()
209
+ ),
210
+ task_type=_nearest_frontmatter_value(
211
+ sidecar_text, "task-type", match.start()
212
+ ),
213
+ source_report=_nearest_frontmatter_value(
214
+ sidecar_text, "source-report", match.start()
215
+ ),
216
+ seq=_nearest_frontmatter_value(sidecar_text, "seq", match.start()),
217
+ )
218
+
219
+
220
+ def _matching_analysis_review_sidecars(
221
+ report_path: Path,
222
+ sidecar_name: re.Pattern[str],
223
+ ) -> tuple[Path, tuple[Path, ...]]:
224
+ run_dir = report_path.parent.parent
225
+ responses_dir = run_dir / "user-responses"
226
+ try:
227
+ responses_mode = responses_dir.lstat().st_mode
228
+ except FileNotFoundError:
229
+ return responses_dir, ()
230
+ except OSError as exc:
231
+ raise UserResponseError(
232
+ f"analysis user-responses directory is unreadable under {run_dir}"
233
+ ) from exc
234
+ if not stat.S_ISDIR(responses_mode):
235
+ raise UserResponseError(
236
+ f"analysis user-responses must be a real directory under {run_dir}"
237
+ )
238
+ try:
239
+ resolved_run_dir = run_dir.resolve(strict=True)
240
+ resolved_responses_dir = responses_dir.resolve(strict=True)
241
+ except OSError as exc:
242
+ raise UserResponseError(
243
+ f"analysis user-responses directory is unreadable under {run_dir}"
244
+ ) from exc
245
+ if resolved_responses_dir != resolved_run_dir / "user-responses":
246
+ raise UserResponseError(
247
+ f"analysis user-responses must stay under {resolved_run_dir}"
248
+ )
249
+ try:
250
+ sidecars = tuple(
251
+ sorted(
252
+ (
253
+ entry
254
+ for entry in responses_dir.iterdir()
255
+ if sidecar_name.fullmatch(entry.name)
256
+ ),
257
+ key=lambda entry: entry.name,
258
+ )
259
+ )
260
+ except OSError as exc:
261
+ raise UserResponseError(
262
+ f"analysis user-responses directory is unreadable under {run_dir}"
263
+ ) from exc
264
+ return responses_dir, sidecars
265
+
266
+
267
+ def _read_analysis_review_sidecar(sidecar: Path, responses_dir: Path) -> str:
268
+ try:
269
+ sidecar_mode = sidecar.lstat().st_mode
270
+ if not stat.S_ISREG(sidecar_mode):
271
+ raise OSError("not a regular file")
272
+ resolved = sidecar.resolve(strict=True)
273
+ if resolved.parent != responses_dir.resolve(strict=True):
274
+ raise OSError("outside user-responses")
275
+ return sidecar.read_text(encoding="utf-8")
276
+ except (OSError, UnicodeError) as exc:
277
+ raise UserResponseError(
278
+ "analysis review sidecar must be a readable regular file under "
279
+ f"{responses_dir}"
280
+ ) from exc
281
+
282
+
283
+ def _analysis_review_matches_with_valid_created_at(
284
+ sidecar_text: str,
285
+ ) -> list[re.Match[str]]:
286
+ matches = list(_ANALYSIS_REVIEW_HEADING_RE.finditer(sidecar_text))
287
+ for match in matches:
288
+ created_at = _nearest_frontmatter_value(
289
+ sidecar_text, "created-at", match.start()
290
+ )
291
+ if _parsed_created_at(created_at) is None:
292
+ raise UserResponseError(
293
+ "ANALYSIS REVIEW requires a valid canonical created-at"
294
+ )
295
+ return matches
296
+
297
+
298
+ def load_authoritative_analysis_review(
299
+ report_path: Path,
300
+ *,
301
+ expected_task_key: str,
302
+ expected_task_type: str,
303
+ ) -> AnalysisReviewRecord | None:
304
+ """Load the created-at-latest review attached to one analysis report."""
305
+ report_match = _ANALYSIS_REPORT_RE.fullmatch(report_path.name)
306
+ if report_match is None:
307
+ raise UserResponseError("analysis review source is not an analysis report")
308
+ sidecar_name = re.compile(
309
+ rf"^user-response-{re.escape(report_match.group('task_type'))}-"
310
+ rf"{re.escape(report_match.group('seq'))}(?:-.+)?\.md$"
311
+ )
312
+ responses_dir, sidecars = _matching_analysis_review_sidecars(
313
+ report_path, sidecar_name
314
+ )
315
+ if not sidecars:
316
+ return None
317
+ attached: list[str] = []
318
+ for sidecar in sidecars:
319
+ text = _read_analysis_review_sidecar(sidecar, responses_dir)
320
+ if _analysis_review_matches_with_valid_created_at(text):
321
+ attached.append(f"\n## {sidecar.name}\n\n{text.strip()}\n")
322
+ if not attached:
323
+ raise UserResponseError(
324
+ "existing review sidecar has no ANALYSIS REVIEW block"
325
+ )
326
+ review = parse_analysis_review("".join(attached))
327
+ if review is None:
328
+ raise UserResponseError("analysis review sidecar is unreadable")
329
+ expected_source = (
330
+ f"runs/{report_match.group('task_type')}/reports/{report_path.name}"
331
+ )
332
+ if review.task_key != expected_task_key:
333
+ raise UserResponseError(
334
+ "analysis review task-key does not match report taskKey"
335
+ )
336
+ if review.task_type != expected_task_type:
337
+ raise UserResponseError(
338
+ "analysis review task-type does not match report taskType"
339
+ )
340
+ if review.source_report != expected_source:
341
+ raise UserResponseError(
342
+ "analysis review source-report does not match report path"
343
+ )
344
+ if review.seq != report_match.group("seq"):
345
+ raise UserResponseError("analysis review seq does not match report runSeq")
346
+ return review
347
+
348
+
45
349
  def parse_user_response_approval(
46
350
  sidecar_text: str,
47
351
  ) -> Optional[UserResponseApprovalRecord]:
@@ -76,7 +380,9 @@ _RESPONSE_HEADING_RE = re.compile(r"^## (?P<id>[A-Za-z][A-Za-z0-9]*-\d+)\s*$", r
76
380
 
77
381
 
78
382
  def _field(block: str, key: str) -> Optional[str]:
79
- m = re.search(rf"^- {re.escape(key)}:\s*(\S.*?)\s*$", block, re.MULTILINE)
383
+ m = re.search(
384
+ rf"^- {re.escape(key)}:[ \t]*(\S.*?)[ \t]*$", block, re.MULTILINE
385
+ )
80
386
  return m.group(1) if m else None
81
387
 
82
388