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
@@ -0,0 +1,265 @@
1
+ """역할별 실행 입력을 최종 리포트 조각으로 바꾸는 순수 투영."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from copy import deepcopy
6
+ from typing import Any, Mapping, Sequence
7
+
8
+ from .design_surfaces import DesignSurfaceTrigger, detect_design_surfaces
9
+ from .report_contract import execution_roles_from_manifest
10
+
11
+
12
+ class ReportProjectionError(ValueError):
13
+ """소유자 입력을 정본 조각으로 투영할 수 없다."""
14
+
15
+
16
+ _AGENT_LABELS = {
17
+ "claude": "Claude Code",
18
+ "claude-code": "Claude Code",
19
+ "codex": "Codex",
20
+ "antigravity": "Antigravity",
21
+ "grok": "Grok",
22
+ "kimi": "Kimi",
23
+ }
24
+ _STATUS = {
25
+ "done": "completed",
26
+ "passed": "completed",
27
+ "failed": "error",
28
+ "blocked": "error",
29
+ "pending": "not-run",
30
+ "prepared": "not-run",
31
+ }
32
+
33
+
34
+ def _text(value: object, fallback: str) -> str:
35
+ text = str(value or "").strip()
36
+ return text or fallback
37
+
38
+
39
+ def _status(value: object) -> str:
40
+ text = str(value or "not-run").strip().lower()
41
+ return _STATUS.get(text, text if text in {
42
+ "completed", "error", "timeout", "not-run", "synthesis-only"
43
+ } else "not-run")
44
+
45
+
46
+ def _agent_label(row: Mapping[str, Any]) -> str:
47
+ value = _text(row.get("agent") or row.get("provider"), "codex")
48
+ return _AGENT_LABELS.get(value.lower(), value)
49
+
50
+
51
+ def _execution_row(row: Mapping[str, Any], *, lead: bool = False) -> dict[str, Any]:
52
+ role = _text(row.get("role"), "Okstra lead" if lead else "Worker")
53
+ result = {
54
+ "agent": _agent_label(row),
55
+ "role": role,
56
+ "model": _text(row.get("model") or row.get("modelExecutionValue"), "unknown"),
57
+ "status": _status(row.get("status")),
58
+ "summary": _text(
59
+ row.get("summary") or row.get("reason"),
60
+ "Run coordination recorded by team state." if lead
61
+ else "Worker execution recorded by team state.",
62
+ ),
63
+ }
64
+ _populate_usage(result, row.get("usage") or {})
65
+ return result
66
+
67
+
68
+ def _populate_usage(row: dict[str, Any], usage: object) -> None:
69
+ source = usage if isinstance(usage, Mapping) else {}
70
+ mapping = {
71
+ "totalTokens": "totalTokens",
72
+ "cacheReadTokens": "cacheReadTokens",
73
+ "billableEquivalentTokens": "billableTokens",
74
+ "estimatedCostUsd": "costUsd",
75
+ "cliTotalTokens": "cliTotalTokens",
76
+ "cliEstimatedCostUsd": "cliCostUsd",
77
+ "durationMs": "durationMs",
78
+ }
79
+ for source_key, target_key in mapping.items():
80
+ if source_key in source and source.get("source") != "unavailable":
81
+ row[target_key] = source[source_key]
82
+
83
+
84
+ def project_execution(
85
+ manifest: Mapping[str, Any], team_state: Mapping[str, Any],
86
+ ) -> dict[str, Any]:
87
+ """실행 역할과 상태를 매니페스트·팀 상태에서만 만든다."""
88
+ lead = team_state.get("lead")
89
+ lead_row = lead if isinstance(lead, Mapping) else {
90
+ "agent": manifest.get("leadRuntime") or manifest.get("leadProvider"),
91
+ "model": manifest.get("leadModel") or "unknown",
92
+ "status": "completed",
93
+ "role": "Okstra lead",
94
+ }
95
+ workers = team_state.get("workers")
96
+ worker_rows = workers if isinstance(workers, list) else []
97
+ result: dict[str, Any] = {
98
+ "executionStatus": [
99
+ _execution_row(lead_row, lead=True),
100
+ *[
101
+ _execution_row(row)
102
+ for row in worker_rows
103
+ if isinstance(row, Mapping)
104
+ ],
105
+ ]
106
+ }
107
+ roles = execution_roles_from_manifest(dict(manifest))
108
+ if roles:
109
+ result.update(executionIdentityVersion=2, executionRoles=roles)
110
+ return result
111
+
112
+
113
+ def _usage_row(summary: Mapping[str, Any], prefix: str, cost: object) -> dict[str, Any]:
114
+ return {
115
+ "totalTokens": summary.get(f"{prefix}TotalTokens"),
116
+ "cacheReadTokens": summary.get(f"{prefix}CacheReadTokens"),
117
+ "billableTokens": summary.get(f"{prefix}BillableEquivalentTokens"),
118
+ "costUsd": cost,
119
+ }
120
+
121
+
122
+ def _worker_details(team_state: Mapping[str, Any]) -> list[dict[str, Any]]:
123
+ result = []
124
+ for worker in team_state.get("workers") or []:
125
+ if not isinstance(worker, Mapping):
126
+ continue
127
+ usage = worker.get("usage") if isinstance(worker.get("usage"), Mapping) else {}
128
+ row = {"label": _text(worker.get("role") or worker.get("workerId"), "Worker")}
129
+ _populate_usage(row, usage)
130
+ for key in ("totalTokens", "cacheReadTokens", "billableTokens", "costUsd", "cliTotalTokens", "cliCostUsd"):
131
+ row.setdefault(key, None)
132
+ result.append(row)
133
+ return result
134
+
135
+
136
+ def project_token_usage(team_state: Mapping[str, Any]) -> dict[str, Any]:
137
+ """팀 상태의 사용량 합계를 리포트 표 구조로 반환한다."""
138
+ summary = team_state.get("usageSummary")
139
+ summary = summary if isinstance(summary, Mapping) else {}
140
+ costs = summary.get("estimatedCostUsd")
141
+ costs = costs if isinstance(costs, Mapping) else {}
142
+ lead_cost = costs.get("lead")
143
+ worker_cost = costs.get("claudeWorkers")
144
+ return {
145
+ "lead": _usage_row(summary, "lead", lead_cost),
146
+ "worker": _usage_row(summary, "worker", worker_cost),
147
+ "grand": _usage_row(
148
+ summary,
149
+ "grand",
150
+ None if lead_cost is None or worker_cost is None else lead_cost + worker_cost,
151
+ ),
152
+ "workerDetails": _worker_details(team_state),
153
+ "cli": {"costUsd": costs.get("cliWorkers")},
154
+ }
155
+
156
+
157
+ def _trigger_rows(trigger: DesignSurfaceTrigger) -> list[dict[str, Any]]:
158
+ return [
159
+ {"step": item.step, "field": item.field, "match": item.match}
160
+ for item in trigger.evidence
161
+ ]
162
+
163
+
164
+ def project_design(
165
+ planning: Mapping[str, Any], detector_snapshot: Mapping[str, Any],
166
+ ) -> dict[str, Any]:
167
+ """탐지 결과가 실제 계획 표면과 일치할 때만 설계 블록을 반환한다."""
168
+ triggers = detect_design_surfaces(planning)
169
+ expected = {(row.stage, row.kind): _trigger_rows(row) for row in triggers}
170
+ coverage = detector_snapshot.get("stageCoverage")
171
+ coverage = coverage if isinstance(coverage, list) else []
172
+ actual: dict[tuple[int, str], list[dict[str, Any]]] = {}
173
+ for stage in coverage:
174
+ if not isinstance(stage, Mapping):
175
+ raise ReportProjectionError("owner=design-surface-detector invalid stageCoverage row")
176
+ for row in stage.get("rows") or []:
177
+ if isinstance(row, Mapping):
178
+ actual[(int(stage.get("stage") or 0), str(row.get("kind") or ""))] = list(row.get("triggerEvidence") or [])
179
+ if expected != actual:
180
+ raise ReportProjectionError("owner=design-surface-detector trigger coverage mismatch")
181
+ preparation = detector_snapshot.get("designPreparation")
182
+ if not isinstance(preparation, Mapping):
183
+ raise ReportProjectionError("owner=design-surface-detector missing designPreparation")
184
+ return {
185
+ "designPreparation": deepcopy(dict(preparation)),
186
+ "stageCoverage": deepcopy(coverage),
187
+ }
188
+
189
+
190
+ def _round_history(state: Mapping[str, Any]) -> dict[str, Any]:
191
+ if not (state.get("config") or {}).get("enabled", True):
192
+ return {"disabled": True}
193
+ rows = []
194
+ for row in state.get("roundHistory") or []:
195
+ if not isinstance(row, Mapping):
196
+ continue
197
+ rows.append({
198
+ "round": row.get("round", 0),
199
+ "inputQueueSize": row.get("inputQueueSize", 0),
200
+ "resolvedCount": row.get("resolvedCount", 0),
201
+ "carriedForwardCount": row.get("carriedForwardCount", 0),
202
+ "dispatches": json.dumps(row.get("dispatches") or [], ensure_ascii=False),
203
+ "skippedWorkers": json.dumps(row.get("skippedWorkers") or [], ensure_ascii=False),
204
+ })
205
+ return {"rounds": rows, "round2SkippedReason": state.get("round2SkippedReason", "not-skipped")}
206
+
207
+
208
+ def _source_items(finding: Mapping[str, Any]) -> list[str]:
209
+ finding_id = _text(finding.get("findingId"), "unknown")
210
+ workers = finding.get("consensusWorkers") or [finding.get("originWorker")]
211
+ return [f"{_text(worker, 'worker').removesuffix('-worker')}:{finding_id}" for worker in workers]
212
+
213
+
214
+ def project_convergence(state: Mapping[str, Any]) -> dict[str, Any]:
215
+ """수렴 상태에서 독자용 합의·이견과 공개 근거만 승격한다."""
216
+ consensus = []
217
+ differences = []
218
+ promoted = []
219
+ for finding in state.get("findings") or []:
220
+ if not isinstance(finding, Mapping):
221
+ continue
222
+ evidence = _text(finding.get("originEvidence"), "")
223
+ ticket_ids = finding.get("ticketIds") or ["unknown"]
224
+ ticket_id = _text(ticket_ids[0] if ticket_ids else "unknown", "unknown")
225
+ if finding.get("classification") in {"full-consensus", "partial-consensus"}:
226
+ row = {
227
+ "id": f"C-{len(consensus) + 1:03d}",
228
+ "ticketId": ticket_id,
229
+ "statement": _text(finding.get("summary"), "Converged finding"),
230
+ "sourceItems": _source_items(finding),
231
+ "evidence": evidence or "convergence state",
232
+ }
233
+ consensus.append(row)
234
+ if evidence and ("/" in evidence or ":" in evidence):
235
+ promoted.append({
236
+ "findingId": finding.get("findingId"),
237
+ "ticketId": ticket_id,
238
+ "evidence": evidence,
239
+ "sourceItems": row["sourceItems"],
240
+ })
241
+ else:
242
+ positions = []
243
+ for worker in [finding.get("originWorker"), *(finding.get("dissentingWorkers") or [])]:
244
+ if worker:
245
+ positions.append({
246
+ "worker": str(worker),
247
+ "itemId": _text(finding.get("findingId"), ""),
248
+ "position": "origin" if worker == finding.get("originWorker") else "dissent",
249
+ })
250
+ differences.append({
251
+ "id": f"D-{len(differences) + 1:03d}",
252
+ "ticketId": ticket_id,
253
+ "disagreement": _text(finding.get("summary"), "Unresolved difference"),
254
+ "workersPosition": positions or [{"worker": "unknown", "itemId": "", "position": "unresolved"}],
255
+ "evidence": evidence or "convergence state",
256
+ })
257
+ result = {
258
+ "crossVerification": {
259
+ "roundHistory": _round_history(state),
260
+ "consensus": consensus,
261
+ "differences": differences,
262
+ },
263
+ "promotedEvidence": promoted,
264
+ }
265
+ return result
@@ -32,7 +32,11 @@ from okstra_project import project_json_path, upsert_project_json
32
32
  from okstra_project.state import slugify
33
33
  from . import fix_cycles
34
34
  from .analysis_packet import build_analysis_packet
35
- from .stage_ledger import build_stage_ledger, render_stage_ledger
35
+ from .stage_ledger import (
36
+ build_stage_ledger,
37
+ render_stage_ledger,
38
+ stage_ledger_notice,
39
+ )
36
40
  from .analysis_inputs import (
37
41
  ANALYSIS_TASK_TYPES,
38
42
  AnalysisInputError,
@@ -109,7 +113,10 @@ from .legacy_model_selection import (
109
113
  serialize_host_session_context,
110
114
  )
111
115
  from .model_defaults import ModelDefaultScopes
112
- from .worker_prompt_policy import ANALYSIS_DUTY_BY_TASK_TYPE
116
+ from .worker_prompt_policy import (
117
+ ANALYSIS_DUTY_BY_TASK_TYPE,
118
+ critic_assignment_ref,
119
+ )
113
120
  from .models import (
114
121
  UnknownProviderError,
115
122
  default_model,
@@ -2504,6 +2511,7 @@ def _resolve_model_bindings(
2504
2511
  worker_assignments,
2505
2512
  critic_assignment,
2506
2513
  translator_assignment,
2514
+ inp.task_type,
2507
2515
  )
2508
2516
  worker_by_provider = {
2509
2517
  row.provider: row for row in worker_assignments
@@ -2653,6 +2661,7 @@ def _project_model_bindings(
2653
2661
  workers,
2654
2662
  critic_assignment,
2655
2663
  _role_assignment(resolved=translator, role="translator"),
2664
+ inp.task_type,
2656
2665
  )
2657
2666
  worker_by_provider = {row.provider: row for row in workers}
2658
2667
  return _ModelBindings(
@@ -2892,6 +2901,7 @@ def _build_invocation_assignments(
2892
2901
  workers: tuple[RoleAssignment, ...],
2893
2902
  critic: RoleAssignment | None,
2894
2903
  translator: RoleAssignment,
2904
+ task_type: str,
2895
2905
  ) -> dict[str, dict[str, object]]:
2896
2906
  assignments = {"lead": lead.to_model_payload()}
2897
2907
  for assignment in workers:
@@ -2902,8 +2912,11 @@ def _build_invocation_assignments(
2902
2912
  assignment.to_model_payload()
2903
2913
  )
2904
2914
  if critic is not None:
2905
- assignments["critic/scope"] = critic.to_model_payload()
2906
- assignments["critic/acceptance"] = critic.to_model_payload()
2915
+ # 한 run 이 도달할 수 있는 critic 은 한 종류다. 둘 다 등록하면 실행될
2916
+ # 수 없는 로스터 행이 남고, `_optional_worker_roles` 가 provider 로만
2917
+ # role 라벨을 만들기 때문에 두 행의 이름이 겹쳐 validate-run 이
2918
+ # `duplicate worker role detected` 로 런을 실패시킨다.
2919
+ assignments[critic_assignment_ref(task_type)] = critic.to_model_payload()
2907
2920
  assignments["translator"] = translator.to_model_payload()
2908
2921
  return assignments
2909
2922
 
@@ -3218,6 +3231,12 @@ def _write_instruction_set_sources(
3218
3231
  render_reference_expectations(
3219
3232
  str(inp.brief_path), str(instruction_set / "reference-expectations.md"), ctx,
3220
3233
  )
3234
+ # 계획 phase 만 원장을 받는다. 원장은 "무엇이 이미 지어졌나" 를 계획 저작
3235
+ # 쪽에 알려 주는 것이므로, 계획을 쓰지 않는 phase 에는 실을 자리가 없다.
3236
+ stage_ledger = (
3237
+ build_stage_ledger(Path(ctx["TASK_MANIFEST_PATH"]).parent)
3238
+ if inp.task_type == "implementation-planning" else None
3239
+ )
3221
3240
  packet = build_analysis_packet(
3222
3241
  task_key=ctx["TASK_KEY"],
3223
3242
  task_type=ctx["TASK_TYPE"],
@@ -3232,11 +3251,8 @@ def _write_instruction_set_sources(
3232
3251
  instruction_set_relative_path=ctx["INSTRUCTION_SET_RELATIVE_PATH"],
3233
3252
  fix_history_text=fix_cycles.packet_summary(
3234
3253
  fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
3235
- stage_ledger_json=(
3236
- render_stage_ledger(
3237
- build_stage_ledger(Path(ctx["TASK_MANIFEST_PATH"]).parent))
3238
- if inp.task_type == "implementation-planning" else ""
3239
- ),
3254
+ stage_ledger_json=render_stage_ledger(stage_ledger),
3255
+ stage_ledger_notice=stage_ledger_notice(stage_ledger),
3240
3256
  )
3241
3257
  if inp.task_type in ANALYSIS_TASK_TYPES:
3242
3258
  packet += (
@@ -1,11 +1,11 @@
1
- """Build a task-type-scoped excerpt of the final-report schema.
1
+ """Build a task-type-scoped excerpt of a versioned final-report schema.
2
2
 
3
- The full schema (``schemas/final-report-v2.0.schema.json``) carries the
3
+ The full schema carries the
4
4
  deliverable property blocks for ALL task-types (``errorAnalysis``, the three
5
5
  read-only analysis blocks, ``implementationPlanning``, ``releaseHandoff``,
6
6
  ``implementation``, and ``finalVerification``) plus a
7
7
  ``$defs`` library (~38% of the file) shared across them. A single run only
8
- authors ONE task-type's data.json, so the report-writer worker only needs
8
+ authors ONE task-type's narrative, so the report-writer worker only needs
9
9
  the common structure + its own task-type's block + the ``$defs`` those
10
10
  reach.
11
11
 
@@ -16,7 +16,7 @@ repo/installed schema (whose `schemas/...` path is not even resolvable
16
16
  from inside a consumer project's task bundle).
17
17
 
18
18
  The excerpt is ADVISORY — a reading aid for the author. Validation always
19
- runs against the FULL schema via ``final_report_schema.load_schema()``, so
19
+ runs against the matching FULL schema, so
20
20
  the excerpt never gates correctness; it only trims what the worker reads.
21
21
  """
