okstra 0.149.0 → 0.151.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 (57) hide show
  1. package/docs/architecture.md +1 -1
  2. package/docs/cli.md +2 -2
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +3 -2
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/report-writer-worker.md +8 -0
  7. package/runtime/agents/workers/translator-worker.md +67 -0
  8. package/runtime/bin/okstra-render-final-report.py +0 -11
  9. package/runtime/bin/okstra-report-translate.py +191 -0
  10. package/runtime/prompts/lead/adapters/claude-code.md +1 -1
  11. package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
  12. package/runtime/prompts/lead/plan-body-verification.md +1 -1
  13. package/runtime/prompts/lead/report-writer.md +16 -15
  14. package/runtime/prompts/lead/team-contract.md +2 -2
  15. package/runtime/prompts/wizard/prompts.ko.json +17 -1
  16. package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
  17. package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
  18. package/runtime/python/okstra_ctl/clarification_items.py +241 -44
  19. package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
  20. package/runtime/python/okstra_ctl/convergence.py +15 -1
  21. package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
  22. package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
  23. package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
  24. package/runtime/python/okstra_ctl/i18n.py +12 -7
  25. package/runtime/python/okstra_ctl/render_final_report.py +18 -17
  26. package/runtime/python/okstra_ctl/report_finalize.py +18 -0
  27. package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
  28. package/runtime/python/okstra_ctl/report_html/render.py +44 -2
  29. package/runtime/python/okstra_ctl/report_translation.py +469 -0
  30. package/runtime/python/okstra_ctl/report_views.py +23 -9
  31. package/runtime/python/okstra_ctl/run.py +1 -1
  32. package/runtime/python/okstra_ctl/user_response.py +11 -6
  33. package/runtime/python/okstra_ctl/wizard.py +100 -25
  34. package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
  35. package/runtime/templates/reports/html/base.template.html +12 -12
  36. package/runtime/templates/reports/html/i18n/en.json +395 -0
  37. package/runtime/templates/reports/html/i18n/ko.json +395 -0
  38. package/runtime/templates/reports/html/macros/forms.html +16 -16
  39. package/runtime/templates/reports/html/macros/visualizations.html +2 -2
  40. package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
  41. package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
  42. package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
  43. package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
  44. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
  45. package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
  46. package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
  47. package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
  48. package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
  49. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
  50. package/runtime/templates/reports/report.js +8 -5
  51. package/runtime/validators/validate-report-views.py +1 -1
  52. package/runtime/validators/validate-run.py +59 -31
  53. package/src/cli-registry.mjs +11 -0
  54. package/src/commands/inspect/worker-liveness.mjs +9 -7
  55. package/src/commands/report/translate.mjs +31 -0
  56. package/src/lib/helper-scripts.mjs +1 -0
  57. package/runtime/templates/reports/i18n/ko.json +0 -273
