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,667 @@
1
+ """토론의 조사·반박·질문 순환 — 주기 열기, 결과 정산, 주기 종료, 직접 질문, 실패 복구.
2
+
3
+ 워커 실행은 공통 배정·디스패치 경로(`agent-prompt materialize` 다음 `worker-dispatch`
4
+ 또는 `team dispatch`)가, 근거 판정은 수렴 엔진(`convergence_engine`)이 맡는다. 여기는 그 앞뒤에서 토론 기록을 읽고 쓰는 일만 한다.
5
+
6
+ 조사 주기 하나는 run 하나다. 수렴 상태·그룹 경로가 run 마다 하나로 고정되어 있어
7
+ (`paths.py` 의 `convergence-<task-type>-<seq>.json`) 한 run 안에서 두 번째 수렴을
8
+ 열 자리가 없다. 다음 주기와 참여자 교체는 `--discussion-start <task-key>` 로 준비한
9
+ 이어가기 run 에서 연다.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import hashlib
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Mapping
17
+
18
+ from okstra_ctl.convergence_engine import validate_final_state
19
+ from okstra_ctl.dispatch_core import CLI_BATCH_LAUNCH_FAILURE
20
+ from okstra_ctl.dispatch_state import dispatch_worker_key, worker_dispatch_records
21
+ from okstra_ctl.execution_manifest import read_execution_manifest
22
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
23
+ from okstra_ctl.report_finalize import run_seq
24
+ from okstra_ctl.worker_prompt_policy import (
25
+ ANALYSIS_DUTY_BY_TASK_TYPE,
26
+ DISCUSSION_QUESTION_DISPATCH_KIND,
27
+ )
28
+
29
+ from .store import (
30
+ LEADER,
31
+ USER,
32
+ DiscussionStore,
33
+ DiscussionStoreError,
34
+ SourceRun,
35
+ read_draft,
36
+ render_draft_text,
37
+ resolve_source_run,
38
+ same_task,
39
+ transaction,
40
+ )
41
+
42
+ RESEARCH_DISPATCH_KIND = "initial"
43
+ CHOICES = ("retry", "replace", "reduce")
44
+ _QUESTION_CHOICES = ("retry", "reduce")
45
+ _DUTY = ANALYSIS_DUTY_BY_TASK_TYPE["discussion"]
46
+
47
+
48
+ class DiscussionWorkflowError(DiscussionStoreError):
49
+ pass
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class DiscussionRun:
54
+ source: SourceRun
55
+ project_root: Path
56
+ manifest: Mapping[str, Any]
57
+ seq: str
58
+
59
+ @property
60
+ def run_dir(self) -> Path:
61
+ return self.project_root / str(self.manifest["runDirectoryPath"])
62
+
63
+ @property
64
+ def analysers(self) -> tuple[dict[str, str], ...]:
65
+ return tuple(
66
+ {"worker": str(row["workerId"]), "model": f"{row['provider']}/{row['model']}"}
67
+ for row in self.manifest.get("workerAssignments") or []
68
+ if isinstance(row, dict) and row.get("role") == "analyser"
69
+ )
70
+
71
+ def project_path(self, value: str) -> Path:
72
+ path = Path(value)
73
+ return path if path.is_absolute() else self.project_root / path
74
+
75
+ def relative(self, path: Path) -> str:
76
+ try:
77
+ return path.resolve().relative_to(self.project_root.resolve()).as_posix()
78
+ except ValueError:
79
+ # expected-miss: 프로젝트 밖 경로는 받은 그대로 기록한다.
80
+ return str(path)
81
+
82
+
83
+ def load_run(store: DiscussionStore, manifest_path: str) -> DiscussionRun:
84
+ source = resolve_source_run(store, manifest_path)
85
+ path = store.project_root / source.manifest
86
+ try:
87
+ manifest = load_owned_object(path, artifact="discussion run manifest")
88
+ except JsonBoundaryError as exc:
89
+ raise DiscussionWorkflowError(str(exc)) from exc
90
+ return DiscussionRun(source, store.project_root, manifest, run_seq(manifest))
91
+
92
+
93
+ def _digest(data: bytes) -> str:
94
+ return "sha256:" + hashlib.sha256(data).hexdigest()
95
+
96
+
97
+ def _cycle_of_run(draft: Mapping[str, Any], run: DiscussionRun) -> dict[str, Any] | None:
98
+ mine = [cycle for cycle in draft["cycles"] if cycle["sourceRun"] == run.source.manifest]
99
+ return mine[-1] if mine else None
100
+
101
+
102
+ def _last_closed(draft: Mapping[str, Any]) -> dict[str, Any] | None:
103
+ closed = [cycle for cycle in draft["cycles"] if cycle["status"] == "closed"]
104
+ return closed[-1] if closed else None
105
+
106
+
107
+ def _claims(draft: Mapping[str, Any], ids: list[str]) -> list[dict[str, Any]]:
108
+ by_id = {claim["recordId"]: claim for cycle in draft["cycles"] for claim in cycle["claims"]}
109
+ return [
110
+ {key: by_id[claim_id][key] for key in ("recordId", "standing", "classification", "summary", "author")}
111
+ for claim_id in ids
112
+ ]
113
+
114
+
115
+ def _present_options(draft: Mapping[str, Any], cycle: Mapping[str, Any]) -> dict[str, Any]:
116
+ return {
117
+ "action": "present-options",
118
+ "reason": (
119
+ f"the research input references equal those of {cycle['recordId']}; no new "
120
+ "evidence, user answer or alternative arrived, so research is not dispatched again"
121
+ ),
122
+ "remainingDisagreements": _claims(draft, draft["remainingDisagreements"]),
123
+ "alternatives": [
124
+ {"recordId": row["recordId"], "text": row["text"], "author": row["author"]}
125
+ for row in draft["materials"] if row["materialKind"] == "alternative"
126
+ ],
127
+ "userOptions": [
128
+ "choose a direction and record it with `okstra discussion decide`",
129
+ "bring new evidence or an alternative with `okstra discussion add-material`, then research again",
130
+ "ask one participant directly with `okstra discussion address`",
131
+ ],
132
+ }
133
+
134
+
135
+ def _waiting(draft: Mapping[str, Any]) -> dict[str, Any] | None:
136
+ if draft["unrecoveredFailures"]:
137
+ return {"action": "await-recovery", "failures": draft["unrecoveredFailures"]}
138
+ if draft["pendingForUser"]:
139
+ return {"action": "await-user", "nextForUser": draft["nextForUser"]}
140
+ if draft["pendingForParticipants"]:
141
+ return {"action": "await-participant", "questions": draft["pendingForParticipants"]}
142
+ return None
143
+
144
+
145
+ def _research_instruction(cycle_id: str, draft: Mapping[str, Any]) -> str:
146
+ refs = ", ".join(draft["inputRefs"]) or "none"
147
+ return (
148
+ f"# Discussion research — {cycle_id}\n\n"
149
+ "Research the confirmed subject and question of the discussion record below.\n\n"
150
+ "- Read the whole record first: every user answer, every participant answer and "
151
+ "every user decision. A user decision stands; do not argue it away with a fact check.\n"
152
+ "- Report each factual claim about the code or an external source as a numbered "
153
+ "finding with its evidence location, and say what the evidence could not reach.\n"
154
+ "- For each alternative direction, state what it requires, what it excludes and the "
155
+ "risk it carries, without ranking it into a decision.\n"
156
+ "- List the questions only the user can answer (intent, scope, priority, acceptable "
157
+ "risk) under the heading `Questions for the user`; do not report them as findings.\n"
158
+ "- A claim already in the record is new only with new evidence: name the record it "
159
+ "extends or refutes.\n\n"
160
+ f"Input references of this cycle: {refs}\n\n"
161
+ + render_draft_text(draft)
162
+ )
163
+
164
+
165
+ def open_cycle(store: DiscussionStore, run: DiscussionRun) -> dict[str, Any]:
166
+ with transaction(store) as txn:
167
+ draft = txn.draft()
168
+ if draft["subject"] is None:
169
+ raise DiscussionWorkflowError(
170
+ "confirm the subject and the question with the user first (`okstra discussion confirm-subject`)"
171
+ )
172
+ if draft["completedRevision"] is not None:
173
+ return {
174
+ "action": "completed",
175
+ "revisionId": draft["completedRevision"],
176
+ "reason": "the user completed the discussion; `okstra discussion reopen` opens a new draft",
177
+ }
178
+ if draft["unrecoveredFailures"]:
179
+ return _waiting(draft)
180
+ mine = _cycle_of_run(draft, run)
181
+ if mine is not None:
182
+ return _cycle_of_this_run(store, run, draft, mine)
183
+ waiting = _waiting(draft)
184
+ if waiting is not None:
185
+ return waiting
186
+ closed = _last_closed(draft)
187
+ if closed is not None and closed["inputRefs"] == draft["inputRefs"]:
188
+ return _present_options(draft, closed)
189
+ cycle_id = f"R-{1 + len(draft['cycles']):04d}"
190
+ text = _research_instruction(cycle_id, draft)
191
+ path = run.run_dir / "prompts" / f"discussion-research-discussion-{run.seq}.md"
192
+ path.parent.mkdir(parents=True, exist_ok=True)
193
+ path.write_text(text, encoding="utf-8")
194
+ record, created = txn.append(
195
+ "cycle",
196
+ {
197
+ "inputRefs": draft["inputRefs"],
198
+ "roster": list(run.analysers),
199
+ "instructionPath": run.relative(path),
200
+ "instructionDigest": _digest(text.encode("utf-8")),
201
+ },
202
+ author=LEADER,
203
+ source_run=run.source,
204
+ )
205
+ return _dispatch_payload(run, record, created)
206
+
207
+
208
+ def _cycle_of_this_run(
209
+ store: DiscussionStore, run: DiscussionRun, draft: Mapping[str, Any], cycle: Mapping[str, Any],
210
+ ) -> dict[str, Any]:
211
+ if cycle["status"] == "open":
212
+ path = run.project_path(cycle["instructionPath"])
213
+ if not path.is_file() or _digest(path.read_bytes()) != cycle["instructionDigest"]:
214
+ raise DiscussionWorkflowError(
215
+ f"the research instruction of {cycle['recordId']} ({cycle['instructionPath']}) is "
216
+ "missing or changed since the cycle opened; it is the input the cycle recorded"
217
+ )
218
+ return _dispatch_payload(run, cycle, False)
219
+ if cycle["status"] == "closed" and draft["inputRefs"] == cycle["inputRefs"]:
220
+ return _present_options(draft, cycle)
221
+ reason = (
222
+ "the user chose to replace a participant"
223
+ if cycle["status"] == "superseded"
224
+ else f"new input arrived after {cycle['recordId']} closed"
225
+ )
226
+ return {
227
+ "action": "prepare-run",
228
+ "reason": f"{reason}; one run holds one research cycle",
229
+ "discussionStart": store.task_key,
230
+ }
231
+
232
+
233
+ def _dispatch_payload(run: DiscussionRun, cycle: Mapping[str, Any], created: bool) -> dict[str, Any]:
234
+ return {
235
+ "action": "dispatch",
236
+ "created": created,
237
+ "cycle": cycle["recordId"],
238
+ "inputRefs": cycle["inputRefs"],
239
+ "instructionPath": cycle["instructionPath"],
240
+ "dispatchKind": RESEARCH_DISPATCH_KIND,
241
+ "audience": _DUTY,
242
+ "workers": [row["worker"] for row in run.analysers],
243
+ }
244
+
245
+
246
+ def _team_state(run: DiscussionRun) -> Mapping[str, Any]:
247
+ try:
248
+ return load_owned_object(run.project_path(str(run.manifest["teamStatePath"])), artifact="team state")
249
+ except JsonBoundaryError as exc:
250
+ raise DiscussionWorkflowError(str(exc)) from exc
251
+
252
+
253
+ def _final_rows(rows: list[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
254
+ """invocation 마다 마지막 시도 행 — 앞 시도의 실패는 기존 재시도 루프 안의 일이다."""
255
+ latest: dict[str, Mapping[str, Any]] = {}
256
+ for row in rows:
257
+ latest[str(row.get("invocationId") or row.get("promptPath"))] = row
258
+ return list(latest.values())
259
+
260
+
261
+ def _cli_outcome(run: DiscussionRun, row: Mapping[str, Any]) -> dict[str, Any]:
262
+ status = str(row.get("status") or "")
263
+ outcome = {
264
+ "label": str(row.get("invocationId") or row.get("promptPath")),
265
+ "invocationId": str(row.get("invocationId") or ""),
266
+ "dispatch": str(row.get("dispatchId") or f"{row.get('promptPath')}#{row.get('attempt')}"),
267
+ "attempts": int(row.get("attempt") or 1),
268
+ }
269
+ if status == "running":
270
+ return {**outcome, "state": "running"}
271
+ if status == "completed":
272
+ result = str(row.get("workerResultPath") or row.get("resultPath"))
273
+ return {**outcome, "state": "completed", "resultPath": run.relative(run.project_path(result))}
274
+ reason = str(row.get("reason") or status)
275
+ return {
276
+ **outcome,
277
+ "state": "failed",
278
+ "stage": "start" if reason.startswith(CLI_BATCH_LAUNCH_FAILURE) else "after-retries",
279
+ "status": status,
280
+ "reason": reason,
281
+ }
282
+
283
+
284
+ def _native_outcomes(
285
+ run: DiscussionRun, team_state: Mapping[str, Any], worker: str, kind: str,
286
+ ) -> list[dict[str, Any]]:
287
+ """호스트 네이티브 세션 디스패치 — 결과는 링크로, 결과 없는 종결은 시도 원장으로 안다."""
288
+ rows = [
289
+ row for row in team_state.get("agentDispatches") or []
290
+ if isinstance(row, Mapping) and row.get("dispatchKind") == kind
291
+ and dispatch_worker_key(row) == worker
292
+ ]
293
+ if not rows:
294
+ return []
295
+ links = {
296
+ link["dispatchId"]: link for link in team_state.get("agentResultLinks") or []
297
+ if isinstance(link, Mapping) and not link.get("supersededBy")
298
+ }
299
+ attempts = {
300
+ (attempt.invocation_ref, attempt.attempt): attempt
301
+ for attempt in read_execution_manifest(run.project_root / run.source.manifest).attempts
302
+ }
303
+ outcomes = []
304
+ for row in _final_rows(rows):
305
+ outcome = {
306
+ "label": str(row["invocationId"]),
307
+ "invocationId": str(row["invocationId"]),
308
+ "dispatch": str(row["dispatchId"]),
309
+ "attempts": int(row.get("attempt") or 1),
310
+ }
311
+ link = links.get(row["dispatchId"])
312
+ attempt = attempts.get((row.get("invocationRef"), row.get("attempt")))
313
+ if link is not None:
314
+ outcomes.append({**outcome, "state": "completed", "resultPath": str(link["resultPath"])})
315
+ elif attempt is None or attempt.finished_at is None:
316
+ outcomes.append({**outcome, "state": "running"})
317
+ else:
318
+ outcomes.append({
319
+ **outcome, "state": "failed", "stage": "after-retries", "status": attempt.status,
320
+ "reason": f"the attempt closed as {attempt.status} without a linked result",
321
+ })
322
+ return outcomes
323
+
324
+
325
+ def _outcomes(
326
+ run: DiscussionRun, team_state: Mapping[str, Any], worker: str, kind: str,
327
+ ) -> list[dict[str, Any]]:
328
+ cli_rows = [row for row in worker_dispatch_records(team_state, worker) if row.get("kind") == kind]
329
+ outcomes = [_cli_outcome(run, row) for row in _final_rows(cli_rows)]
330
+ outcomes += _native_outcomes(run, team_state, worker, kind)
331
+ running = [outcome["label"] for outcome in outcomes if outcome["state"] == "running"]
332
+ if running:
333
+ raise DiscussionWorkflowError(
334
+ f"{', '.join(running)} of {worker} is still running; wait for it "
335
+ "(`okstra team await` or the host's own completion) before settling"
336
+ )
337
+ return outcomes
338
+
339
+
340
+ def _settled_fields(outcome: Mapping[str, Any]) -> tuple[str, dict[str, Any]]:
341
+ if outcome["state"] == "completed":
342
+ return "result", {"dispatch": outcome["dispatch"], "resultPath": outcome["resultPath"]}
343
+ return "failure", {
344
+ key: outcome[key] for key in ("dispatch", "stage", "attempts", "status", "reason")
345
+ }
346
+
347
+
348
+ def settle_cycle(store: DiscussionStore, run: DiscussionRun) -> dict[str, Any]:
349
+ team_state = _team_state(run)
350
+ with transaction(store) as txn:
351
+ cycle = _cycle_of_run(txn.draft(), run)
352
+ if cycle is None or cycle["status"] != "open":
353
+ raise DiscussionWorkflowError(
354
+ "this run has no open research cycle; run `okstra discussion cycle-open` first"
355
+ )
356
+ not_dispatched = []
357
+ for analyser in run.analysers:
358
+ worker = analyser["worker"]
359
+ outcomes = _outcomes(run, team_state, worker, RESEARCH_DISPATCH_KIND)
360
+ if not outcomes:
361
+ not_dispatched.append(worker)
362
+ for outcome in outcomes:
363
+ kind, fields = _settled_fields(outcome)
364
+ txn.append(kind, {"cycleId": cycle["recordId"], **fields}, author=worker, source_run=run.source)
365
+ draft = txn.draft()
366
+ settled = next(c for c in draft["cycles"] if c["recordId"] == cycle["recordId"])
367
+ return {
368
+ "cycle": cycle["recordId"],
369
+ "results": [row["recordId"] for row in settled["results"]],
370
+ "failures": [
371
+ _failure_view(row) for row in settled["failures"] if row["recovery"] is None
372
+ ],
373
+ "notDispatched": not_dispatched,
374
+ "phase": draft["phase"],
375
+ }
376
+
377
+
378
+ def _failure_view(row: Mapping[str, Any]) -> dict[str, Any]:
379
+ return {
380
+ "recordId": row["recordId"],
381
+ "worker": row["author"],
382
+ "stage": row["stage"],
383
+ "attempts": row["attempts"],
384
+ "reason": row["reason"],
385
+ "unverifiedScope": (
386
+ f"question {row['questionId']}" if row.get("questionId")
387
+ else f"the research of {row['author']} in {row['cycleId']}"
388
+ ),
389
+ "choices": list(_QUESTION_CHOICES if row.get("questionId") else CHOICES),
390
+ }
391
+
392
+
393
+ def choose_recovery(
394
+ store: DiscussionStore, run: DiscussionRun, failure_id: str, choice: str,
395
+ ) -> dict[str, Any]:
396
+ with transaction(store) as txn:
397
+ failure = next((row for row in txn.rows if row["recordId"] == failure_id), None)
398
+ if failure is None or failure["kind"] != "failure":
399
+ raise DiscussionWorkflowError(f"no failure {failure_id} in this discussion")
400
+ allowed = _QUESTION_CHOICES if failure.get("questionId") else CHOICES
401
+ if choice not in allowed:
402
+ raise DiscussionWorkflowError(
403
+ f"{failure_id} accepts {', '.join(allowed)}; a direct question goes to a "
404
+ "participant of a replacement run with `okstra discussion address`"
405
+ )
406
+ if choice == "retry" and failure["sourceRun"] != run.source.manifest:
407
+ raise DiscussionWorkflowError(
408
+ f"{failure_id} failed in {failure['sourceRun']}; a retry stays in that run's attempt record"
409
+ )
410
+ record, created = txn.append(
411
+ "recovery",
412
+ {"failureId": failure_id, "choice": choice, "worker": failure["author"],
413
+ "cycleId": failure["cycleId"], "questionId": failure.get("questionId", "")},
414
+ author=USER,
415
+ source_run=run.source,
416
+ )
417
+ draft = txn.draft()
418
+ return {"record": record, "created": created, "next": _recovery_next(store, draft, failure, choice),
419
+ "phase": draft["phase"]}
420
+
421
+
422
+ def _recovery_next(
423
+ store: DiscussionStore, draft: Mapping[str, Any], failure: Mapping[str, Any], choice: str,
424
+ ) -> str:
425
+ worker = failure["author"]
426
+ if failure.get("questionId"):
427
+ if choice == "retry":
428
+ return (
429
+ f"run `okstra discussion question-prompt --question {failure['questionId']}` again: it "
430
+ "names a new invocation for the retry; dispatch it and run `collect-answer`"
431
+ )
432
+ return f"{failure['questionId']} stays unanswered by {worker}; continue without it"
433
+ if choice == "retry":
434
+ cycle = next(c for c in draft["cycles"] if c["recordId"] == failure["cycleId"])
435
+ return (
436
+ f"materialize a new invocation id for {worker} with --assignment-ref initial/{worker}, "
437
+ f"--dispatch-kind {RESEARCH_DISPATCH_KIND} and --instruction {cycle['instructionPath']}, "
438
+ "dispatch it, then run `okstra discussion settle`"
439
+ )
440
+ if choice == "replace":
441
+ return (
442
+ "prepare a continuation run with `--discussion-start "
443
+ f"{store.task_key}` and the replacement roster; the completed results stay recorded "
444
+ "with their authors and reach the new run's research input"
445
+ )
446
+ return (
447
+ f"continue {failure['cycleId']} with the remaining participants; the research of {worker} "
448
+ "stays unverified and the reduced roster is recorded"
449
+ )
450
+
451
+
452
+ def _final_state(store: DiscussionStore, run: DiscussionRun) -> tuple[Path, Mapping[str, Any]]:
453
+ state_path = run.project_path(str(run.manifest["convergenceStatePath"]))
454
+ try:
455
+ state = load_owned_object(state_path, artifact="convergence final state")
456
+ except JsonBoundaryError as exc:
457
+ raise DiscussionWorkflowError(
458
+ f"{exc}; finish the verification cycle with `okstra convergence finalize` first"
459
+ ) from exc
460
+ errors = validate_final_state(state)
461
+ if errors:
462
+ raise DiscussionWorkflowError("the convergence final state is invalid: " + "; ".join(errors))
463
+ if not same_task(str(state.get("taskKey", "")), store.task_key):
464
+ raise DiscussionWorkflowError(
465
+ f"{run.relative(state_path)} belongs to {state.get('taskKey')!r}, not {store.task_key}"
466
+ )
467
+ return state_path, state
468
+
469
+
470
+ def close_cycle(store: DiscussionStore, run: DiscussionRun) -> dict[str, Any]:
471
+ with transaction(store) as txn:
472
+ cycle = _cycle_of_run(txn.draft(), run)
473
+ if cycle is None or cycle["status"] == "superseded":
474
+ raise DiscussionWorkflowError(
475
+ "this run has no research cycle to close; a replaced cycle continues in a new run"
476
+ )
477
+ open_failures = [f["recordId"] for f in cycle["failures"] if f["recovery"] is None]
478
+ if open_failures:
479
+ raise DiscussionWorkflowError(
480
+ f"{', '.join(open_failures)} wait for the user's choice "
481
+ "(`okstra discussion recover`); the cycle closes after it"
482
+ )
483
+ state_path, state = _final_state(store, run)
484
+ claims = []
485
+ for finding in state["findings"]:
486
+ record, _ = txn.append("claim", _claim_fields(cycle["recordId"], finding),
487
+ author=str(finding["originWorker"]), source_run=run.source)
488
+ claims.append(record["recordId"])
489
+ close, created = txn.append(
490
+ "close",
491
+ {
492
+ "cycleId": cycle["recordId"],
493
+ "convergenceState": run.relative(state_path),
494
+ "stateDigest": _digest(state_path.read_bytes()),
495
+ "finalState": state["finalState"],
496
+ "stopReason": state["round2SkippedReason"],
497
+ },
498
+ author=LEADER,
499
+ source_run=run.source,
500
+ )
501
+ draft = txn.draft()
502
+ return {
503
+ "cycle": cycle["recordId"],
504
+ "close": close,
505
+ "created": created,
506
+ "claims": claims,
507
+ "remainingDisagreements": _claims(draft, draft["remainingDisagreements"]),
508
+ "phase": draft["phase"],
509
+ }
510
+
511
+
512
+ def _claim_fields(cycle_id: str, finding: Mapping[str, Any]) -> dict[str, Any]:
513
+ return {
514
+ "cycleId": cycle_id,
515
+ "findingId": finding["findingId"],
516
+ "summary": finding["summary"],
517
+ "category": finding["category"],
518
+ "discoveredBy": sorted(finding["discoveredBy"]),
519
+ "evidence": finding["originEvidence"],
520
+ "classification": finding["classification"],
521
+ "verdicts": [
522
+ {"round": row["round"], "worker": worker, "verdict": vote["verdict"],
523
+ "disagreeBasis": vote.get("disagreeBasis"), "explanation": vote["explanation"]}
524
+ for row in finding["rounds"]
525
+ for worker, vote in row["votes"].items()
526
+ ],
527
+ }
528
+
529
+
530
+ def _question(draft: Mapping[str, Any], question_id: str) -> dict[str, Any]:
531
+ question = next((q for q in draft["questions"] if q["recordId"] == question_id), None)
532
+ if question is None:
533
+ raise DiscussionWorkflowError(f"no question {question_id} in this discussion")
534
+ if question["to"] == USER:
535
+ raise DiscussionWorkflowError(
536
+ f"{question_id} is for the user; put it to the user and record the answer with `answer`"
537
+ )
538
+ return question
539
+
540
+
541
+ def _question_names(run: DiscussionRun, draft: Mapping[str, Any], question: Mapping[str, Any]) -> dict[str, Any]:
542
+ retries = sum(1 for row in draft["failures"] if row.get("questionId") == question["recordId"])
543
+ slug = question["recordId"].lower() + (f"-r{retries}" if retries else "")
544
+ worker = question["to"]
545
+ prompts = run.run_dir / "prompts"
546
+ return {
547
+ "invocationId": f"discussion-question-{slug}-{worker}-{run.seq}",
548
+ "invocationPrefix": f"discussion-question-{question['recordId'].lower()}-",
549
+ "instruction": prompts / f"discussion-question-{slug}-discussion-{run.seq}.md",
550
+ "prompt": prompts / f"{worker}-worker-prompt-discussion-question-{slug}-discussion-{run.seq}.md",
551
+ "result": run.run_dir / "worker-results" / f"{worker}-worker-discussion-question-{slug}-discussion-{run.seq}.md",
552
+ "batch": run.run_dir / "state" / f"discussion-question-{slug}-batch-discussion-{run.seq}.json",
553
+ }
554
+
555
+
556
+ def _question_instruction(question: Mapping[str, Any], draft: Mapping[str, Any]) -> str:
557
+ return (
558
+ f"# Discussion question {question['recordId']} — to {question['to']}\n\n"
559
+ f"{question['author']} asked you this question directly:\n\n{question['text']}\n\n"
560
+ "Answer it in your result file. Base the answer on the discussion record below and on "
561
+ "the evidence you inspect; give each factual claim its evidence location and say what you "
562
+ "could not establish. Your whole result file is recorded as your answer and every "
563
+ "participant reads it in their next input.\n\n"
564
+ + render_draft_text(draft)
565
+ )
566
+
567
+
568
+ def question_dispatch(store: DiscussionStore, run: DiscussionRun, question_id: str) -> dict[str, Any]:
569
+ draft = read_draft(store)
570
+ question = _question(draft, question_id)
571
+ if question["answers"] or question["dropped"]:
572
+ raise DiscussionWorkflowError(f"{question_id} is already settled; ask a follow-up question instead")
573
+ waiting = [
574
+ row["recordId"] for row in draft["failures"]
575
+ if row.get("questionId") == question_id and row["recovery"] is None
576
+ ]
577
+ if waiting:
578
+ raise DiscussionWorkflowError(
579
+ f"{', '.join(waiting)} waits for the user's choice (`okstra discussion recover`) "
580
+ "before the question is dispatched again"
581
+ )
582
+ worker = question["to"]
583
+ if worker not in {row["worker"] for row in run.analysers}:
584
+ raise DiscussionWorkflowError(
585
+ f"{worker} is not an analyser of {run.source.manifest}; address a participant of this run"
586
+ )
587
+ names = _question_names(run, draft, question)
588
+ if not names["instruction"].is_file():
589
+ names["instruction"].parent.mkdir(parents=True, exist_ok=True)
590
+ names["instruction"].write_text(_question_instruction(question, draft), encoding="utf-8")
591
+ entry = {
592
+ "invocation-id": names["invocationId"],
593
+ "worker-id": worker,
594
+ "audience": _DUTY,
595
+ "dispatch-kind": DISCUSSION_QUESTION_DISPATCH_KIND,
596
+ "assignment-ref": f"initial/{worker}",
597
+ "instruction": run.relative(names["instruction"]),
598
+ "prompt": run.relative(names["prompt"]),
599
+ "result": run.relative(names["result"]),
600
+ }
601
+ write_owned_object_atomic(names["batch"], {"invocations": [entry]}, artifact="agent prompt batch")
602
+ return {
603
+ "question": question_id,
604
+ "worker": worker,
605
+ "invocationId": names["invocationId"],
606
+ "dispatchKind": DISCUSSION_QUESTION_DISPATCH_KIND,
607
+ "instructionPath": entry["instruction"],
608
+ "resultPath": entry["result"],
609
+ "batchPath": run.relative(names["batch"]),
610
+ }
611
+
612
+
613
+ def collect_answer(store: DiscussionStore, run: DiscussionRun, question_id: str) -> dict[str, Any]:
614
+ draft = read_draft(store)
615
+ question = _question(draft, question_id)
616
+ names = _question_names(run, draft, question)
617
+ outcomes = [
618
+ outcome for outcome in _outcomes(run, _team_state(run), question["to"], DISCUSSION_QUESTION_DISPATCH_KIND)
619
+ if outcome["invocationId"].startswith(names["invocationPrefix"])
620
+ ]
621
+ if not outcomes:
622
+ raise DiscussionWorkflowError(
623
+ f"no dispatch of {question_id} in {run.source.manifest}; dispatch the batch "
624
+ "`okstra discussion question-prompt` wrote first"
625
+ )
626
+ kind, fields = _settled_fields(outcomes[-1])
627
+ if kind == "result":
628
+ result_path = run.project_path(fields["resultPath"])
629
+ fields = {"questionId": question_id, "text": result_path.read_text(encoding="utf-8"),
630
+ "resultPath": fields["resultPath"]}
631
+ kind = "answer"
632
+ else:
633
+ fields = {"cycleId": "", "questionId": question_id, **fields}
634
+ with transaction(store) as txn:
635
+ record, created = txn.append(kind, fields, author=question["to"], source_run=run.source)
636
+ after = txn.draft()
637
+ return {"record": record, "created": created, "phase": after["phase"],
638
+ "pendingForParticipants": after["pendingForParticipants"]}
639
+
640
+
641
+ def status(draft: Mapping[str, Any]) -> dict[str, Any]:
642
+ by_id = {q["recordId"]: q for q in draft["questions"]}
643
+ next_question = by_id.get(draft["nextForUser"]) if draft["nextForUser"] else None
644
+ return {
645
+ "phase": draft["phase"],
646
+ "draftVersion": draft["recordsDigest"],
647
+ "completedRevision": draft["completedRevision"],
648
+ "revisions": [{key: row[key] for key in ("revisionId", "contentDigest")} for row in draft["revisions"]],
649
+ "basedOn": (
650
+ {"revisionId": draft["basedOn"]["revisionId"], "reason": draft["basedOn"]["reason"]}
651
+ if draft["basedOn"] else None
652
+ ),
653
+ "nextForUser": (
654
+ {key: next_question[key] for key in ("recordId", "author", "text")} if next_question else None
655
+ ),
656
+ "pendingForUser": draft["pendingForUser"],
657
+ "pendingForParticipants": draft["pendingForParticipants"],
658
+ "unrecoveredFailures": [
659
+ _failure_view(row) for row in draft["failures"] if row["recordId"] in draft["unrecoveredFailures"]
660
+ ],
661
+ "remainingDisagreements": _claims(draft, draft["remainingDisagreements"]),
662
+ "cycles": [
663
+ {"recordId": c["recordId"], "status": c["status"], "run": c["sourceRun"],
664
+ "participants": c["roster"]}
665
+ for c in draft["cycles"]
666
+ ],
667
+ }