22
22
  from __future__ import annotations
@@ -28,7 +28,7 @@ from dataclasses import dataclass
28
28
  from pathlib import Path
29
29
 
30
30
  from .final_report_schema import load_schema_version
31
- from .report_contract import CURRENT_REPORT_SCHEMA_VERSION, TASK_TYPE_DATA_PROPERTY
31
+ from .report_contract import TASK_TYPE_DATA_PROPERTY
32
32
 
33
33
 
34
34
  _ALL_PER_TYPE_PROPERTIES = frozenset(TASK_TYPE_DATA_PROPERTY.values())
@@ -168,9 +168,14 @@ def excerpt_contract_skew(
168
168
  cut_from = excerpt_cut_from_version(excerpt)
169
169
  if not cut_from or cut_from == installed:
170
170
  return None
171
+ schema_version = (
172
+ excerpt.get("properties", {})
173
+ .get("schemaVersion", {})
174
+ .get("const", "2.0")
175
+ )
171
176
  try:
172
177
  fresh = build_schema_excerpt(
173
- load_schema_version(CURRENT_REPORT_SCHEMA_VERSION), task_type, installed
178
+ load_schema_version(str(schema_version)), task_type, installed
174
179
  )
175
180
  except Exception: # noqa: BLE001 — an unloadable schema proves no drift
176
181
  return None
@@ -1,8 +1,8 @@
1
1
  """Derive the fix-run carry for an implementation stage whose previous run
2
2
  ended with one or more verifier FAIL verdicts.
3
3
 
4
- Pure read-side: scans the stage run directory's final-report data.json files
5
- (the JSON SSOT written by the report writer) and the stage worktree HEAD.
4
+ Pure read-side: scans the stage run directory's assembled final-report data.json
5
+ files and the stage worktree HEAD.
6
6
  Returns None when the upcoming run is not a fix run, so callers can treat
7
7
  "no carry" and "first run on this stage" identically.
8
8
  """
@@ -47,8 +47,8 @@ class StageFixCarry:
47
47
  "`report-writer.md` § Fix-run incremental authoring).",
48
48
  "This block is inlined into the executor and verifier prompts by "
49
49
  "`initial_prompt_materialization`; do not transcribe it by hand. "
50
- "Lead duties: pass the previous data.json path above to the "
51
- "report-writer dispatch so it authors incrementally.",
50
+ "Lead duties: pass the previous data.json path above as read context "
51
+ "to the report-writer dispatch so it updates only changed narrative blocks.",
52
52
  ])