@@ -0,0 +1,469 @@
1
+ """Which final-report strings a translator may rewrite, and how to apply them.
2
+
3
+ The data.json is authored in English and is the SSOT for every downstream
4
+ consumer. The human HTML is the one artifact that renders in the reader's
5
+ language, so a translation arrives as a sidecar keyed by JSON Pointer and is
6
+ overlaid at render time. Nothing here mutates the SSOT.
7
+
8
+ Classification is by leaf key name, and the split is not "prose vs. term" —
9
+ the translator is told to leave a term in English when English reads better.
10
+ The line drawn here is **load-bearing vs. displayed**: a value the renderer
11
+ feeds into a CSS class, a label-table lookup, or an anchor id cannot change
12
+ without breaking the page, so it never reaches the translator. Everything a
13
+ reader merely reads is offered, and the translator decides.
14
+
15
+ When a key name carries load-bearing values in even one place, it counts as
16
+ structural everywhere. Missing a translation leaves English on the page;
17
+ translating an anchor id breaks the link.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from typing import Any, Iterator, Mapping, NamedTuple
23
+
24
+ # Values the renderer reads as text and nothing else.
25
+ PROSE_KEYS = frozenset({
26
+ "acceptance",
27
+ "alternativesConsidered",
28
+ "approach",
29
+ "approvalDisposition",
30
+ "approvalEvidence",
31
+ "behavior",
32
+ "blastRadius",
33
+ "blockReason",
34
+ "blockedReason",
35
+ "body",
36
+ "briefEvidence",
37
+ "carryIn",
38
+ "causeSummary",
39
+ "change",
40
+ "check",
41
+ "claim",
42
+ "condition",
43
+ "confirmingSignal",
44
+ "conformanceExemption",
45
+ "consequences",
46
+ "constraint",
47
+ "context",
48
+ "coreReason",
49
+ "decision",
50
+ "declinedFixRecommendations",
51
+ "description",
52
+ "details",
53
+ "disagreement",
54
+ "discrepancy",
55
+ "disproveWith",
56
+ "environmentSetup",
57
+ "escalationTrigger",
58
+ "evidence",
59
+ "evidenceRequired",
60
+ "exemptionReaffirm",
61
+ "exemptionReason",
62
+ "exitContract",
63
+ "exitContractSummary",
64
+ "expected",
65
+ "expectedBehaviorAfter",
66
+ "expectedForm",
67
+ "expectedOutcome",
68
+ "expectedResult",
69
+ "failureHandling",
70
+ "finalConclusion",
71
+ "headline",
72
+ "howToStart",
73
+ "hypothesis",
74
+ "item",
75
+ "justification",
76
+ "label",
77
+ "mitigation",
78
+ "motivation",
79
+ "name",
80
+ "need",
81
+ "nextStep",
82
+ "noVariationRationale",
83
+ "notApplicableReason",
84
+ "note",
85
+ "observableFailure",
86
+ "observed",
87
+ "option",
88
+ "outcome",
89
+ "outputSummary",
90
+ "performed",
91
+ "planItem",
92
+ "position",
93
+ "problem",
94
+ "question",
95
+ "rationale",
96
+ "readyWhen",
97
+ "reason",
98
+ "rejectedSummary",
99
+ "rejectingSignal",
100
+ "requiredDecision",
101
+ "requiredWork",
102
+ "requirement",
103
+ "resolution",
104
+ "responsibility",
105
+ "rolloutCost",
106
+ "routingRecommendation",
107
+ "selfFixNote",
108
+ "stageTitle",
109
+ "stageValidation",
110
+ "statement",
111
+ "suggestedAction",
112
+ "summary",
113
+ "supersededStatement",
114
+ "symptom",
115
+ "systemInterpretation",
116
+ "testCaseBoundary",
117
+ "testCaseFailure",
118
+ "testCaseSuccess",
119
+ "testCoverageCost",
120
+ "text",
121
+ "title",
122
+ "topic",
123
+ "trigger",
124
+ "triggerSignal",
125
+ "userChoice",
126
+ "userInput",
127
+ "verification",
128
+ "verificationMethod",
129
+ "verificationSignal",
130
+ "workingAssumption",
131
+ })
132
+
133
+ # Values something other than the reader depends on: a CSS class, a label-table
134
+ # key, an anchor id, a path the reader clicks, a command they paste, or a quote
135
+ # that must stay verbatim to be evidence at all.
136
+ STRUCTURAL_KEYS = frozenset({
137
+ "action", # implementation.py: _FILE_ACTIONS lookup key
138
+ "adapter",
139
+ "affectedId",
140
+ "affectedTarget",
141
+ "area", # slug-shaped ('scan-coverage')
142
+ "artifact",
143
+ "baseBranch",
144
+ "baseRefSha",
145
+ "baseSha",
146
+ "boundary",
147
+ "branchName",
148
+ "canonical",
149
+ "capturedHeadSha",
150
+ "carriedForwardFromSeq",
151
+ "carryJson",
152
+ "clarificationId",
153
+ "claudeCode",
154
+ "collectorBranch",
155
+ "command",
156
+ "commandOrObservation",
157
+ "commandOrTest",
158
+ "commitListQuote",
159
+ "complexity",
160
+ "componentId",
161
+ "confidence",
162
+ "conformanceTests",
163
+ "coveredBy",
164
+ "createdAt",
165
+ "cycle",
166
+ "dataStore",
167
+ "date",
168
+ "dependsOn",
169
+ "diffSummaryQuote",
170
+ "direction",
171
+ "dispatches",
172
+ "executorWorktreePath",
173
+ "existingPrUrl",
174
+ "externalSystem",
175
+ "featureId",
176
+ "field",
177
+ "file",
178
+ "files",
179
+ "followUpPhase",
180
+ "fromComponentId",
181
+ "fromTaskKey",
182
+ "fullSha",
183
+ "gitDiffStat",
184
+ "gitStatusShort",
185
+ "h1c",
186
+ "h2",
187
+ "id",
188
+ "impact", # 'high' / 'medium' alongside prose
189
+ "impactKind",
190
+ "implementationBaseRef",
191
+ "implementationOption",
192
+ "independentValidationRerun",
193
+ "injectedAs",
194
+ "input",
195
+ "interfaceKind",
196
+ "interfaces",
197
+ "itemId",
198
+ "kind", # feature_analysis.py: _FLOW_KINDS lookup key
199
+ "leadModel",
200
+ "leadingCauseId",
201
+ "lens", # enum_label("lens")
202
+ "lines",
203
+ "linkedWork",
204
+ "match",
205
+ "model",
206
+ "newTaskId",
207
+ "number",
208
+ "okstraVersion",
209
+ "output",
210
+ "outputTail",
211
+ "owner",
212
+ "ownerComponentId",
213
+ "path",
214
+ "planFile",
215
+ "planStep",
216
+ "prepItemId",
217
+ "project",
218
+ "projectId",
219
+ "raw",
220
+ "rawStat",
221
+ "readBoundary",
222
+ "readOnlyCommandLog",
223
+ "relatedIds",
224
+ "relation",
225
+ "replacedInTest",
226
+ "replacedWith",
227
+ "reportPath",
228
+ "requestKind",
229
+ "requestPath",
230
+ "requestVerbatim", # the user's own words, quoted as evidence
231
+ "requestedValue",
232
+ "reversibility",
233
+ "risk", # tone-{{ row.risk }} class; an enum on ProjectHotspot
234
+ "role",
235
+ "rollbackCommand",
236
+ "runManifest",
237
+ "runSeq",
238
+ "scope", # 'PF-001' alongside prose
239
+ "sections",
240
+ "shortSha",
241
+ "signature",
242
+ "singleTicket",
243
+ "skippedWorkers",
244
+ "sliceValue",
245
+ "slug",
246
+ "source", # enum_label("rejectionSource") and evidence ids
247
+ "sourceCommit",
248
+ "sourceFile",
249
+ "sourceImpactId",
250
+ "sourceSection",
251
+ "status", # CSS class + enum_label("coverage")
252
+ "styleLintTypecheck",
253
+ "subject", # commit subject, must stay verbatim
254
+ "symbol",
255
+ "symptomVerbatim",
256
+ "target",
257
+ "targetReport",
258
+ "taskGroup",
259
+ "taskId",
260
+ "taskKey",
261
+ "taskType",
262
+ "tddEvidence",
263
+ "tddExemption",
264
+ "terminal",
265
+ "toComponentId",
266
+ "toTaskKey",
267
+ "url",
268
+ "verdictTokenQuote",
269
+ "verifier",
270
+ "version",
271
+ "worker",
272
+ "workerRole",
273
+ "worktreePath",
274
+ "writeBoundary",
275
+ })
276
+
277
+
278
+ class UnclassifiedKeys(ValueError):
279
+ """A schema string field belongs to neither set."""
280
+
281
+
282
+ # A key name says what a field is *for*; these say what one value actually is.
283
+ # `evidence` is the largest prose bucket in a real report and also holds bare
284
+ # `src/server.ts:33` citations — the key cannot separate them, the value can.
285
+ _MACHINE_CHARS = re.compile(r"^[A-Za-z0-9_.:@/\\#-]+$")
286
+ _LOWER_TOKEN = re.compile(r"^[a-z][a-z0-9]*([-_][a-z0-9]+)*$")
287
+ _FILE_EXTENSION = re.compile(r"\.[A-Za-z0-9]{1,6}$")
288
+ _LINE_NUMBER = re.compile(r":\d+$")
289
+
290
+
291
+ def is_translatable_value(text: str) -> bool:
292
+ """Whether one value is prose rather than a citation or a token.
293
+
294
+ Anything with whitespace is prose. A single run of ASCII path characters
295
+ is not, once it carries a directory separator, a file extension, or a line
296
+ number — and neither is a bare lowercase token, which is how an enum reads.
297
+ """
298
+ value = text.strip()
299
+ if not value:
300
+ return False
301
+ if any(char.isspace() for char in value):
302
+ return True
303
+ if not _MACHINE_CHARS.match(value):
304
+ return True
305
+ if "/" in value or "\\" in value:
306
+ return False
307
+ if _FILE_EXTENSION.search(value) or _LINE_NUMBER.search(value):
308
+ return False
309
+ return _LOWER_TOKEN.match(value) is None
310
+
311
+
312
+ def escape_token(token: str) -> str:
313
+ """Escape one JSON Pointer reference token (RFC 6901)."""
314
+ return token.replace("~", "~0").replace("/", "~1")
315
+
316
+
317
+ def unescape_token(token: str) -> str:
318
+ return token.replace("~1", "/").replace("~0", "~")
319
+
320
+
321
+ def _walk(node: Any, pointer: str, key: str) -> Iterator[tuple[str, str]]:
322
+ if isinstance(node, Mapping):
323
+ for child_key, value in node.items():
324
+ yield from _walk(value, f"{pointer}/{escape_token(str(child_key))}", str(child_key))
325
+ elif isinstance(node, (list, tuple)):
326
+ for index, value in enumerate(node):
327
+ # An array element inherits the key that named the array, so
328
+ # `evidence: [...]` translates each entry.
329
+ yield from _walk(value, f"{pointer}/{index}", key)
330
+ elif isinstance(node, str) and key in PROSE_KEYS and is_translatable_value(node):
331
+ yield pointer, node
332
+
333
+
334
+ def extract(data: Mapping[str, Any]) -> dict[str, str]:
335
+ """Map every translatable pointer to the English text at it.
336
+
337
+ The translator fills in this map rather than authoring pointers, so a
338
+ sidecar cannot cite a path the document does not have.
339
+ """
340
+ return dict(_walk(data, "", ""))
341
+
342
+
343
+ class OverlayReport(NamedTuple):
344
+ applied: int
345
+ # Pointers the extractor offered that the sidecar left untranslated. They
346
+ # render in English — a partial translation is a readable page, not a
347
+ # failure — but a silent fallback is how a half-empty sidecar ships
348
+ # unnoticed, so the count is reported.
349
+ untranslated: tuple[str, ...]
350
+ # Pointers the sidecar carries that do not resolve to a string here. A
351
+ # sidecar written against a different report.
352
+ unresolved: tuple[str, ...]
353
+
354
+
355
+ def _resolve_parent(data: Any, pointer: str) -> tuple[Any, str | int] | None:
356
+ tokens = [unescape_token(t) for t in pointer.split("/")[1:]]
357
+ if not tokens:
358
+ return None
359
+ node = data
360
+ for token in tokens[:-1]:
361
+ if isinstance(node, Mapping) and token in node:
362
+ node = node[token]
363
+ elif isinstance(node, list) and token.isdigit() and int(token) < len(node):
364
+ node = node[int(token)]
365
+ else:
366
+ return None
367
+ last = tokens[-1]
368
+ if isinstance(node, Mapping) and isinstance(node.get(last), str):
369
+ return node, last
370
+ if isinstance(node, list) and last.isdigit() and int(last) < len(node):
371
+ return (node, int(last)) if isinstance(node[int(last)], str) else None
372
+ return None
373
+
374
+
375
+ def overlay(
376
+ data: Mapping[str, Any], strings: Mapping[str, str]
377
+ ) -> tuple[dict[str, Any], OverlayReport]:
378
+ """Return a deep copy of ``data`` with the sidecar's strings substituted."""
379
+ import copy
380
+
381
+ out = copy.deepcopy(dict(data))
382
+ applied = 0
383
+ unresolved: list[str] = []
384
+ for pointer, text in strings.items():
385
+ target = _resolve_parent(out, pointer)
386
+ if target is None or not isinstance(text, str) or not text:
387
+ unresolved.append(pointer)
388
+ continue
389
+ parent, key = target
390
+ parent[key] = text
391
+ applied += 1
392
+ untranslated = tuple(sorted(set(extract(data)) - set(strings)))
393
+ return out, OverlayReport(applied, untranslated, tuple(sorted(unresolved)))
394
+
395
+
396
+ # Above this share of Hangul, the prose was authored in Korean rather than
397
+ # quoting some. Measured across real reports: Korean-authored ones sit at
398
+ # 35-38%, English ones that quote a Korean brief or a worker's Korean phrase
399
+ # reach 8% at most. The gap is wide enough that no threshold inside it is
400
+ # delicate. Only the strings `extract` offers are counted — a verbatim quote of
401
+ # the user's request is structural and never reaches this.
402
+ HANGUL_PROSE_LIMIT = 0.20
403
+
404
+
405
+ def hangul_share(data: Mapping[str, Any]) -> tuple[float, int]:
406
+ """Return the Hangul share of the report's authored prose, and its length."""
407
+ text = "".join(extract(data).values())
408
+ if not text:
409
+ return 0.0, 0
410
+ hangul = sum(1 for char in text if "가" <= char <= "힣")
411
+ return hangul / len(text), len(text)
412
+
413
+
414
+ def prose_is_english(data: Mapping[str, Any]) -> bool:
415
+ """Whether the data.json was authored in English, as the SSOT contract requires.
416
+
417
+ The report language names what the human HTML renders in, not what the
418
+ worker writes. A worker that authors Korean anyway hands every later phase,
419
+ validator and agent a record in a language they do not read.
420
+ """
421
+ share, _ = hangul_share(data)
422
+ return share < HANGUL_PROSE_LIMIT
423
+
424
+
425
+ def _schema_string_keys(schema: Mapping[str, Any], *, enums: bool) -> set[str]:
426
+ found: set[str] = set()
427
+
428
+ def walk(node: Any) -> None:
429
+ if not isinstance(node, Mapping):
430
+ return
431
+ if node.get("type") == "object":
432
+ for key, child in (node.get("properties") or {}).items():
433
+ if isinstance(child, Mapping) and child.get("type") == "string":
434
+ if ("enum" in child) == enums:
435
+ found.add(str(key))
436
+ elif isinstance(child, Mapping) and enums and "enum" in child:
437
+ # An enum with no declared type is still an enum.
438
+ found.add(str(key))
439
+ walk(child)
440
+ elif node.get("type") == "array":
441
+ walk(node.get("items") or {})
442
+ for combinator in ("oneOf", "anyOf", "allOf"):
443
+ for branch in node.get(combinator) or []:
444
+ walk(branch)
445
+
446
+ for definition in (schema.get("$defs") or {}).values():
447
+ walk(definition)
448
+ walk(schema)
449
+ return found
450
+
451
+
452
+ def schema_string_keys(schema: Mapping[str, Any]) -> set[str]:
453
+ """Every leaf key in the schema whose value is a free-form string.
454
+
455
+ The drift guard compares this against the two sets above, so adding a
456
+ report field forces a decision about whether a translator may touch it.
457
+ """
458
+ return _schema_string_keys(schema, enums=False)
459
+
460
+
461
+ def schema_enum_keys(schema: Mapping[str, Any]) -> set[str]:
462
+ """Every leaf key the schema constrains to an enum, anywhere.
463
+
464
+ A name can be an enum in one definition and free prose in another —
465
+ `risk` is `high|medium|low` on a hotspot and a sentence on a risk row.
466
+ The renderer reaches for the same name either way (`tone-{{ row.risk }}`),
467
+ so one enum occurrence makes the name structural everywhere.
468
+ """
469
+ return _schema_string_keys(schema, enums=True)
@@ -132,12 +132,23 @@ def _infer_run_meta_from_path(path: Path) -> dict:
132
132
  return {"task_type": m.group("task_type"), "seq": m.group("seq")} if m else {}
