okstra 0.187.0 → 0.188.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 (36) hide show
  1. package/dist/commands/lifecycle/install.mjs +3 -1
  2. package/dist/commands/lifecycle/install.mjs.map +1 -1
  3. package/docs/architecture.md +2 -2
  4. package/docs/cli.md +2 -2
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/bin/okstra-render-report-views.py +23 -5
  8. package/runtime/prompts/lead/adapters/cmux.md +3 -3
  9. package/runtime/prompts/lead/convergence.md +3 -1
  10. package/runtime/prompts/lead/report-writer.md +1 -1
  11. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +23 -0
  12. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +8 -2
  13. package/runtime/python/okstra_ctl/agent/activity.py +4 -1
  14. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +25 -12
  15. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +229 -32
  16. package/runtime/python/okstra_ctl/approval_decisions.py +27 -2
  17. package/runtime/python/okstra_ctl/context_cost.py +16 -2
  18. package/runtime/python/okstra_ctl/convergence.py +6 -1
  19. package/runtime/python/okstra_ctl/dispatch_core.py +134 -24
  20. package/runtime/python/okstra_ctl/dispatch_state.py +150 -3
  21. package/runtime/python/okstra_ctl/domain/worker_presentation.py +7 -0
  22. package/runtime/python/okstra_ctl/exact_coverage.py +5 -0
  23. package/runtime/python/okstra_ctl/final_report_schema.py +229 -1
  24. package/runtime/python/okstra_ctl/implementation_options.py +7 -2
  25. package/runtime/python/okstra_ctl/render_final_report.py +1 -1
  26. package/runtime/python/okstra_ctl/report_finalize.py +7 -1
  27. package/runtime/python/okstra_ctl/report_narrative.py +132 -33
  28. package/runtime/python/okstra_ctl/report_synthesis_packet.py +135 -0
  29. package/runtime/python/okstra_ctl/report_view_artifacts.py +42 -2
  30. package/runtime/python/okstra_ctl/verdict_blocks.py +28 -8
  31. package/runtime/python/okstra_ctl/worker_runner.py +42 -8
  32. package/runtime/python/okstra_token_usage/antigravity.py +50 -12
  33. package/runtime/python/okstra_token_usage/collect.py +105 -31
  34. package/runtime/python/okstra_token_usage/pricing.py +6 -3
  35. package/runtime/templates/report-writer-prompt-preamble.md +2 -0
  36. package/runtime/validators/validate-run.py +6 -1
@@ -67,6 +67,11 @@ def _allowed_top_level() -> frozenset[str]:
67
67
  return frozenset(_narrative_schema().get("properties", {}))
68
68
 
69
69
 
70
+ def allowed_top_level_fields() -> frozenset[str]:
71
+ """작성자가 최상위에 쓸 수 있는 필드 이름(서사 스키마의 properties)."""
72
+ return _allowed_top_level()
73
+
74
+
70
75
  # 완성 리포트 스키마가 작성자 소유 블록 안에서 required 로 거는 기계 소유 필드.
71
76
  # `writer_owned_data` 가 떼어내고 `_NESTED_FORBIDDEN` 이 저작을 막는 바로 그
72
77
  # 이름들이라, 값 제약을 그대로 쓰면 정상 서사가 전부 "필수 필드 없음" 으로
@@ -273,7 +278,7 @@ def _parse_tree(markdown: str) -> _Node:
273
278
  _append_node(stack, field, item, level, number)
274
279
  elif value:
275
280
  level = _line_level(value.group("indent"), number)
276
- _append_value(stack, value.group("value"), level, number)
281
+ _append_value(stack, value.group("value"), level, number, lines)
277
282
  else:
