okstra 0.214.1 → 0.216.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 (118) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-registry.d.mts +14 -0
  3. package/dist/cli-registry.mjs +12 -0
  4. package/dist/cli-registry.mjs.map +1 -1
  5. package/dist/commands/chat/chat.mjs +2 -56
  6. package/dist/commands/chat/chat.mjs.map +1 -1
  7. package/dist/commands/execute/render-bundle.mjs +5 -2
  8. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  9. package/dist/commands/execute/wizard.mjs +6 -0
  10. package/dist/commands/execute/wizard.mjs.map +1 -1
  11. package/dist/commands/memory/memory.mjs +57 -6
  12. package/dist/commands/memory/memory.mjs.map +1 -1
  13. package/dist/lib/file-lock.d.mts +1 -0
  14. package/dist/lib/file-lock.mjs +60 -0
  15. package/dist/lib/file-lock.mjs.map +1 -0
  16. package/dist/lib/python-command.mjs +11 -1
  17. package/dist/lib/python-command.mjs.map +1 -1
  18. package/dist/lib/skill-catalog.mjs +1 -0
  19. package/dist/lib/skill-catalog.mjs.map +1 -1
  20. package/dist/lib/types.d.mts +1 -1
  21. package/docs/architecture.md +2 -2
  22. package/docs/cli.md +36 -14
  23. package/docs/project-structure-overview.md +6 -3
  24. package/package.json +1 -1
  25. package/runtime/BUILD.json +2 -2
  26. package/runtime/bin/lib/okstra/cli.sh +9 -2
  27. package/runtime/bin/lib/okstra/globals.sh +2 -0
  28. package/runtime/bin/lib/okstra/interactive.sh +8 -2
  29. package/runtime/bin/lib/okstra/usage.sh +3 -1
  30. package/runtime/bin/okstra.sh +7 -1
  31. package/runtime/prompts/duties/discussion-worker.json +32 -0
  32. package/runtime/prompts/lead/convergence.md +58 -136
  33. package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
  34. package/runtime/prompts/profiles/_common-contract.md +1 -1
  35. package/runtime/prompts/wizard/prompts.ko.json +14 -1
  36. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
  37. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
  38. package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
  39. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
  40. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
  41. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
  42. package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
  43. package/runtime/python/okstra_ctl/consumers.py +8 -2
  44. package/runtime/python/okstra_ctl/convergence.py +143 -29
  45. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
  46. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
  47. package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
  48. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
  49. package/runtime/python/okstra_ctl/convergence_store.py +11 -4
  50. package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
  51. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  52. package/runtime/python/okstra_ctl/manager_cli.py +27 -1
  53. package/runtime/python/okstra_ctl/manager_launch.py +44 -4
  54. package/runtime/python/okstra_ctl/manager_sync.py +14 -1
  55. package/runtime/python/okstra_ctl/manager_view.py +53 -1
  56. package/runtime/python/okstra_ctl/next_phase.py +1 -0
  57. package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
  58. package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
  59. package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
  60. package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
  61. package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
  62. package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
  63. package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
  64. package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
  65. package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
  66. package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
  67. package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
  68. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
  69. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
  70. package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
  71. package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
  72. package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
  73. package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
  74. package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
  75. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
  76. package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
  77. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
  78. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
  79. package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
  80. package/runtime/python/okstra_ctl/render.py +7 -20
  81. package/runtime/python/okstra_ctl/render_final_report.py +2 -1
  82. package/runtime/python/okstra_ctl/report_assembly.py +22 -0
  83. package/runtime/python/okstra_ctl/report_contract.py +2 -0
  84. package/runtime/python/okstra_ctl/report_narrative.py +13 -4
  85. package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
  86. package/runtime/python/okstra_ctl/run.py +40 -7
  87. package/runtime/python/okstra_ctl/stage_close.py +46 -11
  88. package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
  89. package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
  90. package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
  91. package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
  92. package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
  93. package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
  94. package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
  95. package/runtime/python/okstra_ctl/wizard/render.py +4 -1
  96. package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
  97. package/runtime/python/okstra_ctl/wizard/state.py +11 -1
  98. package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
  99. package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
  100. package/runtime/python/okstra_token_usage/pricing.py +2 -0
  101. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  102. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  103. package/runtime/schemas/final-report-v3.0.schema.json +90 -1
  104. package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
  105. package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
  106. package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
  107. package/runtime/skills/_fragments/wizard-preflight.md +75 -0
  108. package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
  109. package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
  110. package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
  111. package/runtime/skills/okstra-discussion/SKILL.md +347 -0
  112. package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
  113. package/runtime/skills/okstra-run/SKILL.md +22 -2
  114. package/runtime/templates/manager/view.template.html +3 -0
  115. package/runtime/templates/reports/html/i18n/en.json +5 -0
  116. package/runtime/templates/reports/html/i18n/ko.json +5 -0
  117. package/runtime/validators/validate-brief.py +3 -2
  118. package/runtime/validators/validate-run.py +4 -6