133
133
 
134
134
 
135
+ # 리포트 헤더는 이 값들을 인라인 코드로 렌더한다 (`- Task Type: \`x\``). 마커를
136
+ # 벗기지 않으면 백틱이 sidecar 파일명과 frontmatter 로 새고, 다음 run 의
137
+ # clarification 캐리인이 `user-response-<task-type>-<seq>.md` 매칭에 실패한다.
138
+ _INLINE_CODE_VALUE_RE = re.compile(r"^`(?P<value>.+)`$")
139
+
140
+
141
+ def _strip_inline_code(value: str) -> str:
142
+ m = _INLINE_CODE_VALUE_RE.match(value)
143
+ return m.group("value").strip() if m else value
144
+
145
+
135
146
  def _infer_run_meta_from_body(text: str) -> dict:
136
147
  found: dict[str, str] = {}
137
148
  for label, key in (("Task Key", "task_key"), ("Task Type", "task_type")):
138
149
  m = re.search(rf"^- {label}:\s*(\S.*?)\s*$", text, re.MULTILINE)
139
150
  if m:
140
- found[key] = m.group(1)
151
+ found[key] = _strip_inline_code(m.group(1))
141
152
  return found
142
153
 
143
154
 
@@ -145,9 +156,14 @@ def infer_run_meta(report_path: Path, *, task_key: Optional[str] = None,
145
156
  task_type: Optional[str] = None, seq: Optional[str] = None,
146
157
  source_report: Optional[str] = None) -> RunMeta:
