okstra 0.143.0 → 0.145.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +18 -2
  3. package/docs/cli.md +39 -2
  4. package/docs/project-structure-overview.md +19 -6
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/coding-preflight/overview.md +1 -1
  8. package/runtime/prompts/lead/convergence.md +11 -3
  9. package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
  10. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  11. package/runtime/prompts/profiles/_common-contract.md +1 -1
  12. package/runtime/prompts/profiles/_implementation-verifier.md +48 -2
  13. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  14. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  15. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  16. package/runtime/prompts/profiles/project-analysis.md +24 -0
  17. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  18. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  19. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  20. package/runtime/python/okstra_ctl/mutation_probe.py +1263 -0
  21. package/runtime/python/okstra_ctl/render.py +77 -4
  22. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  23. package/runtime/python/okstra_ctl/report_views.py +134 -3
  24. package/runtime/python/okstra_ctl/run.py +118 -0
  25. package/runtime/python/okstra_ctl/run_context.py +34 -2
  26. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  27. package/runtime/python/okstra_ctl/self_mock_signals.py +183 -0
  28. package/runtime/python/okstra_ctl/user_response.py +309 -3
  29. package/runtime/python/okstra_ctl/wizard.py +545 -32
  30. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  31. package/runtime/python/okstra_ctl/workflow.py +22 -0
  32. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  33. package/runtime/skills/okstra-run/SKILL.md +13 -1
  34. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  35. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  36. package/runtime/templates/reports/final-report.template.md +220 -0
  37. package/runtime/templates/reports/i18n/en.json +8 -0
  38. package/runtime/templates/reports/i18n/ko.json +8 -0
  39. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  40. package/runtime/templates/reports/report.js +84 -5
  41. package/runtime/templates/reports/user-response.template.md +19 -1
  42. package/runtime/validators/detect_self_mock.py +220 -0
  43. package/runtime/validators/validate-report-views.py +61 -7
  44. package/runtime/validators/validate-run.py +518 -0
  45. package/runtime/validators/validate_analysis_report.py +864 -0
  46. package/src/commands/execute/render-bundle.mjs +3 -0
@@ -0,0 +1,24 @@
1
+ # Project Analysis Profile
2
+
3
+ - Purpose: map a bounded project area so later work can navigate components, dependencies, entry points, repositories, and external integrations without changing the source
4
+ - Required workers:
5
+ - claude
6
+ - codex
7
+ - report-writer
8
+ - Optional workers (opt-in via `--workers`):
9
+ - antigravity
10
+ {{INCLUDE:_common-contract.md}}
11
+ - Phase 1.5 questions:
12
+ - Which repositories, directories, and external systems are inside the scan scope?
13
+ - Which components or feature areas must remain shallow rather than receiving a detailed analysis?
14
+ - Which user intent and preserved boundaries constrain this map?
15
+ - Required report blocks:
16
+ - scan scope and excluded areas
17
+ - components, dependency directions, entry points, repositories, and external integrations
18
+ - shallow feature index and unresolved navigation questions
19
+ - Cross-verification mode:
20
+ - Phase 5.5 convergence runs in adversarial mode (`convergence.adversarial=true`).
21
+ - Non-goals:
22
+ - source or configuration edits
23
+ - tests, builds, migrations, or deployments
24
+ - implementation alternatives, file change specifications, or execution plans
@@ -90,7 +90,7 @@
90
90
  }
91
91
  },
92
92
  "task_type_text": {
93
- "label": "Task type? (입력 가능: requirements-discovery, improvement-discovery, error-analysis, implementation-planning, implementation, final-verification, release-handoff)",
93
+ "label": "Task type? (입력 가능: requirements-discovery, improvement-discovery, project-analysis, feature-analysis, change-impact-analysis, error-analysis, implementation-planning, implementation, final-verification, release-handoff)",
94
94
  "echo_template": "task-type: {value}"
95
95
  },
96
96
  "brief_keep": {
@@ -123,6 +123,49 @@
123
123
  "label": "task brief markdown 의 경로를 알려주세요 (project root 기준 상대경로 또는 절대경로)",
124
124
  "echo_template": "brief: {value}"
125
125
  },