@@ -0,0 +1,1143 @@
1
+ """task 단위 토론 기록 — append-only 입력 기록과 초안 읽기 모델.
2
+
3
+ `<task-root>/discussion/` 아래:
4
+
5
+ - `records.jsonl` — 입력 기록(시작 입력·확인된 대상과 질문·질문·답변·결정, 조사 주기·
6
+ 워커 결과·실패·참여자 변경·주장·주기 종료·새 근거와 대안). 디렉터리 잠금 아래 다른
7
+ 무엇보다 먼저 append 한다.
8
+ - `draft.json` — 기록에서 다시 만든 읽기 모델. 만든 기록의 digest 를 실어, 기록보다
9
+ 뒤처지거나 없으면 다음 읽기에서 다시 만든다.
10
+ - `revisions/revision-NNN.json` — 사용자 완료 확인마다 한 번 쓰는 불변 확정본. 토론 결과의
11
+ 정본이고, run 보고서는 확정본 id 와 내용 digest 만 참조한다.
12
+ - `links.jsonl` — 후속 작업 연결과 확정본 적용 기록(`followup.py`). 후속 작업이 쓰는 확정본의
13
+ 정본은 그 작업의 브리프이고, 이 목록은 대상 위치를 찾는 조회용이다.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ import os
20
+ from contextlib import contextmanager
21
+ from dataclasses import dataclass, field
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any, Iterator, Optional
25
+
26
+ from okstra_project.slug import slugify
27
+
28
+ from okstra_ctl.json_boundary import (
29
+ JsonBoundaryError,
30
+ load_owned_object,
31
+ write_owned_object_atomic,
32
+ )
33
+ from okstra_ctl.paths import RunRef, task_dir
34
+ from okstra_ctl.run_context import dir_flock
35
+
36
+ SCHEMA_VERSION = "1.0"
37
+ # 읽기 모델의 모양이 바뀌면 올린다 — 기록 digest 가 같아도 옛 모양의 draft 는 다시 만든다.
38
+ DRAFT_SCHEMA_VERSION = "1.3"
39
+ REVISION_SCHEMA_VERSION = "1.1"
40
+ STORE_DIRNAME = "discussion"
41
+ RECORDS_FILENAME = "records.jsonl"
42
+ DRAFT_JSON_FILENAME = "draft.json"
43
+ INPUT_FILENAME = "input.md"
44
+ REVISIONS_DIRNAME = "revisions"
45
+ LINKS_FILENAME = "links.jsonl"
46
+ _LOCK_FILENAME = ".discussion.lock"
47
+
48
+ USER = "user"
49
+ LEADER = "leader"
50
+ _ID_PREFIX = {
51
+ "start": "I",
52
+ "subject": "S",
53
+ "question": "Q",
54
+ "answer": "A",
55
+ "decision": "D",
56
+ "cycle": "R",
57
+ "result": "W",
58
+ "failure": "X",
59
+ "recovery": "P",
60
+ "claim": "K",
61
+ "close": "E",
62
+ "material": "M",
63
+ "requirement": "N",
64
+ "scope": "B",
65
+ "confirmation": "F",
66
+ "publication": "V",
67
+ "reopen": "O",
68
+ "withdrawal": "U",
69
+ }
70
+ # 중복 판정 키에 넣는 필드. 출처 run 은 빼서 재개한 run 의 재전송도 같은 기록으로 본다.
71
+ # 조사 주기만 run 을 넣는다 — 교체 run 이 같은 입력으로 다시 연 주기는 새 주기다.
72
+ _IDENTITY_FIELDS = {
73
+ "start": ("startKind", "text"),
74
+ "subject": ("subject", "question"),
75
+ "question": ("to", "text"),
76
+ "answer": ("questionId", "text"),
77
+ "decision": ("questionId", "text"),
78
+ "cycle": ("inputRefs", "sourceRun"),
79
+ "result": ("cycleId", "dispatch"),
80
+ "failure": ("cycleId", "questionId", "dispatch"),
81
+ "recovery": ("failureId", "choice"),
82
+ "claim": ("cycleId", "findingId"),
83
+ "close": ("cycleId",),
84
+ "material": ("materialKind", "text"),
85
+ "requirement": ("appliesTo", "text", "draftGeneration"),
86
+ "scope": ("scopeKind", "text", "draftGeneration"),
87
+ "confirmation": ("draftVersion", "direction", "riskAcknowledgement"),
88
+ "publication": ("confirmationId",),
89
+ "reopen": ("revisionId", "reason"),
90
+ "withdrawal": ("itemId", "reason", "draftGeneration"),
91
+ }
92
+ # 비어 있어도 되는 문자열 필드 — 질문에 묶이지 않은 결정, 주기 밖 직접 질문의 실패.
93
+ _OPTIONAL_FIELDS = frozenset({"questionId", "cycleId"})
94
+ # 다음 조사 입력의 참조 집합이 되는 기록 — 시작 입력·대상·답변·새 근거와 대안. 사용자 결정은
95
+ # 방향 선택이라 넣지 않는다: 넣으면 방향을 고른 직후 같은 근거로 조사가 다시 열린다.
96
+ # 주기의 산출물(결과·주장)은 넣지 않는다: 그것을 넣으면 같은 주장의 반복도 늘 새 입력이 된다.
97
+ INPUT_REF_KINDS = ("start", "subject", "answer", "material")
98
+ SCOPE_KINDS = ("in-scope", "excluded")
99
+ ITEM_KINDS = ("requirement", "scope")
100
+ COMMON = "common"
101
+ # 확정본 발행 뒤에 받는 기록. 나머지는 재토론(`reopen`)이 새 초안을 연 뒤에만 받는다.
102
+ _AFTER_COMPLETION_KINDS = frozenset({"reopen", "publication"})
103
+
104
+
105
+ class DiscussionStoreError(Exception):
106
+ pass
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class SourceRun:
111
+ manifest: str
112
+ participants: frozenset[str]
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class DiscussionStore:
117
+ project_root: Path
118
+ task_key: str
119
+
120
+ @property
121
+ def task_root(self) -> Path:
122
+ _, task_group, task_id = _split_task_key(self.task_key)
123
+ return task_dir(self.project_root, task_group, task_id)
124
+
125
+ @property
126
+ def dir(self) -> Path:
127
+ return self.task_root / STORE_DIRNAME
128
+
129
+ @property
130
+ def records_path(self) -> Path:
131
+ return self.dir / RECORDS_FILENAME
132
+
133
+ @property
134
+ def draft_json_path(self) -> Path:
135
+ return self.dir / DRAFT_JSON_FILENAME
136
+
137
+ @property
138
+ def input_path(self) -> Path:
139
+ return self.dir / INPUT_FILENAME
140
+
141
+ @property
142
+ def revisions_dir(self) -> Path:
143
+ return self.dir / REVISIONS_DIRNAME
144
+
145
+ @property
146
+ def links_path(self) -> Path:
147
+ return self.dir / LINKS_FILENAME
148
+
149
+ def revision_path(self, sequence: int) -> Path:
150
+ return self.revisions_dir / f"revision-{sequence:03d}.json"
151
+
152
+
153
+ def _split_task_key(task_key: str) -> tuple[str, str, str]:
154
+ parts = (task_key or "").split(":")
155
+ if len(parts) != 3 or not all(part.strip() for part in parts):
156
+ raise DiscussionStoreError(
157
+ f"invalid task key {task_key!r} (expected project-id:task-group:task-id)"
158
+ )
159
+ return parts[0], parts[1], parts[2]
160
+
161
+
162
+ def same_task(left: str, right: str) -> bool:
163
+ """task-group·task-id 는 경로 세그먼트(slug)로 비교한다 — task 루트가 같으면 같은 task 다."""
164
+ try:
165
+ a = _split_task_key(left)
166
+ b = _split_task_key(right)
167
+ except DiscussionStoreError:
168
+ return False
169
+ return a[0] == b[0] and slugify(a[1]) == slugify(b[1]) and slugify(a[2]) == slugify(b[2])
170
+
171
+
172
+ def _now_iso() -> str:
173
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
174
+
175
+
176
+ def _project_relative(store: DiscussionStore, path: Path) -> str:
177
+ return path.resolve().relative_to(store.project_root.resolve()).as_posix()
178
+
179
+
180
+ def resolve_source_run(store: DiscussionStore, manifest_path: str) -> SourceRun:
181
+ if not manifest_path.strip():
182
+ raise DiscussionStoreError("--run-manifest is required: name the discussion run manifest")
183
+ path = Path(manifest_path)
184
+ if not path.is_absolute():
185
+ path = store.project_root / path
186
+ manifests_dir = RunRef.from_task_root(store.task_root, "discussion").run_dir / "manifests"
187
+ try:
188
+ path.resolve().relative_to(manifests_dir.resolve())
189
+ except ValueError as exc:
190
+ raise DiscussionStoreError(
191
+ f"run manifest {manifest_path} is not a discussion run of {store.task_key}; "
192
+ f"records of another task are refused (expected under {manifests_dir})"
193
+ ) from exc
194
+ try:
195
+ manifest = load_owned_object(path, artifact="discussion run manifest")
196
+ except JsonBoundaryError as exc:
197
+ raise DiscussionStoreError(str(exc)) from exc
198
+ if not same_task(str(manifest.get("taskKey", "")), store.task_key):
199
+ raise DiscussionStoreError(
200
+ f"run manifest {manifest_path} belongs to {manifest.get('taskKey')!r}, "
201
+ f"not {store.task_key}; records of another task are refused"
202
+ )
203
+ if manifest.get("taskType") != "discussion":
204
+ raise DiscussionStoreError(
205
+ f"run manifest {manifest_path} is a {manifest.get('taskType')!r} run, not a discussion run"
206
+ )
207
+ workers = {
208
+ str(row["workerId"])
209
+ for row in manifest.get("workerAssignments") or []
210
+ if isinstance(row, dict) and row.get("workerId")
211
+ }
212
+ return SourceRun(
213
+ manifest=_project_relative(store, path),
214
+ participants=frozenset({LEADER, *workers}),
215
+ )
216
+
217
+
218
+ def _complete_lines(raw: bytes) -> bytes:
219
+ """끝 개행이 없는 마지막 조각은 중단된 append 다 — 확정된 기록이 아니다."""
220
+ end = raw.rfind(b"\n")
221
+ return raw[: end + 1] if end >= 0 else b""
222
+
223
+
224
+ def _read_records(store: DiscussionStore) -> tuple[list[dict[str, Any]], str]:
225
+ path = store.records_path
226
+ raw = path.read_bytes() if path.is_file() else b""
227
+ complete = _complete_lines(raw)
228
+ rows: list[dict[str, Any]] = []
229
+ for number, line in enumerate(complete.decode("utf-8").splitlines(), start=1):
230
+ if not line.strip():
231
+ continue
232
+ try:
233
+ row = json.loads(line)
234
+ except json.JSONDecodeError as exc:
235
+ raise DiscussionStoreError(
236
+ f"{path}:{number} is not a JSON record ({exc.msg}); the discussion record is damaged"
237
+ ) from exc
238
+ if not isinstance(row, dict) or row.get("kind") not in _ID_PREFIX:
239
+ raise DiscussionStoreError(f"{path}:{number} is not a discussion record")
240
+ if not same_task(str(row.get("taskKey", "")), store.task_key):
241
+ raise DiscussionStoreError(
242
+ f"{path}:{number} belongs to {row.get('taskKey')!r}, not {store.task_key}"
243
+ )
244
+ rows.append(row)
245
+ return rows, "sha256:" + hashlib.sha256(complete).hexdigest()
246
+
247
+
248
+ def _drop_interrupted_tail(store: DiscussionStore) -> None:
249
+ drop_incomplete_line(store.records_path)
250
+
251
+
252
+ def drop_incomplete_line(path: Path) -> None:
253
+ if not path.is_file():
254
+ return
255
+ raw = path.read_bytes()
256
+ complete = _complete_lines(raw)
257
+ if len(complete) != len(raw):
258
+ with path.open("r+b") as handle:
259
+ handle.truncate(len(complete))
260
+ handle.flush()
261
+ os.fsync(handle.fileno())
262
+
263
+
264
+ def _identity_key(kind: str, author: str, fields: dict[str, Any]) -> str:
265
+ identity = {"kind": kind, "author": author}
266
+ identity.update({name: fields.get(name, "") for name in _IDENTITY_FIELDS[kind]})
267
+ encoded = json.dumps(identity, ensure_ascii=False, sort_keys=True).encode("utf-8")
268
+ return "sha256:" + hashlib.sha256(encoded).hexdigest()
269
+
270
+
271
+ def _check_record(
272
+ kind: str, author: str, fields: dict[str, Any], rows: list[dict[str, Any]],
273
+ source_run: SourceRun,
274
+ ) -> None:
275
+ by_id = {row["recordId"]: row for row in rows}
276
+ completed = completed_publication(rows)
277
+ if completed is not None and kind not in _AFTER_COMPLETION_KINDS:
278
+ raise DiscussionStoreError(
279
+ f"the discussion is completed as {completed['revisionId']}; open a new draft with "
280
+ f"`okstra discussion reopen --revision {completed['revisionId']} --reason \"<why it changes>\"` "
281
+ "before recording more"
282
+ )
283
+ if kind == "reopen":
284
+ if completed is None or fields["revisionId"] != completed["revisionId"]:
285
+ current = completed["revisionId"] if completed else "none (the discussion is a draft)"
286
+ raise DiscussionStoreError(
287
+ f"reopen names {fields['revisionId']}, but the latest completed revision is {current}"
288
+ )
289
+ return
290
+ if kind == "withdrawal":
291
+ items = draft_items(rows)
292
+ withdrawn = next((row for row in items["withdrawn"] if row["recordId"] == fields["itemId"]), None)
293
+ if withdrawn is not None:
294
+ raise DiscussionStoreError(
295
+ f"{fields['itemId']} is already withdrawn from the current draft "
296
+ f"({withdrawn['withdrawal']['recordId']})"
297
+ )
298
+ if fields["itemId"] not in {row["recordId"] for row in items["effective"]}:
299
+ raise DiscussionStoreError(
300
+ f"{fields['itemId']} is not a requirement or scope item of the current draft"
301
+ )
302
+ return
303
+ if kind in ITEM_KINDS and author != USER and author not in source_run.participants:
304
+ raise DiscussionStoreError(
305
+ f"author {author!r} is neither the user nor a participant of {source_run.manifest}"
306
+ )
307
+ if kind == "scope" and fields["scopeKind"] not in SCOPE_KINDS:
308
+ raise DiscussionStoreError(f"--kind must be one of {', '.join(SCOPE_KINDS)}")
309
+ for name, expected in (("cycleId", "cycle"), ("failureId", "failure")):
310
+ ref = fields.get(name)
311
+ if ref and (by_id.get(ref) or {}).get("kind") != expected:
312
+ raise DiscussionStoreError(f"no {expected} {ref} in this discussion")
313
+ if kind == "recovery":
314
+ earlier = next(
315
+ (row for row in rows if row["kind"] == "recovery" and row["failureId"] == fields["failureId"]),
316
+ None,
317
+ )
318
+ if earlier is not None:
319
+ raise DiscussionStoreError(
320
+ f"{fields['failureId']} already has the user's choice {earlier['choice']!r} "
321
+ f"({earlier['recordId']}); a later failure gets its own choice"
322
+ )
323
+ return
324
+ if kind == "close":
325
+ earlier = next(
326
+ (row for row in rows if row["kind"] == "close" and row["cycleId"] == fields["cycleId"]),
327
+ None,
328
+ )
329
+ if earlier is not None:
330
+ raise DiscussionStoreError(
331
+ f"{fields['cycleId']} is already closed ({earlier['recordId']})"
332
+ )
333
+ return
334
+ if kind == "material" and author != USER and author not in source_run.participants:
335
+ raise DiscussionStoreError(
336
+ f"author {author!r} is neither the user nor a participant of {source_run.manifest}"
337
+ )
338
+ if kind == "question":
339
+ if author == USER:
340
+ if fields["to"] not in source_run.participants - {LEADER}:
341
+ raise DiscussionStoreError(
342
+ f"{fields['to']!r} is not a participant of {source_run.manifest}; "
343
+ f"address one of: {', '.join(sorted(source_run.participants - {LEADER}))}"
344
+ )
345
+ elif author not in source_run.participants:
346
+ raise DiscussionStoreError(
347
+ f"author {author!r} is not a participant of {source_run.manifest}"
348
+ )
349
+ return
350
+ if kind == "answer" and not fields.get("questionId"):
351
+ raise DiscussionStoreError("an answer names the question it answers (--question)")
352
+ if kind in ("answer", "decision") and fields.get("questionId"):
353
+ question = by_id.get(fields["questionId"])
354
+ if question is None or question["kind"] != "question":
355
+ raise DiscussionStoreError(f"no question {fields['questionId']} in this discussion")
356
+ if kind == "answer":
357
+ if author != question["to"]:
358
+ raise DiscussionStoreError(
359
+ f"{fields['questionId']} is addressed to {question['to']}; "
360
+ f"an answer by {author} is refused"
361
+ )
362
+ earlier = next(
363
+ (row for row in rows
364
+ if row["kind"] == "answer" and row["questionId"] == fields["questionId"]),
365
+ None,
366
+ )
367
+ if earlier is not None:
368
+ raise DiscussionStoreError(
369
+ f"{fields['questionId']} is already answered ({earlier['recordId']}); "
370
+ "record a decision or ask a follow-up question instead"
371
+ )
372
+
373
+
374
+ @dataclass
375
+ class Transaction:
376
+ """잠금 하나 안에서 읽은 기록과, 그 위에 이어 쓰는 append."""
377
+
378
+ store: DiscussionStore
379
+ rows: list[dict[str, Any]] = field(default_factory=list)
380
+
381
+ def append(
382
+ self, kind: str, fields: dict[str, Any], *, author: str, source_run: SourceRun,
383
+ ) -> tuple[dict[str, Any], bool]:
384
+ if kind not in _ID_PREFIX:
385
+ raise DiscussionStoreError(f"unknown discussion record kind: {kind}")
386
+ for name, value in fields.items():
387
+ if isinstance(value, str) and name not in _OPTIONAL_FIELDS and not value.strip():
388
+ raise DiscussionStoreError(f"--{name} must not be empty")
389
+ # 재토론 초안은 같은 요구사항을 다시 적어도 새 기록이다 — 앞 확정본의 기록으로 합치지 않는다.
390
+ generation = next((row["recordId"] for row in reversed(self.rows) if row["kind"] == "reopen"), "")
391
+ key = _identity_key(
392
+ kind, author, {**fields, "sourceRun": source_run.manifest, "draftGeneration": generation},
393
+ )
394
+ existing = next((row for row in reversed(self.rows) if row.get("idempotencyKey") == key), None)
395
+ if kind in ITEM_KINDS:
396
+ # 철회한 항목을 다시 적으면 새 기록이다. 이월된 같은 항목은 그 기록을 그대로 돌려준다.
397
+ effective = draft_items(self.rows)["effective"]
398
+ if existing is not None and existing["recordId"] not in {row["recordId"] for row in effective}:
399
+ existing = None
400
+ existing = existing or next(
401
+ (row for row in effective
402
+ if row["kind"] == kind and all(row.get(name) == fields.get(name) for name in fields)),
403
+ None,
404
+ )
405
+ if existing is not None:
406
+ return existing, False
407
+ _check_record(kind, author, fields, self.rows, source_run)
408
+ sequence = 1 + sum(1 for row in self.rows if row["kind"] == kind)
409
+ record = {
410
+ "schemaVersion": SCHEMA_VERSION,
411
+ "taskKey": self.store.task_key,
412
+ "recordId": f"{_ID_PREFIX[kind]}-{sequence:04d}",
413
+ "kind": kind,
414
+ "author": author,
415
+ **fields,
416
+ "sourceRun": source_run.manifest,
417
+ "idempotencyKey": key,
418
+ "recordedAt": _now_iso(),
419
+ }
420
+ line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
421
+ with self.store.records_path.open("ab") as handle:
422
+ handle.write(line.encode("utf-8"))
423
+ handle.flush()
424
+ os.fsync(handle.fileno())
425
+ self.rows.append(record)
426
+ return record, True
427
+
428
+ def draft(self) -> dict[str, Any]:
429
+ _, digest = _read_records(self.store)
430
+ return _project(self.rows, self.store.task_key, digest)
431
+
432
+
433
+ @contextmanager
434
+ def transaction(store: DiscussionStore) -> Iterator[Transaction]:
435
+ """여러 기록을 한 판정으로 쓸 때 — 판정에 쓴 기록과 append 사이에 다른 쓰기가 끼지 않는다."""
436
+ with dir_flock(store.dir, _LOCK_FILENAME):
437
+ _drop_interrupted_tail(store)
438
+ rows, _ = _read_records(store)
439
+ txn = Transaction(store, rows)
440
+ try:
441
+ yield txn
442
+ finally:
443
+ _rebuild_locked(store)
444
+
445
+
446
+ def append_record(
447
+ store: DiscussionStore,
448
+ kind: str,
449
+ fields: dict[str, Any],
450
+ *,
451
+ author: str,
452
+ source_run: SourceRun,
453
+ ) -> tuple[dict[str, Any], bool]:
454
+ with transaction(store) as txn:
455
+ return txn.append(kind, fields, author=author, source_run=source_run)
456
+
457
+
458
+ _SUPPORTED = ("full-consensus", "partial-consensus")
459
+ _DISPUTED = ("contested", "worker-unique")
460
+
461
+
462
+ def _claim_standing(classification: Any) -> str:
463
+ if classification in _SUPPORTED:
464
+ return "supported"
465
+ if classification in _DISPUTED:
466
+ return "disputed"
467
+ return "unverified"
468
+
469
+
470
+ def _of(rows: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]:
471
+ return [row for row in rows if row["kind"] == kind]
472
+
473
+
474
+ def _cycles(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
475
+ recoveries = {row["failureId"]: row for row in _of(rows, "recovery")}
476
+ closes = {row["cycleId"]: row for row in _of(rows, "close")}
477
+ cycles = []
478
+ for row in _of(rows, "cycle"):
479
+ cycle_id = row["recordId"]
480
+ failures = [
481
+ {**failure, "recovery": recoveries.get(failure["recordId"])}
482
+ for failure in _of(rows, "failure") if failure["cycleId"] == cycle_id
483
+ ]
484
+ replaced = any((f["recovery"] or {}).get("choice") == "replace" for f in failures)
485
+ cycles.append({
486
+ **row,
487
+ "status": "closed" if cycle_id in closes else "superseded" if replaced else "open",
488
+ "close": closes.get(cycle_id),
489
+ "results": [r for r in _of(rows, "result") if r["cycleId"] == cycle_id],
490
+ "failures": failures,
491
+ "claims": [
492
+ {**claim, "standing": _claim_standing(claim["classification"])}
493
+ for claim in _of(rows, "claim") if claim["cycleId"] == cycle_id
494
+ ],
495
+ })
496
+ return cycles
497
+
498
+
499
+ def completed_publication(rows: list[dict[str, Any]]) -> Optional[dict[str, Any]]:
500
+ """재토론이 열리지 않은 마지막 확정본 발행 기록 — 없으면 토론은 초안이다."""
501
+ for row in reversed(rows):
502
+ if row["kind"] == "reopen":
503
+ return None
504
+ if row["kind"] == "publication":
505
+ return row
506
+ return None
507
+
508
+
509
+ def draft_items(rows: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
510
+ """현재 초안의 요구사항·범위 — 재토론이 연 확정본의 유효 항목을 이월하고, 이 초안의 철회와 추가를 접는다.
511
+
512
+ 이월 항목은 원 기록 그대로(id·작성자·run)이고 `carriedFrom` 에 그 확정본 id 를 단다. 확정본도 같은
513
+ 기록에서 만들어지므로 파일을 읽지 않고 기록만으로 같은 집합이 나온다.
514
+ """
515
+ effective: dict[str, dict[str, Any]] = {}
516
+ withdrawn: list[dict[str, Any]] = []
517
+ for row in rows:
518
+ if row["kind"] == "reopen":
519
+ effective = {key: {**item, "carriedFrom": row["revisionId"]} for key, item in effective.items()}
520
+ withdrawn = []
521
+ elif row["kind"] in ITEM_KINDS:
522
+ effective[row["recordId"]] = {**row, "carriedFrom": None}
523
+ elif row["kind"] == "withdrawal" and row["itemId"] in effective:
524
+ withdrawn.append({**effective.pop(row["itemId"]), "withdrawal": _ref(row, "reason")})
525
+ return {"effective": list(effective.values()), "withdrawn": withdrawn}
526
+
527
+
528
+ def _phase(draft: dict[str, Any]) -> str:
529
+ """토론이 지금 기다리는 것. `completed` 는 사용자 완료 확인으로 발행한 확정본에서만 나온다."""
530
+ cycles = draft["cycles"]
531
+ last = cycles[-1] if cycles else None
532
+ closed = [cycle for cycle in cycles if cycle["status"] == "closed"]
533
+ if draft["completedRevision"] is not None:
534
+ return "completed"
535
+ if draft["subject"] is None:
536
+ return "framing"
537
+ if draft["unrecoveredFailures"]:
538
+ return "awaiting-recovery"
539
+ if last is not None and last["status"] == "open":
540
+ return "researching"
541
+ if last is not None and last["status"] == "superseded":
542
+ return "awaiting-new-run"
543
+ if draft["pendingForUser"]:
544
+ return "awaiting-user"
545
+ if draft["pendingForParticipants"]:
546
+ return "awaiting-participant"
547
+ if not closed or closed[-1]["inputRefs"] != draft["inputRefs"]:
548
+ return "ready-for-research"
549
+ return "awaiting-choice"
550
+
551
+
552
+ def _dropped_questions(rows: list[dict[str, Any]]) -> set[str]:
553
+ """참여자가 답하지 못했고 사용자가 그 답 없이 계속하기로 한 질문."""
554
+ reduced = {row["failureId"] for row in _of(rows, "recovery") if row["choice"] == "reduce"}
555
+ return {
556
+ row["questionId"] for row in _of(rows, "failure")
557
+ if row.get("questionId") and row["recordId"] in reduced
558
+ }
559
+
560
+
561
+ def _project(rows: list[dict[str, Any]], task_key: str, digest: str) -> dict[str, Any]:
562
+ dropped = _dropped_questions(rows)
563
+ questions = [
564
+ {
565
+ **row,
566
+ "answers": [a for a in rows if a["kind"] == "answer" and a["questionId"] == row["recordId"]],
567
+ "dropped": row["recordId"] in dropped,
568
+ }
569
+ for row in rows if row["kind"] == "question"
570
+ ]
571
+ subjects = _of(rows, "subject")
572
+ recovered = {row["failureId"] for row in _of(rows, "recovery")}
573
+ cycles = _cycles(rows)
574
+ closed = [cycle for cycle in cycles if cycle["status"] == "closed"]
575
+ pending_for_user = [q["recordId"] for q in questions if q["to"] == USER and not q["answers"]]
576
+ draft = {
577
+ "schemaVersion": DRAFT_SCHEMA_VERSION,
578
+ "taskKey": task_key,
579
+ "recordCount": len(rows),
580
+ "recordsDigest": digest,
581
+ "startInputs": _of(rows, "start"),
582
+ "subject": subjects[-1] if subjects else None,
583
+ "decisions": _of(rows, "decision"),
584
+ "questions": questions,
585
+ "pendingForUser": pending_for_user,
586
+ "nextForUser": pending_for_user[0] if pending_for_user else None,
587
+ "pendingForParticipants": [
588
+ q["recordId"] for q in questions if q["to"] != USER and not q["answers"] and not q["dropped"]
589
+ ],
590
+ "materials": _of(rows, "material"),
591
+ "cycles": cycles,
592
+ "failures": [
593
+ {**row, "recovery": next((r for r in _of(rows, "recovery") if r["failureId"] == row["recordId"]), None)}
594
+ for row in _of(rows, "failure")
595
+ ],
596
+ "unrecoveredFailures": [
597
+ row["recordId"] for row in _of(rows, "failure") if row["recordId"] not in recovered
598
+ ],
599
+ "remainingDisagreements": [
600
+ claim["recordId"] for claim in (closed[-1]["claims"] if closed else [])
601
+ if claim["standing"] != "supported"
602
+ ],
603
+ "inputRefs": [row["recordId"] for row in rows if row["kind"] in INPUT_REF_KINDS],
604
+ }
605
+ items = draft_items(rows)
606
+ reopens = _of(rows, "reopen")
607
+ completed = completed_publication(rows)
608
+ draft.update({
609
+ "requirements": _of(items["effective"], "requirement"),
610
+ "scope": _of(items["effective"], "scope"),
611
+ "withdrawn": items["withdrawn"],
612
+ "confirmations": _of(rows, "confirmation"),
613
+ "revisions": [
614
+ {key: row[key] for key in ("recordId", "revisionId", "sequence", "revisionPath",
615
+ "contentDigest", "confirmationId", "sourceRun")}
616
+ for row in _of(rows, "publication")
617
+ ],
618
+ "completedRevision": completed["revisionId"] if completed else None,
619
+ "basedOn": reopens[-1] if reopens and completed is None else None,
620
+ })
621
+ draft["phase"] = _phase(draft)
622
+ return draft
623
+
624
+
625
+ def _rebuild_locked(store: DiscussionStore) -> dict[str, Any]:
626
+ rows, digest = _read_records(store)
627
+ draft = _project(rows, store.task_key, digest)
628
+ write_owned_object_atomic(store.draft_json_path, draft, artifact="discussion draft")
629
+ return draft
630
+
631
+
632
+ def completed_revision(store: DiscussionStore) -> Optional[str]:
633
+ """쓰지 않고 읽기만 한다 — prepare 가 어떤 쓰기보다 먼저 부른다."""
634
+ rows, _ = _read_records(store)
635
+ publication = completed_publication(rows)
636
+ return publication["revisionId"] if publication else None
637
+
638
+
639
+ def has_records(store: DiscussionStore) -> bool:
640
+ return store.records_path.is_file() and bool(_complete_lines(store.records_path.read_bytes()).strip())
641
+
642
+
643
+ def read_draft(store: DiscussionStore) -> dict[str, Any]:
644
+ _, digest = _read_records(store)
645
+ current = _load_draft(store)
646
+ if (
647
+ current is not None
648
+ and current.get("recordsDigest") == digest
649
+ and current.get("schemaVersion") == DRAFT_SCHEMA_VERSION
650
+ ):
651
+ return current
652
+ with dir_flock(store.dir, _LOCK_FILENAME):
653
+ _drop_interrupted_tail(store)
654
+ return _rebuild_locked(store)
655
+
656
+
657
+ def project_draft(store: DiscussionStore) -> dict[str, Any]:
658
+ """기록에서 읽기 모델을 만들기만 한다 — 다른 프로젝트를 읽는 조회(관리자·후속 연결)는 그 프로젝트에 쓰지 않는다."""
659
+ rows, digest = _read_records(store)
660
+ return _project(rows, store.task_key, digest)
661
+
662
+
663
+ def _load_draft(store: DiscussionStore) -> Optional[dict[str, Any]]:
664
+ if not store.draft_json_path.is_file():
665
+ return None
666
+ try:
667
+ return load_owned_object(store.draft_json_path, artifact="discussion draft")
668
+ except JsonBoundaryError:
669
+ # expected-miss: 깨진 읽기 모델은 기록에서 다시 만든다.
670
+ return None
671
+
672
+
673
+ _REVISION_SEPARATOR = "/revision-"
674
+
675
+
676
+ def revision_id(task_key: str, sequence: int) -> str:
677
+ """확정본 id — 토론 task key 와 완료 순번. run 순번과 무관하다."""
678
+ return f"{task_key}{_REVISION_SEPARATOR}{sequence:03d}"
679
+
680
+
681
+ def parse_revision_id(value: str) -> tuple[str, int]:
682
+ task_key, separator, number = (value or "").rpartition(_REVISION_SEPARATOR)
683
+ if not separator or not number.isdigit() or int(number) < 1:
684
+ raise DiscussionStoreError(
685
+ f"invalid revision id {value!r} (expected <task-key>{_REVISION_SEPARATOR}NNN)"
686
+ )
687
+ _split_task_key(task_key)
688
+ return task_key, int(number)
689
+
690
+
691
+ def _digest_bytes(data: bytes) -> str:
692
+ return "sha256:" + hashlib.sha256(data).hexdigest()
693
+
694
+
695
+ def _ref(row: dict[str, Any], *names: str) -> dict[str, Any]:
696
+ return {
697
+ "recordId": row["recordId"], "author": row["author"], "sourceRun": row["sourceRun"],
698
+ **{name: row.get(name) for name in names},
699
+ }
700
+
701
+
702
+ def _claim_view(claim: dict[str, Any]) -> dict[str, Any]:
703
+ return {
704
+ **_ref(claim, "cycleId", "findingId", "summary", "classification", "standing", "evidence",
705
+ "discoveredBy"),
706
+ "verdicts": claim["verdicts"],
707
+ }
708
+
709
+
710
+ def _unverified(draft: dict[str, Any], claims: list[dict[str, Any]]) -> list[dict[str, Any]]:
711
+ rows: list[dict[str, Any]] = [
712
+ {"recordId": claim["recordId"], "kind": "claim", "detail": claim["summary"]}
713
+ for claim in claims if claim["standing"] == "unverified"
714
+ ]
715
+ rows += [
716
+ {"recordId": failure["recordId"], "kind": "reduced-participant",
717
+ "detail": f"{failure['author']} failed ({failure['reason']}); the user continued without it"}
718
+ for failure in draft["failures"] if (failure["recovery"] or {}).get("choice") == "reduce"
719
+ ]
720
+ rows += [
721
+ {"recordId": cycle["recordId"], "kind": "cycle-not-cross-checked",
722
+ "detail": "fewer than two participants returned research, so its claims were not cross-checked"}
723
+ for cycle in draft["cycles"] if (cycle["close"] or {}).get("stopReason") == "auto-disabled"
724
+ ]
725
+ rows += [
726
+ {"recordId": question["recordId"], "kind": "dropped-question", "detail": question["text"]}
727
+ for question in draft["questions"] if question["dropped"]
728
+ ]
729
+ return rows
730
+
731
+
732
+ def _build_revision(
733
+ store: DiscussionStore, rows: list[dict[str, Any]], confirmation: dict[str, Any], sequence: int,
734
+ ) -> dict[str, Any]:
735
+ """확인 기록까지의 기록에서만 만든다 — 같은 기록이면 언제 다시 만들어도 같은 바이트다."""
736
+ upto = rows[: next(i for i, row in enumerate(rows) if row["recordId"] == confirmation["recordId"]) + 1]
737
+ draft = _project(upto, store.task_key, confirmation["draftVersion"])
738
+ claims = [claim for cycle in draft["cycles"] for claim in cycle["claims"]]
739
+ disputed = set(draft["remainingDisagreements"])
740
+ previous = None
741
+ if draft["basedOn"] is not None:
742
+ reopen = draft["basedOn"]
743
+ earlier = next(row for row in _of(upto, "publication") if row["revisionId"] == reopen["revisionId"])
744
+ previous = {
745
+ "revisionId": reopen["revisionId"],
746
+ "contentDigest": earlier["contentDigest"],
747
+ "reopenRecord": reopen["recordId"],
748
+ "changeReason": reopen["reason"],
749
+ }
750
+ failures = draft["failures"]
751
+ return {
752
+ "schemaVersion": REVISION_SCHEMA_VERSION,
753
+ "revisionId": revision_id(store.task_key, sequence),
754
+ "sequence": sequence,
755
+ "taskKey": store.task_key,
756
+ "previousRevision": previous,
757
+ "confirmation": {
758
+ **_ref(confirmation, "draftVersion", "direction", "riskAcknowledgement"),
759
+ "confirmedAt": confirmation["recordedAt"],
760
+ },
761
+ "subject": _ref(draft["subject"], "subject", "question"),
762
+ "startInputs": [_ref(row, "startKind", "text") for row in draft["startInputs"]],
763
+ "materials": [_ref(row, "materialKind", "text") for row in draft["materials"]],
764
+ "direction": confirmation["direction"],
765
+ "requirements": [_ref(row, "appliesTo", "text", "carriedFrom") for row in draft["requirements"]],
766
+ "scope": {
767
+ "inScope": [_ref(row, "text", "carriedFrom") for row in draft["scope"] if row["scopeKind"] == "in-scope"],
768
+ "excluded": [_ref(row, "text", "carriedFrom") for row in draft["scope"] if row["scopeKind"] == "excluded"],
769
+ },
770
+ "itemChanges": _item_changes(draft),
771
+ "decisions": [_ref(row, "questionId", "text") for row in draft["decisions"]],
772
+ "answeredQuestions": [
773
+ {**_ref(question, "to", "text"), "answers": [_ref(answer, "text") for answer in question["answers"]]}
774
+ for question in draft["questions"] if question["answers"]
775
+ ],
776
+ "disagreements": [_claim_view(claim) for claim in claims if claim["recordId"] in disputed],
777
+ "evidence": [_claim_view(claim) for claim in claims],
778
+ "unverified": _unverified(draft, claims),
779
+ "participants": {
780
+ "cycles": [
781
+ {"cycleId": cycle["recordId"], "run": cycle["sourceRun"], "status": cycle["status"],
782
+ "roster": cycle["roster"],
783
+ "results": [_ref(row, "resultPath") for row in cycle["results"]]}
784
+ for cycle in draft["cycles"]
785
+ ],
786
+ "failures": [
787
+ {**_ref(row, "cycleId", "questionId", "stage", "attempts", "reason"),
788
+ "recovery": _ref(row["recovery"], "choice") if row["recovery"] else None}
789
+ for row in failures
790
+ ],
791
+ "reducedRoster": any((row["recovery"] or {}).get("choice") == "reduce" for row in failures),
792
+ },
793
+ }
794
+
795
+
796
+ def _item_view(row: dict[str, Any]) -> dict[str, Any]:
797
+ detail = "appliesTo" if row["kind"] == "requirement" else "scopeKind"
798
+ return {"recordId": row["recordId"], "kind": row["kind"], detail: row[detail], "text": row["text"]}
799
+
800
+
801
+ def _item_changes(draft: dict[str, Any]) -> dict[str, Any]:
802
+ """이전 확정본에서 무엇을 이월·철회·추가했는가 — 첫 확정본은 이월이 없다."""
803
+ items = [*draft["requirements"], *draft["scope"]]
804
+ return {
805
+ "carriedFrom": draft["basedOn"]["revisionId"] if draft["basedOn"] else None,
806
+ "carried": [_item_view(row) for row in items if row["carriedFrom"]],
807
+ "added": [_item_view(row) for row in items if not row["carriedFrom"]],
808
+ "withdrawn": [
809
+ {**_item_view(row), "carriedFrom": row["carriedFrom"], "withdrawal": row["withdrawal"]}
810
+ for row in draft["withdrawn"]
811
+ ],
812
+ }
813
+
814
+
815
+ def _revision_bytes(revision: dict[str, Any]) -> bytes:
816
+ return (json.dumps(revision, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
817
+
818
+
819
+ def _write_revision(path: Path, data: bytes) -> None:
820
+ """한 번만 쓴다 — 임시 파일을 끝까지 쓴 뒤 link 로 붙여, 중단돼도 반쪽 확정본이 남지 않는다."""
821
+ if path.exists():
822
+ if path.read_bytes() != data:
823
+ raise DiscussionStoreError(
824
+ f"{path} already holds different content; a published revision is never rewritten"
825
+ )
826
+ return
827
+ path.parent.mkdir(parents=True, exist_ok=True)
828
+ temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
829
+ try:
830
+ with temp.open("wb") as handle:
831
+ handle.write(data)
832
+ handle.flush()
833
+ os.fsync(handle.fileno())
834
+ os.chmod(temp, 0o444)
835
+ os.link(temp, path)
836
+ finally:
837
+ temp.unlink(missing_ok=True)
838
+
839
+
840
+ def _publish_locked(txn: "Transaction", confirmation: dict[str, Any], source_run: SourceRun) -> dict[str, Any]:
841
+ from .validation import revision_errors
842
+
843
+ existing = next(
844
+ (row for row in _of(txn.rows, "publication") if row["confirmationId"] == confirmation["recordId"]),
845
+ None,
846
+ )
847
+ if existing is not None:
848
+ return existing
849
+ sequence = 1 + len(_of(txn.rows, "publication"))
850
+ revision = _build_revision(txn.store, txn.rows, confirmation, sequence)
851
+ errors = revision_errors(revision)
852
+ if errors:
853
+ raise DiscussionStoreError("the revision is incomplete: " + "; ".join(errors))
854
+ data = _revision_bytes(revision)
855
+ path = txn.store.revision_path(sequence)
856
+ _write_revision(path, data)
857
+ record, _ = txn.append(
858
+ "publication",
859
+ {
860
+ "confirmationId": confirmation["recordId"],
861
+ "revisionId": revision["revisionId"],
862
+ "sequence": sequence,
863
+ "revisionPath": _project_relative(txn.store, path),
864
+ "contentDigest": _digest_bytes(data),
865
+ },
866
+ author=LEADER,
867
+ source_run=source_run,
868
+ )
869
+ return record
870
+
871
+
872
+ def confirm_completion(
873
+ store: DiscussionStore,
874
+ *,
875
+ draft_version: str,
876
+ direction: str,
877
+ risk_acknowledgement: str,
878
+ source_run: SourceRun,
879
+ ) -> tuple[dict[str, Any], bool]:
880
+ """사용자 완료 확인을 그 초안 버전에 묶어 기록하고 확정본을 발행한다.
881
+
882
+ 같은 확인의 재전송은 이미 발행한 확정본을 돌려준다. 확인 뒤 기록이 하나라도 늘었으면
883
+ 그 확인은 낡은 초안의 것이라 발행하지 않는다.
884
+ """
885
+ from .validation import completion_errors
886
+
887
+ fields = {"draftVersion": draft_version, "direction": direction,
888
+ "riskAcknowledgement": risk_acknowledgement}
889
+ with transaction(store) as txn:
890
+ key = _identity_key("confirmation", USER, fields)
891
+ earlier = next((row for row in txn.rows if row.get("idempotencyKey") == key), None)
892
+ if earlier is not None:
893
+ publication = next(
894
+ (row for row in _of(txn.rows, "publication") if row["confirmationId"] == earlier["recordId"]),
895
+ None,
896
+ )
897
+ if publication is not None:
898
+ return publication, False
899
+ later = [row["recordId"] for row in txn.rows[txn.rows.index(earlier) + 1:]]
900
+ if later:
901
+ raise DiscussionStoreError(
902
+ f"{earlier['recordId']} confirmed draft version {draft_version}, but "
903
+ f"{', '.join(later)} arrived after it; show the user the current draft and confirm again"
904
+ )
905
+ return _publish_locked(txn, earlier, source_run), True
906
+ _, current = _read_records(store)
907
+ if draft_version != current:
908
+ raise DiscussionStoreError(
909
+ f"the confirmation names draft version {draft_version}, but the current draft is "
910
+ f"{current}; show the user the current draft (`okstra discussion draft`) and confirm again"
911
+ )
912
+ draft = txn.draft()
913
+ if draft["completedRevision"] is not None:
914
+ raise DiscussionStoreError(
915
+ f"the discussion is already completed as {draft['completedRevision']}; "
916
+ "reopen it to confirm a new revision"
917
+ )
918
+ errors = completion_errors(draft, direction, risk_acknowledgement)
919
+ if errors:
920
+ raise DiscussionStoreError("the draft cannot be completed yet: " + "; ".join(errors))
921
+ confirmation, _ = txn.append("confirmation", fields, author=USER, source_run=source_run)
922
+ return _publish_locked(txn, confirmation, source_run), True
923
+
924
+
925
+ def read_revision(store: DiscussionStore, value: str) -> tuple[dict[str, Any], bytes]:
926
+ """발행 기록과 파일 바이트가 맞는 확정본만 돌려준다."""
927
+ task_key, sequence = parse_revision_id(value)
928
+ if not same_task(task_key, store.task_key):
929
+ raise DiscussionStoreError(f"{value} is a revision of {task_key}, not of {store.task_key}")
930
+ rows, _ = _read_records(store)
931
+ publication = next((row for row in _of(rows, "publication") if row["sequence"] == sequence), None)
932
+ if publication is None:
933
+ raise DiscussionStoreError(f"no revision {value} is published in this discussion")
934
+ path = store.revision_path(sequence)
935
+ if not path.is_file():
936
+ raise DiscussionStoreError(f"{publication['revisionPath']} of {value} is missing")
937
+ data = path.read_bytes()
938
+ if _digest_bytes(data) != publication["contentDigest"]:
939
+ raise DiscussionStoreError(
940
+ f"{publication['revisionPath']} no longer matches the published digest {publication['contentDigest']}"
941
+ )
942
+ return publication, data
943
+
944
+
945
+ def _attribution(row: dict[str, Any]) -> str:
946
+ return f"{row['recordId']} · {row['author']} · run `{row['sourceRun']}`"
947
+
948
+
949
+ def render_draft_text(draft: dict[str, Any]) -> str:
950
+ lines = [
951
+ "# Discussion record",
952
+ "",
953
+ f"- Task: `{draft['taskKey']}`",
954
+ f"- Records: {draft['recordCount']} (`{draft['recordsDigest']}`)",
955
+ f"- Phase: {draft['phase']}",
956
+ f"- Draft version: `{draft['recordsDigest']}`",
957
+ "",
958
+ "## Confirmed subject and question",
959
+ "",
960
+ ]
961
+ subject = draft["subject"]
962
+ if subject is None:
963
+ lines.append("- Not confirmed yet. Confirm the subject and the question with the user first.")
964
+ else:
965
+ lines += [
966
+ f"- Subject: {subject['subject']}",
967
+ f"- Question: {subject['question']}",
968
+ f"- Confirmed: {_attribution(subject)}",
969
+ ]
970
+ lines += ["", "## Start inputs", ""]
971
+ lines += [
972
+ f"- [{row['startKind']}] {row['text']} ({_attribution(row)})"
973
+ for row in draft["startInputs"]
974
+ ] or ["- None."]
975
+ lines += ["", "## User decisions", ""]
976
+ lines += [
977
+ f"- {row['text']}"
978
+ + (f" (on {row['questionId']})" if row.get("questionId") else "")
979
+ + f" ({_attribution(row)})"
980
+ for row in draft["decisions"]
981
+ ] or ["- None."]
982
+ by_id = {q["recordId"]: q for q in draft["questions"]}
983
+ lines += ["", "## Questions waiting for the user", ""]
984
+ lines += [
985
+ f"- {qid} from {by_id[qid]['author']}: {by_id[qid]['text']}"
986
+ for qid in draft["pendingForUser"]
987
+ ] or ["- None."]
988
+ if draft["nextForUser"]:
989
+ lines.append(f"- Ask {draft['nextForUser']} next; put one question to the user at a time.")
990
+ lines += ["", "## Questions waiting for a participant", ""]
991
+ lines += [
992
+ f"- {qid} to {by_id[qid]['to']}: {by_id[qid]['text']}"
993
+ for qid in draft["pendingForParticipants"]
994
+ ] or ["- None."]
995
+ lines += [
996
+ f"- {q['recordId']} to {q['to']}: not answered — the participant failed and the user "
997
+ f"continued without the answer: {q['text']}"
998
+ for q in draft["questions"] if q["dropped"]
999
+ ]
1000
+ lines += ["", "## Answered questions", ""]
1001
+ answered = [q for q in draft["questions"] if q["answers"]]
1002
+ if not answered:
1003
+ lines.append("- None.")
1004
+ for question in answered:
1005
+ lines.append(f"### {question['recordId']} — {question['author']} to {question['to']}")
1006
+ lines += ["", question["text"], ""]
1007
+ for answer in question["answers"]:
1008
+ lines += [f"Answer ({_attribution(answer)}):", "", answer["text"], ""]
1009
+ lines += ["", "## New evidence and alternatives", ""]
1010
+ lines += [
1011
+ f"- [{row['materialKind']}] {row['text']} ({_attribution(row)})"
1012
+ for row in draft["materials"]
1013
+ ] or ["- None."]
1014
+ lines += _render_cycles(draft)
1015
+ lines += _render_claims(draft)
1016
+ lines += _render_completion(draft)
1017
+ return "\n".join(lines).rstrip("\n") + "\n"
1018
+
1019
+
1020
+ def _render_completion(draft: dict[str, Any]) -> list[str]:
1021
+ lines = ["", "## Requirements of this draft", ""]
1022
+ lines += [
1023
+ f"- [{row['appliesTo']}] {row['text']} ({_attribution(row)}{_carried(row)})" for row in draft["requirements"]
1024
+ ] or ["- None yet."]
1025
+ lines += ["", "## Scope of this draft", ""]
1026
+ lines += [
1027
+ f"- [{row['scopeKind']}] {row['text']} ({_attribution(row)}{_carried(row)})" for row in draft["scope"]
1028
+ ] or ["- None yet."]
1029
+ if draft["withdrawn"]:
1030
+ lines += ["", "## Withdrawn from this draft", ""]
1031
+ lines += [
1032
+ f"- {row['recordId']} [{row.get('appliesTo') or row.get('scopeKind')}] {row['text']} — withdrawn by "
1033
+ f"{row['withdrawal']['author']} in {row['withdrawal']['recordId']}: {row['withdrawal']['reason']}"
1034
+ for row in draft["withdrawn"]
1035
+ ]
1036
+ if draft["basedOn"]:
1037
+ lines += [
1038
+ "",
1039
+ "Items carried from the reopened revision stay in this draft until "
1040
+ "`okstra discussion withdraw --item <id> --reason \"<why>\"` removes them; "
1041
+ "replace an item by withdrawing it and adding the new one.",
1042
+ ]
1043
+ lines += ["", "## Revisions", ""]
1044
+ lines += [
1045
+ f"- {row['revisionId']} (`{row['contentDigest']}`) confirmed by the user in {row['confirmationId']}"
1046
+ for row in draft["revisions"]
1047
+ ] or ["- None. The discussion completes only when the user confirms completion."]
1048
+ if draft["completedRevision"]:
1049
+ lines.append(f"- Completed as {draft['completedRevision']}; reopen it to change the outcome.")
1050
+ if draft["basedOn"]:
1051
+ lines.append(
1052
+ f"- This draft reopens {draft['basedOn']['revisionId']} ({_attribution(draft['basedOn'])}): "
1053
+ f"{draft['basedOn']['reason']}"
1054
+ )
1055
+ return lines
1056
+
1057
+
1058
+ def _carried(row: dict[str, Any]) -> str:
1059
+ return f" · carried from {row['carriedFrom']}" if row.get("carriedFrom") else ""
1060
+
1061
+
1062
+ def _render_cycles(draft: dict[str, Any]) -> list[str]:
1063
+ lines = ["", "## Research cycles", ""]
1064
+ if not draft["cycles"]:
1065
+ return lines + ["- None yet."]
1066
+ for cycle in draft["cycles"]:
1067
+ roster = ", ".join(f"{row['worker']} ({row['model']})" for row in cycle["roster"]) or "none"
1068
+ lines += [
1069
+ f"### {cycle['recordId']} — {cycle['status']} (run `{cycle['sourceRun']}`)",
1070
+ "",
1071
+ f"- Participants: {roster}",
1072
+ f"- Input references: {', '.join(cycle['inputRefs']) or 'none'}",
1073
+ ]
1074
+ close = cycle["close"]
1075
+ if close is not None:
1076
+ lines.append(
1077
+ f"- Verification cycle ended: convergence `{close['finalState']}` "
1078
+ f"({close['stopReason']}) recorded in {close['recordId']}"
1079
+ )
1080
+ if close["stopReason"] == "auto-disabled":
1081
+ lines.append(
1082
+ "- The claims of this cycle were not cross-checked: fewer than two "
1083
+ "participants returned research"
1084
+ )
1085
+ lines += [f"- Result {_attribution(row)}: `{row['resultPath']}`" for row in cycle["results"]]
1086
+ for failure in cycle["failures"]:
1087
+ recovery = failure["recovery"]
1088
+ choice = (
1089
+ f"user's choice: {recovery['choice']} ({recovery['recordId']})"
1090
+ if recovery else "waiting for the user's choice"
1091
+ )
1092
+ lines.append(
1093
+ f"- Failure {_attribution(failure)}: {failure['stage']} after "
1094
+ f"{failure['attempts']} attempt(s) — {failure['reason']}; {choice}"
1095
+ )
1096
+ lines.append("")
1097
+ return lines
1098
+
1099
+
1100
+ def _render_claims(draft: dict[str, Any]) -> list[str]:
1101
+ lines = [
1102
+ "",
1103
+ "## Evidence claims",
1104
+ "",
1105
+ "Fact checks of claims about the code or external sources. They never settle the "
1106
+ "user's intent: the user decisions above stand until the user changes them.",
1107
+ "",
1108
+ ]
1109
+ claims = [claim for cycle in draft["cycles"] for claim in cycle["claims"]]
1110
+ if not claims:
1111
+ return lines + ["- None yet."]
1112
+ for claim in claims:
1113
+ lines += [
1114
+ f"### {claim['recordId']} — {claim['standing']} ({claim['classification']}), "
1115
+ f"{claim['findingId']} of {claim['cycleId']}",
1116
+ "",
1117
+ claim["summary"],
1118
+ "",
1119
+ f"- Claimed by: {', '.join(claim['discoveredBy'])} (origin {claim['author']}); "
1120
+ f"evidence: {claim['evidence']}",
1121
+ ]
1122
+ lines += [
1123
+ f"- Round {vote['round']} · {vote['worker']}: {vote['verdict']}"
1124
+ + (f" ({vote['disagreeBasis']})" if vote.get("disagreeBasis") else "")
1125
+ + f" — {vote['explanation']}"
1126
+ for vote in claim["verdicts"]
1127
+ ] or ["- No independent participant checked this claim."]
1128
+ lines.append("")
1129
+ return lines
1130
+
1131
+
1132
+ def render_full_record(store: DiscussionStore) -> str:
1133
+ """토론 기록 전문 — 초안 읽기 모델에 각 참여자의 조사 결과 본문을 붙인다."""
1134
+ draft = read_draft(store)
1135
+ lines = [render_draft_text(draft).rstrip("\n"), "", "## Research result texts", ""]
1136
+ results = [(cycle, row) for cycle in draft["cycles"] for row in cycle["results"]]
1137
+ if not results:
1138
+ lines.append("- None yet.")
1139
+ for cycle, row in results:
1140
+ path = store.project_root / row["resultPath"]
1141
+ body = path.read_text(encoding="utf-8") if path.is_file() else f"(missing: `{row['resultPath']}`)\n"
1142
+ lines += [f"### {row['recordId']} — {row['author']} in {cycle['recordId']}", "", body.rstrip("\n"), ""]
1143
+ return "\n".join(lines).rstrip("\n") + "\n"