okstra 0.179.2 → 0.180.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 (78) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +14 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/execute/incremental-carry.mjs +9 -8
  5. package/dist/commands/execute/incremental-carry.mjs.map +1 -1
  6. package/dist/commands/execute/plan-verify.mjs +3 -1
  7. package/dist/commands/execute/plan-verify.mjs.map +1 -1
  8. package/dist/commands/report/approval-decision.d.mts +1 -0
  9. package/dist/commands/report/approval-decision.mjs +21 -0
  10. package/dist/commands/report/approval-decision.mjs.map +1 -0
  11. package/dist/commands/report/design-snapshot.d.mts +1 -0
  12. package/dist/commands/report/design-snapshot.mjs +19 -0
  13. package/dist/commands/report/design-snapshot.mjs.map +1 -0
  14. package/docs/architecture/storage-model.md +1 -1
  15. package/docs/architecture.md +10 -10
  16. package/docs/cli.md +11 -8
  17. package/docs/project-structure-overview.md +15 -6
  18. package/docs/task-process/implementation-planning.md +2 -2
  19. package/package.json +1 -1
  20. package/runtime/BUILD.json +2 -2
  21. package/runtime/agents/workers/report-writer-worker.md +15 -164
  22. package/runtime/prompts/launch.template.md +6 -5
  23. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  24. package/runtime/prompts/lead/convergence.md +2 -2
  25. package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
  26. package/runtime/prompts/lead/plan-body-verification.md +39 -18
  27. package/runtime/prompts/lead/report-writer.md +64 -423
  28. package/runtime/prompts/lead/team-contract.md +1 -1
  29. package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
  30. package/runtime/prompts/profiles/_common-contract.md +3 -3
  31. package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
  32. package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
  33. package/runtime/prompts/profiles/error-analysis.md +1 -1
  34. package/runtime/prompts/profiles/feature-analysis.md +1 -1
  35. package/runtime/prompts/profiles/implementation-planning.md +13 -11
  36. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  37. package/runtime/prompts/profiles/project-analysis.md +1 -1
  38. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  39. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
  40. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  41. package/runtime/python/okstra_ctl/agent_activity.py +23 -3
  42. package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
  43. package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
  44. package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
  45. package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
  46. package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
  47. package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
  48. package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
  49. package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
  50. package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
  51. package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
  52. package/runtime/python/okstra_ctl/path_hints.py +12 -0
  53. package/runtime/python/okstra_ctl/paths.py +12 -0
  54. package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
  55. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
  56. package/runtime/python/okstra_ctl/render.py +48 -1
  57. package/runtime/python/okstra_ctl/render_final_report.py +7 -6
  58. package/runtime/python/okstra_ctl/report_assembly.py +354 -0
  59. package/runtime/python/okstra_ctl/report_contract.py +2 -1
  60. package/runtime/python/okstra_ctl/report_finalize.py +60 -22
  61. package/runtime/python/okstra_ctl/report_inputs.py +72 -0
  62. package/runtime/python/okstra_ctl/report_markdown.py +69 -8
  63. package/runtime/python/okstra_ctl/report_narrative.py +319 -0
  64. package/runtime/python/okstra_ctl/report_projections.py +265 -0
  65. package/runtime/python/okstra_ctl/run.py +25 -9
  66. package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
  67. package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
  68. package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
  69. package/runtime/python/okstra_ctl/stage_map.py +70 -22
  70. package/runtime/python/okstra_ctl/team.py +1 -1
  71. package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
  72. package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
  73. package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
  74. package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
  75. package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
  76. package/runtime/templates/report-writer-prompt-preamble.md +15 -21
  77. package/runtime/templates/reports/html/macros/forms.html +6 -4
  78. package/runtime/validators/validate-run.py +258 -10
