okstra 0.198.2 → 0.199.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/architecture/storage-model.md +10 -0
- package/docs/cli.md +4 -3
- package/docs/project-structure-overview.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/host-orchestration/implementation-planning.md +7 -0
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/plan-body-verification.md +12 -5
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +56 -1
- package/runtime/python/okstra_ctl/direct_work.py +109 -0
- package/runtime/python/okstra_ctl/group_context.py +14 -1
- package/runtime/python/okstra_ctl/material.py +29 -0
- package/runtime/python/okstra_ctl/model_io/lines.py +1 -0
- package/runtime/python/okstra_ctl/model_io/renderers.py +6 -0
- package/runtime/python/okstra_ctl/plan_items.py +39 -5
- package/runtime/python/okstra_ctl/plan_items_cli.py +61 -6
- package/runtime/python/okstra_ctl/recap.py +6 -0
- package/runtime/python/okstra_ctl/render.py +8 -4
- package/runtime/python/okstra_ctl/report_narrative.py +4 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
- package/runtime/python/okstra_ctl/run.py +5 -0
- package/runtime/python/okstra_ctl/set_work_status.py +90 -40
- package/runtime/python/okstra_ctl/stage_map.py +6 -4
- package/runtime/python/okstra_ctl/task_list_cli.py +2 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +1 -0
- package/runtime/python/okstra_project/state.py +4 -0
- package/runtime/schemas/final-report-v2.0.schema.json +1 -0
- package/runtime/schemas/final-report-v3.0.schema.json +11 -0
- package/runtime/skills/okstra-inspect/SKILL.md +6 -1
- package/runtime/skills/okstra-inspect/facets/recap.md +6 -1
- package/runtime/skills/okstra-inspect/facets/status.md +12 -0
- package/runtime/skills/okstra-run/SKILL.md +7 -0
- package/runtime/templates/reports/html/assets/base.css +4 -0
- package/runtime/templates/reports/html/assets/base.js +30 -0
- package/runtime/validators/validate-implementation-plan-stages.py +10 -15
- package/runtime/validators/validate-run.py +47 -6
- package/runtime/validators/validate_session_conformance.py +5 -0
|
@@ -930,11 +930,36 @@ class NextDispatch:
|
|
|
930
930
|
}
|
|
931
931
|
|
|
932
932
|
|
|
933
|
+
def self_fix_rounds(verification: Mapping[str, Any]) -> frozenset[int]:
|
|
934
|
+
"""마지막 검증 라운드 번호와 자동 수정 횟수를 구분한다."""
|
|
935
|
+
rounds = frozenset(
|
|
936
|
+
group["round"] for group in (verification.get("selfFixGroups") or [])
|
|
937
|
+
if isinstance(group, Mapping)
|
|
938
|
+
and type(group.get("round")) is int and group["round"] > 0
|
|
939
|
+
)
|
|
940
|
+
applied = verification.get("selfFixRoundsApplied")
|
|
941
|
+
if not rounds and type(applied) is int and applied > 0:
|
|
942
|
+
return frozenset({applied})
|
|
943
|
+
return rounds
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def lead_decision_basis(item: Mapping[str, Any]) -> str:
|
|
947
|
+
"""본문·범위·판정이 바뀌면 이전 리드 결정으로 새 쟁점을 해소할 수 없다."""
|
|
948
|
+
basis = {key: item.get(key) for key in (
|
|
949
|
+
"id", "subject", "block", "stageScope", "contentHash",
|
|
950
|
+
"verifiedContentHash", "verdicts",
|
|
951
|
+
)}
|
|
952
|
+
return hashlib.sha256(
|
|
953
|
+
json.dumps(basis, sort_keys=True, ensure_ascii=False).encode("utf-8")
|
|
954
|
+
).hexdigest()
|
|
955
|
+
|
|
956
|
+
|
|
933
957
|
def next_dispatch(
|
|
934
958
|
items: Sequence[Mapping[str, Any]],
|
|
935
959
|
payloads: Mapping[str, Mapping[str, Any]] | None = None,
|
|
936
960
|
*,
|
|
937
961
|
critic_rostered: bool = True,
|
|
962
|
+
decision_items: Sequence[Mapping[str, Any]] = (),
|
|
938
963
|
) -> NextDispatch:
|
|
939
964
|
"""환경 전용 UNVERIFIABLE 은 전체 라운드를 만들지 않는다. 일괄 오류만 그 워커.
|
|
940
965
|
|
|
@@ -945,6 +970,19 @@ def next_dispatch(
|
|
|
945
970
|
`critic-tie` 다: critic 이 있는 run 을 사용자 결정으로 보내는 쪽이 더
|
|
946
971
|
나쁜 오답이다.
|
|
947
972
|
"""
|
|
973
|
+
for authority in ("lead", "user"):
|
|
974
|
+
pending = tuple(
|
|
975
|
+
str(row["id"]) for row in decision_items
|
|
976
|
+
if row.get("decisionAuthority") == authority
|
|
977
|
+
and not row.get("leadDecisionApplied")
|
|
978
|
+
)
|
|
979
|
+
if pending:
|
|
980
|
+
return NextDispatch(
|
|
981
|
+
kind=f"{authority}-decision", workers=(), item_ids=pending,
|
|
982
|
+
reason="automatic self-fix finished; resolve remaining decisions without another worker batch",
|
|
983
|
+
)
|
|
984
|
+
if decision_items:
|
|
985
|
+
return NextDispatch(kind="none", workers=(), item_ids=(), reason="remaining decisions resolved")
|
|
948
986
|
assigned = tuple(
|
|
949
987
|
str(item["id"]) for item in items
|
|
950
988
|
if isinstance(item.get("id"), str) and item["id"]
|
|
@@ -981,10 +1019,7 @@ def next_dispatch(
|
|
|
981
1019
|
"approval decision per item"
|
|
982
1020
|
),
|
|
983
1021
|
)
|
|
984
|
-
return NextDispatch(
|
|
985
|
-
kind="none", workers=(), item_ids=(),
|
|
986
|
-
reason="no worker batch",
|
|
987
|
-
)
|
|
1022
|
+
return NextDispatch(kind="none", workers=(), item_ids=(), reason="no worker batch")
|
|
988
1023
|
|
|
989
1024
|
|
|
990
1025
|
def correction_prompt_text(queue_markdown: str) -> str:
|
|
@@ -1000,4 +1035,3 @@ def critic_tie_prompt_text(queue_markdown: str) -> str:
|
|
|
1000
1035
|
def reverify_prompt_text(queue_markdown: str) -> str:
|
|
1001
1036
|
"""라운드 2+ 프롬프트. 직전 반대 의견을 읽으라는 지시가 큐보다 앞이다."""
|
|
1002
1037
|
return f"{REVERIFY_PREAMBLE.rstrip()}\n\n{queue_markdown.lstrip()}"
|
|
1003
|
-
|
|
@@ -37,10 +37,12 @@ from .plan_items import (
|
|
|
37
37
|
critic_tie_prompt_text,
|
|
38
38
|
dispatch_item_ids,
|
|
39
39
|
extract_plan_items,
|
|
40
|
+
lead_decision_basis,
|
|
40
41
|
next_dispatch,
|
|
41
42
|
planning_stage_ledger,
|
|
42
43
|
reverify_item_ids,
|
|
43
44
|
reverify_prompt_text,
|
|
45
|
+
self_fix_rounds,
|
|
44
46
|
tie_vote_item_ids,
|
|
45
47
|
voting_analyser_keys,
|
|
46
48
|
with_rendered_by,
|
|
@@ -301,6 +303,12 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
301
303
|
|
|
302
304
|
|
|
303
305
|
def _add_dispatch_commands(commands: Any) -> None:
|
|
306
|
+
resolve = commands.add_parser(
|
|
307
|
+
"resolve-dissent", help="record an evidence-based lead decision after the single self-fix",
|
|
308
|
+
)
|
|
309
|
+
resolve.add_argument("--state", type=Path, required=True)
|
|
310
|
+
resolve.add_argument("--item", required=True)
|
|
311
|
+
resolve.add_argument("--decision-file", type=Path, required=True)
|
|
304
312
|
nxt = commands.add_parser(
|
|
305
313
|
"next-dispatch",
|
|
306
314
|
help="decide whether this round opens a worker batch",
|
|
@@ -1631,6 +1639,12 @@ def _plan_gate_summary(verification: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
1631
1639
|
|
|
1632
1640
|
|
|
1633
1641
|
def _record_self_fixes(args: argparse.Namespace, audit: list[object], verification: dict[str, Any]) -> None:
|
|
1642
|
+
previous = self_fix_rounds(verification)
|
|
1643
|
+
if (args.self_fix_group or args.self_fix_note) and previous - {args.round_number}:
|
|
1644
|
+
raise PlanItemContractError(
|
|
1645
|
+
"automatic self-fix is limited to one rewrite; resolve remaining "
|
|
1646
|
+
"items through a lead decision or user confirmation"
|
|
1647
|
+
)
|
|
1634
1648
|
notes: dict[str, str] = {}
|
|
1635
1649
|
known = {item.get("id") for item in audit if isinstance(item, Mapping)}
|
|
1636
1650
|
if args.self_fix_group and not args.self_fix_stop_reason:
|
|
@@ -1653,6 +1667,8 @@ def _record_self_fixes(args: argparse.Namespace, audit: list[object], verificati
|
|
|
1653
1667
|
for item in audit:
|
|
1654
1668
|
if isinstance(item, dict) and item.get("id") in notes:
|
|
1655
1669
|
item["selfFixNote"] = notes[item["id"]]
|
|
1670
|
+
if notes and not args.self_fix_group and not previous:
|
|
1671
|
+
raise PlanItemContractError("--self-fix-note requires --self-fix-group to account for the rewrite")
|
|
1656
1672
|
groups = []
|
|
1657
1673
|
for raw in args.self_fix_group:
|
|
1658
1674
|
filename, separator, item_ids = raw.partition("=")
|
|
@@ -1780,6 +1796,7 @@ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1780
1796
|
raise PlanItemContractError(
|
|
1781
1797
|
"advisory plan-body gating forbids the self-fix loop"
|
|
1782
1798
|
)
|
|
1799
|
+
_record_self_fixes(args, audit, verification)
|
|
1783
1800
|
_reject_round_gap(history, args.round_number)
|
|
1784
1801
|
snapshots, summary = _round_snapshots(current, verification, args.round_number)
|
|
1785
1802
|
_record_audit_round(audit, snapshots, args.round_number)
|
|
@@ -1808,21 +1825,17 @@ def _complete_round(args: argparse.Namespace) -> dict[str, Any]:
|
|
|
1808
1825
|
"setAside": summary["setAside"],
|
|
1809
1826
|
"participatingAnalysers": {"rostered": rostered, "voting": voting},
|
|
1810
1827
|
"uniformVerifiers": uniform})
|
|
1811
|
-
_record_self_fixes(args, audit, verification)
|
|
1812
1828
|
if args.self_fix_group:
|
|
1813
1829
|
data["selfFixRoundsApplied"] = args.round_number
|
|
1814
1830
|
history[:] = [row for row in history if row.get("round") != args.round_number]
|
|
1815
1831
|
history.append({"round": args.round_number, "completedAt": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "gateResult": gate, "gateBlockedBy": verification["gateBlockedBy"]})
|
|
1816
1832
|
write_json_atomic(args.state, data)
|
|
1817
|
-
payloads = _prepared_payloads(args.run_manifest)
|
|
1818
1833
|
return {
|
|
1819
1834
|
"ok": True,
|
|
1820
1835
|
"operation": "complete-round",
|
|
1821
1836
|
"path": str(args.state),
|
|
1822
1837
|
"gateResult": gate,
|
|
1823
|
-
"nextDispatch":
|
|
1824
|
-
current, payloads, critic_rostered=critic_is_rostered(manifest),
|
|
1825
|
-
).as_dict(),
|
|
1838
|
+
"nextDispatch": _state_next_dispatch(args).as_dict(),
|
|
1826
1839
|
}
|
|
1827
1840
|
|
|
1828
1841
|
|
|
@@ -1859,14 +1872,55 @@ def _rostered_critic(run_manifest: Path | None) -> bool:
|
|
|
1859
1872
|
|
|
1860
1873
|
|
|
1861
1874
|
def _state_next_dispatch(args: argparse.Namespace) -> NextDispatch:
|
|
1862
|
-
|
|
1875
|
+
data, current, _audit, _history = _round_inputs(args)
|
|
1863
1876
|
run_manifest = getattr(args, "run_manifest", None)
|
|
1864
1877
|
payloads = _prepared_payloads(run_manifest)
|
|
1865
1878
|
return next_dispatch(
|
|
1866
1879
|
current, payloads, critic_rostered=_rostered_critic(run_manifest),
|
|
1880
|
+
decision_items=(
|
|
1881
|
+
_plan_gate_summary(data["planBodyVerification"])["items"]
|
|
1882
|
+
if self_fix_rounds(data["planBodyVerification"]) else ()
|
|
1883
|
+
),
|
|
1867
1884
|
)
|
|
1868
1885
|
|
|
1869
1886
|
|
|
1887
|
+
def _resolve_dissent(args: argparse.Namespace) -> dict[str, Any]:
|
|
1888
|
+
data, current, _audit, history = _round_inputs(args)
|
|
1889
|
+
verification = data["planBodyVerification"]
|
|
1890
|
+
item = next((row for row in current if row.get("id") == args.item), None)
|
|
1891
|
+
if item is None:
|
|
1892
|
+
raise PlanItemContractError(f"unknown plan item `{args.item}`")
|
|
1893
|
+
closed = {row.get("round") for row in history if isinstance(row, Mapping)}
|
|
1894
|
+
if any(row.get("round") not in closed for row in item.get("verdicts", [])):
|
|
1895
|
+
raise PlanItemContractError("complete-round must record every verdict before a lead decision")
|
|
1896
|
+
summary = _plan_gate_summary(verification)
|
|
1897
|
+
authority = next(row for row in summary["items"] if row["id"] == args.item)
|
|
1898
|
+
if authority["decisionAuthority"] != "lead":
|
|
1899
|
+
raise PlanItemContractError(
|
|
1900
|
+
f"`{args.item}` is not a lead-owned judgement after self-fix; "
|
|
1901
|
+
"use user confirmation for unresolved facts, requirements, risks or preferences"
|
|
1902
|
+
)
|
|
1903
|
+
try:
|
|
1904
|
+
decision = args.decision_file.read_text(encoding="utf-8").strip()
|
|
1905
|
+
except (OSError, UnicodeError) as exc:
|
|
1906
|
+
raise PlanItemContractError(f"cannot read lead decision: {exc}") from exc
|
|
1907
|
+
if not decision:
|
|
1908
|
+
raise PlanItemContractError("lead decision must state the decision, authority and cited evidence")
|
|
1909
|
+
item["leadDecision"] = {"basisHash": lead_decision_basis(item), "decision": decision}
|
|
1910
|
+
entry = {"planItem": args.item, "workerRole": "lead", "body": decision}
|
|
1911
|
+
dissent = verification.setdefault("dissentLog", [])
|
|
1912
|
+
if entry not in dissent:
|
|
1913
|
+
dissent.append(entry)
|
|
1914
|
+
summary = _plan_gate_summary(verification)
|
|
1915
|
+
verification.update({
|
|
1916
|
+
"gateResult": summary["recomputed"], "gateBlockedBy": summary["blockedBy"],
|
|
1917
|
+
"setAside": summary["setAside"],
|
|
1918
|
+
})
|
|
1919
|
+
write_json_atomic(args.state, data)
|
|
1920
|
+
return {"ok": True, "operation": "resolve-dissent", "itemId": args.item,
|
|
1921
|
+
"gateResult": summary["recomputed"]}
|
|
1922
|
+
|
|
1923
|
+
|
|
1870
1924
|
def _next_dispatch(args: argparse.Namespace) -> dict[str, Any]:
|
|
1871
1925
|
decision = _state_next_dispatch(args)
|
|
1872
1926
|
return {"ok": True, "operation": "next-dispatch", **decision.as_dict()}
|
|
@@ -1946,6 +2000,7 @@ _HANDLERS = {
|
|
|
1946
2000
|
"apply-verdicts": _apply_verdicts,
|
|
1947
2001
|
"complete-round": _complete_round,
|
|
1948
2002
|
"next-dispatch": _next_dispatch,
|
|
2003
|
+
"resolve-dissent": _resolve_dissent,
|
|
1949
2004
|
"correction-prompt": _correction_prompt,
|
|
1950
2005
|
}
|
|
1951
2006
|
|
|
@@ -26,6 +26,7 @@ from okstra_project import (
|
|
|
26
26
|
find_task_root,
|
|
27
27
|
list_project_tasks,
|
|
28
28
|
read_task_key,
|
|
29
|
+
read_task_manifest,
|
|
29
30
|
resolve_project_root,
|
|
30
31
|
tasks_root,
|
|
31
32
|
)
|
|
@@ -109,6 +110,7 @@ def rerun_readiness(project_root: Path, runs: list[dict]) -> dict | None:
|
|
|
109
110
|
|
|
110
111
|
|
|
111
112
|
def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
113
|
+
manifest = read_task_manifest(task_root) or {}
|
|
112
114
|
# timeline 항목의 status / workflowSnapshot / reportRecordPath 는 준비 시점
|
|
113
115
|
# 값이다. 각 run 의 종료 상태는 그 run 의 run-manifest 에서 덮어쓴다.
|
|
114
116
|
runs = [
|
|
@@ -142,6 +144,8 @@ def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
|
142
144
|
return {
|
|
143
145
|
"taskKey": read_task_key(task_root),
|
|
144
146
|
"runCount": len(runs),
|
|
147
|
+
"workStatus": manifest.get("workStatus", ""),
|
|
148
|
+
"latestWorkRecordPath": manifest.get("latestWorkRecordPath", ""),
|
|
145
149
|
"transitions": transitions,
|
|
146
150
|
"latestPhaseStates": latest_states,
|
|
147
151
|
# `null` when nothing is waiting to be carried — the key is always
|
|
@@ -177,6 +181,7 @@ def _group_catalog(project_root: Path, task_group: str) -> dict[str, dict]:
|
|
|
177
181
|
def _memory_block(entry: group_context.MemoryEntry) -> dict[str, Any]:
|
|
178
182
|
return {
|
|
179
183
|
"date": entry.date,
|
|
184
|
+
"source": entry.source,
|
|
180
185
|
"taskType": entry.task_type,
|
|
181
186
|
"seq": entry.seq,
|
|
182
187
|
"nextPhase": entry.next_phase,
|
|
@@ -237,6 +242,7 @@ def assemble_group_recap(project_root: Path, task_group: str) -> dict:
|
|
|
237
242
|
"currentPhaseState": str(catalog.get("currentPhaseState") or "") if catalog else "",
|
|
238
243
|
"latestRunStatus": str(catalog.get("latestRunStatus") or "") if catalog else "",
|
|
239
244
|
"workStatus": str(catalog.get("workStatus") or "") if catalog else "",
|
|
245
|
+
"latestWorkRecordPath": str(catalog.get("latestWorkRecordPath") or "") if catalog else "",
|
|
240
246
|
"nextRecommendedPhase": pointer,
|
|
241
247
|
"runCount": len(_load_timeline(task_root)) if task_root else 0,
|
|
242
248
|
"reportPath": (
|
|
@@ -921,6 +921,7 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
|
|
|
921
921
|
if not task_key:
|
|
922
922
|
continue
|
|
923
923
|
task_root = manifest_path.parent
|
|
924
|
+
identity = task_key.split(":")
|
|
924
925
|
timeline_relative = s(manifest, "historyTimelinePath").strip()
|
|
925
926
|
timeline_file = (
|
|
926
927
|
(project_root / timeline_relative)
|
|
@@ -947,16 +948,17 @@ def render_task_catalog_discovery(output_path: str, ctx: dict) -> None:
|
|
|
947
948
|
entries.append(
|
|
948
949
|
{
|
|
949
950
|
"taskKey": task_key,
|
|
950
|
-
"taskGroup": s(manifest, "taskGroup"),
|
|
951
|
-
"taskId": s(manifest, "taskId"),
|
|
952
|
-
"taskGroupPathSegment": s(manifest, "taskGroupPathSegment"),
|
|
953
|
-
"taskIdPathSegment": s(manifest, "taskIdPathSegment"),
|
|
951
|
+
"taskGroup": s(manifest, "taskGroup") or (identity[1] if len(identity) == 3 else ""),
|
|
952
|
+
"taskId": s(manifest, "taskId") or (identity[2] if len(identity) == 3 else ""),
|
|
953
|
+
"taskGroupPathSegment": s(manifest, "taskGroupPathSegment") or task_root.parent.name,
|
|
954
|
+
"taskIdPathSegment": s(manifest, "taskIdPathSegment") or task_root.name,
|
|
954
955
|
"taskType": s(manifest, "taskType"),
|
|
955
956
|
"workCategory": s(manifest, "workCategory"),
|
|
956
957
|
"currentStatus": s(manifest, "currentStatus"),
|
|
957
958
|
"workStatus": s(manifest, "workStatus"),
|
|
958
959
|
"workStatusUpdatedAt": s(manifest, "workStatusUpdatedAt"),
|
|
959
960
|
"workStatusNote": s(manifest, "workStatusNote"),
|
|
961
|
+
"latestWorkRecordPath": s(manifest, "latestWorkRecordPath"),
|
|
960
962
|
"updatedAt": s(manifest, "updatedAt"),
|
|
961
963
|
"currentPhase": (workflow or {}).get("currentPhase", "")
|
|
962
964
|
if isinstance(workflow, dict)
|
|
@@ -1466,6 +1468,8 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
|
|
|
1466
1468
|
"workStatus": existing.get("workStatus", ""),
|
|
1467
1469
|
"workStatusUpdatedAt": existing.get("workStatusUpdatedAt", ""),
|
|
1468
1470
|
"workStatusNote": existing.get("workStatusNote", ""),
|
|
1471
|
+
"latestWorkRecordPath": existing.get("latestWorkRecordPath", ""),
|
|
1472
|
+
"registrationSource": existing.get("registrationSource", ""),
|
|
1469
1473
|
"taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
|
|
1470
1474
|
"recommendedWorkers": reviewers,
|
|
1471
1475
|
"relatedTasks": related_tasks,
|
|
@@ -144,7 +144,7 @@ def _without_machine_owned_required(node: Any, *, negated: bool = False) -> Any:
|
|
|
144
144
|
return result
|
|
145
145
|
|
|
146
146
|
|
|
147
|
-
def
|
|
147
|
+
def writer_owned_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
148
148
|
"""작성자가 쓴 서사에 걸 값 제약 — 완성 리포트 스키마에서 잘라 온다.
|
|
149
149
|
|
|
150
150
|
`report-narrative-v3.0.schema.json` 은 최상위 **이름** 허용목록이고 값은
|
|
@@ -176,7 +176,7 @@ def _writer_owned_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
176
176
|
|
|
177
177
|
def validate_writer_owned(data: Mapping[str, Any], schema: Mapping[str, Any]) -> list[str]:
|
|
178
178
|
"""작성자 소유 값 제약으로 서사 자료를 검증한다 — `parse_narrative` 와 같은 스키마."""
|
|
179
|
-
return validate(dict(data),
|
|
179
|
+
return validate(dict(data), writer_owned_schema(schema))
|
|
180
180
|
|
|
181
181
|
|
|
182
182
|
def writer_owned_path_defect(path: str) -> str | None:
|
|
@@ -570,7 +570,7 @@ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
570
570
|
"""Markdown을 작성자 소유 자료로 읽고, 소유권 표면과 값 제약을 검증한다.
|
|
571
571
|
|
|
572
572
|
`schema` 는 완성 리포트 스키마다. 이름·타입 해석과 값 검증이 모두 그
|
|
573
|
-
한 벌에서 나온다(`
|
|
573
|
+
한 벌에서 나온다(`writer_owned_schema`). 값 단계의 결함(강제 변환·모양·
|
|
574
574
|
허용되지 않는 필드)과 스키마 검증의 결함을 한 예외에 모두 싣는다 — 값
|
|
575
575
|
단계가 보고한 자리는 스키마 검증에서 다시 말하지 않는다.
|
|
576
576
|
"""
|
|
@@ -608,7 +608,7 @@ def parse_narrative_structure(
|
|
|
608
608
|
)
|
|
609
609
|
errors = [
|
|
610
610
|
error
|
|
611
|
-
for error in validate(result,
|
|
611
|
+
for error in validate(result, writer_owned_schema(schema))
|
|
612
612
|
if not defects.covers(error)
|
|
613
613
|
]
|
|
614
614
|
return result, defects.messages + errors
|
|
@@ -11,6 +11,7 @@ from dataclasses import dataclass
|
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
from typing import Any, Mapping
|
|
13
13
|
|
|
14
|
+
from .analysis_packet import reference_source_extracts
|
|
14
15
|
from .json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
15
16
|
from .implementation_options import (
|
|
16
17
|
CANDIDATE_COMPARISON_ROUTING,
|
|
@@ -22,7 +23,8 @@ from .implementation_options import (
|
|
|
22
23
|
MIN_FEASIBLE_VOTES,
|
|
23
24
|
NO_VALID_OPTIONS_ROUTING,
|
|
24
25
|
)
|
|
25
|
-
from .report_narrative import writer_owned_data
|
|
26
|
+
from .report_narrative import writer_owned_data, writer_owned_schema
|
|
27
|
+
from .schema_excerpt import build_schema_excerpt
|
|
26
28
|
from .scope_provenance import brief_end_state_id_sequence
|
|
27
29
|
|
|
28
30
|
from .exact_coverage import COVERAGE_VERDICT_PRECEDENCE
|
|
@@ -297,6 +299,7 @@ class ReportSynthesisPacket:
|
|
|
297
299
|
"- Responsibility: write the complete human-readable narrative while preserving settled values",
|
|
298
300
|
"- Runtime-owned values: session identifiers, token usage, estimated cost, "
|
|
299
301
|
"user response carry-in",
|
|
302
|
+
"- Accounting details stay in the JSON snapshot; assembly supplies them.",
|
|
300
303
|
"- Validation: source digest match, writer-owned fields, all defects collected",
|
|
301
304
|
]
|
|
302
305
|
# 작성자가 실제로 읽는 것은 마크다운이다. JSON 정본에만 실으면 지시가
|
|
@@ -305,9 +308,8 @@ class ReportSynthesisPacket:
|
|
|
305
308
|
lines.extend(f"- {text}" for text in self._schema_instructions())
|
|
306
309
|
lines.extend(f"- {text}" for text in self._task_instructions())
|
|
307
310
|
lines.extend(f"- {text}" for text in self._carry_instructions())
|
|
308
|
-
lines.extend(_accounting_markdown(self.accounting_snapshot))
|
|
309
311
|
for source in self.sources:
|
|
310
|
-
lines.extend(_source_markdown(source))
|
|
312
|
+
lines.extend(_source_markdown(source, self.task_type, self.sources))
|
|
311
313
|
return "\n".join(lines).rstrip() + "\n"
|
|
312
314
|
|
|
313
315
|
|
|
@@ -324,8 +326,50 @@ _SOURCE_FIELDS = (
|
|
|
324
326
|
)
|
|
325
327
|
|
|
326
328
|
|
|
327
|
-
def _source_markdown(
|
|
329
|
+
def _source_markdown(
|
|
330
|
+
source: ReportSynthesisSource, task_type: str,
|
|
331
|
+
sources: tuple[ReportSynthesisSource, ...],
|
|
332
|
+
) -> list[str]:
|
|
328
333
|
content = source.content.rstrip("\n")
|
|
334
|
+
view = []
|
|
335
|
+
if source.label == "Analysis packet":
|
|
336
|
+
texts = {item.label: item.content for item in sources}
|
|
337
|
+
content = reference_source_extracts(
|
|
338
|
+
source.content, task_type,
|
|
339
|
+
brief_text=texts.get("Task brief", ""),
|
|
340
|
+
profile_text=texts.get("Analysis profile", ""),
|
|
341
|
+
reference_text=texts.get("Reference expectations", ""),
|
|
342
|
+
clarification_text=texts.get("Clarification response", ""),
|
|
343
|
+
).rstrip("\n")
|
|
344
|
+
if content != source.content.rstrip("\n"):
|
|
345
|
+
view = [
|
|
346
|
+
"- View: repeated extracts refer to frozen sources below; "
|
|
347
|
+
"the original line index is omitted. The digest identifies "
|
|
348
|
+
"the frozen original.",
|
|
349
|
+
]
|
|
350
|
+
if source.label == "Final report schema":
|
|
351
|
+
try:
|
|
352
|
+
schema = json.loads(source.content)
|
|
353
|
+
except json.JSONDecodeError:
|
|
354
|
+
schema = {}
|
|
355
|
+
view = ["- View: schema is not JSON; the original text is retained."]
|
|
356
|
+
if isinstance(schema, dict) and schema.get("properties"):
|
|
357
|
+
# 원본과 해시는 JSON 보관본에 남긴다. 저작용 읽기에는 검증기와
|
|
358
|
+
# 같은 소유권 투영을 쓰고, 도달하지 않는 정의만 기존 발췌기로 뺀다.
|
|
359
|
+
owned = writer_owned_schema(schema)
|
|
360
|
+
# 부분 서사 검증에서 빠지는 루트 조건도 작성 지침에는 남긴다.
|
|
361
|
+
# 판정별 금지 필드와 후속 작업 조건은 최종 조립에 적용된다.
|
|
362
|
+
owned["allOf"] = schema.get("allOf", [])
|
|
363
|
+
owned["required"] = [
|
|
364
|
+
key for key in schema.get("required", [])
|
|
365
|
+
if key in owned["properties"]
|
|
366
|
+
]
|
|
367
|
+
excerpt = build_schema_excerpt(owned, task_type)
|
|
368
|
+
content = json.dumps(excerpt, ensure_ascii=False, indent=2)
|
|
369
|
+
view = [
|
|
370
|
+
"- View: writer-owned schema excerpt; the digest identifies the "
|
|
371
|
+
"frozen original. Task-specific requirements are listed above.",
|
|
372
|
+
]
|
|
329
373
|
longest = max((len(run) for run in re.findall(r"`+", content)), default=0)
|
|
330
374
|
fence = "`" * max(3, longest + 1)
|
|
331
375
|
return [
|
|
@@ -335,6 +379,7 @@ def _source_markdown(source: ReportSynthesisSource) -> list[str]:
|
|
|
335
379
|
f"- Owner: `{source.owner}`",
|
|
336
380
|
f"- Path: `{source.path}`",
|
|
337
381
|
f"- Digest: `{source.digest}`",
|
|
382
|
+
*view,
|
|
338
383
|
"",
|
|
339
384
|
f"{fence}text",
|
|
340
385
|
content,
|
|
@@ -342,42 +387,6 @@ def _source_markdown(source: ReportSynthesisSource) -> list[str]:
|
|
|
342
387
|
]
|
|
343
388
|
|
|
344
389
|
|
|
345
|
-
def _accounting_markdown(snapshot: Mapping[str, Any]) -> list[str]:
|
|
346
|
-
summary = snapshot.get("usageSummary")
|
|
347
|
-
usage_summary = summary if isinstance(summary, Mapping) else {}
|
|
348
|
-
estimated = usage_summary.get("estimatedCostUsd")
|
|
349
|
-
estimated_cost = estimated if isinstance(estimated, Mapping) else {}
|
|
350
|
-
sessions = snapshot.get("leadSessionIds")
|
|
351
|
-
lead_sessions = sessions if isinstance(sessions, list) else []
|
|
352
|
-
lines = [
|
|
353
|
-
"",
|
|
354
|
-
"## Runtime-owned accounting snapshot",
|
|
355
|
-
"",
|
|
356
|
-
"- Lead sessions: " + (", ".join(map(str, lead_sessions)) or "not recorded"),
|
|
357
|
-
f"- Total tokens: {usage_summary.get('grandTotalTokens', 'not recorded')}",
|
|
358
|
-
f"- Estimated cost USD: {estimated_cost.get('grandTotal', 'not recorded')}",
|
|
359
|
-
]
|
|
360
|
-
workers = snapshot.get("workerUsage")
|
|
361
|
-
if not isinstance(workers, list):
|
|
362
|
-
return lines
|
|
363
|
-
for worker in workers:
|
|
364
|
-
if not isinstance(worker, Mapping):
|
|
365
|
-
continue
|
|
366
|
-
usage = worker.get("usage")
|
|
367
|
-
usage_value = usage if isinstance(usage, Mapping) else {}
|
|
368
|
-
cost = usage_value.get(
|
|
369
|
-
"estimatedCostUsd",
|
|
370
|
-
usage_value.get("cliEstimatedCostUsd", "not recorded"),
|
|
371
|
-
)
|
|
372
|
-
lines.append(
|
|
373
|
-
"- Worker "
|
|
374
|
-
f"{worker.get('workerId', 'unknown')}: "
|
|
375
|
-
f"tokens={usage_value.get('totalTokens', 'not recorded')}, "
|
|
376
|
-
f"costUsd={cost}"
|
|
377
|
-
)
|
|
378
|
-
return lines
|
|
379
|
-
|
|
380
|
-
|
|
381
390
|
def _string(value: object) -> str:
|
|
382
391
|
return value.strip() if isinstance(value, str) else ""
|
|
383
392
|
|
|
@@ -76,6 +76,7 @@ from .implementation_direction import (
|
|
|
76
76
|
from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
|
|
77
77
|
from .material import (
|
|
78
78
|
build_analysis_material,
|
|
79
|
+
direct_work_context,
|
|
79
80
|
related_tasks_bullets,
|
|
80
81
|
related_tasks_inline,
|
|
81
82
|
resolve_related_tasks,
|
|
@@ -3803,6 +3804,10 @@ def _write_instruction_set_sources(
|
|
|
3803
3804
|
stage_ledger_json=render_stage_ledger(stage_ledger),
|
|
3804
3805
|
stage_ledger_notice=stage_ledger_notice(stage_ledger),
|
|
3805
3806
|
prior_planning_summary=prior_planning_summary,
|
|
3807
|
+
direct_work_text=direct_work_context(
|
|
3808
|
+
Path(ctx["PROJECT_ROOT"]), ctx["TASK_KEY"],
|
|
3809
|
+
json.loads(ctx.get("RELATED_TASKS_JSON", "[]")),
|
|
3810
|
+
),
|
|
3806
3811
|
)
|
|
3807
3812
|
if inp.task_type in ANALYSIS_TASK_TYPES:
|
|
3808
3813
|
packet += (
|