53
53
  return "\n".join(lines)
54
54
 
@@ -4,8 +4,17 @@
4
4
  이 모듈은 그 사실만 모아 준다 — 무엇을 계획해야 하는지는 말하지 않는다.
5
5
 
6
6
  판정은 소유하지 않는다. 상태 어휘와 lifecycle 판정은 stage_targets 가, stage
7
- map 의 출처 판정은 stage_map.load_task_stage_map 이 소유한다. 여기서는 둘을
8
- 잇고 직렬화만 한다.
7
+ map 의 출처 판정은 stage_map 이 소유한다. 여기서는 둘을 잇고 직렬화만 한다.
8
+
9
+ 원장은 두 질문에 답하고, 답의 출처가 서로 다르다.
10
+
11
+ - "무엇이 이미 지어졌나" — carry 사이드카가 가리키는 계획이 답한다. 실행이
12
+ 실제로 따른 문서이기 때문이다.
13
+ - "어떤 stage 번호가 이미 쓰였나" — 최신 계획이 답한다. ADR-0015 의
14
+ append-only 는 최신 계획의 `max` 로만 판정할 수 있다.
15
+
16
+ 한 소스가 둘 다 답하면, 완료 이후 stage 를 덧붙인 계획이 있을 때 그 번호가
17
+ 비어 있는 것처럼 보이고 새 stage 가 그 번호를 다시 받는다.
9
18
  """
10
19
  from __future__ import annotations
11
20
 
@@ -15,25 +24,31 @@ from typing import Any
15
24
 
16
25
  from .consumers import read_stage_consumer_state
17
26
  from .paths import RunRef
18
- from .stage_map import StageMapError, load_task_stage_map
27
+ from .stage_map import (
28
+ StageMapError,
29
+ load_latest_plan_stage_map,
30
+ load_task_stage_map,
31
+ )
19
32
  from .stage_targets import stage_lifecycle_snapshot_from_state
20
33
  from .task_target import infer_project_root
21
34
 
22
35
 
23
36
  def build_stage_ledger(task_root: Path) -> dict[str, Any] | None:
24
- """이 task 의 Stage 원장. 계획 리포트가 아직 없으면 ``None``.
37
+ """이 task 의 Stage 원장.
38
+
39
+ 세 결과를 구분한다. 셋이 하나로 접히면 소비처가 "계획이 아직 없다" 와
40
+ "계획을 못 읽었다" 를 같은 것으로 읽는다.
25
41
 
26
- ``None`` 과 빈 리스트를 구분한다 — 계획이 없는 첫 run 과 stage 가 하나도
27
- 없는 계획은 저작 쪽에 다른 뜻이다.
42
+ - ``None`` — 계획 리포트가 아직 없다. 이 task 의 첫 계획 run 이다.
43
+ - ``{"unreadable": <사유>}`` — 계획은 있는데 Stage Map 을 못 읽었다.
44
+ - 그 외 — 사실 기록.
28
45
  """