147
158
  """Derive a ``RunMeta`` from a final-report path/body, honouring any
148
- explicit override. Single reference point for BOTH the HTML view render
149
- script and the in-session user-response writer, so sidecar match keys
150
- (task_type/seq/source-report) never drift between the two paths."""
159
+ explicit override.
160
+
161
+ Used by the schema-v1 HTML view render script and by the in-session
162
+ user-response writer. Schema-v2 resolves its own ``HtmlRunMeta`` from the
163
+ data.json header instead, so the sidecar match keys (task_type/seq/
164
+ source-report) this produces must agree with that header — the sidecar
165
+ name the v2 HTML advertises is the one the next run's carry-in looks for.
166
+ """
151
167
  text = report_path.read_text(encoding="utf-8")
152
168
  inferred = {**_infer_run_meta_from_path(report_path), **_infer_run_meta_from_body(text)}
153
169
  return RunMeta(
@@ -1194,9 +1210,7 @@ def analysis_review_context(src_md_path: Path) -> AnalysisReviewContext | None:
1194
1210
  return AnalysisReviewContext(selector_ids=tuple(sorted(found)))
1195
1211
 
1196
1212
 
1197
- def plan_approval_context(
1198
- src_md_path: Path, src_text: str
1199
- ) -> PlanApprovalContext | None:
1213
+ def plan_approval_context(src_md_path: Path) -> PlanApprovalContext | None:
1200
1214
  """implementation-planning 보고서 + sibling data.json 의 optionCandidates 가