126
+ "feature_evidence_pick": {
127
+ "label": "변경 영향 분석에 사용할 수락된 기능 기준선 보고서를 선택하세요.",
128
+ "echo_template": "feature-evidence: {value}",
129
+ "options": {
130
+ "__skip__": "건너뛰기",
131
+ "__free_input__": "다른 보고서 경로 입력"
132
+ },
133
+ "echo_variants": {
134
+ "skip": "feature-evidence: (none)",
135
+ "selected": "feature-evidence: {path}"
136
+ }
137
+ },
138
+ "feature_evidence": {
139
+ "label": "기능 기준선 final-report 경로를 입력하세요.",
140
+ "echo_template": "feature-evidence: {value}"
141
+ },
142
+ "project_evidence_pick": {
143
+ "label": "분석에 사용할 수락된 프로젝트 문맥 보고서를 선택하세요.",
144
+ "echo_template": "project-evidence: {value}",
145
+ "options": {
146
+ "__skip__": "건너뛰기",
147
+ "__free_input__": "다른 보고서 경로 입력"
148
+ },
149
+ "echo_variants": {
150
+ "skip": "project-evidence: (none)",
151
+ "selected": "project-evidence: {path}"
152
+ }
153
+ },
154
+ "project_evidence": {
155
+ "label": "프로젝트 문맥 final-report 경로를 입력하세요.",
156
+ "echo_template": "project-evidence: {value}"
157
+ },
158
+ "analysis_target_pick": {
159
+ "label": "프로젝트 기능 색인에서 분석 대상을 선택하세요.",
160
+ "echo_template": "analysis-target: {value}",
161
+ "options": {
162
+ "__free_input__": "자연어로 대상 입력"
163
+ }
164
+ },
165
+ "analysis_target": {
166
+ "label": "분석할 기능·여정·역량을 자연어로 입력하세요.",
167
+ "echo_template": "analysis-target: {value}"
168
+ },
126
169
  "handoff_stage_pick": {
127
170
  "label": "PR 로 내보낼 범위를 선택하세요 (복수 선택 가능 — 검증 accepted + 미-PR stage 만 표시). 제외된 stage: {blocked}",
128
171
  "echo_template": "handoff-scope: {value}",
@@ -0,0 +1,369 @@
1
+ """Shared evidence resolution for read-only analysis sidetracks."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Mapping, Sequence
11
+
12
+ from .final_report_paths import final_report_data_path
13
+ from .paths import task_manifest_file
14
+ from .user_response import (
15
+ UserResponseError,
16
+ load_authoritative_analysis_review,
17
+ )
18
+
19
+
20
+ ANALYSIS_TASK_TYPES = (
21
+ "project-analysis",
22
+ "feature-analysis",
23
+ "change-impact-analysis",
24
+ )
25
+
26
+
27
+ class AnalysisInputError(ValueError):
28
+ """Raised when a report cannot safely serve as analysis evidence."""
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AnalysisReportCandidate:
33
+ report_path: Path
34
+ task_key: str
35
+ task_type: str
36
+ created_at: str
37
+ run_seq: str
38
+ source_commit: str
39
+ review_status: str
40
+ feature_index: tuple[object, ...]
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ResolvedEvidenceInput:
45
+ report_path: Path
46
+ task_key: str
47
+ task_type: str
48
+ run_seq: str
49
+ source_commit: str
50
+ review_status: str
51
+ relation: str
52
+ freshness: str
53
+
54
+ def to_dict(self) -> dict[str, str]:
55
+ return {
56
+ "taskKey": self.task_key,
57
+ "taskType": self.task_type,
58
+ "reportPath": str(self.report_path),
59
+ "runSeq": self.run_seq,
60
+ "sourceCommit": self.source_commit,
61
+ "relation": self.relation,
62
+ "freshness": self.freshness,
63
+ "reviewStatus": self.review_status,
64
+ }
65
+
66
+
67
+ _EVIDENCE_RELATION_BY_TYPES = {
68
+ ("feature-analysis", "project-analysis"): "project-context",
69
+ ("change-impact-analysis", "project-analysis"): "project-context",
70
+ ("change-impact-analysis", "feature-analysis"): "feature-baseline",
71
+ }
72
+ _REPORT_NAME_RE = re.compile(r"^final-report-.+-(?P<run_seq>[^-]+)\.md$")
73
+ _FEATURE_ID_RE = re.compile(r"PF-\d{3}")
74
+ _FULL_COMMIT_RE = re.compile(r"[0-9a-f]{40}")
75
+ _RUN_SEQ_RE = re.compile(r"[0-9]{3}")
76
+ _CREATED_AT_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
77
+
78
+
79
+ def parse_evidence_paths(
80
+ raw_paths: str, project_root: Path | None = None,
81
+ ) -> tuple[Path, ...]:
82
+ """Parse a comma-separated evidence selection in its supplied order."""
83
+ root = project_root.resolve() if project_root else None
84
+ paths: list[Path] = []
85
+ for raw_path in raw_paths.split(","):
86
+ value = raw_path.strip()
87
+ if not value:
88
+ continue
89
+ path = Path(value).expanduser()
90
+ if root and not path.is_absolute():
91
+ path = root / path
92
+ paths.append(path.resolve())
93
+ return tuple(paths)
94
+
95
+
96
+ def load_candidate_map(
97
+ project_root: Path, report_paths: Sequence[Path],
98
+ ) -> dict[Path, AnalysisReportCandidate]:
99
+ """Load canonical metadata for every explicitly selected report."""
100
+ return {
101
+ candidate.report_path: candidate
102
+ for candidate in (
103
+ load_analysis_report_candidate(project_root, report_path)
104
+ for report_path in report_paths
105
+ )
106
+ }
107
+
108
+
109
+ def resolve_analysis_head(analysis_root: Path, task_type: str = "") -> str:
110
+ """Return an analysis worktree's immutable HEAD, or reject an invalid one."""
111
+ if task_type and task_type not in ANALYSIS_TASK_TYPES:
112
+ return ""
113
+ try:
114
+ result = subprocess.run(
115
+ ["git", "-C", str(analysis_root), "rev-parse", "HEAD"],
116
+ check=True,
117
+ capture_output=True,
118
+ text=True,
119
+ )
120
+ except (OSError, subprocess.CalledProcessError) as exc:
121
+ raise AnalysisInputError(
122
+ f"cannot resolve analysis source commit: {analysis_root}"
123
+ ) from exc
124
+ return _validated_commit(result.stdout.strip(), "analysis source commit")
125
+
126
+
127
+ def _resolved_within(path: Path, root: Path, description: str) -> Path:
128
+ resolved = path.resolve()
129
+ try:
130
+ resolved.relative_to(root)
131
+ except ValueError as exc:
132
+ raise AnalysisInputError(f"{description} must be under {root}") from exc
133
+ return resolved
134
+
135
+
136
+ def _required_string(data: Mapping[str, object], key: str, context: str) -> str:
137
+ value = data.get(key)
138
+ if not isinstance(value, str) or not value:
139
+ raise AnalysisInputError(f"data.json {context}.{key} must be a non-empty string")
140
+ return value
141
+
142
+
143
+ def _validated_commit(value: str, field: str) -> str:
144
+ if not _FULL_COMMIT_RE.fullmatch(value):
145
+ raise AnalysisInputError(
146
+ f"{field} must be a 40-character lowercase hexadecimal commit"
147
+ )
148
+ return value
149
+
150
+
151
+ def _validated_created_at(value: str) -> str:
152
+ if _CREATED_AT_RE.fullmatch(value) is None:
153
+ raise AnalysisInputError(
154
+ "data.json header.createdAt must use YYYY-MM-DDTHH:MM:SSZ"
155
+ )
156
+ try:
157
+ datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
158
+ except ValueError as exc:
159
+ raise AnalysisInputError("data.json header.createdAt is invalid") from exc
160
+ return value
161
+
162
+
163
+ def _report_path_metadata(project_root: Path, report_path: Path) -> tuple[Path, str, str]:
164
+ okstra_root = project_root.resolve() / ".okstra"
165
+ relative = report_path.relative_to(okstra_root)
166
+ parts = relative.parts
167
+ if (
168
+ len(parts) != 7
169
+ or parts[0] != "tasks"
170
+ or parts[3] != "runs"
171
+ or parts[5] != "reports"
172
+ ):
173
+ raise AnalysisInputError("report path is not an analysis run report")
174
+ return report_path.parents[3], parts[4], parts[6]
175
+
176
+
177
+ def _task_key_from_manifest(task_root: Path, okstra_root: Path) -> str:
178
+ manifest_path = _resolved_within(
179
+ task_manifest_file(task_root), okstra_root, "task manifest path"
180
+ )
181
+ try:
182
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
183
+ except (OSError, json.JSONDecodeError) as exc:
184
+ raise AnalysisInputError(f"cannot read task manifest: {manifest_path}") from exc
185
+ if not isinstance(manifest, dict):
186
+ raise AnalysisInputError("task manifest must contain an object")
187
+ return _required_string(manifest, "taskKey", "task manifest")
188
+
189
+
190
+ def load_analysis_report_candidate(
191
+ project_root: Path, report_path: Path
192
+ ) -> AnalysisReportCandidate:
193
+ """Load one report only after validating its path and canonical metadata."""
194
+ root = project_root.resolve()
195
+ okstra_root = root / ".okstra"
196
+ report = _resolved_within(report_path, okstra_root, "report path")
197
+ if not report.is_file():
198
+ raise AnalysisInputError(f"report does not exist: {report}")
199
+ task_root, path_task_type, filename = _report_path_metadata(root, report)
200
+ filename_match = _REPORT_NAME_RE.fullmatch(filename)
201
+ if not filename_match:
202
+ raise AnalysisInputError("report filename is not a final report")
203
+ data_path = _resolved_within(final_report_data_path(report), okstra_root, "data.json path")
204
+ try:
205
+ data = json.loads(data_path.read_text(encoding="utf-8"))
206
+ except (OSError, json.JSONDecodeError) as exc:
207
+ raise AnalysisInputError(f"cannot read data.json: {data_path}") from exc
208
+ if not isinstance(data, dict):
209
+ raise AnalysisInputError("data.json must contain an object")
210
+ header = data.get("header")
211
+ common = data.get("analysisCommon")
212
+ if not isinstance(header, dict) or not isinstance(common, dict):
213
+ raise AnalysisInputError("data.json requires header and analysisCommon objects")
214
+ task_key = _required_string(header, "taskKey", "header")
215
+ task_type = _required_string(header, "taskType", "header")
216
+ created_at = _validated_created_at(
217
+ _required_string(header, "createdAt", "header")
218
+ )
219
+ run_seq = _required_string(common, "runSeq", "analysisCommon")
220
+ if not _RUN_SEQ_RE.fullmatch(run_seq):
221
+ raise AnalysisInputError("data.json analysisCommon.runSeq must be 3-digit")
222
+ source_commit = _validated_commit(
223
+ _required_string(common, "sourceCommit", "analysisCommon"),
224
+ "sourceCommit",
225
+ )
226
+ if task_key != _task_key_from_manifest(task_root, okstra_root):
227
+ raise AnalysisInputError("data.json header.taskKey does not match task manifest")
228
+ if task_type != path_task_type:
229
+ raise AnalysisInputError("data.json header.taskType does not match report path")
230
+ if filename != f"final-report-{task_type}-{run_seq}.md":
231
+ raise AnalysisInputError("data.json analysisCommon.runSeq does not match report filename")
232
+ feature_index: Sequence[object] = ()
233
+ if task_type == "project-analysis":
234
+ project_analysis = data.get("projectAnalysis")
235
+ if not isinstance(project_analysis, dict):
236
+ raise AnalysisInputError("data.json projectAnalysis must be an object")
237
+ feature_index = project_analysis.get("featureIndex")
238
+ if not isinstance(feature_index, list):
239
+ raise AnalysisInputError(
240
+ "data.json projectAnalysis.featureIndex must be a list"
241
+ )
242
+ try:
243
+ review = load_authoritative_analysis_review(
244
+ report,
245
+ expected_task_key=task_key,
246
+ expected_task_type=task_type,
247
+ )
248
+ except UserResponseError as exc:
249
+ raise AnalysisInputError(f"invalid review sidecar: {exc}") from exc
250
+ review_status = review.status if review is not None else "unreviewed"
251
+ return AnalysisReportCandidate(
252
+ report_path=report,
253
+ task_key=task_key,
254
+ task_type=task_type,
255
+ created_at=created_at,
256
+ run_seq=run_seq,
257
+ source_commit=source_commit,
258
+ review_status=review_status,
259
+ feature_index=tuple(feature_index),
260
+ )
261
+
262
+
263
+ def list_evidence_candidates(
264
+ project_root: Path, consumer_task_type: str, relation: str
265
+ ) -> list[AnalysisReportCandidate]:
266
+ """Return accepted, compatible reports in deterministic newest-first order."""
267
+ root = project_root.resolve()
268
+ compatible_types = [
269
+ source_type
270
+ for (consumer, source_type), allowed_relation in _EVIDENCE_RELATION_BY_TYPES.items()
271
+ if consumer == consumer_task_type and allowed_relation == relation
272
+ ]
273
+ if not compatible_types:
274
+ return []
275
+ reports_root = root / ".okstra" / "tasks"
276
+ candidates: list[AnalysisReportCandidate] = []
277
+ for report in reports_root.glob("*/*/runs/*/reports/*.md"):
278
+ try:
279
+ candidate = load_analysis_report_candidate(root, report)
280
+ except AnalysisInputError:
281
+ continue
282
+ if candidate.task_type in compatible_types and candidate.review_status == "accepted":
283
+ candidates.append(candidate)
284
+ candidates.sort(
285
+ key=lambda candidate: (
286
+ candidate.task_key,
287
+ -int(candidate.run_seq),
288
+ str(candidate.report_path),
289
+ )
290
+ )
291
+ candidates.sort(key=lambda candidate: candidate.created_at, reverse=True)
292
+ return candidates
293
+
294
+
295
+ def resolve_evidence_inputs(
296
+ project_root: Path,
297
+ consumer_task_type: str,
298
+ report_paths: Sequence[Path],
299
+ current_commit: str,
300
+ ) -> tuple[ResolvedEvidenceInput, ...]:
301
+ """Validate the explicit evidence selection that a consumer will use."""
302
+ _validated_commit(current_commit, "current_commit")
303
+ resolved: list[ResolvedEvidenceInput] = []
304
+ seen: set[Path] = set()
305
+ for report_path in report_paths:
306
+ candidate = load_analysis_report_candidate(project_root, report_path)
307
+ if candidate.report_path in seen:
308
+ raise AnalysisInputError(f"duplicate evidence report: {candidate.report_path}")
309
+ seen.add(candidate.report_path)
310
+ relation = _EVIDENCE_RELATION_BY_TYPES.get(
311
+ (consumer_task_type, candidate.task_type)
312
+ )
313
+ if relation is None:
314
+ raise AnalysisInputError(
315
+ f"evidence relation is not allowed: {consumer_task_type} <- {candidate.task_type}"
316
+ )
317
+ if candidate.review_status in {"revision-requested", "rejected"}:
318
+ raise AnalysisInputError(
319
+ f"evidence report has {candidate.review_status} review status"
320
+ )
321
+ resolved.append(ResolvedEvidenceInput(
322
+ report_path=candidate.report_path,
323
+ task_key=candidate.task_key,
324
+ task_type=candidate.task_type,
325
+ run_seq=candidate.run_seq,
326
+ source_commit=candidate.source_commit,
327
+ review_status=("accepted" if candidate.review_status == "accepted" else "user-unverified"),
328
+ relation=relation,
329
+ freshness="exact" if candidate.source_commit == current_commit else "stale",
330
+ ))
331
+ return tuple(resolved)
332
+
333
+
334
+ def resolve_analysis_target(
335
+ raw_target: str,
336
+ evidence_inputs: Sequence[ResolvedEvidenceInput],
337
+ candidates: Mapping[Path, AnalysisReportCandidate],
338
+ ) -> dict[str, object]:
339
+ """Normalize a feature-index identifier or a non-empty free-text target."""
340
+ target = raw_target.strip()
341
+ if not target:
342
+ raise AnalysisInputError("analysis target must not be empty")
343
+ if not _FEATURE_ID_RE.fullmatch(target):
344
+ return {"inputMode": "free-text", "requestedValue": target}
345
+ matches: list[dict[object, object]] = []
346
+ for evidence in evidence_inputs:
347
+ if evidence.relation != "project-context":
348
+ continue
349
+ candidate = candidates.get(evidence.report_path)
350
+ if candidate is None:
351
+ candidate = next(
352
+ (value for path, value in candidates.items() if path.resolve() == evidence.report_path),
353
+ None,
354
+ )
355
+ if candidate is None:
356
+ continue
357
+ matches.extend(
358
+ feature for feature in candidate.feature_index
359
+ if isinstance(feature, dict) and feature.get("id") == target
360
+ )
361
+ if len(matches) != 1:
362
+ raise AnalysisInputError(
363
+ f"exactly one project-context feature must match {target}"
364
+ )
365
+ return {
366
+ "inputMode": "feature-index",
367
+ "requestedValue": target,
368
+ "feature": dict(matches[0]),
369
+ }
@@ -372,6 +372,79 @@ def user_response_sidecars(source: Path) -> list[Path]:
372
372
  )
