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.
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/project-structure-overview.md +1 -1
- package/package.json +3 -2
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +8 -0
- package/runtime/agents/workers/translator-worker.md +67 -0
- package/runtime/bin/okstra-render-final-report.py +0 -11
- package/runtime/bin/okstra-report-translate.py +191 -0
- package/runtime/prompts/lead/adapters/claude-code.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -0
- package/runtime/prompts/lead/plan-body-verification.md +1 -1
- package/runtime/prompts/lead/report-writer.md +16 -15
- package/runtime/prompts/lead/team-contract.md +2 -2
- package/runtime/prompts/wizard/prompts.ko.json +17 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +24 -9
- package/runtime/python/okstra_ctl/analysis_packet.py +23 -1
- package/runtime/python/okstra_ctl/clarification_items.py +241 -44
- package/runtime/python/okstra_ctl/codex_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/convergence.py +15 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_state.py +12 -1
- package/runtime/python/okstra_ctl/final_report_paths.py +22 -1
- package/runtime/python/okstra_ctl/i18n.py +12 -7
- package/runtime/python/okstra_ctl/render_final_report.py +18 -17
- package/runtime/python/okstra_ctl/report_finalize.py +18 -0
- package/runtime/python/okstra_ctl/report_html/filters.py +15 -77
- package/runtime/python/okstra_ctl/report_html/render.py +44 -2
- package/runtime/python/okstra_ctl/report_translation.py +469 -0
- package/runtime/python/okstra_ctl/report_views.py +23 -9
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/user_response.py +11 -6
- package/runtime/python/okstra_ctl/wizard.py +100 -25
- package/runtime/python/okstra_ctl/worker_liveness.py +130 -36
- package/runtime/templates/reports/html/base.template.html +12 -12
- package/runtime/templates/reports/html/i18n/en.json +395 -0
- package/runtime/templates/reports/html/i18n/ko.json +395 -0
- package/runtime/templates/reports/html/macros/forms.html +16 -16
- package/runtime/templates/reports/html/macros/visualizations.html +2 -2
- package/runtime/templates/reports/html/tasks/change-impact-analysis.template.html +17 -17
- package/runtime/templates/reports/html/tasks/error-analysis.template.html +12 -12
- package/runtime/templates/reports/html/tasks/feature-analysis.template.html +16 -16
- package/runtime/templates/reports/html/tasks/final-verification.template.html +12 -12
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +37 -37
- package/runtime/templates/reports/html/tasks/implementation.template.html +18 -18
- package/runtime/templates/reports/html/tasks/improvement-discovery.template.html +7 -7
- package/runtime/templates/reports/html/tasks/project-analysis.template.html +29 -29
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +13 -13
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +14 -14
- package/runtime/templates/reports/report.js +8 -5
- package/runtime/validators/validate-report-views.py +1 -1
- package/runtime/validators/validate-run.py +59 -31
- package/src/cli-registry.mjs +11 -0
- package/src/commands/inspect/worker-liveness.mjs +9 -7
- package/src/commands/report/translate.mjs +31 -0
- package/src/lib/helper-scripts.mjs +1 -0
- 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.
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
324
|
-
|
|
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(
|
|
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
|
|
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
|