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.
- package/README.md +1 -1
- package/dist/cli-registry.mjs +14 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/incremental-carry.mjs +9 -8
- package/dist/commands/execute/incremental-carry.mjs.map +1 -1
- package/dist/commands/execute/plan-verify.mjs +3 -1
- package/dist/commands/execute/plan-verify.mjs.map +1 -1
- package/dist/commands/report/approval-decision.d.mts +1 -0
- package/dist/commands/report/approval-decision.mjs +21 -0
- package/dist/commands/report/approval-decision.mjs.map +1 -0
- package/dist/commands/report/design-snapshot.d.mts +1 -0
- package/dist/commands/report/design-snapshot.mjs +19 -0
- package/dist/commands/report/design-snapshot.mjs.map +1 -0
- package/docs/architecture/storage-model.md +1 -1
- package/docs/architecture.md +10 -10
- package/docs/cli.md +11 -8
- package/docs/project-structure-overview.md +15 -6
- package/docs/task-process/implementation-planning.md +2 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +15 -164
- package/runtime/prompts/launch.template.md +6 -5
- package/runtime/prompts/lead/adapters/cmux.md +1 -1
- package/runtime/prompts/lead/convergence.md +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +19 -18
- package/runtime/prompts/lead/plan-body-verification.md +39 -18
- package/runtime/prompts/lead/report-writer.md +64 -423
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +5 -4
- package/runtime/prompts/profiles/_common-contract.md +3 -3
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/change-impact-analysis.md +1 -1
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/feature-analysis.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +13 -11
- package/runtime/prompts/profiles/improvement-discovery.md +1 -1
- package/runtime/prompts/profiles/project-analysis.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +2 -1
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
- package/runtime/python/okstra_ctl/agent_activity.py +23 -3
- package/runtime/python/okstra_ctl/agent_prompt_cli.py +6 -6
- package/runtime/python/okstra_ctl/analysis_packet.py +43 -2
- package/runtime/python/okstra_ctl/approval_decisions.py +327 -0
- package/runtime/python/okstra_ctl/design_snapshot.py +134 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +62 -4
- package/runtime/python/okstra_ctl/dispatch_state.py +29 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +6 -2
- package/runtime/python/okstra_ctl/final_report_schema.py +24 -15
- package/runtime/python/okstra_ctl/incremental_carry.py +128 -16
- package/runtime/python/okstra_ctl/incremental_scope.py +4 -1
- package/runtime/python/okstra_ctl/path_hints.py +12 -0
- package/runtime/python/okstra_ctl/paths.py +12 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +113 -16
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +2 -1
- package/runtime/python/okstra_ctl/render.py +48 -1
- package/runtime/python/okstra_ctl/render_final_report.py +7 -6
- package/runtime/python/okstra_ctl/report_assembly.py +354 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -1
- package/runtime/python/okstra_ctl/report_finalize.py +60 -22
- package/runtime/python/okstra_ctl/report_inputs.py +72 -0
- package/runtime/python/okstra_ctl/report_markdown.py +69 -8
- package/runtime/python/okstra_ctl/report_narrative.py +319 -0
- package/runtime/python/okstra_ctl/report_projections.py +265 -0
- package/runtime/python/okstra_ctl/run.py +25 -9
- package/runtime/python/okstra_ctl/schema_excerpt.py +11 -6
- package/runtime/python/okstra_ctl/stage_fix_carry.py +4 -4
- package/runtime/python/okstra_ctl/stage_ledger.py +132 -18
- package/runtime/python/okstra_ctl/stage_map.py +70 -22
- package/runtime/python/okstra_ctl/team.py +1 -1
- package/runtime/python/okstra_ctl/worker_dispatch.py +5 -2
- package/runtime/python/okstra_ctl/worker_prompt_body.py +35 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +31 -3
- package/runtime/schemas/final-report-v3.0.schema.json +10210 -0
- package/runtime/schemas/report-narrative-v3.0.schema.json +30 -0
- package/runtime/templates/report-writer-prompt-preamble.md +15 -21
- package/runtime/templates/reports/html/macros/forms.html +6 -4
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
The order is load-bearing
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
466
|
-
ctx.project_root,
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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":
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
704
|
+
for index, name in enumerate(order):
|
|
668
705
|
if name in failed:
|
|
669
|
-
return list(
|
|
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 = [
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|