373
373
 
374
374
 
375
+ _ANALYSIS_REPORT_NAME_RE = re.compile(
376
+ r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
377
+ r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
378
+ )
379
+ _SIDECAR_FRONTMATTER_RE = re.compile(
380
+ r"\A---[ \t]*\r?\n(?P<body>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
381
+ re.DOTALL,
382
+ )
383
+ _ANALYSIS_REVIEW_HEADING_RE = re.compile(
384
+ r"^## ANALYSIS REVIEW\s*$", re.MULTILINE
385
+ )
386
+
387
+
388
+ def _sidecar_frontmatter_value(text: str, key: str) -> str:
389
+ match = _SIDECAR_FRONTMATTER_RE.match(text)
390
+ if match is None:
391
+ return ""
392
+ value = re.search(
393
+ rf"^{re.escape(key)}:\s*(\S.*?)\s*$",
394
+ match.group("body"),
395
+ re.MULTILINE,
396
+ )
397
+ return value.group(1) if value else ""
398
+
399
+
400
+ def _sidecars_for_attachment(source: Path) -> list[Path]:
401
+ sidecars = user_response_sidecars(source)
402
+ report_match = _ANALYSIS_REPORT_NAME_RE.fullmatch(source.name)
403
+ if report_match is None:
404
+ return sidecars
405
+ expected_source = (
406
+ f"runs/{report_match.group('task_type')}/reports/{source.name}"
407
+ )
408
+ ordinary: list[Path] = []
409
+ candidates: list[tuple[Path, str]] = []
410
+ for sidecar in sidecars:
411
+ try:
412
+ text = sidecar.read_text(encoding="utf-8")
413
+ except OSError:
414
+ ordinary.append(sidecar)
415
+ continue
416
+ if _ANALYSIS_REVIEW_HEADING_RE.search(text) is None:
417
+ ordinary.append(sidecar)
418
+ continue
419
+ source_report = _sidecar_frontmatter_value(text, "source-report")
420
+ seq = _sidecar_frontmatter_value(text, "seq")
421
+ if source_report and source_report != expected_source:
422
+ continue
423
+ if seq and seq != report_match.group("seq"):
424
+ continue
425
+ candidates.append((sidecar, text))
426
+
427
+ from okstra_ctl.user_response import UserResponseError, parse_analysis_review
428
+
429
+ valid: list[Path] = []
430
+ malformed: list[Path] = []
431
+ for sidecar, text in candidates:
432
+ try:
433
+ review = parse_analysis_review(text)
434
+ except UserResponseError:
435
+ malformed.append(sidecar)
436
+ continue
437
+ if review is not None and (
438
+ review.source_report == expected_source
439
+ and review.seq == report_match.group("seq")
440
+ ):
441
+ valid.append(sidecar)
442
+ else:
443
+ malformed.append(sidecar)
444
+ selected = valid if valid else malformed
445
+ return sorted([*ordinary, *selected])
446
+
447
+
375
448
  def _sidecar_answers(source: Path) -> dict[str, str]:
376
449
  """`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
377
450
 
@@ -399,7 +472,7 @@ def attached_user_responses_section(source: Path) -> str:
399
472
  첨부할 때 쓴다. `clarification_response_with_sidecars` 와 같은 직렬화 포맷을
400
473
  한 곳에서 만들어 두 carry-in 경로가 갈라지지 않게 한다.
401
474
  """
402
- sidecars = user_response_sidecars(source)
475
+ sidecars = _sidecars_for_attachment(source)
403
476
  if not sidecars:
404
477
  return ""
405
478
  parts = ["# Attached User Responses\n"]