1201
1215
  있을 때만 컨텍스트를 만든다. planning 여부는 task-type 문자열이 아니라
1202
1216
  data.json 의 ``implementationPlanning`` 키(SSOT)로 판정한다 — renderer 와
@@ -1209,7 +1223,7 @@ def plan_approval_context(
1209
1223
  state = plan_approval_state(data)
1210
1224
  if state is None:
1211
1225
  return None
1212
- scan = scan_approval_gate(src_text)
1226
+ scan = scan_approval_gate(src_md_path)
1213
1227
  blocker_ids = state.blocker_ids
1214
1228
  reason = state.disabled_reason
1215
1229
  if scan.unreadable_reason:
@@ -1418,7 +1432,7 @@ def render_html_view(
1418
1432
  "re-render the report so §1 matches the schema before generating "
1419
1433
  "the HTML view."
1420
1434
  )
1421
- approval_ctx = plan_approval_context(src_md_path, src_text)
1435
+ approval_ctx = plan_approval_context(src_md_path)
1422
1436
  analysis_review_ctx = analysis_review_context(src_md_path)
1423
1437
  reader_ctx = reader_dashboard_context(src_md_path, src_text, approval_ctx)
1424
1438
  has_clarifications = report_has_clarification_items(src_text)
@@ -434,7 +434,7 @@ def _validate_approved_plan(path: str) -> None:
434
434
  _validate_data_json_approval_consistency(p, markdown_approved=True)
435
435
  # frontmatter approved == true 상태. §1 Clarification Items 의
436
436
  # Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
437
- scan = scan_approval_gate(body)
437
+ scan = scan_approval_gate(p)
438
438
  if scan.unreadable_reason:
439
439
  raise PrepareError(
440
440
  f"approved plan §1 approval gate could not be read: {path}\n"
@@ -24,7 +24,7 @@ from okstra_ctl.report_views import (
24
24
  from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
25
25
  from okstra_ctl.listing import list_runs, absolute_final_report_path
26
26
  from okstra_ctl.clarification_items import (
27
- parse_clarification_rows,
27
+ read_clarification_rows,
28
28
  scan_open_user_input,
29
29
  section_1_present_but_unparsed,
30
30
  _section_1_slice,
@@ -320,9 +320,14 @@ def load_authoritative_analysis_review(
320
320
  if _analysis_review_matches_with_valid_created_at(text):
321
321
  attached.append(f"\n## {sidecar.name}\n\n{text.strip()}\n")
322
322
  if not attached:
323
- raise UserResponseError(
324
- "existing review sidecar has no ANALYSIS REVIEW block"
325
- )
323
+ # `sidecar_name` matches every user-response sidecar for this run, not
324
+ # just review ones recording a clarification answer produces the same
325
+ # filename. No `## ANALYSIS REVIEW` block in any of them means no review
326
+ # was attached, which is what `None` says; raising here made the routine
327
+ # act of answering a clarification disqualify the report as a carry-in
328
+ # candidate. A block that *is* present but malformed still raises, in
329
+ # `_analysis_review_matches_with_valid_created_at` above.
330
+ return None
326
331
  review = parse_analysis_review("".join(attached))
327
332
  if review is None:
328
333
  raise UserResponseError("analysis review sidecar is unreadable")
@@ -443,7 +448,7 @@ def list_awaiting_tasks(home: Path, project_id: str, limit: int) -> list[dict]:
443
448
  if report is None or not report.is_file():
444
449
  continue
445
450
  text = report.read_text(encoding="utf-8")
446
- scan = scan_open_user_input(text)
451
+ scan = scan_open_user_input(report)
447
452
  base = {"taskKey": key, "taskType": row.get("taskType", ""),
448
453
  "seq": _seq_from_report(report), "reportPath": str(report),
449
454
  "reportMtime": report.stat().st_mtime}
@@ -541,7 +546,7 @@ def resolve_refs(report_text: str, refs: list[str]) -> list[dict]:
541
546
  def show_open_rows(report_path: Path) -> dict:
542
547
  text = report_path.read_text(encoding="utf-8")
543
548
  rows = []
544
- for r in parse_clarification_rows(text):
549
+ for r in read_clarification_rows(report_path):
545
550
  it = r["item"]
546
551
  if it.status not in ("open", "answered"):
547
552
  continue