@@ -1,10 +1,11 @@
1
1
  """Phase 7 report post-processing — the single reference point.
2
2
 
3
- Phase 7 turns a Phase 6 final-report data.json into shippable artifacts through
4
- six ordered steps: activity projection, English-SSOT verification, usage
5
- substitution, html view rendering, follow-up task spawning, and run validation.
6
- The order is load-bearing — rendering before substitution ships `--` token
7
- cells, and validating before rendering trips the report-views contract.
3
+ Contract 3.0 collects usage into team state, assembles the role-owned inputs
4
+ into final-report data.json once, then verifies and renders that published
5
+ record. Contract 2.0 keeps its historical in-place projection sequence for
6
+ read-only compatibility. The order is load-bearing: rendering before assembly
7
+ or usage collection publishes stale derived views, and validating before
8
+ rendering trips the report-views contract.
8
9
 
9
10
  `token-usage` is the one step whose failure does not stop the sequence, which
10
11
  deliberately accepts that first state: its input is the lead session log, so a
@@ -39,6 +40,7 @@ from typing import Any, Callable, Mapping, Sequence
39
40
 
40
41
  from .agent_activity import ActivityProjectionError, project_agent_activity
41
42
  from .report_contract import apply_execution_roles
43
+ from .report_assembly import ReportAssemblyError, assemble_report
42
44
  from .dispatch_state import DispatchError, link_agent_dispatch_result
43
45
  from .final_report_paths import final_report_data_path, final_report_markdown_path
44
46
  from .paths import task_dir, task_manifest_file
@@ -74,6 +76,16 @@ STEP_ORDER = (
74
76
  STEP_TEARDOWN_STAGES,
75
77
  )
76
78
 
79
+ V3_STEP_ORDER = (
80
+ STEP_TOKEN_USAGE,
81
+ STEP_PROJECT_ACTIVITY,
82
+ STEP_CHECK_SOURCE,
83
+ STEP_RENDER_VIEWS,
84
+ STEP_SPAWN_FOLLOWUPS,
85
+ STEP_VALIDATE_RUN,
86
+ STEP_TEARDOWN_STAGES,
87
+ )
88
+
77
89
 
78
90
  class FinalizeError(Exception):
79
91
  """Raised when the Phase 7 sequence cannot be assembled."""
@@ -226,6 +238,7 @@ class FinalizeContext:
226
238
  task_id: str
227
239
  seq: str
228
240
  final_status_path: Path | None
241
+ report_contract_version: str = "2.0"
229
242
 
230
243
  @property
231
244
  def markdown_path(self) -> Path:
@@ -257,13 +270,16 @@ class FinalizeContext:
257
270
  final_status_path=resolve_optional_path(
258
271
  project_root, manifest.get("finalStatusPath")
259
272
  ),
273
+ report_contract_version=str(
274
+ manifest.get("reportContractVersion") or "2.0"
275
+ ),
260
276
  )
261
277
 
262
278
 
263
279
  def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
264
280
  """Assemble the ordered Phase 7 argv list. Order is contractual."""
265
281
  markdown_path = ctx.markdown_path
266
- return [
282
+ commands = [
267
283
  (
268
284
  STEP_PROJECT_ACTIVITY,
269
285
  [
@@ -344,6 +360,16 @@ def build_commands(ctx: FinalizeContext) -> list[tuple[str, list[str]]]:
344
360
  ["<in-process>", "teardown-stages", str(ctx.data_path)],
345
361
  ),
346
362
  ]
363
+ if ctx.report_contract_version != "3.0":
364
+ return commands
365
+ by_name = dict(commands)
366
+ usage = by_name[STEP_TOKEN_USAGE]
367
+ marker = usage.index("--substitute-data")
368
+ by_name[STEP_TOKEN_USAGE] = usage[:marker]
369
+ by_name[STEP_PROJECT_ACTIVITY] = [
370
+ "<in-process>", "report-assembly", str(ctx.manifest_path), str(ctx.data_path)
371
+ ]
372
+ return [(name, by_name[name]) for name in V3_STEP_ORDER]
347
373
 
348
374
 
349
375
  def _teardown_stage_worktrees(
@@ -441,14 +467,16 @@ def run_finalize(
441
467
  steps: list[dict[str, Any]] = []
442
468
  deferred = ""
443
469
  try:
444
- write_execution_roles(ctx)
470
+ if ctx.report_contract_version != "3.0":
471
+ write_execution_roles(ctx)
445
472
  commands = build_commands(ctx)
446
473
  except FinalizeError as exc:
447
474
  return {"ok": False, "reason": str(exc), "steps": steps}
448
475
 
449
476
  if only:
450
477
  selected = set(only)
451
- unknown = sorted(selected - set(STEP_ORDER))
478
+ contract_order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
479
+ unknown = sorted(selected - set(contract_order))
452
480
  if unknown:
453
481
  return {
454
482
  "ok": False,
@@ -462,12 +490,17 @@ def run_finalize(
462
490
  before_step(name)
463
491
  if name == STEP_PROJECT_ACTIVITY:
464
492
  try:
465
- rows = project_agent_activity(
466
- ctx.project_root,
467
- ctx.manifest_path,
468
- ctx.data_path,
469
- )
470
- except ActivityProjectionError as exc:
493
+ if ctx.report_contract_version == "3.0":
494
+ assembled = assemble_report(ctx.project_root, ctx.manifest_path)
495
+ count = len(assembled.get("agentActivity") or [])
496
+ else:
497
+ rows = project_agent_activity(
498
+ ctx.project_root,
499
+ ctx.manifest_path,
500
+ ctx.data_path,
501
+ )
502
+ count = len(rows)
503
+ except (ActivityProjectionError, ReportAssemblyError) as exc:
471
504
  result = subprocess.CompletedProcess(
472
505
  command,
473
506
  1,
@@ -478,7 +511,7 @@ def run_finalize(
478
511
  result = subprocess.CompletedProcess(
479
512
  command,
480
513
  0,
481
- json.dumps({"count": len(rows)}),
514
+ json.dumps({"count": count}),
482
515
  "",
483
516
  )
484
517
  else:
@@ -633,7 +666,9 @@ def _parser() -> argparse.ArgumentParser:
633
666
  return parser
634
667
 
635
668
 
636
- def _step_summary_lines(result: Mapping[str, Any]) -> list[str]:
669
+ def _step_summary_lines(
670
+ result: Mapping[str, Any], order: Sequence[str] = STEP_ORDER,
671
+ ) -> list[str]:
637
672
  """One human-readable line per step, so the outcome is legible without
