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.
Files changed (40) hide show
  1. package/docs/architecture/storage-model.md +10 -0
  2. package/docs/cli.md +4 -3
  3. package/docs/project-structure-overview.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/prompts/host-orchestration/implementation-planning.md +7 -0
  7. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  8. package/runtime/prompts/lead/plan-body-verification.md +12 -5
  9. package/runtime/prompts/lead/report-writer.md +1 -1
  10. package/runtime/prompts/profiles/_common-contract.md +1 -1
  11. package/runtime/prompts/profiles/implementation-planning.md +2 -2
  12. package/runtime/python/okstra_ctl/analysis_packet.py +56 -1
  13. package/runtime/python/okstra_ctl/direct_work.py +109 -0
  14. package/runtime/python/okstra_ctl/group_context.py +14 -1
  15. package/runtime/python/okstra_ctl/material.py +29 -0
  16. package/runtime/python/okstra_ctl/model_io/lines.py +1 -0
  17. package/runtime/python/okstra_ctl/model_io/renderers.py +6 -0
  18. package/runtime/python/okstra_ctl/plan_items.py +39 -5
  19. package/runtime/python/okstra_ctl/plan_items_cli.py +61 -6
  20. package/runtime/python/okstra_ctl/recap.py +6 -0
  21. package/runtime/python/okstra_ctl/render.py +8 -4
  22. package/runtime/python/okstra_ctl/report_narrative.py +4 -4
  23. package/runtime/python/okstra_ctl/report_synthesis_packet.py +49 -40
  24. package/runtime/python/okstra_ctl/run.py +5 -0
  25. package/runtime/python/okstra_ctl/set_work_status.py +90 -40
  26. package/runtime/python/okstra_ctl/stage_map.py +6 -4
  27. package/runtime/python/okstra_ctl/task_list_cli.py +2 -0
  28. package/runtime/python/okstra_ctl/worker_prompt_headers.py +1 -0
  29. package/runtime/python/okstra_project/state.py +4 -0
  30. package/runtime/schemas/final-report-v2.0.schema.json +1 -0
  31. package/runtime/schemas/final-report-v3.0.schema.json +11 -0
  32. package/runtime/skills/okstra-inspect/SKILL.md +6 -1
  33. package/runtime/skills/okstra-inspect/facets/recap.md +6 -1
  34. package/runtime/skills/okstra-inspect/facets/status.md +12 -0
  35. package/runtime/skills/okstra-run/SKILL.md +7 -0
  36. package/runtime/templates/reports/html/assets/base.css +4 -0
  37. package/runtime/templates/reports/html/assets/base.js +30 -0
  38. package/runtime/validators/validate-implementation-plan-stages.py +10 -15
  39. package/runtime/validators/validate-run.py +47 -6
  40. 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": next_dispatch(
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
- _data, current, _audit, _history = _round_inputs(args)
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 _writer_owned_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
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), _writer_owned_schema(schema))
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
- 한 벌에서 나온다(`_writer_owned_schema`). 값 단계의 결함(강제 변환·모양·
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, _writer_owned_schema(schema))
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(source: ReportSynthesisSource) -> list[str]:
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 += (