29
46
  task_root = Path(task_root)
30
47
  try:
31
- snapshot = load_task_stage_map(task_root, {})
32
- except StageMapError:
33
- # 계획 출처가 충돌하거나 읽히지 않는 경우. 원장을 추측해 싣느니 싣지
34
- # 않는다 — 틀린 원장은 없는 원장보다 나쁘다.
35
- return None
36
- if snapshot.state != "ready" or not snapshot.stages:
48
+ latest = load_latest_plan_stage_map(task_root)
49
+ except StageMapError as exc:
50
+ return {"unreadable": _failure_reason(task_root, exc)}
51
+ if latest.state != "ready" or not latest.stages:
37
52
  return None
38
53
 
39
54
  plan_run_root = RunRef.from_task_root(
@@ -43,20 +58,119 @@ def build_stage_ledger(task_root: Path) -> dict[str, Any] | None:
43
58
  # 같은 멱등 복구이며, 이걸 건너뛰면 크래시 창에 걸린 완료 stage 가 원장에
44
59
  # 미완으로 실려 저작 쪽이 이미 구현된 stage 를 다시 계획한다.
45
60
  state = read_stage_consumer_state(plan_run_root, recover_from_carry=True)
46
- lifecycle = stage_lifecycle_snapshot_from_state(snapshot.stages, state)
47
- return {
48
- "sourcePlan": _project_relative(task_root, snapshot.source_plan_path),
49
- "stages": lifecycle.ledger_records(),
61
+ lifecycle = stage_lifecycle_snapshot_from_state(latest.stages, state)
62
+ records = lifecycle.ledger_records()
63
+
64
+ built_from, divergence = _built_from(task_root, latest)
65
+ divergence += _renumbering_divergence(
66
+ latest, built_from, state.done_stages
67
+ )
68
+ ledger: dict[str, Any] = {
69
+ "sourcePlan": _project_relative(
70
+ task_root, built_from.source_plan_path if built_from else ""
71
+ ),
72
+ "latestPlan": _project_relative(task_root, latest.source_plan_path),
73
+ "stages": records,
50
74
  }
75
+ if divergence:
76
+ ledger["planDivergence"] = divergence
77
+ return ledger
51
78
 
52
79
 
53
80
  def render_stage_ledger(ledger: dict[str, Any] | None) -> str:
54
- """packet 에 실릴 JSON 본문. 원장이 없으면 빈 문자열."""
55
- if not ledger:
81
+ """packet 에 실릴 JSON 본문. 사실 기록이 없으면 빈 문자열."""
82
+ if not ledger or ledger.get("unreadable"):
56
83
  return ""
57
84
  return json.dumps(ledger, ensure_ascii=False, indent=2)
58
85
 
59
86
 
87
+ def stage_ledger_notice(ledger: dict[str, Any] | None) -> str:
88
+ """원장을 실을 수 없는 사유. 실을 수 있으면 빈 문자열.
89
+
90
+ 평문이다. 실패 통지는 저작 쪽이 파싱할 데이터가 아니라 읽을 판정이고,
91
+ JSON 표면을 하나 더 만들면 그만큼 오독할 키가 늘어난다.
92
+ """
93
+ if not ledger:
94
+ return ""
95
+ return str(ledger.get("unreadable") or "")
96
+
97
+
98
+ def _built_from(task_root: Path, latest: Any):
99
+ """완료된 stage 가 따른 계획. 못 읽으면 ``(None, [사유])``.
100
+
101
+ 이걸 못 읽어도 원장 자체는 낼 수 있다 — 상태는 consumers 원장에서 오고,
102
+ stage 목록은 최신 계획에서 온다. 잃는 것은 번호 재배치 검사뿐이므로,
103
+ 실패를 원장 부재로 승격하지 않고 그 사실만 함께 싣는다.
104
+ """
105
+ try:
106
+ return load_task_stage_map(task_root, {}), []
107
+ except StageMapError as exc:
108
+ return None, [
109
+ "could not read the plan the completed stages were built against, "
110
+ "so stage renumbering could not be checked: "
111
+ + _failure_reason(task_root, exc)
112
+ ]
113
+
114
+
115
+ def _renumbering_divergence(
116
+ latest: Any,
117
+ built_from: Any,
118
+ done_stages: set[int],
119
+ ) -> list[str]:
120
+ """완료된 stage 가 두 계획에서 같은 작업을 가리키는지.
121
+
122
+ ADR-0015 는 stage 번호의 재사용과 재배치를 금지하므로, 규칙이 지켜졌다면
123
+ 최신 계획 위에 실행 상태를 겹쳐 쓰는 것이 안전하다. 지켜졌는지는 검사할
124
+ 사실이지 가정할 사실이 아니다 — 과거 replan 이 번호를 다시 매겼다면 완료
125
+ 커밋이 엉뚱한 stage 에 귀속되고, 그 오류는 통합 시점까지 조용하다.
126
+ """
127
+ if built_from is None or built_from.state != "ready":
128
+ return []
129
+ if built_from.source_plan_path == latest.source_plan_path:
130
+ # 완료된 stage 를 지은 계획이 곧 최신 계획이다. 비교할 두 번째 계획이
131
+ # 없으므로 재배치가 일어날 자리 자체가 없다.
132
+ return []
133
+ latest_titles = {
134
+ int(row["stage_number"]): str(row.get("title") or "")
135
+ for row in latest.stages
136
+ }
137
+ built_titles = {
138
+ int(row["stage_number"]): str(row.get("title") or "")
139
+ for row in built_from.stages
140
+ }
141
+ notes: list[str] = []
142
+ for stage in sorted(done_stages):
143
+ if stage not in built_titles:
144
+ continue
145
+ if stage not in latest_titles:
146
+ notes.append(
147
+ f"stage {stage} is recorded done but the latest plan does not "
148
+ "declare it; a completed stage was dropped rather than cancelled"
149
+ )
150
+ continue
151
+ if built_titles[stage] != latest_titles[stage]:
152
+ notes.append(
153
+ f"stage {stage} names different work in the two plans "
154
+ f"(built: {built_titles[stage]!r}; latest: "
155
+ f"{latest_titles[stage]!r}); its completion may be attributed "
156
+ "to the wrong stage"
157
+ )
158
+ return notes
159
+
160
+
161
+ def _failure_reason(task_root: Path, exc: StageMapError) -> str:
162
+ """StageMapError 를 프로젝트 상대 경로로 낮춘 한 줄."""
163
+ parts = [exc.reason]
164
+ if exc.source_plan_path:
165
+ parts.append(f"source={_project_relative(task_root, exc.source_plan_path)}")
166
+ if exc.conflicting_paths:
167
+ conflicts = ", ".join(
168
+ _project_relative(task_root, path) for path in exc.conflicting_paths
169
+ )
170
+ parts.append(f"conflicts={conflicts}")
171
+ return "; ".join(parts)
172
+
173
+
60
174
  def _project_relative(task_root: Path, source_plan_path: str) -> str:
61
175
  """절대 경로를 프로젝트 상대로 낮춘다.
62
176