638
673
  parsing the JSON payload."""
639
674
  lines = []
@@ -641,13 +676,15 @@ def _step_summary_lines(result: Mapping[str, Any]) -> list[str]:
641
676
  code = step.get("exitCode")
642
677
  mark = "ok " if code == 0 else "FAIL"
643
678
  lines.append(f" [{mark}] {step.get('name')} (exit {code})")
644
- for name in STEP_ORDER:
679
+ for name in order:
645
680
  if not any(s.get("name") == name for s in (result.get("steps") or [])):
646
681
  lines.append(f" [skip] {name}")
647
682
  return lines
648
683
 
649
684
 
650
- def _recovery_step_names(result: Mapping[str, Any]) -> list[str]:
685
+ def _recovery_step_names(
686
+ result: Mapping[str, Any], order: Sequence[str] = STEP_ORDER,
687
+ ) -> list[str]:
651
688
  """The steps a retry has to re-run: every step from the earliest failure on.
652
689
 
653
690
  Naming only the failed steps would prescribe half a recovery. `token-usage`
@@ -664,9 +701,9 @@ def _recovery_step_names(result: Mapping[str, Any]) -> list[str]:
664
701
  for step in (result.get("steps") or [])
665
702
  if step.get("exitCode") != 0
666
703
  }
667
- for index, name in enumerate(STEP_ORDER):
704
+ for index, name in enumerate(order):
668
705
  if name in failed:
669
- return list(STEP_ORDER[index:])
706
+ return list(order[index:])
670
707
  return []
671
708
 
672
709
 
@@ -682,13 +719,14 @@ def main(argv: Sequence[str] | None = None) -> int:
682
719
  # sequence because the token-usage step below reads `leadSessionIds`.
683
720
  observe_lead_session(ctx.project_root, ctx.team_state_path)
684
721
  result = run_finalize(ctx, only=args.only or None)
722
+ order = V3_STEP_ORDER if ctx.report_contract_version == "3.0" else STEP_ORDER
685
723
  print(json.dumps(result, indent=2, ensure_ascii=False))
686
724
  print("finalize steps:", file=sys.stderr)
687
- for line in _step_summary_lines(result):
725
+ for line in _step_summary_lines(result, order):
688
726
  print(line, file=sys.stderr)
689
727
  if not result["ok"]:
690
728
  print(f"error: {result['reason']}", file=sys.stderr)
691
- recovery = _recovery_step_names(result)
729
+ recovery = _recovery_step_names(result, order)
692
730
  if recovery:
693
731
  flags = " ".join(f"--only {name}" for name in recovery)
694
732
  print(
@@ -0,0 +1,72 @@
1
+ """리포트 계약 3.0의 역할별 입력 경로와 단일 소유자 레지스트리."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Mapping
7
+
8
+
9
+ REPORT_CONTRACT_V3 = "3.0"
10
+
11
+
12
+ class ReportInputError(ValueError):
13
+ """3.0 입력 경로나 소유자가 매니페스트에서 해소되지 않았다."""
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ReportInputPath:
18
+ key: str
19
+ owner: str
20
+ path: Path
21
+
22
+
23
+ _INPUT_FIELDS = (
24
+ ("narrative", "report-writer", "reportNarrativePath"),
25
+ ("approval-decisions", "lead", "approvalDecisionsPath"),
26
+ ("agent-activity", "activity-ledger", "leadEventsPath"),
27
+ ("execution-status", "team-state", "teamStatePath"),
28
+ ("convergence", "convergence", "convergenceStatePath"),
29
+ )
30
+
31
+ _PLANNING_INPUT_FIELDS = (
32
+ ("design-preparation", "design-surface-detector", "designPreparationPath"),
33
+ ("plan-body-verification", "convergence", "planBodyVerificationPath"),
34
+ )
35
+
36
+
37
+ def uses_report_contract_v3(manifest: Mapping[str, Any]) -> bool:
38
+ return str(manifest.get("reportContractVersion") or "").strip() == REPORT_CONTRACT_V3
39
+
40
+
41
+ def _project_path(project_root: Path, value: object, field: str) -> Path:
42
+ if not isinstance(value, str) or not value.strip():
43
+ raise ReportInputError(f"report contract 3.0 requires {field}")
44
+ path = Path(value)
45
+ return path if path.is_absolute() else project_root / path
46
+
47
+
48
+ def report_narrative_path(
49
+ project_root: Path, manifest: Mapping[str, Any],
50
+ ) -> Path:
51
+ if not uses_report_contract_v3(manifest):
52
+ raise ReportInputError("report narrative path belongs to report contract 3.0")
53
+ return _project_path(project_root, manifest.get("reportNarrativePath"), "reportNarrativePath")
54
+
55
+
56
+ def report_input_paths(
57
+ project_root: Path, manifest: Mapping[str, Any],
58
+ ) -> tuple[ReportInputPath, ...]:
59
+ if not uses_report_contract_v3(manifest):
60
+ raise ReportInputError("role-owned report inputs require report contract 3.0")
61
+ fields = _INPUT_FIELDS + (
62
+ _PLANNING_INPUT_FIELDS
63
+ if manifest.get("taskType") == "implementation-planning"
64
+ else ()
65
+ )
66
+ rows = tuple(
67
+ ReportInputPath(key, owner, _project_path(project_root, manifest.get(field), field))
68
+ for key, owner, field in fields
69
+ )
70
+ if len({row.key for row in rows}) != len(rows):
71
+ raise ReportInputError("report input keys must be unique")
72
+ return rows
@@ -105,6 +105,7 @@ class SchemaIndex:
105
105
 
106
106
  def __init__(self, schema: Any) -> None:
107
107
  self._defs = schema.get("$defs", {}) if isinstance(schema, dict) else {}
108
+ self._schema = schema if isinstance(schema, dict) else {}
108
109
 
109
110
  def resolve(self, node: Any) -> dict:
110
111
  seen: set[str] = set()
@@ -118,10 +119,21 @@ class SchemaIndex:
118
119
 
119
120
  def _branches(self, node: Any) -> list[dict]:
120
121
  resolved = self.resolve(node)
121
- branches = [resolved]
122
- for keyword in ("allOf", "oneOf", "anyOf"):
123
- for branch in resolved.get(keyword) or ():
124
- branches.append(self.resolve(branch))
122
+ branches: list[dict] = []
123
+ pending = [resolved]
124
+ seen: set[int] = set()
125
+ while pending:
126
+ branch = pending.pop(0)
127
+ identity = id(branch)
128
+ if identity in seen:
129
+ continue
130
+ seen.add(identity)
131
+ branches.append(branch)
132
+ for keyword in ("allOf", "oneOf", "anyOf"):
133
+ pending.extend(
134
+ self.resolve(candidate)
135
+ for candidate in branch.get(keyword) or ()
136
+ )
125
137
  return branches
126
138
 
127
139
  def key_order(self, node: Any) -> list[str]:
@@ -133,17 +145,66 @@ class SchemaIndex:
133
145
  return order
134
146
 
135
147
  def child(self, node: Any, key: str) -> dict:
148
+ candidates: list[dict] = []
136
149
  for branch in self._branches(node):
137
150
  candidate = (branch.get("properties") or {}).get(key)
138
151
  if candidate is not None:
139
- return self.resolve(candidate)
140
- return {}
152
+ candidates.append(self.resolve(candidate))
153
+ return max(candidates, key=_schema_detail, default={})
141
154
 
142
155
  def item(self, node: Any) -> dict:
156
+ candidates: list[dict] = []
143
157
  for branch in self._branches(node):
144
158
  if "items" in branch:
145
- return self.resolve(branch["items"])
146
- return {}
159
+ candidates.append(self.resolve(branch["items"]))
160
+ return max(candidates, key=_schema_detail, default={})
161
+
162
+ def keys_for_label(self, label: str) -> list[str]:
163
+ """전체 스키마에서 사람이 읽는 표기가 일치하는 고유 키 후보."""
164
+ found: set[str] = set()
165
+ pending: list[Any] = [self._schema]
166
+ seen: set[int] = set()
167
+ while pending:
168
+ node = pending.pop()
169
+ if isinstance(node, list):
170
+ pending.extend(node)
171
+ continue
172
+ if not isinstance(node, dict) or id(node) in seen:
173
+ continue
174
+ seen.add(id(node))
175
+ for key in (node.get("properties") or {}):
176
+ if humanise(key) == label:
177
+ found.add(key)
178
+ pending.extend(node.values())
179
+ return sorted(found)
180
+
181
+ def schema_for_key(self, key: str) -> dict:
182
+ """전체 스키마에서 이 키를 가장 구체적으로 설명하는 후보."""
183
+ found: list[dict] = []
184
+ pending: list[Any] = [self._schema]
185
+ seen: set[int] = set()
186
+ while pending:
187
+ node = pending.pop()
188
+ if isinstance(node, list):
189
+ pending.extend(node)
190
+ continue
191
+ if not isinstance(node, dict) or id(node) in seen:
192
+ continue
193
+ seen.add(id(node))
194
+ candidate = (node.get("properties") or {}).get(key)
195
+ if isinstance(candidate, dict):
196
+ found.append(self.resolve(candidate))
197
+ pending.extend(node.values())
198
+ return max(found, key=_schema_detail, default={})
199
+
200
+
201
+ def _schema_detail(node: dict) -> tuple[int, int]:
202
+ """구체적인 분기 스키마가 빈 호환 스키마보다 먼저 선택되게 한다."""
203
+ structural = sum(
204
+ 1 for key in ("properties", "items", "required", "enum", "const", "type")
205
+ if key in node
206
+ )
207
+ return structural, len(str(node))
147
208
 
148
209
 
149
210
  def _is_scalar(value: Any) -> bool:
@@ -0,0 +1,319 @@
1
+ """보고서 작성자 전담 Markdown 서사의 손실 없는 읽기·쓰기 계약."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import re
6
+ from copy import deepcopy
7
+ from pathlib import Path
8
+ from typing import Any, Mapping
9
+
10
+ from .final_report_schema import validate
11
+ from .report_markdown import SchemaIndex, humanise
12
+
13
+
14
+ TITLE = "# OKSTRA Report Narrative"
15
+ EMPTY_MARKER = "_none_"
16
+ _FIELD_RE = re.compile(r"^(?P<indent> *)- \*\*(?P<label>.+)\*\*$")
17
+ _ITEM_RE = re.compile(r"^(?P<indent> *)- Item (?P<position>[1-9]\d*)$")
18
+ _VALUE_RE = re.compile(r"^(?P<indent> *)> ?(?P<value>.*)$")
19
+ _NESTED_FORBIDDEN = frozenset(
20
+ {
21
+ "implementationPlanning.designPreparation",
22
+ "implementationPlanning.planBodyVerification",
23
+ }
24
+ )
25
+
26
+
27
+ class NarrativeContractError(ValueError):
28
+ """서사 입력이 보고서 작성자 소유권이나 Markdown 문법을 위반했다."""
29
+
30
+
31
+ class _Node:
32
+ def __init__(self, kind: str, label: str, level: int) -> None:
33
+ self.kind = kind
34
+ self.label = label
35
+ self.level = level
36
+ self.values: list[str] = []
37
+ self.children: list[_Node] = []
38
+
39
+
40
+ def _narrative_schema_path() -> Path:
41
+ return Path(__file__).resolve().parents[2] / "schemas" / "report-narrative-v3.0.schema.json"
42
+
43
+
44
+ def _narrative_schema() -> dict[str, Any]:
45
+ return json.loads(_narrative_schema_path().read_text(encoding="utf-8"))
46
+
47
+
48
+ def _allowed_top_level() -> frozenset[str]:
49
+ return frozenset(_narrative_schema().get("properties", {}))
50
+
51
+
52
+ def writer_owned_data(data: Mapping[str, Any]) -> dict[str, Any]:
53
+ """완성 리포트에서 보고서 작성자 소유 필드만 복사한다."""
54
+ owned = {
55
+ key: deepcopy(value)
56
+ for key, value in data.items()
57
+ if key in _allowed_top_level()
58
+ }
59
+ planning = owned.get("implementationPlanning")
60
+ if isinstance(planning, dict):
61
+ planning.pop("designPreparation", None)
62
+ planning.pop("planBodyVerification", None)
63
+ for stage in planning.get("stages", ()):
64
+ if isinstance(stage, dict):
65
+ stage.pop("designSurfaceCoverage", None)
66
+ return owned
67
+
68
+
69
+ def _schema_branches(node: Any, index: SchemaIndex) -> list[dict[str, Any]]:
70
+ resolved = index.resolve(node)
71
+ branches = [resolved]
72
+ for keyword in ("allOf", "oneOf", "anyOf"):
73
+ branches.extend(index.resolve(branch) for branch in resolved.get(keyword, ()))
74
+ return branches
75
+
76
+
77
+ def _node_types(node: Any, index: SchemaIndex) -> set[str]:
78
+ result: set[str] = set()
79
+ for branch in _schema_branches(node, index):
80
+ value = branch.get("type")
81
+ if isinstance(value, str):
82
+ result.add(value)
83
+ elif isinstance(value, list):
84
+ result.update(item for item in value if isinstance(item, str))
85
+ if "properties" in branch:
86
+ result.add("object")
87
+ if "items" in branch:
88
+ result.add("array")
89
+ constant = branch.get("const")
90
+ if isinstance(constant, bool):
91
+ result.add("boolean")
92
+ elif isinstance(constant, int):
93
+ result.add("integer")
94
+ elif isinstance(constant, float):
95
+ result.add("number")
96
+ elif isinstance(constant, str):
97
+ result.add("string")
98
+ for item in branch.get("enum", ()):
99
+ if isinstance(item, bool):
100
+ result.add("boolean")
101
+ elif isinstance(item, int):
102
+ result.add("integer")
103
+ elif isinstance(item, float):
104
+ result.add("number")
105
+ elif isinstance(item, str):
106
+ result.add("string")
107
+ return result
108
+
109
+
110
+ def _scalar_lines(value: Any) -> list[str]:
111
+ if value is None:
112
+ return [EMPTY_MARKER]
113
+ if isinstance(value, bool):
114
+ return [str(value).lower()]
115
+ if isinstance(value, (int, float)):
116
+ return [str(value)]
117
+ return str(value).split("\n")
118
+
119
+
120
+ def _render_value(
121
+ value: Any, node: Any, index: SchemaIndex, level: int,
122
+ ) -> list[str]:
123
+ prefix = " " * level
124
+ if isinstance(value, Mapping):
125
+ if not value:
126
+ return [f"{prefix}> {EMPTY_MARKER}"]
127
+ return _render_fields(value, node, index, level)
128
+ if isinstance(value, list):
129
+ if not value:
130
+ return [f"{prefix}> {EMPTY_MARKER}"]
131
+ lines: list[str] = []
132
+ for position, item in enumerate(value, start=1):
133
+ lines.append(f"{prefix}- Item {position}")
134
+ lines.extend(_render_value(item, index.item(node), index, level + 1))
135
+ return lines
136
+ return [f"{prefix}> {line}" for line in _scalar_lines(value)]
137
+
138
+
139
+ def _render_fields(
140
+ value: Mapping[str, Any], node: Any, index: SchemaIndex, level: int,
141
+ ) -> list[str]:
142
+ order = index.key_order(node)
143
+ keys = [key for key in order if key in value]
144
+ keys.extend(key for key in value if key not in keys)
145
+ lines: list[str] = []
146
+ prefix = " " * level
147
+ for key in keys:
148
+ lines.append(f"{prefix}- **{humanise(key)}**")
149
+ lines.extend(_render_value(value[key], index.child(node, key), index, level + 1))
150
+ return lines
151
+
152
+
153
+ def render_narrative(data: Mapping[str, Any], schema: Mapping[str, Any]) -> str:
154
+ """작성자 소유 자료를 계층형 Markdown 목록으로 렌더한다."""
155
+ unknown = sorted(set(data) - _allowed_top_level())
156
+ if unknown:
157
+ raise NarrativeContractError(
158
+ f"owner=report-writer cannot author fields: {unknown}"
159
+ )
160
+ index = SchemaIndex(schema)
161
+ lines = [TITLE, ""]
162
+ lines.extend(_render_fields(data, schema, index, 0))
163
+ return "\n".join(lines).rstrip() + "\n"
164
+
165
+
166
+ def _line_level(indent: str, line_number: int) -> int:
167
+ if len(indent) % 2:
168
+ raise NarrativeContractError(
169
+ f"line {line_number}: indentation must use pairs of spaces"
170
+ )
171
+ return len(indent) // 2
172
+
173
+
174
+ def _parse_tree(markdown: str) -> _Node:
175
+ lines = markdown.splitlines()
176
+ if not lines or lines[0].strip() != TITLE:
177
+ raise NarrativeContractError(f"narrative must start with `{TITLE}`")
178
+ root = _Node("root", "", -1)
179
+ stack = [root]
180
+ for number, line in enumerate(lines[1:], start=2):
181
+ if not line.strip():
182
+ continue
183
+ field = _FIELD_RE.match(line)
184
+ item = _ITEM_RE.match(line)
185
+ value = _VALUE_RE.match(line)
186
+ if field or item:
187
+ match = field or item
188
+ level = _line_level(match.group("indent"), number)
189
+ _append_node(stack, field, item, level, number)
190
+ elif value:
191
+ level = _line_level(value.group("indent"), number)
192
+ _append_value(stack, value.group("value"), level, number)
193
+ else:
194
+ raise NarrativeContractError(f"line {number}: unsupported Markdown syntax")
195
+ return root
196
+
197
+
198
+ def _append_node(stack: list[_Node], field: Any, item: Any, level: int, number: int) -> None:
199
+ while stack[-1].level >= level:
200
+ stack.pop()
201
+ if stack[-1].level != level - 1:
202
+ raise NarrativeContractError(f"line {number}: skipped a nesting level")
203
+ node = _Node(
204
+ "field" if field else "item",
205
+ field.group("label") if field else item.group("position"),
206
+ level,
207
+ )
208
+ stack[-1].children.append(node)
209
+ stack.append(node)
210
+
211
+
212
+ def _append_value(stack: list[_Node], value: str, level: int, number: int) -> None:
213
+ if stack[-1].level != level - 1:
214
+ raise NarrativeContractError(f"line {number}: value is outside its field")
215
+ if stack[-1].children:
216
+ raise NarrativeContractError(f"line {number}: field mixes values and children")
217
+ stack[-1].values.append(value)
218
+
219
+
220
+ def _field_key(label: str, node: Any, index: SchemaIndex, path: str) -> str:
221
+ candidates: dict[str, list[str]] = {}
222
+ for key in index.key_order(node):
223
+ candidates.setdefault(humanise(key), []).append(key)
224
+ matches = candidates.get(label, [])
225
+ if not matches:
226
+ matches = index.keys_for_label(label)
227
+ display_path = f"{path}.{label}" if path else label
228
+ if len(matches) != 1:
229
+ raise NarrativeContractError(
230
+ f"owner=report-writer field `{display_path}` is not an allowed unique field"
231
+ )
232
+ return matches[0]
233
+
234
+
235
+ def _parse_scalar(values: list[str], node: Any, index: SchemaIndex, path: str) -> Any:
236
+ types = _node_types(node, index)
237
+ text = "\n".join(values)
238
+ if text == EMPTY_MARKER and "null" in types:
239
+ return None
240
+ if "boolean" in types and text in {"true", "false"}:
241
+ return text == "true"
242
+ if "integer" in types:
243
+ try:
244
+ return int(text)
245
+ except ValueError as exc:
246
+ raise NarrativeContractError(f"{path}: expected integer") from exc
247
+ if "number" in types:
248
+ try:
249
+ return float(text)
250
+ except ValueError as exc:
251
+ raise NarrativeContractError(f"{path}: expected number") from exc
252
+ return text
253
+
254
+
255
+ def _parse_value(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> Any:
256
+ types = _node_types(schema_node, index)
257
+ if node.values:
258
+ if node.values == [EMPTY_MARKER] and "null" in types:
259
+ return None
260
+ if node.values == [EMPTY_MARKER] and "object" in types:
261
+ return {}
262
+ if node.values == [EMPTY_MARKER] and "array" in types:
263
+ return []
264
+ return _parse_scalar(node.values, schema_node, index, path)
265
+ if "array" in types or all(child.kind == "item" for child in node.children):
266
+ return _parse_array(node, schema_node, index, path)
267
+ return _parse_object(node, schema_node, index, path)
268
+
269
+
270
+ def _parse_array(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> list[Any]:
271
+ if any(child.kind != "item" for child in node.children):
272
+ raise NarrativeContractError(f"{path}: array requires Item rows")
273
+ positions = [int(child.label) for child in node.children]
274
+ if positions != list(range(1, len(positions) + 1)):
275
+ raise NarrativeContractError(f"{path}: Item numbers must be 1..N")
276
+ item_schema = index.item(schema_node)
277
+ return [
278
+ _parse_value(child, item_schema, index, f"{path}[{position - 1}]")
279
+ for position, child in zip(positions, node.children, strict=True)
280
+ ]
281
+
282
+
283
+ def _parse_object(node: _Node, schema_node: Any, index: SchemaIndex, path: str) -> dict[str, Any]:
284
+ result: dict[str, Any] = {}
285
+ for child in node.children:
286
+ if child.kind != "field":
287
+ raise NarrativeContractError(f"{path}: object requires named fields")
288
+ key = _field_key(child.label, schema_node, index, path)
289
+ child_path = f"{path}.{key}" if path else key
290
+ if child_path in _NESTED_FORBIDDEN:
291
+ raise NarrativeContractError(
292
+ f"owner=report-writer cannot author `{path}.{child.label}`"
293
+ )
294
+ if key in result:
295
+ raise NarrativeContractError(f"duplicate field: {child_path}")
296
+ child_schema = index.child(schema_node, key)
297
+ if not _node_types(child_schema, index):
298
+ child_schema = index.schema_for_key(key)
299
+ result[key] = _parse_value(
300
+ child, child_schema, index, child_path
301
+ )
302
+ return result
303
+
304
+
305
+ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
306
+ """Markdown을 작성자 소유 자료로 읽고 소유권 표면을 검증한다."""
307
+ root = _parse_tree(markdown)
308
+ index = SchemaIndex(schema)
309
+ result = _parse_object(root, schema, index, "")
310
+ unknown = sorted(set(result) - _allowed_top_level())
311
+ if unknown:
312
+ labels = [humanise(key) for key in unknown]
313
+ raise NarrativeContractError(
314
+ f"owner=report-writer cannot author fields: {labels}"
315
+ )
316
+ errors = validate(result, _narrative_schema())
317
+ if errors:
318
+ raise NarrativeContractError("; ".join(errors))
319
+ return result