278
283
  raise NarrativeContractError(
279
284
  f"line {number}: unsupported Markdown syntax `{line.strip()}` — "
@@ -299,9 +304,45 @@ def _append_node(stack: list[_Node], field: Any, item: Any, level: int, number:
299
304
  stack.append(node)
300
305
 
301
306
 
302
- def _append_value(stack: list[_Node], value: str, level: int, number: int) -> None:
307
+ def _misplaced_value_lines(lines: list[str]) -> int:
308
+ """자기 라벨보다 두 칸 더 들여쓰지 않은 `> value` 줄의 수.
309
+
310
+ 파서는 첫 결함에서 멈추므로 규모를 말하지 못한다 — 129줄이 전부 0열에
311
+ 붙은 서술문이 `line 5` 한 건으로 보고됐다(2026-09-02 실측). 여기서는
312
+ 직전 라벨(`- **…**` / `- Item N`)의 들여쓰기와 견줘 세기만 한다; 판정은
313
+ 여전히 파서의 것이다.
314
+ """
315
+ misplaced = 0
316
+ label_indent = None
317
+ for line in lines[1:]:
318
+ if not line.strip():
319
+ continue
320
+ match = _FIELD_RE.match(line) or _ITEM_RE.match(line)
321
+ if match:
322
+ label_indent = len(match.group("indent"))
323
+ continue
324
+ value = _VALUE_RE.match(line)
325
+ if value and label_indent is not None:
326
+ if len(value.group("indent")) != label_indent + 2:
327
+ misplaced += 1
328
+ return misplaced
329
+
330
+
331
+ def _append_value(
332
+ stack: list[_Node], value: str, level: int, number: int, lines: list[str] | None = None,
333
+ ) -> None:
303
334
  if stack[-1].level != level - 1:
304
- raise NarrativeContractError(f"line {number}: value is outside its field")
335
+ count = _misplaced_value_lines(lines) if lines is not None else 0
336
+ scale = (
337
+ f"; {count} value line(s) in this file share the defect"
338
+ if count > 1
339
+ else ""
340
+ )
341
+ raise NarrativeContractError(
342
+ f"line {number}: value is outside its field — a `> value` line is "
343
+ "indented exactly two spaces deeper than the `- **Label**` or "
344
+ f"`- Item N` line it belongs to{scale}"
345
+ )
305
346
  if stack[-1].children:
306
347
  raise NarrativeContractError(f"line {number}: field mixes values and children")
307
348
  stack[-1].values.append(value)
@@ -317,7 +358,37 @@ def _allowed_labels(node: Any, index: SchemaIndex, path: str) -> list[str]:
317
358
  return sorted(humanise(key) for key in keys)
318
359
 
319
360
 
320
- def _field_key(label: str, node: Any, index: SchemaIndex, path: str) -> str:
361
+ class _Defects:
362
+ """값 단계에서 모은 결함. 첫 건에서 멈추지 않고 문서 전체를 읽는다.
363
+
364
+ 파서가 첫 강제 변환 실패에서 예외를 내던 동안, 같은 종류의 결함이 여러
365
+ 자리에 있어도 한 자리만 보고돼 작성자가 같은 문서를 회차마다 한 자리씩
366
+ 고쳤다(2026-09-03 실측, dev-10626 implementation-option-selection: 소수
367
+ `coveragePercent` 가 랭킹 옵션 둘에 있는데 `rankedOptions[1]` 한 건만).
368
+ 스키마 검증(`validate`)은 이미 전건을 모으므로 값 단계도 그렇게 한다.
369
+ """
370
+
371
+ def __init__(self) -> None:
372
+ self.messages: list[str] = []
373
+ self.paths: list[str] = []
374
+
375
+ def add(self, path: str, message: str) -> None:
376
+ self.paths.append(path)
377
+ self.messages.append(message)
378
+
379
+ def covers(self, error: str) -> bool:
380
+ """스키마 검증이 같은 자리(또는 그 아래)에 낸 오류인가 — 값 단계가 이미
381
+ 보고한 자리를 두 번 말하지 않는다."""
382
+ location = error.split(": ", 1)[0]
383
+ return any(
384
+ location == path or location.startswith(f"{path}.") or location.startswith(f"{path}[")
385
+ for path in self.paths
386
+ )
387
+
388
+
389
+ def _field_key(
390
+ label: str, node: Any, index: SchemaIndex, path: str, defects: _Defects,
391
+ ) -> str | None:
321
392
  candidates: dict[str, list[str]] = {}
322
393
  for key in index.key_order(node):
323
394
  candidates.setdefault(label_key(humanise(key)), []).append(key)
@@ -330,20 +401,26 @@ def _field_key(label: str, node: Any, index: SchemaIndex, path: str) -> str:
330
401
  if not matches:
331
402
  allowed = _allowed_labels(node, index, path)
332
403
  listing = ", ".join(f"`{item}`" for item in allowed) or "(none)"
333
- raise NarrativeContractError(
404
+ defects.add(
405
+ display_path,
334
406
  f"owner=report-writer field `{display_path}` is not an allowed unique "
335
- f"field; allowed at this position: {listing}"
407
+ f"field; allowed at this position: {listing}",
336
408
  )
409
+ return None
337
410
  if len(matches) > 1:
338
411
  collisions = ", ".join(f"`{key}`" for key in matches)
339
- raise NarrativeContractError(
412
+ defects.add(
413
+ display_path,
340
414
  f"owner=report-writer field `{display_path}` is not an allowed unique "
341
- f"field; the label matches more than one schema key: {collisions}"
415
+ f"field; the label matches more than one schema key: {collisions}",
342
416
  )
417
+ return None
343
418
  return matches[0]
344
419
 
345
420
 
346
- def _parse_scalar(values: list[str], node: Any, index: SchemaIndex, path: str) -> Any:
421
+ def _parse_scalar(
422
+ values: list[str], node: Any, index: SchemaIndex, path: str, defects: _Defects,
423
+ ) -> Any:
347
424
  types = _node_types(node, index)
348
425
  text = "\n".join(values)
349
426
  if text == EMPTY_MARKER and "null" in types:
@@ -353,17 +430,21 @@ def _parse_scalar(values: list[str], node: Any, index: SchemaIndex, path: str) -
353
430
  if "integer" in types:
354
431
  try:
355
432
  return int(text)
356
- except ValueError as exc:
357
- raise NarrativeContractError(f"{path}: expected integer") from exc
433
+ except ValueError:
434
+ defects.add(path, f"{path}: expected integer")
435
+ return text
358
436
  if "number" in types:
359
437
  try:
360
438
  return float(text)
361
- except ValueError as exc:
362
- raise NarrativeContractError(f"{path}: expected number") from exc
439
+ except ValueError:
440
+ defects.add(path, f"{path}: expected number")
441
+ return text
363
442
  return text
364
443
 
365
444
 
366
- def _parse_value(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> Any:
445
+ def _parse_value(
446
+ node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
447
+ ) -> Any:
367
448
  types = _node_types(schema_node, index)
368
449
  if node.values:
369
450
  if node.values == [EMPTY_MARKER] and "null" in types:
@@ -372,43 +453,54 @@ def _parse_value(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -
372
453
  return {}
373
454
  if node.values == [EMPTY_MARKER] and "array" in types:
374
455
  return []
375
- return _parse_scalar(node.values, schema_node, index, path)
456
+ return _parse_scalar(node.values, schema_node, index, path, defects)
376
457
  if "array" in types or all(child.kind == "item" for child in node.children):
377
- return _parse_array(node, schema_node, index, path)
378
- return _parse_object(node, schema_node, index, path)
458
+ return _parse_array(node, schema_node, index, path, defects)
459
+ return _parse_object(node, schema_node, index, path, defects)
379
460
 
380
461
 
381
- def _parse_array(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> list[Any]:
462
+ def _parse_array(
463
+ node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
464
+ ) -> list[Any]:
382
465
  if any(child.kind != "item" for child in node.children):
383
- raise NarrativeContractError(f"{path}: array requires Item rows")
466
+ defects.add(path, f"{path}: array requires Item rows")
467
+ return []
384
468
  positions = [int(child.label) for child in node.children]
385
469
  if positions != list(range(1, len(positions) + 1)):
386
- raise NarrativeContractError(f"{path}: Item numbers must be 1..N")
470
+ defects.add(path, f"{path}: Item numbers must be 1..N")
471
+ return []
387
472
  item_schema = index.item(schema_node)
388
473
  return [
389
- _parse_value(child, item_schema, index, f"{path}[{position - 1}]")
474
+ _parse_value(child, item_schema, index, f"{path}[{position - 1}]", defects)
390
475
  for position, child in zip(positions, node.children, strict=True)
391
476
  ]
392
477
 
393
478
 
394
- def _parse_object(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> dict[str, Any]:
479
+ def _parse_object(
480
+ node: _Node, schema_node: Any, index: SchemaIndex, path: str, defects: _Defects,
481
+ ) -> dict[str, Any]:
395
482
  result: dict[str, Any] = {}
396
483
  for child in node.children:
397
484
  if child.kind != "field":
398
- raise NarrativeContractError(f"{path}: object requires named fields")
399
- key = _field_key(child.label, schema_node, index, path)
485
+ defects.add(path, f"{path}: object requires named fields")
486
+ return result
487
+ key = _field_key(child.label, schema_node, index, path, defects)
488
+ if key is None:
489
+ continue
400
490
  child_path = f"{path}.{key}" if path else key
401
491
  if child_path in _NESTED_FORBIDDEN:
402
- raise NarrativeContractError(
403
- f"owner=report-writer cannot author `{path}.{child.label}`"
492
+ defects.add(
493
+ child_path, f"owner=report-writer cannot author `{path}.{child.label}`"
404
494
  )
495
+ continue
405
496
  if key in result:
406
- raise NarrativeContractError(f"duplicate field: {child_path}")
497
+ defects.add(child_path, f"duplicate field: {child_path}")
498
+ continue
407
499
  child_schema = index.child(schema_node, key)
408
500
  if not _node_types(child_schema, index):
409
501
  child_schema = index.schema_for_key(key)
410
502
  result[key] = _parse_value(
411
- child, child_schema, index, child_path
503
+ child, child_schema, index, child_path, defects
412
504
  )
413
505
  return result
414
506
 
@@ -417,11 +509,14 @@ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
417
509
  """Markdown을 작성자 소유 자료로 읽고, 소유권 표면과 값 제약을 검증한다.
418
510
 
419
511
  `schema` 는 완성 리포트 스키마다. 이름·타입 해석과 값 검증이 모두 그
420
- 한 벌에서 나온다(`_writer_owned_schema`).
512
+ 한 벌에서 나온다(`_writer_owned_schema`). 값 단계의 결함(강제 변환·모양·
513
+ 허용되지 않는 필드)과 스키마 검증의 결함을 한 예외에 모두 싣는다 — 값
514
+ 단계가 보고한 자리는 스키마 검증에서 다시 말하지 않는다.
421
515
  """
422
516
  root = _parse_tree(markdown)
423
517
  index = SchemaIndex(schema)
424
- result = _parse_object(root, schema, index, "")
518
+ defects = _Defects()
519
+ result = _parse_object(root, schema, index, "", defects)
425
520
  unknown = sorted(set(result) - _allowed_top_level())
426
521
  if unknown:
427
522
  labels = [humanise(key) for key in unknown]
@@ -432,7 +527,11 @@ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
432
527
  f"owner=report-writer cannot author fields: {labels}; "
433
528
  f"writer-owned top-level fields: {owned}"
434
529
  )
435
- errors = validate(result, _writer_owned_schema(schema))
436
- if errors:
437
- raise NarrativeContractError("; ".join(errors))
530
+ errors = [
531
+ error
532
+ for error in validate(result, _writer_owned_schema(schema))
533
+ if not defects.covers(error)
534
+ ]
535
+ if defects.messages or errors:
536
+ raise NarrativeContractError("; ".join(defects.messages + errors))
438
537
  return result
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import copy
5
5
  import hashlib
6
+ import json
6
7
  import os
7
8
  import re
8
9
  import tempfile
@@ -16,6 +17,7 @@ from .implementation_options import (
16
17
  EVALUATION_CRITERIA,
17
18
  MAX_CRITERION_VALUE,
18
19
  MAX_RANKED_OPTIONS,
20
+ MAX_RAW_CANDIDATES_PER_ANALYSER,
19
21
  MIN_CRITERION_VALUE,
20
22
  MIN_FEASIBLE_VOTES,
21
23
  NO_VALID_OPTIONS_ROUTING,
@@ -23,6 +25,12 @@ from .implementation_options import (
23
25
  from .report_narrative import writer_owned_data
24
26
  from .scope_provenance import brief_end_state_id_sequence
25
27
 
28
+ from .exact_coverage import COVERAGE_VERDICT_PRECEDENCE
29
+ from .final_report_schema import task_block_rules, verdict_token_rule
30
+ from .report_contract import TASK_TYPE_DATA_PROPERTY
31
+ from .report_markdown import humanise
32
+ from .report_narrative import NarrativeContractError, allowed_top_level_fields
33
+
26
34
 
27
35
  @dataclass(frozen=True)
28
36
  class ReportSynthesisPacketIssue:
@@ -67,6 +75,21 @@ class ReportSynthesisPacket:
67
75
  participating_analysers: tuple[str, ...] = ()
68
76
  # 증분 판정. 저작 계약이 이번 run 의 stage 번호를 말할 수 있는 유일한 출처다.
69
77
  incremental_decision: dict[str, Any] | None = None
78
+ # 넘겨받은 완성 리포트 스키마에서 뽑은 값. 작성자는 스키마 가지를 읽지 않으므로
79
+ # 여기 적어 줘야 도달한다(2026-09-02 실측: `Human Summary` 절 누락, `Verdict
80
+ # Token` 에 `analysis-complete` — 둘 다 HTML 렌더에서야 거절됐다).
81
+ required_top_level: tuple[str, ...] = ()
82
+ verdict_tokens: tuple[str, ...] = ()
83
+ # 이 task type 의 데이터 블록과, 스키마가 그 블록 안쪽에 못 박은 모양(객체당
84
+ # 한 줄). 최상위 필드까지만 싣던 동안 리드는 하위 구조의 필수 필드·식별자
85
+ # 패턴을 스키마 JSON 에서 손으로 뽑았고, 놓친 만큼 리포트를 다시 썼다
86
+ # (2026-09-03 실측: implementation-option-selection 네 회차).
87
+ block_key: str = ""
88
+ block_rules: tuple[str, ...] = ()
89
+ # 작성자가 최상위에 쓸 수 있는 필드 전체(서사 스키마의 properties). 필수만
90
+ # 적던 동안 리드가 어느 task type 에도 없는 절을 지시했고, 작성자는 조립이
91
+ # 거절할 때까지 그 지시를 거를 근거가 없었다(2026-09-03 실측).
92
+ allowed_top_level: tuple[str, ...] = ()
70
93
 
71
94
  def _carry_instructions(self) -> list[str]:
72
95
  """이번 run 의 이월 지시. 증분이 아니면 빈 목록.
@@ -90,6 +113,45 @@ class ReportSynthesisPacket:
90
113
  f"rejects a carried stage that changed or is missing.",
91
114
  ]
92
115
 
116
+ def _schema_instructions(self) -> list[str]:
117
+ """스키마가 이 task type 에 못 박은 값을 저작 계약 문장으로 옮긴다."""
118
+ lines: list[str] = []
119
+ if self.required_top_level:
120
+ labels = ", ".join(f"`{label}`" for label in self.required_top_level)
121
+ lines.append(
122
+ f"Required top-level fields for this task type: {labels}. "
123
+ "Report assembly refuses a narrative that omits any of them."
124
+ )
125
+ if len(self.verdict_tokens) == 1:
126
+ lines.append(
127
+ f"`Final Verdict` -> `Verdict Token`: write exactly "
128
+ f"`{self.verdict_tokens[0]}`; the schema pins it for task type "
129
+ f"`{self.task_type}` and refuses every other value."
130
+ )
131
+ elif self.verdict_tokens:
132
+ allowed = ", ".join(f"`{token}`" for token in self.verdict_tokens)
133
+ lines.append(
134
+ f"`Final Verdict` -> `Verdict Token`: one of {allowed} for task type "
135
+ f"`{self.task_type}`."
136
+ )
137
+ if self.allowed_top_level:
138
+ labels = ", ".join(f"`{label}`" for label in self.allowed_top_level)
139
+ lines.append(
140
+ f"Writer-owned top-level fields, the complete set a narrative may "
141
+ f"contain: {labels}. Report assembly refuses any other top-level "
142
+ "field, whichever instruction asked for it."
143
+ )
144
+ if self.block_rules:
145
+ lines.append(
146
+ f"Shape of the `{self.block_key}` block, one line per object, from "
147
+ "the frozen schema. Paths are schema keys: write each key as its "
148
+ "Title Case label (`rankedOptions` -> `Ranked Options`), and `[]` "
149
+ "marks the items of an `- Item N` list. Report assembly refuses a "
150
+ "narrative that breaks any line."
151
+ )
152
+ lines.extend(self.block_rules)
153
+ return lines
154
+
93
155
  def _carry_validation_rules(self) -> list[str]:
94
156
  return ["carried-stages-unchanged"] if self.incremental_decision else []
95
157
 
@@ -114,6 +176,24 @@ class ReportSynthesisPacket:
114
176
  "`criterionScores` row per evaluation criterion. Weights and scores "
115
177
  f"are integers from {MIN_CRITERION_VALUE} through {MAX_CRITERION_VALUE}; "
116
178
  "`weightedScore` is the recalculated weighted mean.",
179
+ "`proposedBy` names exactly one participating analyser id, never a "
180
+ "list. Counting `rankedOptions` and `candidateAudit` together, each "
181
+ f"analyser may propose at most {MAX_RAW_CANDIDATES_PER_ANALYSER} raw "
182
+ "candidates and the run may hold at most "
183
+ f"{MAX_RAW_CANDIDATES_PER_ANALYSER} times the analyser count.",
184
+ "`coverageSummary` is recalculated from the row's `requirementCoverage` "
185
+ "statuses and `scopeCommitments`, and every field must equal the "
186
+ "recalculation: `totalCount` = number of original requirement ids; "
187
+ "`coveredCount` = rows with status `covered`; `coveragePercent` = "
188
+ "round(coveredCount / totalCount * 100, 2); `unmappedCommitments` = "
189
+ "commitment ids whose `requirementIds` is empty or names an id outside "
190
+ "the original set; `scopePrecisionPercent` = round((commitments - "
191
+ "unmapped) / commitments * 100, 2); `contradictedRequirements` = "
192
+ "original ids with status `contradicted`, in original order; "
193
+ "`coverageVerdict` = the first that applies of "
194
+ + ", ".join(f"`{verdict}`" for verdict in COVERAGE_VERDICT_PRECEDENCE)
195
+ + " (any contradicted; coveredCount below totalCount; any unmapped; "
196
+ "otherwise).",
117
197
  "A ranked option is valid only when every participating analyser "
118
198
  "supplied one feasibility vote, at least "
119
199
  f"{MIN_FEASIBLE_VOTES} votes are `feasible`, and both `safetyBlockers` "
@@ -170,6 +250,7 @@ class ReportSynthesisPacket:
170
250
  "Do not invent a value when a source is missing or contradictory.",
171
251
  "Write only the narrative, pointer, and audit artifacts named by the dispatch.",
172
252
  *_INTERNAL_IDENTIFIER_INSTRUCTION,
253
+ *self._schema_instructions(),
173
254
  *self._task_instructions(),
174
255
  *self._carry_instructions(),
175
256
  ],
@@ -208,6 +289,7 @@ class ReportSynthesisPacket:
208
289
  # 작성자가 실제로 읽는 것은 마크다운이다. JSON 정본에만 실으면 지시가
209
290
  # 도달하지 않는다.
210
291
  lines.extend(f"- {text}" for text in _INTERNAL_IDENTIFIER_INSTRUCTION)
292
+ lines.extend(f"- {text}" for text in self._schema_instructions())
211
293
  lines.extend(f"- {text}" for text in self._task_instructions())
212
294
  lines.extend(f"- {text}" for text in self._carry_instructions())
213
295
  lines.extend(_accounting_markdown(self.accounting_snapshot))
@@ -646,10 +728,18 @@ def build_report_synthesis_packet(
646
728
  for worker in roster
647
729
  if _string(worker) and _string(worker) != "report-writer"
648
730
  )
731
+ required_top_level, verdict_tokens, block_rules = _schema_authoring_rules(
732
+ sources, task_type
733
+ )
649
734
  return ReportSynthesisPacket(
650
735
  task_key=_string(manifest.get("taskKey")),
651
736
  task_type=task_type,
652
737
  result_path=_relative(project_root, narrative_path),
738
+ required_top_level=required_top_level,
739
+ verdict_tokens=verdict_tokens,
740
+ block_key=TASK_TYPE_DATA_PROPERTY.get(task_type, "") if block_rules else "",
741
+ block_rules=block_rules,
742
+ allowed_top_level=_allowed_top_level_labels(),
653
743
  sources=sources,
654
744
  accounting_snapshot=_accounting_snapshot(team_state),
655
745
  original_requirement_ids=original_requirement_ids,
@@ -658,6 +748,51 @@ def build_report_synthesis_packet(
658
748
  )
659
749
 
660
750
 
751
+ def _schema_authoring_rules(
752
+ sources: tuple[ReportSynthesisSource, ...], task_type: str,
753
+ ) -> tuple[tuple[str, ...], tuple[str, ...], tuple[str, ...]]:
754
+ """넘겨받은(동결된) 완성 리포트 스키마에서 작성자 몫의 규칙 세 가지를 뽑는다.
755
+
756
+ 최상위 `required` 가운데 작성자 소유 이름(서사 스키마의 properties)만
757
+ 사람이 읽는 라벨로, 이 task type 의 `Verdict Token` 허용값, 그리고 이 task
758
+ type 의 데이터 블록 안쪽 모양(`task_block_rules`). 스키마 소스가 없거나
759
+ JSON 이 아니면 빈 값이다 — 이 함수는 조립 검증을 대신하지 않고 도달하지
760
+ 못하던 규칙을 저작 계약에 옮길 뿐이다.
761
+ """
762
+ schema_text = next(
763
+ (source.content for source in sources if source.label == "Final report schema"),
764
+ "",
765
+ )
766
+ try:
767
+ schema = json.loads(schema_text) if schema_text else {}
768
+ except ValueError:
769
+ schema = {}
770
+ if not isinstance(schema, dict):
771
+ schema = {}
772
+ try:
773
+ allowed = allowed_top_level_fields()
774
+ except (NarrativeContractError, JsonBoundaryError):
775
+ allowed = frozenset()
776
+ required = schema.get("required")
777
+ required_top_level = tuple(
778
+ humanise(key)
779
+ for key in (required if isinstance(required, list) else [])
780
+ if isinstance(key, str) and key in allowed
781
+ )
782
+ block_key = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
783
+ block_rules = task_block_rules(schema, block_key) if block_key else ()
784
+ return required_top_level, verdict_token_rule(schema, task_type), block_rules
785
+
786
+
787
+ def _allowed_top_level_labels() -> tuple[str, ...]:
788
+ """작성자 소유 최상위 필드 전체 — 서사 스키마 부재는 빈 값(조립이 판정한다)."""
789
+ try:
790
+ allowed = allowed_top_level_fields()
791
+ except (NarrativeContractError, JsonBoundaryError):
792
+ return ()
793
+ return tuple(humanise(key) for key in sorted(allowed))
794
+
795
+
661
796
  def report_synthesis_packet_paths(narrative_path: Path) -> tuple[Path, Path]:
662
797
  name = narrative_path.name
663
798
  prefix = "report-writer-narrative-"
@@ -3,6 +3,8 @@ from __future__ import annotations
3
3
 
4
4
  from pathlib import Path
5
5
 
6
+ from .json_boundary import JsonBoundaryError, load_owned_object
7
+
6
8
 
7
9
  def html_view_path(report_path: Path) -> Path:
8
10
  """Return the HTML sibling for a final-report Markdown or data path."""
@@ -18,5 +20,43 @@ def user_responses_dir_for_report(report_path: Path) -> Path:
18
20
 
19
21
 
20
22
  def team_state_path_for_report(report_path: Path, task_type: str, seq: str) -> Path:
21
- """Return the team-state sibling that records the run's usage windows."""
22
- return report_path.parent.parent / "state" / f"team-state-{task_type}-{seq}.json"
23
+ """The team-state of the run that produced this report.
24
+
25
+ The report's sequence is not the run's: categories are numbered on their
26
+ own (`paths.compute_run_paths`), so two prepared-and-abandoned runs leave
27
+ the report slot free while consuming state slots, and the run that finally
28
+ writes `final-report-…-002` keeps its usage in `team-state-…-004`. Naming
29
+ the state after the report's seq read the earlier run's usage — a header
30
+ elapsed of 389h taken from a run that shared nothing but the number
31
+ (observed 2026-09-02, dev-10626 error-analysis). The run manifest names
32
+ both files (`expectedReportRecordPath`, `teamStatePath`), so the latest
33
+ manifest naming this report decides; the seq-derived sibling is the
34
+ fallback for a run directory without such a manifest.
35
+ """
36
+ run_dir = report_path.parent.parent
37
+ recorded = _team_state_from_manifests(run_dir, report_path.name)
38
+ if recorded is not None:
39
+ return recorded
40
+ return run_dir / "state" / f"team-state-{task_type}-{seq}.json"
41
+
42
+
43
+ def _team_state_from_manifests(run_dir: Path, report_name: str) -> Path | None:
44
+ # 최신 manifest 부터 본다 — 같은 보고서 자리를 이름한 run 이 여럿이면(앞선
45
+ # run 이 준비만 하고 끝난 경우) 마지막 run 이 그 보고서를 쓴 run 이다.
46
+ # `Path.glob` 는 디렉터리가 없어도 빈 결과를 낸다.
47
+ for manifest_path in sorted(
48
+ (run_dir / "manifests").glob("run-manifest-*.json"), reverse=True
49
+ ):
50
+ try:
51
+ manifest = load_owned_object(manifest_path, artifact="run manifest")
52
+ except (OSError, JsonBoundaryError):
53
+ # 깨진 manifest 는 이 보고서를 이름할 수 없다 — 다음 후보를 본다.
54
+ continue
55
+ expected = manifest.get("expectedReportRecordPath")
56
+ team_state = manifest.get("teamStatePath")
57
+ if not isinstance(expected, str) or not isinstance(team_state, str):
58
+ continue
59
+ if Path(expected).name != report_name or not team_state:
60
+ continue
61
+ return run_dir / "state" / Path(team_state).name
62
+ return None
@@ -106,10 +106,33 @@ def parse_finding_votes(text: str, *, adversarial: bool) -> dict[str, FindingVot
106
106
  an unfamiliar token silently read as a different verdict.
107
107
  """
108
108
  vocabulary = ADVERSARIAL_VERDICTS if adversarial else COLLABORATIVE_VERDICTS
109
- return {
110
- finding_id: _finding_vote(finding_id, fields, vocabulary)
111
- for finding_id, fields in _scan_blocks(text).items()
112
- }
109
+ return _collect_blocks(
110
+ _scan_blocks(text),
111
+ lambda item_id, fields: _finding_vote(item_id, fields, vocabulary),
112
+ )
113
+
114
+
115
+ def _collect_blocks(blocks: dict[str, dict[str, str]], parse) -> dict:
116
+ """모든 블록을 읽고 결함을 한 번에 보고한다.
117
+
118
+ 첫 결함에서 멈추면 15건 중 13건이 같은 결함인 응답도 한 건씩만 드러나,
119
+ 교정 디스패치를 그 수만큼 반복하게 된다(2026-09-02 실측). 한 응답의 결함은
120
+ 서로 독립이므로 전부 모아 하나의 오류로 낸다.
121
+ """
122
+ parsed: dict = {}
123
+ errors: list[str] = []
124
+ for item_id, fields in blocks.items():
125
+ try:
126
+ parsed[item_id] = parse(item_id, fields)
127
+ except VerdictBlockError as exc:
128
+ errors.append(str(exc))
129
+ if errors:
130
+ if len(errors) == 1:
131
+ raise VerdictBlockError(errors[0])
132
+ raise VerdictBlockError(
133
+ f"{len(errors)} of {len(blocks)} blocks are malformed: " + "; ".join(errors)
134
+ )
135
+ return parsed
113
136
 
114
137
 
115
138
  def _finding_vote(
@@ -155,10 +178,7 @@ class VerdictBlock:
155
178
 
156
179
  def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
157
180
  """Every `### <item-id>` plan-body verdict block in *text*, keyed by item id."""
158
- return {
159
- item_id: _block(item_id, fields)
160
- for item_id, fields in _scan_blocks(text).items()
161
- }
181
+ return _collect_blocks(_scan_blocks(text), _block)
162
182
 
163
183
 
164
184
  def _verdict_token(item_id: str, raw: str) -> tuple[str, str]: