okstra 0.131.0 → 0.132.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/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -308,6 +308,66 @@ def _build_index(headings: list, definitions: list, labels: dict) -> list[str]:
|
|
|
308
308
|
return block
|
|
309
309
|
|
|
310
310
|
|
|
311
|
+
# --- Prose ventilation post-render pass -----------------------------------
|
|
312
|
+
# Split prose paragraphs at sentence boundaries so raw markdown reads one
|
|
313
|
+
# sentence per line; the HTML view turns those newlines into <br>
|
|
314
|
+
# (report_views._markdown_to_html). Prose only — table cells already carry
|
|
315
|
+
# their own <br>, and headings / lists / code / blockquotes must stay intact.
|
|
316
|
+
# A leading-whitespace line is a list continuation, not a prose paragraph:
|
|
317
|
+
# only column-0 non-block lines are treated as prose (allowlist).
|
|
318
|
+
_LIST_LINE_RE = re.compile(r"^\s*(?:[-*]\s|\d+\.\s)")
|
|
319
|
+
_SENT_BOUNDARY_RE = re.compile(r"[.?!][ \t]+(?=\S)")
|
|
320
|
+
# Trailing tokens whose dot is not a sentence end (compared lowercased with
|
|
321
|
+
# the trailing dot stripped).
|
|
322
|
+
_ABBREV = {
|
|
323
|
+
"e.g", "i.e", "etc", "cf", "vs", "al", "no", "fig",
|
|
324
|
+
"dr", "mr", "ms", "inc", "ltd", "jr", "sr",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _ventilate_segment(seg: str) -> str:
|
|
329
|
+
def replace_boundary(match: re.Match) -> str:
|
|
330
|
+
mark = match.start()
|
|
331
|
+
prev = seg[mark - 1] if mark > 0 else ""
|
|
332
|
+
# A number, a bare-digit, or an ellipsis dot is not a sentence end.
|
|
333
|
+
if not prev or prev.isspace() or prev.isdigit() or prev == ".":
|
|
334
|
+
return match.group(0)
|
|
335
|
+
tail = re.search(r"[A-Za-z.]+$", seg[: mark + 1])
|
|
336
|
+
if tail and tail.group(0).rstrip(".").lower() in _ABBREV:
|
|
337
|
+
return match.group(0)
|
|
338
|
+
return seg[mark] + "\n"
|
|
339
|
+
|
|
340
|
+
return _SENT_BOUNDARY_RE.sub(replace_boundary, seg)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _split_prose_line(line: str) -> str:
|
|
344
|
+
parts = line.split("`")
|
|
345
|
+
for even in range(0, len(parts), 2): # segments outside backtick code spans
|
|
346
|
+
parts[even] = _ventilate_segment(parts[even])
|
|
347
|
+
return "`".join(parts)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _ventilate_prose(markdown: str) -> str:
|
|
351
|
+
"""Break prose paragraphs into one sentence per line. Idempotent: a line
|
|
352
|
+
already holding a single sentence has no internal boundary to split."""
|
|
353
|
+
lines = markdown.split("\n")
|
|
354
|
+
mask = _code_line_mask(lines)
|
|
355
|
+
for i, line in enumerate(lines):
|
|
356
|
+
if mask[i]: # frontmatter / fenced code
|
|
357
|
+
continue
|
|
358
|
+
if not line or line != line.lstrip(): # blank or indented continuation
|
|
359
|
+
continue
|
|
360
|
+
if (
|
|
361
|
+
_HEADING_RE.match(line)
|
|
362
|
+
or line.startswith("|")
|
|
363
|
+
or line.startswith(">")
|
|
364
|
+
or _LIST_LINE_RE.match(line)
|
|
365
|
+
):
|
|
366
|
+
continue
|
|
367
|
+
lines[i] = _split_prose_line(line)
|
|
368
|
+
return "\n".join(lines)
|
|
369
|
+
|
|
370
|
+
|
|
311
371
|
def _inject_index_and_anchors(markdown: str, dictionary: dict | None) -> str:
|
|
312
372
|
"""Append scroll anchors + a top-of-report index to a rendered report.
|
|
313
373
|
Idempotent: re-running on already-anchored markdown is a no-op for
|
|
@@ -500,7 +560,8 @@ def render(
|
|
|
500
560
|
try:
|
|
501
561
|
template = env.get_template(template_path.name)
|
|
502
562
|
rendered = template.render(**data)
|
|
503
|
-
|
|
563
|
+
rendered = _inject_index_and_anchors(rendered, dictionary)
|
|
564
|
+
return _ventilate_prose(rendered)
|
|
504
565
|
except I18nError as exc:
|
|
505
566
|
raise FinalReportRenderError(
|
|
506
567
|
f"i18n lookup failed while rendering {template_path.name}: {exc}"
|
|
@@ -399,7 +399,9 @@ def _markdown_to_html(
|
|
|
399
399
|
para_lines.append(ln)
|
|
400
400
|
i += 1
|
|
401
401
|
if para_lines:
|
|
402
|
-
|
|
402
|
+
# The renderer's prose ventilation puts one sentence per source
|
|
403
|
+
# line; join with <br> so those breaks survive into the HTML.
|
|
404
|
+
out.append("<p>" + _inline("<br>".join(para_lines)) + "</p>")
|
|
403
405
|
|
|
404
406
|
return "\n".join(out), headings
|
|
405
407
|
|
|
@@ -78,7 +78,10 @@ from okstra_ctl.worker_prompt_contract import ( # noqa: E402
|
|
|
78
78
|
PromptRecord,
|
|
79
79
|
validate_initial_prompt_records,
|
|
80
80
|
)
|
|
81
|
-
from okstra_ctl.convergence_engine import
|
|
81
|
+
from okstra_ctl.convergence_engine import ( # noqa: E402
|
|
82
|
+
grouped_input_digest,
|
|
83
|
+
validate_final_state,
|
|
84
|
+
)
|
|
82
85
|
|
|
83
86
|
TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
|
|
84
87
|
ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
|
|
@@ -1609,6 +1612,14 @@ def _validate_conformance(
|
|
|
1609
1612
|
f"conformance gate BLOCKING for stage {stage_key}: "
|
|
1610
1613
|
"requires=[] is declaration/contract trouble and cannot be waived"
|
|
1611
1614
|
)
|
|
1615
|
+
if entry.get("requires") and entry.get("exemption"):
|
|
1616
|
+
failures.append(
|
|
1617
|
+
f"conformance gate BLOCKING for stage {stage_key}: "
|
|
1618
|
+
f"a stage that declares a gated surface (requires="
|
|
1619
|
+
f"{entry.get('requires')}) cannot be exempted — submit a real "
|
|
1620
|
+
"conformance result or record a user-acknowledged waiver. See "
|
|
1621
|
+
"docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md §7.1."
|
|
1622
|
+
)
|
|
1612
1623
|
results = _load_conformance_results(qa_dir, scoped)
|
|
1613
1624
|
for verdict in evaluate_conformance(scoped, results):
|
|
1614
1625
|
if verdict.status == "ADVISORY":
|
|
@@ -5081,6 +5092,292 @@ def _validate_convergence_states(run_dir, failures) -> None:
|
|
|
5081
5092
|
)
|
|
5082
5093
|
|
|
5083
5094
|
|
|
5095
|
+
def _nonempty_string(value) -> bool:
|
|
5096
|
+
"""True for a non-blank string, matching the schema's `\\S` pattern."""
|
|
5097
|
+
return isinstance(value, str) and value.strip() != ""
|
|
5098
|
+
|
|
5099
|
+
|
|
5100
|
+
# Round-0 grouping filename: `convergence-groups-<task-type>-<seq>.json`. The
|
|
5101
|
+
# `<task-type>-<seq>` suffix is shared verbatim with the worker-result and
|
|
5102
|
+
# working-state basenames, so capture it as one token to reconstruct both.
|
|
5103
|
+
_CONVERGENCE_GROUPS_BASENAME_RE = re.compile(
|
|
5104
|
+
r"^convergence-groups-(?P<suffix>[a-z][a-z-]*?-\d{3})\.json$"
|
|
5105
|
+
)
|
|
5106
|
+
|
|
5107
|
+
|
|
5108
|
+
def _convergence_group_claims(document) -> list[tuple[str, str, str]] | None:
|
|
5109
|
+
"""Extract `(findingId, worker, itemId)` provenance claims from a grouping.
|
|
5110
|
+
|
|
5111
|
+
Returns ``None`` when the document does not match the convergence-groups
|
|
5112
|
+
schema shape closely enough to read its provenance fields safely — the
|
|
5113
|
+
caller refuses to judge such a file. Otherwise returns the deduplicated
|
|
5114
|
+
claims drawn from every group's ``sourceItems[]`` and ``discoveredBy`` map.
|
|
5115
|
+
"""
|
|
5116
|
+
if not isinstance(document, dict) or not isinstance(document.get("groups"), list):
|
|
5117
|
+
return None
|
|
5118
|
+
claims: list[tuple[str, str, str]] = []
|
|
5119
|
+
seen: set[tuple[str, str, str]] = set()
|
|
5120
|
+
for group in document["groups"]:
|
|
5121
|
+
if not isinstance(group, dict):
|
|
5122
|
+
return None
|
|
5123
|
+
finding_id = group.get("findingId")
|
|
5124
|
+
source_items = group.get("sourceItems")
|
|
5125
|
+
discovered_by = group.get("discoveredBy")
|
|
5126
|
+
if (
|
|
5127
|
+
not _nonempty_string(finding_id)
|
|
5128
|
+
or not isinstance(source_items, list)
|
|
5129
|
+
or not isinstance(discovered_by, dict)
|
|
5130
|
+
):
|
|
5131
|
+
return None
|
|
5132
|
+
pairs: list[tuple[object, object]] = []
|
|
5133
|
+
for item in source_items:
|
|
5134
|
+
if not isinstance(item, dict):
|
|
5135
|
+
return None
|
|
5136
|
+
pairs.append((item.get("worker"), item.get("itemId")))
|
|
5137
|
+
for worker, discovery in discovered_by.items():
|
|
5138
|
+
if not isinstance(discovery, dict):
|
|
5139
|
+
return None
|
|
5140
|
+
pairs.append((worker, discovery.get("itemId")))
|
|
5141
|
+
for worker, item_id in pairs:
|
|
5142
|
+
if not _nonempty_string(worker) or not _nonempty_string(item_id):
|
|
5143
|
+
return None
|
|
5144
|
+
claim = (finding_id, worker, item_id)
|
|
5145
|
+
if claim not in seen:
|
|
5146
|
+
seen.add(claim)
|
|
5147
|
+
claims.append(claim)
|
|
5148
|
+
return claims
|
|
5149
|
+
|
|
5150
|
+
|
|
5151
|
+
def _convergence_groups_digest_ok(state_dir, suffix: str, document: dict) -> bool:
|
|
5152
|
+
"""False only when a recorded groupsDigest exists and no longer matches.
|
|
5153
|
+
|
|
5154
|
+
``okstra convergence seed`` pins ``grouped_input_digest`` of the grouping
|
|
5155
|
+
into the working state. When that record is present and disagrees with the
|
|
5156
|
+
on-disk grouping, the file was tampered with or written out of band — not
|
|
5157
|
+
evidence of fabrication — so the caller refuses to judge it. A missing
|
|
5158
|
+
working state or missing digest means judge the file as parsed.
|
|
5159
|
+
"""
|
|
5160
|
+
work_path = state_dir / f"convergence-work-{suffix}.json"
|
|
5161
|
+
try:
|
|
5162
|
+
work = json.loads(work_path.read_text(encoding="utf-8"))
|
|
5163
|
+
except (OSError, ValueError):
|
|
5164
|
+
return True
|
|
5165
|
+
recorded = work.get("groupsDigest") if isinstance(work, dict) else None
|
|
5166
|
+
if not _nonempty_string(recorded):
|
|
5167
|
+
return True
|
|
5168
|
+
return grouped_input_digest(document) == recorded
|
|
5169
|
+
|
|
5170
|
+
|
|
5171
|
+
def _read_canonical_worker_result(worker_results_dir, worker: str, suffix: str):
|
|
5172
|
+
"""Return the `<worker>-<suffix>.md` text, or ``None`` when it is not a
|
|
5173
|
+
resolvable file directly inside ``worker-results/`` (missing, unreadable,
|
|
5174
|
+
or a worker slug that is not a single path component)."""
|
|
5175
|
+
candidate = worker_results_dir / f"{worker}-{suffix}.md"
|
|
5176
|
+
try:
|
|
5177
|
+
if candidate.resolve().parent != worker_results_dir.resolve():
|
|
5178
|
+
return None
|
|
5179
|
+
return candidate.read_text(encoding="utf-8")
|
|
5180
|
+
except (OSError, ValueError):
|
|
5181
|
+
return None
|
|
5182
|
+
|
|
5183
|
+
|
|
5184
|
+
def _id_occurs_wordbounded(text: str, item_id: str) -> bool:
|
|
5185
|
+
"""True when `item_id` occurs as a literal not flanked by another
|
|
5186
|
+
identifier char, so `F-7` matches neither `F-70` nor `xF-7`."""
|
|
5187
|
+
pattern = r"(?<![0-9A-Za-z_-])" + re.escape(item_id) + r"(?![0-9A-Za-z_-])"
|
|
5188
|
+
return re.search(pattern, text) is not None
|
|
5189
|
+
|
|
5190
|
+
|
|
5191
|
+
def _validate_convergence_group_provenance(run_dir, failures) -> None:
|
|
5192
|
+
"""Every source item a round-0 grouping cites must exist in the worker file.
|
|
5193
|
+
|
|
5194
|
+
The lead mints its own finding IDs, but each ``sourceItems[].itemId`` and
|
|
5195
|
+
``discoveredBy.<worker>.itemId`` is copied verbatim from a worker's own
|
|
5196
|
+
result. A grouping that cites ``codex-worker:F-007`` when codex never wrote
|
|
5197
|
+
F-007 is a fabricated provenance link the final report cannot expose. This
|
|
5198
|
+
replays that one claim: the cited item ID must occur, word-boundary
|
|
5199
|
+
anchored, in the worker's canonical result file.
|
|
5200
|
+
|
|
5201
|
+
A validator failure blocks user approval, so unjudgeable input is skipped
|
|
5202
|
+
silently: no groups file, unreadable/malformed JSON, a groups file that
|
|
5203
|
+
fails the schema shape, a missing worker result file (provider-unavailable
|
|
5204
|
+
substitution is legitimate), or a recorded groupsDigest that no longer
|
|
5205
|
+
matches.
|
|
5206
|
+
"""
|
|
5207
|
+
from pathlib import Path as _Path
|
|
5208
|
+
|
|
5209
|
+
state_dir = _Path(run_dir) / "state"
|
|
5210
|
+
worker_results_dir = _Path(run_dir) / "worker-results"
|
|
5211
|
+
if not state_dir.is_dir() or not worker_results_dir.is_dir():
|
|
5212
|
+
return
|
|
5213
|
+
for groups_path in sorted(state_dir.glob("convergence-groups-*.json")):
|
|
5214
|
+
match = _CONVERGENCE_GROUPS_BASENAME_RE.match(groups_path.name)
|
|
5215
|
+
if match is None:
|
|
5216
|
+
continue
|
|
5217
|
+
suffix = match.group("suffix")
|
|
5218
|
+
try:
|
|
5219
|
+
document = json.loads(groups_path.read_text(encoding="utf-8"))
|
|
5220
|
+
except (OSError, ValueError):
|
|
5221
|
+
continue
|
|
5222
|
+
claims = _convergence_group_claims(document)
|
|
5223
|
+
if claims is None:
|
|
5224
|
+
continue
|
|
5225
|
+
if not _convergence_groups_digest_ok(state_dir, suffix, document):
|
|
5226
|
+
continue
|
|
5227
|
+
contents: dict[str, str | None] = {}
|
|
5228
|
+
for finding_id, worker, item_id in claims:
|
|
5229
|
+
if worker not in contents:
|
|
5230
|
+
contents[worker] = _read_canonical_worker_result(
|
|
5231
|
+
worker_results_dir, worker, suffix
|
|
5232
|
+
)
|
|
5233
|
+
text = contents[worker]
|
|
5234
|
+
if text is None or _id_occurs_wordbounded(text, item_id):
|
|
5235
|
+
continue
|
|
5236
|
+
failures.append(
|
|
5237
|
+
f"convergence groups `{groups_path.name}` group {finding_id} "
|
|
5238
|
+
f"cites source item `{worker}:{item_id}`, but that ID does not "
|
|
5239
|
+
f"occur in the worker's result file `{worker}-{suffix}.md` — a "
|
|
5240
|
+
"grouping may not invent a provenance link to an item the "
|
|
5241
|
+
"worker never reported."
|
|
5242
|
+
)
|
|
5243
|
+
|
|
5244
|
+
|
|
5245
|
+
# Reverify-prompt basename: `<role-slug>-reverify-r<N>-<task-type>-<seq>.md`.
|
|
5246
|
+
# The role slug ends in `-worker` like the worker-result files, but the round
|
|
5247
|
+
# plan's `dispatches[].worker` may carry either that full slug or the bare
|
|
5248
|
+
# provider token (`claude` vs `claude-worker`), depending on how the roster was
|
|
5249
|
+
# seeded — worker resolution below accepts both. `-reverify-r` never occurs
|
|
5250
|
+
# inside a slug, so the non-greedy slug capture stops at that literal anchor.
|
|
5251
|
+
_REVERIFY_PROMPT_BASENAME_RE = re.compile(
|
|
5252
|
+
r"^(?P<slug>[a-z][a-z0-9-]*?)-reverify-r(?P<round>\d+)"
|
|
5253
|
+
r"-(?P<task_type>[a-z][a-z-]*?)-(?P<seq>\d{3})\.md$"
|
|
5254
|
+
)
|
|
5255
|
+
|
|
5256
|
+
# The reverify prompt's finding identifier is the lead-minted `### F-NNN` /
|
|
5257
|
+
# `### C-NNN` H3 heading inside the `## Findings to verify` block. The older
|
|
5258
|
+
# `### VF-N (origin ...)` / `G-NNN` heading shape this token regex deliberately
|
|
5259
|
+
# does not match, so historical prompts parse zero ids and are skipped.
|
|
5260
|
+
_FINDINGS_TO_VERIFY_HEADING_RE = re.compile(r"^##\s+Findings to verify\s*$")
|
|
5261
|
+
_TWO_HASH_HEADING_RE = re.compile(r"^##\s")
|
|
5262
|
+
_FINDING_HEADING_TOKEN_RE = re.compile(r"^###\s+(F-\d+|C-\d+)")
|
|
5263
|
+
|
|
5264
|
+
|
|
5265
|
+
def _reverify_prompt_finding_ids(content: str) -> set[str] | None:
|
|
5266
|
+
"""Distinct `### F-NNN`/`### C-NNN` tokens in the `## Findings to verify`
|
|
5267
|
+
block, or ``None`` when the prompt has no such block.
|
|
5268
|
+
|
|
5269
|
+
The `## Response format` section re-echoes the same headings, so the block
|
|
5270
|
+
is bounded at the next `## ` heading and the tokens are collected into a
|
|
5271
|
+
set — order and re-echoed duplicates are irrelevant because votes are keyed
|
|
5272
|
+
by findingId. An empty set means the block exists but yielded no recognized
|
|
5273
|
+
heading (the historical `VF-N`/`G-NNN` shape); the caller skips both.
|
|
5274
|
+
"""
|
|
5275
|
+
lines = content.splitlines()
|
|
5276
|
+
start = None
|
|
5277
|
+
for index, line in enumerate(lines):
|
|
5278
|
+
if _FINDINGS_TO_VERIFY_HEADING_RE.match(line):
|
|
5279
|
+
start = index + 1
|
|
5280
|
+
break
|
|
5281
|
+
if start is None:
|
|
5282
|
+
return None
|
|
5283
|
+
ids: set[str] = set()
|
|
5284
|
+
for line in lines[start:]:
|
|
5285
|
+
if _TWO_HASH_HEADING_RE.match(line):
|
|
5286
|
+
break # next `## ` section (e.g. `## Response format`) ends the block
|
|
5287
|
+
match = _FINDING_HEADING_TOKEN_RE.match(line)
|
|
5288
|
+
if match:
|
|
5289
|
+
ids.add(match.group(1))
|
|
5290
|
+
return ids
|
|
5291
|
+
|
|
5292
|
+
|
|
5293
|
+
def _plan_dispatch_finding_ids(plan, prompt_slug: str) -> set[str] | None:
|
|
5294
|
+
"""The `findingIds` set of the single `dispatches[]` row the prompt slug
|
|
5295
|
+
resolves to, or ``None`` when the plan shape is malformed or the slug does
|
|
5296
|
+
not resolve to exactly one row (refuse to judge either way).
|
|
5297
|
+
|
|
5298
|
+
A prompt slug `claude-worker` matches a row whose `worker` is `claude-worker`
|
|
5299
|
+
(exact) or `claude` (bare token, `worker + "-worker" == slug`). The round
|
|
5300
|
+
plan has no JSON schema, so the `dispatches[]` shape is validated defensively.
|
|
5301
|
+
"""
|
|
5302
|
+
if not isinstance(plan, dict):
|
|
5303
|
+
return None
|
|
5304
|
+
dispatches = plan.get("dispatches")
|
|
5305
|
+
if not isinstance(dispatches, list):
|
|
5306
|
+
return None
|
|
5307
|
+
matched: list[set[str]] = []
|
|
5308
|
+
for row in dispatches:
|
|
5309
|
+
if not isinstance(row, dict):
|
|
5310
|
+
return None
|
|
5311
|
+
worker = row.get("worker")
|
|
5312
|
+
finding_ids = row.get("findingIds")
|
|
5313
|
+
if not _nonempty_string(worker) or not isinstance(finding_ids, list):
|
|
5314
|
+
return None
|
|
5315
|
+
if any(not _nonempty_string(fid) for fid in finding_ids):
|
|
5316
|
+
return None
|
|
5317
|
+
if worker == prompt_slug or f"{worker}-worker" == prompt_slug:
|
|
5318
|
+
matched.append(set(finding_ids))
|
|
5319
|
+
if len(matched) != 1:
|
|
5320
|
+
return None
|
|
5321
|
+
return matched[0]
|
|
5322
|
+
|
|
5323
|
+
|
|
5324
|
+
def _validate_reverify_prompt_matches_plan(run_dir, failures) -> None:
|
|
5325
|
+
"""Each reverify prompt must show exactly the findings its plan row assigned.
|
|
5326
|
+
|
|
5327
|
+
`okstra convergence apply-round` checks *who* voted (`votes ⊆ plan`), but
|
|
5328
|
+
nothing checks that the reverify *prompt* carried the findings the plan
|
|
5329
|
+
assigned. A prompt-generation bug or a tampered prompt that drops or adds a
|
|
5330
|
+
`### F-NNN` heading relative to `dispatches[].findingIds` goes undetected,
|
|
5331
|
+
and a vote can be attributed to a finding the worker was never shown. This
|
|
5332
|
+
replays `plan == prompt`, which with the engine's check yields
|
|
5333
|
+
`votes ⊆ what-was-shown`. It stays independent of the engine's own check.
|
|
5334
|
+
|
|
5335
|
+
A validator failure blocks user approval, so any unjudgeable input is
|
|
5336
|
+
skipped silently: no paired plan file for the prompt's (round, task-type,
|
|
5337
|
+
seq) — which also excludes a superseded plan whose seq/task-type differs;
|
|
5338
|
+
no recognizable `## Findings to verify` block or zero headings (guards the
|
|
5339
|
+
historical `VF-N`/`G-NNN` formats); a worker slug that resolves to other
|
|
5340
|
+
than exactly one `dispatches[]` row; and an unreadable/malformed plan.
|
|
5341
|
+
"""
|
|
5342
|
+
from pathlib import Path as _Path
|
|
5343
|
+
|
|
5344
|
+
run_dir = _Path(run_dir)
|
|
5345
|
+
prompts_dir = run_dir / "prompts"
|
|
5346
|
+
state_dir = run_dir / "state"
|
|
5347
|
+
if not prompts_dir.is_dir() or not state_dir.is_dir():
|
|
5348
|
+
return
|
|
5349
|
+
for prompt_path in sorted(prompts_dir.glob("*-reverify-r*.md")):
|
|
5350
|
+
match = _REVERIFY_PROMPT_BASENAME_RE.match(prompt_path.name)
|
|
5351
|
+
if match is None:
|
|
5352
|
+
continue
|
|
5353
|
+
round_n = match.group("round")
|
|
5354
|
+
plan_path = state_dir / (
|
|
5355
|
+
f"convergence-round-{round_n}-plan-"
|
|
5356
|
+
f"{match.group('task_type')}-{match.group('seq')}.json"
|
|
5357
|
+
)
|
|
5358
|
+
if not plan_path.is_file():
|
|
5359
|
+
continue # no paired plan (~96% of artifacts) — never infer from the prompt
|
|
5360
|
+
try:
|
|
5361
|
+
content = prompt_path.read_text(encoding="utf-8")
|
|
5362
|
+
plan = json.loads(plan_path.read_text(encoding="utf-8"))
|
|
5363
|
+
except (OSError, ValueError):
|
|
5364
|
+
continue
|
|
5365
|
+
prompt_ids = _reverify_prompt_finding_ids(content)
|
|
5366
|
+
if not prompt_ids: # None (no block) or empty set (no F-/C- headings)
|
|
5367
|
+
continue
|
|
5368
|
+
plan_ids = _plan_dispatch_finding_ids(plan, match.group("slug"))
|
|
5369
|
+
if plan_ids is None or prompt_ids == plan_ids:
|
|
5370
|
+
continue
|
|
5371
|
+
failures.append(
|
|
5372
|
+
f"reverify prompt `{prompt_path.name}` (round {round_n}, worker "
|
|
5373
|
+
f"`{match.group('slug')}`) does not show the findings its plan row "
|
|
5374
|
+
f"assigned: in the plan but not the prompt {sorted(plan_ids - prompt_ids)}; "
|
|
5375
|
+
f"in the prompt but not the plan {sorted(prompt_ids - plan_ids)}. The "
|
|
5376
|
+
f"prompt must carry exactly `dispatches[].findingIds` so a vote cannot "
|
|
5377
|
+
f"be attributed to a finding the worker was never shown."
|
|
5378
|
+
)
|
|
5379
|
+
|
|
5380
|
+
|
|
5084
5381
|
def _validate_requirements_discovery_fanout(run_dir, failures, brief_path=None) -> None:
|
|
5085
5382
|
"""requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
|
|
5086
5383
|
실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
|
|
@@ -5558,6 +5855,8 @@ def main() -> int:
|
|
|
5558
5855
|
_validate_convergence_rounds_match_manifest(
|
|
5559
5856
|
report_path.parent.parent, run_manifest, failures
|
|
5560
5857
|
)
|
|
5858
|
+
_validate_convergence_group_provenance(report_path.parent.parent, failures)
|
|
5859
|
+
_validate_reverify_prompt_matches_plan(report_path.parent.parent, failures)
|
|
5561
5860
|
validate_report_views(report_path, failures)
|
|
5562
5861
|
if task_type == "implementation":
|
|
5563
5862
|
_validate_missing_qa_categories_recorded(
|