cowork-flow 0.0.37 → 0.0.39

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 (111) hide show
  1. package/README.md +27 -14
  2. package/package.json +1 -1
  3. package/src/commands/init.js +39 -28
  4. package/src/commands/sync.js +2 -2
  5. package/src/lib/asset-plan.js +127 -0
  6. package/src/lib/copy-template.js +101 -141
  7. package/src/lib/host-assets.js +231 -0
  8. package/src/lib/plan-applier.js +329 -0
  9. package/src/lib/platforms.js +15 -76
  10. package/src/lib/skill-registry.js +503 -0
  11. package/template/.claude/agents/cowork-check.md +10 -1
  12. package/template/.claude/agents/cowork-implement.md +11 -2
  13. package/template/.claude/hooks/inject-workflow-state.py +51 -254
  14. package/template/.codex/agents/cowork-check.toml +12 -3
  15. package/template/.codex/agents/cowork-implement.toml +11 -2
  16. package/template/.codex/agents/default.toml +3 -2
  17. package/template/.codex/agents/explorer.toml +3 -2
  18. package/template/.codex/agents/worker.toml +2 -1
  19. package/template/.codex/hooks/inject-workflow-state.py +46 -265
  20. package/template/.cowork-flow/.version +1 -1
  21. package/template/.cowork-flow/scripts/application/__init__.py +1 -0
  22. package/template/.cowork-flow/scripts/application/batch_execution.py +743 -0
  23. package/template/.cowork-flow/scripts/application/runtime_context_service.py +365 -0
  24. package/template/.cowork-flow/scripts/application/task_archive.py +220 -0
  25. package/template/.cowork-flow/scripts/application/task_context.py +765 -0
  26. package/template/.cowork-flow/scripts/application/task_creation.py +201 -0
  27. package/template/.cowork-flow/scripts/application/task_lifecycle.py +532 -0
  28. package/template/.cowork-flow/scripts/application/task_tree.py +179 -0
  29. package/template/.cowork-flow/scripts/commands/doctor.py +78 -57
  30. package/template/.cowork-flow/scripts/commands/party_mode_v2.py +671 -425
  31. package/template/.cowork-flow/scripts/commands/subagent.py +38 -26
  32. package/template/.cowork-flow/scripts/commands/task.py +535 -1953
  33. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +195 -0
  34. package/template/.cowork-flow/scripts/commands/task_context_commands.py +240 -0
  35. package/template/.cowork-flow/scripts/commands/task_create_command.py +164 -0
  36. package/template/.cowork-flow/scripts/commands/task_navigation.py +407 -0
  37. package/template/.cowork-flow/scripts/commands/task_parser.py +364 -0
  38. package/template/.cowork-flow/scripts/commands/task_support.py +76 -0
  39. package/template/.cowork-flow/scripts/commands/task_tree_commands.py +160 -0
  40. package/template/.cowork-flow/scripts/common/core/execution_context.py +161 -85
  41. package/template/.cowork-flow/scripts/common/core/host_manifest.py +393 -0
  42. package/template/.cowork-flow/scripts/common/core/skill_registry.py +568 -0
  43. package/template/.cowork-flow/scripts/common/gates/coding_standards.py +58 -7
  44. package/template/.cowork-flow/scripts/common/gates/gates.py +323 -46
  45. package/template/.cowork-flow/scripts/common/gates/models.py +196 -0
  46. package/template/.cowork-flow/scripts/common/gates/registry.py +79 -0
  47. package/template/.cowork-flow/scripts/common/gates/tdd_evidence.py +13 -13
  48. package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
  49. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +39 -22
  50. package/template/.cowork-flow/scripts/common/gates/validate_rules.py +223 -40
  51. package/template/.cowork-flow/scripts/common/git/git_context.py +216 -182
  52. package/template/.cowork-flow/scripts/common/host/__init__.py +1 -0
  53. package/template/.cowork-flow/scripts/common/host/workflow_state_hook.py +303 -0
  54. package/template/.cowork-flow/scripts/common/storage/__init__.py +1 -0
  55. package/template/.cowork-flow/scripts/common/storage/operation_log.py +72 -0
  56. package/template/.cowork-flow/scripts/common/storage/state_store.py +279 -0
  57. package/template/.cowork-flow/scripts/common/storage/unit_of_work.py +214 -0
  58. package/template/.cowork-flow/scripts/common/task/active_task.py +93 -135
  59. package/template/.cowork-flow/scripts/common/task/batch_mode.py +33 -120
  60. package/template/.cowork-flow/scripts/common/task/decision_review.py +85 -0
  61. package/template/.cowork-flow/scripts/common/task/readiness.py +9 -15
  62. package/template/.cowork-flow/scripts/common/task/task_repository.py +156 -0
  63. package/template/.cowork-flow/scripts/run.py +24 -0
  64. package/template/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  65. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  66. package/template/.cowork-flow/spec/contracts/workflow-state-templates.md +1 -1
  67. package/template/.cowork-flow/spec/protocols/decision-review.md +28 -0
  68. package/template/.cowork-flow/spec/protocols/review.md +34 -0
  69. package/template/.cowork-flow/spec/protocols/spec-maintenance.md +16 -0
  70. package/template/.cowork-flow/spec/protocols/tdd.md +30 -0
  71. package/template/.cowork-flow/spec/references/definition-of-done.md +4 -4
  72. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  73. package/template/.cowork-flow/spec/runtime/contract-registry.json +25 -0
  74. package/template/.cowork-flow/spec/runtime/host-assets.json +214 -0
  75. package/template/.cowork-flow/spec/runtime/index.md +8 -0
  76. package/template/.cowork-flow/spec/runtime/rules.json +61 -10
  77. package/template/.cowork-flow/spec/runtime/skill-registry.json +397 -0
  78. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +173 -0
  79. package/template/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  80. package/template/.cowork-flow/spec/schemas/skill-registry.schema.json +202 -0
  81. package/template/.cowork-flow/workflow.md +18 -13
  82. package/template/.opencode/agents/cowork-check.md +11 -0
  83. package/template/.opencode/agents/cowork-implement.md +16 -1
  84. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  85. package/template/.zcode/hooks/inject-context.js +3 -85
  86. package/template/.zcode/hooks/inject-context.selfcheck.mjs +6 -6
  87. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/decision-anchor.md +8 -2
  88. package/template/.zcode/scaffold/.cowork-flow/spec/contracts/subagent-dispatch.md +2 -4
  89. package/template/.zcode/scaffold/.cowork-flow/spec/runtime/rules.json +61 -10
  90. package/template/.zcode/scaffold/.cowork-flow/spec/schemas/rules.schema.json +10 -1
  91. package/template/.zcode/scaffold/.cowork-flow/workflow.md +18 -13
  92. package/template/.zcode/scaffold/AGENTS.md +18 -8
  93. package/template/AGENTS.md +1 -1
  94. package/template/skills/batch-mode/SKILL.md +140 -76
  95. package/template/skills/brainstorming/SKILL.md +2 -2
  96. package/template/skills/cowork-flow/SKILL.md +42 -0
  97. package/template/skills/party-mode/SHARED-BOUNDARIES.md +1 -1
  98. package/template/skills/party-mode/SKILL.md +48 -119
  99. package/template/.cowork-flow/scripts/common/gates/spec_validator.py +0 -53
  100. package/template/.cowork-flow/scripts/common/gates/validate_jsonl.py +0 -32
  101. package/template/skills/before-dev/SKILL.md +0 -41
  102. package/template/skills/check/SKILL.md +0 -53
  103. package/template/skills/check/simplification-guide.md +0 -47
  104. package/template/skills/continue/SKILL.md +0 -29
  105. package/template/skills/doubt-review/SKILL.md +0 -118
  106. package/template/skills/finish-work/SKILL.md +0 -32
  107. package/template/skills/party-mode-v2/SKILL.md +0 -75
  108. package/template/skills/start/SKILL.md +0 -78
  109. package/template/skills/tdd/SKILL.md +0 -74
  110. package/template/skills/update-spec/SKILL.md +0 -39
  111. package/template/skills/using-cowork-flow/SKILL.md +0 -80
@@ -207,7 +207,20 @@ def _prompt_text(
207
207
  phase: str,
208
208
  ) -> str:
209
209
  agent_id = agent["agent_id"]
210
- base = (
210
+ base = _prompt_header(discussion_id, agent, round_number, phase)
211
+ if phase == "publish":
212
+ return base + _publish_prompt_payload(discussion_id, agent_id, round_number)
213
+ return base + _respond_prompt_payload(discussion_id, agent_id, round_number)
214
+
215
+
216
+ def _prompt_header(
217
+ discussion_id: str,
218
+ agent: dict[str, str],
219
+ round_number: int,
220
+ phase: str,
221
+ ) -> str:
222
+ agent_id = agent["agent_id"]
223
+ return (
211
224
  f"# Party Mode V2 Child: {agent_id}\n\n"
212
225
  f"discussion_id: {discussion_id}\n"
213
226
  f"agent_id: {agent_id}\n"
@@ -221,29 +234,32 @@ def _prompt_text(
221
234
  f".\\.cowork-flow\\run.cmd party-v2 view --discussion-id {discussion_id} --agent-id {agent_id}\n"
222
235
  "```\n"
223
236
  )
224
- if phase == "publish":
225
- return (
226
- base
227
- + "\nSubmit one current-round post with `party-v2 post --file`:\n\n"
228
- "```powershell\n"
229
- f".\\.cowork-flow\\run.cmd party-v2 post --discussion-id {discussion_id} --agent-id {agent_id} --file <payload.json>\n"
230
- "```\n\n"
231
- "Payload fields:\n\n"
232
- "```json\n"
233
- "{\n"
234
- f" \"round\": {round_number},\n"
235
- " \"claim\": \"...\",\n"
236
- " \"evidence\": [\"...\"],\n"
237
- " \"risk\": \"...\",\n"
238
- " \"tradeoff\": \"...\",\n"
239
- " \"acceptance_signal\": \"...\",\n"
240
- " \"what_would_change_my_mind\": \"...\"\n"
241
- "}\n"
242
- "```\n"
243
- )
237
+
238
+
239
+ def _publish_prompt_payload(discussion_id: str, agent_id: str, round_number: int) -> str:
240
+ return (
241
+ "\nSubmit one current-round post with `party-v2 post --file`:\n\n"
242
+ "```powershell\n"
243
+ f".\\.cowork-flow\\run.cmd party-v2 post --discussion-id {discussion_id} --agent-id {agent_id} --file <payload.json>\n"
244
+ "```\n\n"
245
+ "Payload fields:\n\n"
246
+ "```json\n"
247
+ "{\n"
248
+ f" \"round\": {round_number},\n"
249
+ " \"claim\": \"...\",\n"
250
+ " \"evidence\": [\"...\"],\n"
251
+ " \"risk\": \"...\",\n"
252
+ " \"tradeoff\": \"...\",\n"
253
+ " \"acceptance_signal\": \"...\",\n"
254
+ " \"what_would_change_my_mind\": \"...\"\n"
255
+ "}\n"
256
+ "```\n"
257
+ )
258
+
259
+
260
+ def _respond_prompt_payload(discussion_id: str, agent_id: str, round_number: int) -> str:
244
261
  return (
245
- base
246
- + "\nRespond to current-round target posts only. Respect `max_rebuttal_targets_per_agent`.\n\n"
262
+ "\nRespond to current-round target posts only. Respect `max_rebuttal_targets_per_agent`.\n\n"
247
263
  "Submit with `party-v2 respond --file`:\n\n"
248
264
  "```powershell\n"
249
265
  f".\\.cowork-flow\\run.cmd party-v2 respond --discussion-id {discussion_id} --agent-id {agent_id} --file <payload.json>\n"
@@ -267,6 +283,24 @@ def _prompt_text(
267
283
  )
268
284
 
269
285
 
286
+ def _round_empty_state(items: list[Any], phase: str, *, item_type: str) -> str | None:
287
+ if items:
288
+ return None
289
+ if item_type == "posts":
290
+ if phase == "publish":
291
+ return "waiting_for_current_round_posts"
292
+ if phase == "closed":
293
+ return "discussion_closed"
294
+ return "no_current_round_posts"
295
+ if phase == "publish":
296
+ return "responses_not_open_yet"
297
+ if phase == "respond":
298
+ return "waiting_for_current_round_responses"
299
+ if phase == "closed":
300
+ return "discussion_closed"
301
+ return "no_current_round_responses"
302
+
303
+
270
304
  def _build_public_round(board: dict[str, Any]) -> dict[str, Any]:
271
305
  current_round = int(board["round"]["current"])
272
306
  current = next(
@@ -280,24 +314,6 @@ def _build_public_round(board: dict[str, Any]) -> dict[str, Any]:
280
314
  posts = list(current.get("posts", []))
281
315
  responses = list(current.get("responses", []))
282
316
  phase = board["round"]["phase"]
283
- if posts:
284
- posts_empty = None
285
- elif phase == "publish":
286
- posts_empty = "waiting_for_current_round_posts"
287
- elif phase == "closed":
288
- posts_empty = "discussion_closed"
289
- else:
290
- posts_empty = "no_current_round_posts"
291
- if responses:
292
- responses_empty = None
293
- elif phase == "publish":
294
- responses_empty = "responses_not_open_yet"
295
- elif phase == "respond":
296
- responses_empty = "waiting_for_current_round_responses"
297
- elif phase == "closed":
298
- responses_empty = "discussion_closed"
299
- else:
300
- responses_empty = "no_current_round_responses"
301
317
  expected_next_action = {
302
318
  "publish": "post",
303
319
  "respond": "respond",
@@ -313,13 +329,59 @@ def _build_public_round(board: dict[str, Any]) -> dict[str, Any]:
313
329
  "visible_responses": responses,
314
330
  "moderator_events": list(current.get("moderator_events", [])),
315
331
  "empty_state": {
316
- "visible_posts": posts_empty,
317
- "visible_responses": responses_empty,
332
+ "visible_posts": _round_empty_state(posts, phase, item_type="posts"),
333
+ "visible_responses": _round_empty_state(responses, phase, item_type="responses"),
318
334
  },
319
335
  "expected_next_action": expected_next_action,
320
336
  }
321
337
 
322
338
 
339
+ def _agent_prompt_file(
340
+ base_dir: Path,
341
+ discussion_id: str,
342
+ agent: dict[str, str],
343
+ *,
344
+ round_number: int,
345
+ phase: str,
346
+ ) -> tuple[str, str]:
347
+ agent_id = agent["agent_id"]
348
+ prompt_name = f"{agent_id}-r{round_number}-{phase}.md"
349
+ prompt_path = base_dir / "prompts" / prompt_name
350
+ prompt_path.write_text(
351
+ _prompt_text(discussion_id, agent, round_number, phase),
352
+ encoding="utf-8",
353
+ )
354
+ prompt_file = Path(DIR_WORKFLOW) / RUNTIME_DIR / MODE_DIR / discussion_id / "prompts" / prompt_name
355
+ return prompt_name, str(prompt_file)
356
+
357
+
358
+ def _child_action(
359
+ base_dir: Path,
360
+ discussion_id: str,
361
+ agent: dict[str, str],
362
+ *,
363
+ round_number: int,
364
+ phase: str,
365
+ ) -> dict[str, Any]:
366
+ agent_id = agent["agent_id"]
367
+ _prompt_name, prompt_file = _agent_prompt_file(
368
+ base_dir,
369
+ discussion_id,
370
+ agent,
371
+ round_number=round_number,
372
+ phase=phase,
373
+ )
374
+ return {
375
+ "action_id": f"r{round_number}-{phase}-{agent_id}",
376
+ "type": "dispatch_child" if phase == "publish" else "send_control_message",
377
+ "agent_id": agent_id,
378
+ "agent_kind": "advisory",
379
+ "lens": agent["lens"],
380
+ "message_kind": f"board_{phase}",
381
+ "prompt_file": prompt_file,
382
+ }
383
+
384
+
323
385
  def _build_actions(
324
386
  base_dir: Path,
325
387
  discussion_id: str,
@@ -332,37 +394,21 @@ def _build_actions(
332
394
  prompts_dir = base_dir / "prompts"
333
395
  prompts_dir.mkdir(parents=True, exist_ok=True)
334
396
  for agent in agents:
335
- agent_id = agent["agent_id"]
336
- prompt_name = f"{agent_id}-r{round_number}-{phase}.md"
337
- prompt_path = prompts_dir / prompt_name
338
- prompt_path.write_text(
339
- _prompt_text(discussion_id, agent, round_number, phase),
340
- encoding="utf-8",
341
- )
342
- action_id = f"r{round_number}-{phase}-{agent_id}"
343
- action = {
344
- "action_id": action_id,
345
- "type": "dispatch_child" if phase == "publish" else "send_control_message",
346
- "agent_id": agent_id,
347
- "agent_kind": "advisory",
348
- "lens": agent["lens"],
349
- "message_kind": f"board_{phase}",
350
- "prompt_file": str(
351
- Path(DIR_WORKFLOW) / RUNTIME_DIR / MODE_DIR / discussion_id / "prompts" / prompt_name
352
- ),
353
- }
354
- actions.append(
355
- action
397
+ action = _child_action(
398
+ base_dir,
399
+ discussion_id,
400
+ agent,
401
+ round_number=round_number,
402
+ phase=phase,
356
403
  )
404
+ actions.append(action)
357
405
  _append_action_history(base_dir, "action-issued", action)
358
406
  wait_action = {
359
407
  "action_id": f"r{round_number}-{phase}-wait",
360
408
  "type": "wait_children",
361
409
  "agent_ids": [agent["agent_id"] for agent in agents],
362
410
  }
363
- actions.append(
364
- wait_action
365
- )
411
+ actions.append(wait_action)
366
412
  _append_action_history(base_dir, "action-issued", wait_action)
367
413
  return {"schema_version": 1, "discussion_id": discussion_id, "next_actions": actions}
368
414
 
@@ -405,6 +451,34 @@ def _with_terminal_state(data: dict[str, Any], terminal: bool) -> dict[str, Any]
405
451
  return data
406
452
 
407
453
 
454
+ def _initial_board(discussion_id: str, topic: str, max_rounds: int) -> dict[str, Any]:
455
+ return {
456
+ "schema_version": 1,
457
+ "discussion_id": discussion_id,
458
+ "topic": topic,
459
+ "round": {"current": 1, "max": max_rounds, "phase": "publish"},
460
+ "rounds": [{"round": 1, "posts": [], "responses": [], "moderator_events": []}],
461
+ "termination": {"reason": None},
462
+ }
463
+
464
+
465
+ def _initial_agents_state(discussion_id: str, agents: list[dict[str, str]]) -> dict[str, Any]:
466
+ return {
467
+ "schema_version": 1,
468
+ "discussion_id": discussion_id,
469
+ "agents": [
470
+ {
471
+ "agent_id": agent["agent_id"],
472
+ "lens": agent["lens"],
473
+ "status": "pending",
474
+ "drift_warnings": 0,
475
+ "host_child_id": None,
476
+ }
477
+ for agent in agents
478
+ ],
479
+ }
480
+
481
+
408
482
  def init_discussion(
409
483
  repo_root: Path,
410
484
  *,
@@ -419,35 +493,8 @@ def init_discussion(
419
493
  base_dir = discussion_dir(repo_root, discussion_id)
420
494
  with _state_lock(base_dir):
421
495
  max_rounds = int(config["max_rounds"])
422
- board = {
423
- "schema_version": 1,
424
- "discussion_id": discussion_id,
425
- "topic": topic,
426
- "round": {"current": 1, "max": max_rounds, "phase": "publish"},
427
- "rounds": [
428
- {
429
- "round": 1,
430
- "posts": [],
431
- "responses": [],
432
- "moderator_events": [],
433
- }
434
- ],
435
- "termination": {"reason": None},
436
- }
437
- agents_state = {
438
- "schema_version": 1,
439
- "discussion_id": discussion_id,
440
- "agents": [
441
- {
442
- "agent_id": agent["agent_id"],
443
- "lens": agent["lens"],
444
- "status": "pending",
445
- "drift_warnings": 0,
446
- "host_child_id": None,
447
- }
448
- for agent in agents
449
- ],
450
- }
496
+ board = _initial_board(discussion_id, topic, max_rounds)
497
+ agents_state = _initial_agents_state(discussion_id, agents)
451
498
  actions = _build_actions(base_dir, discussion_id, agents, round_number=1, phase="publish")
452
499
  public_round = _build_public_round(board)
453
500
  _write_json(base_dir / "board.json", board)
@@ -556,6 +603,33 @@ def _require_non_empty_list(payload: dict[str, Any], key: str, error: str) -> li
556
603
  return value
557
604
 
558
605
 
606
+ def _build_post_payload(
607
+ *,
608
+ current_round: int,
609
+ agent_id: str,
610
+ payload: dict[str, Any],
611
+ post_number: int,
612
+ ) -> dict[str, Any]:
613
+ return {
614
+ "post_id": f"r{current_round}-{agent_id}-p{post_number}",
615
+ "agent_id": agent_id,
616
+ "claim": _require_non_empty_text(payload, "claim", "missing_claim"),
617
+ "evidence": _require_non_empty_list(payload, "evidence", "missing_evidence"),
618
+ "risk": _require_non_empty_text(payload, "risk", "missing_risk"),
619
+ "tradeoff": _require_non_empty_text(payload, "tradeoff", "missing_tradeoff"),
620
+ "acceptance_signal": _require_non_empty_text(
621
+ payload,
622
+ "acceptance_signal",
623
+ "missing_acceptance_signal",
624
+ ),
625
+ "what_would_change_my_mind": _require_non_empty_text(
626
+ payload,
627
+ "what_would_change_my_mind",
628
+ "missing_what_would_change_my_mind",
629
+ ),
630
+ }
631
+
632
+
559
633
  def _write_runtime_state(base_dir: Path, board: dict[str, Any]) -> None:
560
634
  _write_json(base_dir / "board.json", board)
561
635
  _write_json(base_dir / "public_round.json", _build_public_round(board))
@@ -579,34 +653,16 @@ def post_submission(
579
653
  raise ValueError("agent_not_active")
580
654
  current_round = int(board["round"]["current"])
581
655
  _payload_round(payload, current_round, config)
582
- claim = _require_non_empty_text(payload, "claim", "missing_claim")
583
- evidence = _require_non_empty_list(payload, "evidence", "missing_evidence")
584
- risk = _require_non_empty_text(payload, "risk", "missing_risk")
585
- tradeoff = _require_non_empty_text(payload, "tradeoff", "missing_tradeoff")
586
- acceptance_signal = _require_non_empty_text(
587
- payload,
588
- "acceptance_signal",
589
- "missing_acceptance_signal",
590
- )
591
- what_would_change_my_mind = _require_non_empty_text(
592
- payload,
593
- "what_would_change_my_mind",
594
- "missing_what_would_change_my_mind",
595
- )
596
656
  current = _current_round(board)
597
657
  posts = current.setdefault("posts", [])
598
658
  if any(post.get("agent_id") == agent_id for post in posts):
599
659
  raise ValueError("duplicate_post")
600
- post = {
601
- "post_id": f"r{current_round}-{agent_id}-p{len(posts) + 1}",
602
- "agent_id": agent_id,
603
- "claim": claim,
604
- "evidence": evidence,
605
- "risk": risk,
606
- "tradeoff": tradeoff,
607
- "acceptance_signal": acceptance_signal,
608
- "what_would_change_my_mind": what_would_change_my_mind,
609
- }
660
+ post = _build_post_payload(
661
+ current_round=current_round,
662
+ agent_id=agent_id,
663
+ payload=payload,
664
+ post_number=len(posts) + 1,
665
+ )
610
666
  posts.append(post)
611
667
  _write_runtime_state(base_dir, board)
612
668
  _append_audit(base_dir, "post", {"agent_id": agent_id, "post_id": post["post_id"]})
@@ -652,6 +708,149 @@ def _confidence(payload: dict[str, Any]) -> str:
652
708
  return value
653
709
 
654
710
 
711
+ def _response_target_and_decision(
712
+ board: dict[str, Any],
713
+ agent_id: str,
714
+ payload: dict[str, Any],
715
+ ) -> tuple[str, str]:
716
+ target_post_id = _require_non_empty_text(payload, "target_post_id", "missing_target_post_id")
717
+ post_agent_map = _current_post_agent_map(board)
718
+ if target_post_id not in post_agent_map:
719
+ raise ValueError("target_not_in_current_round")
720
+ if post_agent_map[target_post_id] == agent_id:
721
+ raise ValueError("self_target_response")
722
+ decision = _require_non_empty_text(payload, "decision", "missing_decision")
723
+ if decision not in {"maintain", "revise", "concede"}:
724
+ raise ValueError("invalid_decision")
725
+ return target_post_id, decision
726
+
727
+
728
+ def _validate_response_target_limit(
729
+ responses: list[Any],
730
+ *,
731
+ agent_id: str,
732
+ target_post_id: str,
733
+ max_targets: int,
734
+ ) -> None:
735
+ agent_targets = {
736
+ str(response["target_post_id"])
737
+ for response in responses
738
+ if str(response.get("agent_id")) == agent_id
739
+ }
740
+ if target_post_id in agent_targets:
741
+ raise ValueError("duplicate_target_response")
742
+ if len(agent_targets) >= max_targets:
743
+ raise ValueError("too_many_rebuttal_targets")
744
+
745
+
746
+ def _build_response_base(
747
+ *,
748
+ current_round: int,
749
+ agent_id: str,
750
+ target_post_id: str,
751
+ decision: str,
752
+ payload: dict[str, Any],
753
+ response_number: int,
754
+ ) -> dict[str, Any]:
755
+ return {
756
+ "response_id": f"r{current_round}-{agent_id}-resp{response_number}",
757
+ "agent_id": agent_id,
758
+ "target_post_id": target_post_id,
759
+ "decision": decision,
760
+ "my_current_position": _require_non_empty_text(
761
+ payload,
762
+ "my_current_position",
763
+ "missing_current_position",
764
+ ),
765
+ "opponent_claim": _require_non_empty_text(
766
+ payload,
767
+ "opponent_claim",
768
+ "missing_opponent_claim",
769
+ ),
770
+ "opponent_evidence_i_checked": _require_non_empty_list(
771
+ payload,
772
+ "opponent_evidence_i_checked",
773
+ "missing_checked_evidence",
774
+ ),
775
+ "reasoning": _require_non_empty_text(payload, "reasoning", "missing_reasoning"),
776
+ "confidence_after_review": _confidence(payload),
777
+ }
778
+
779
+
780
+ def _apply_concede_response(response: dict[str, Any], payload: dict[str, Any]) -> None:
781
+ response["why_opponent_is_right"] = _require_non_empty_text(
782
+ payload,
783
+ "why_opponent_is_right",
784
+ "shallow_concession",
785
+ )
786
+ response["accepted_evidence"] = _require_non_empty_list(
787
+ payload,
788
+ "accepted_evidence",
789
+ "shallow_concession",
790
+ )
791
+ response["why_my_previous_position_failed"] = _require_non_empty_text(
792
+ payload,
793
+ "why_my_previous_position_failed",
794
+ "shallow_concession",
795
+ )
796
+ response["position_delta"] = "changed"
797
+ response["still_disagree"] = False
798
+
799
+
800
+ def _apply_revise_response(response: dict[str, Any], payload: dict[str, Any]) -> None:
801
+ response["accepted_part"] = _require_non_empty_text(
802
+ payload,
803
+ "accepted_part",
804
+ "vague_revision",
805
+ )
806
+ response["rejected_part"] = _require_non_empty_text(
807
+ payload,
808
+ "rejected_part",
809
+ "vague_revision",
810
+ )
811
+ response["updated_position"] = _require_non_empty_text(
812
+ payload,
813
+ "updated_position",
814
+ "vague_revision",
815
+ )
816
+ response["position_delta"] = str(payload.get("position_delta") or "narrowed")
817
+ response["still_disagree"] = _require_bool(payload, "still_disagree", True)
818
+
819
+
820
+ def _apply_maintain_response(response: dict[str, Any], payload: dict[str, Any]) -> None:
821
+ response["why_opponent_is_wrong"] = _require_non_empty_text(
822
+ payload,
823
+ "why_opponent_is_wrong",
824
+ "unsupported_rebuttal",
825
+ )
826
+ counter_evidence = payload.get("counter_evidence")
827
+ counter_reasoning = payload.get("counter_reasoning")
828
+ if not counter_evidence and not counter_reasoning:
829
+ raise ValueError("unsupported_rebuttal")
830
+ if counter_evidence is not None:
831
+ if not isinstance(counter_evidence, list) or not counter_evidence:
832
+ raise ValueError("unsupported_rebuttal")
833
+ response["counter_evidence"] = counter_evidence
834
+ if counter_reasoning is not None:
835
+ response["counter_reasoning"] = str(counter_reasoning)
836
+ response["position_delta"] = "unchanged"
837
+ response["still_disagree"] = True
838
+
839
+
840
+ def _apply_decision_response(
841
+ response: dict[str, Any],
842
+ *,
843
+ decision: str,
844
+ payload: dict[str, Any],
845
+ ) -> None:
846
+ if decision == "concede":
847
+ _apply_concede_response(response, payload)
848
+ elif decision == "revise":
849
+ _apply_revise_response(response, payload)
850
+ else:
851
+ _apply_maintain_response(response, payload)
852
+
853
+
655
854
  def respond_submission(
656
855
  repo_root: Path,
657
856
  *,
@@ -670,107 +869,24 @@ def respond_submission(
670
869
  raise ValueError("agent_not_active")
671
870
  current_round = int(board["round"]["current"])
672
871
  _payload_round(payload, current_round, config)
673
- target_post_id = _require_non_empty_text(payload, "target_post_id", "missing_target_post_id")
674
- post_agent_map = _current_post_agent_map(board)
675
- if target_post_id not in post_agent_map:
676
- raise ValueError("target_not_in_current_round")
677
- if post_agent_map[target_post_id] == agent_id:
678
- raise ValueError("self_target_response")
679
- decision = _require_non_empty_text(payload, "decision", "missing_decision")
680
- if decision not in {"maintain", "revise", "concede"}:
681
- raise ValueError("invalid_decision")
682
- my_current_position = _require_non_empty_text(
683
- payload,
684
- "my_current_position",
685
- "missing_current_position",
686
- )
687
- opponent_claim = _require_non_empty_text(payload, "opponent_claim", "missing_opponent_claim")
688
- checked_evidence = _require_non_empty_list(
689
- payload,
690
- "opponent_evidence_i_checked",
691
- "missing_checked_evidence",
692
- )
693
- reasoning = _require_non_empty_text(payload, "reasoning", "missing_reasoning")
694
-
872
+ target_post_id, decision = _response_target_and_decision(board, agent_id, payload)
695
873
  current = _current_round(board)
696
874
  responses = current.setdefault("responses", [])
697
- agent_targets = {
698
- str(response["target_post_id"])
699
- for response in responses
700
- if str(response.get("agent_id")) == agent_id
701
- }
702
- if target_post_id in agent_targets:
703
- raise ValueError("duplicate_target_response")
704
- max_targets = int(config["max_rebuttal_targets_per_agent"])
705
- if len(agent_targets) >= max_targets:
706
- raise ValueError("too_many_rebuttal_targets")
707
-
708
- response: dict[str, Any] = {
709
- "response_id": f"r{current_round}-{agent_id}-resp{len(responses) + 1}",
710
- "agent_id": agent_id,
711
- "target_post_id": target_post_id,
712
- "decision": decision,
713
- "my_current_position": my_current_position,
714
- "opponent_claim": opponent_claim,
715
- "opponent_evidence_i_checked": checked_evidence,
716
- "reasoning": reasoning,
717
- "confidence_after_review": _confidence(payload),
718
- }
719
- if decision == "concede":
720
- response["why_opponent_is_right"] = _require_non_empty_text(
721
- payload,
722
- "why_opponent_is_right",
723
- "shallow_concession",
724
- )
725
- response["accepted_evidence"] = _require_non_empty_list(
726
- payload,
727
- "accepted_evidence",
728
- "shallow_concession",
729
- )
730
- response["why_my_previous_position_failed"] = _require_non_empty_text(
731
- payload,
732
- "why_my_previous_position_failed",
733
- "shallow_concession",
734
- )
735
- response["position_delta"] = "changed"
736
- response["still_disagree"] = False
737
- elif decision == "revise":
738
- response["accepted_part"] = _require_non_empty_text(
739
- payload,
740
- "accepted_part",
741
- "vague_revision",
742
- )
743
- response["rejected_part"] = _require_non_empty_text(
744
- payload,
745
- "rejected_part",
746
- "vague_revision",
747
- )
748
- response["updated_position"] = _require_non_empty_text(
749
- payload,
750
- "updated_position",
751
- "vague_revision",
752
- )
753
- response["position_delta"] = str(payload.get("position_delta") or "narrowed")
754
- response["still_disagree"] = _require_bool(payload, "still_disagree", True)
755
- else:
756
- response["why_opponent_is_wrong"] = _require_non_empty_text(
757
- payload,
758
- "why_opponent_is_wrong",
759
- "unsupported_rebuttal",
760
- )
761
- counter_evidence = payload.get("counter_evidence")
762
- counter_reasoning = payload.get("counter_reasoning")
763
- if not counter_evidence and not counter_reasoning:
764
- raise ValueError("unsupported_rebuttal")
765
- if counter_evidence is not None:
766
- if not isinstance(counter_evidence, list) or not counter_evidence:
767
- raise ValueError("unsupported_rebuttal")
768
- response["counter_evidence"] = counter_evidence
769
- if counter_reasoning is not None:
770
- response["counter_reasoning"] = str(counter_reasoning)
771
- response["position_delta"] = "unchanged"
772
- response["still_disagree"] = True
773
-
875
+ _validate_response_target_limit(
876
+ responses,
877
+ agent_id=agent_id,
878
+ target_post_id=target_post_id,
879
+ max_targets=int(config["max_rebuttal_targets_per_agent"]),
880
+ )
881
+ response = _build_response_base(
882
+ current_round=current_round,
883
+ agent_id=agent_id,
884
+ target_post_id=target_post_id,
885
+ decision=decision,
886
+ payload=payload,
887
+ response_number=len(responses) + 1,
888
+ )
889
+ _apply_decision_response(response, decision=decision, payload=payload)
774
890
  responses.append(response)
775
891
  _write_runtime_state(base_dir, board)
776
892
  _append_audit(
@@ -801,6 +917,131 @@ def _write_actions_for_phase(
801
917
  _write_json(base_dir / "actions.json", actions)
802
918
 
803
919
 
920
+ def _advance_publish_phase(
921
+ repo_root: Path,
922
+ *,
923
+ base_dir: Path,
924
+ discussion_id: str,
925
+ board: dict[str, Any],
926
+ current: dict[str, Any],
927
+ active_agents: list[str],
928
+ current_round: int,
929
+ ) -> dict[str, Any]:
930
+ posted_agents = {str(post["agent_id"]) for post in current.get("posts", [])}
931
+ missing = sorted(set(active_agents) - posted_agents)
932
+ if missing:
933
+ raise ValueError(f"publish_incomplete:{','.join(missing)}")
934
+ board["round"]["phase"] = "respond"
935
+ _write_runtime_state(base_dir, board)
936
+ _write_actions_for_phase(base_dir, discussion_id, current_round, "respond")
937
+ _append_audit(
938
+ base_dir,
939
+ "advance",
940
+ {"round": current_round, "from": "publish", "to": "respond"},
941
+ )
942
+ return monitor_discussion(repo_root, discussion_id=discussion_id)
943
+
944
+
945
+ def _close_discussion(
946
+ repo_root: Path,
947
+ *,
948
+ base_dir: Path,
949
+ discussion_id: str,
950
+ board: dict[str, Any],
951
+ current_round: int,
952
+ reason: str,
953
+ ) -> dict[str, Any]:
954
+ board["round"]["phase"] = "closed"
955
+ board["termination"] = {"reason": reason}
956
+ _write_runtime_state(base_dir, board)
957
+ _write_json(base_dir / "actions.json", _empty_actions(discussion_id))
958
+ _append_audit(
959
+ base_dir,
960
+ "advance",
961
+ {"round": current_round, "from": "respond", "to": "closed", "reason": reason},
962
+ )
963
+ return finalize_discussion(repo_root, discussion_id=discussion_id)
964
+
965
+
966
+ def _advance_next_round(
967
+ repo_root: Path,
968
+ *,
969
+ base_dir: Path,
970
+ discussion_id: str,
971
+ board: dict[str, Any],
972
+ current_round: int,
973
+ ) -> dict[str, Any]:
974
+ next_round = current_round + 1
975
+ config = get_party_mode_v2_config(repo_root)
976
+ close_actions = []
977
+ if bool(config.get("fresh_context_per_round", True)):
978
+ close_actions = _build_close_actions(
979
+ base_dir,
980
+ _agent_lenses(base_dir),
981
+ round_number=current_round,
982
+ reason="fresh_context_per_round",
983
+ )
984
+ board["round"] = {"current": next_round, "max": board["round"]["max"], "phase": "publish"}
985
+ board.setdefault("rounds", []).append(
986
+ {"round": next_round, "posts": [], "responses": [], "moderator_events": []}
987
+ )
988
+ _write_runtime_state(base_dir, board)
989
+ _write_actions_for_phase(
990
+ base_dir,
991
+ discussion_id,
992
+ next_round,
993
+ "publish",
994
+ leading_actions=close_actions,
995
+ )
996
+ _append_audit(
997
+ base_dir,
998
+ "advance",
999
+ {"round": current_round, "from": "respond", "to": "publish", "next_round": next_round},
1000
+ )
1001
+ return monitor_discussion(repo_root, discussion_id=discussion_id)
1002
+
1003
+
1004
+ def _advance_respond_phase(
1005
+ repo_root: Path,
1006
+ *,
1007
+ base_dir: Path,
1008
+ discussion_id: str,
1009
+ board: dict[str, Any],
1010
+ current: dict[str, Any],
1011
+ active_agents: list[str],
1012
+ current_round: int,
1013
+ ) -> dict[str, Any]:
1014
+ responded_agents = {str(response["agent_id"]) for response in current.get("responses", [])}
1015
+ missing = sorted(set(active_agents) - responded_agents)
1016
+ if missing:
1017
+ raise ValueError(f"respond_incomplete:{','.join(missing)}")
1018
+ if not any(bool(response.get("still_disagree")) for response in current.get("responses", [])):
1019
+ return _close_discussion(
1020
+ repo_root,
1021
+ base_dir=base_dir,
1022
+ discussion_id=discussion_id,
1023
+ board=board,
1024
+ current_round=current_round,
1025
+ reason="converged",
1026
+ )
1027
+ if current_round >= int(board["round"]["max"]):
1028
+ return _close_discussion(
1029
+ repo_root,
1030
+ base_dir=base_dir,
1031
+ discussion_id=discussion_id,
1032
+ board=board,
1033
+ current_round=current_round,
1034
+ reason="max_rounds_unconverged",
1035
+ )
1036
+ return _advance_next_round(
1037
+ repo_root,
1038
+ base_dir=base_dir,
1039
+ discussion_id=discussion_id,
1040
+ board=board,
1041
+ current_round=current_round,
1042
+ )
1043
+
1044
+
804
1045
  def advance_discussion(repo_root: Path, *, discussion_id: str) -> dict[str, Any]:
805
1046
  base_dir = discussion_dir(repo_root, discussion_id)
806
1047
  with _state_lock(base_dir):
@@ -809,85 +1050,28 @@ def advance_discussion(repo_root: Path, *, discussion_id: str) -> dict[str, Any]
809
1050
  active_agents = _active_agent_ids(base_dir)
810
1051
  current_round = int(board["round"]["current"])
811
1052
  if board["round"]["phase"] == "publish":
812
- posted_agents = {str(post["agent_id"]) for post in current.get("posts", [])}
813
- missing = sorted(set(active_agents) - posted_agents)
814
- if missing:
815
- raise ValueError(f"publish_incomplete:{','.join(missing)}")
816
- board["round"]["phase"] = "respond"
817
- _write_runtime_state(base_dir, board)
818
- _write_actions_for_phase(base_dir, discussion_id, current_round, "respond")
819
- _append_audit(
820
- base_dir,
821
- "advance",
822
- {"round": current_round, "from": "publish", "to": "respond"},
1053
+ return _advance_publish_phase(
1054
+ repo_root,
1055
+ base_dir=base_dir,
1056
+ discussion_id=discussion_id,
1057
+ board=board,
1058
+ current=current,
1059
+ active_agents=active_agents,
1060
+ current_round=current_round,
823
1061
  )
824
- return monitor_discussion(repo_root, discussion_id=discussion_id)
825
1062
 
826
1063
  if board["round"]["phase"] != "respond":
827
1064
  raise ValueError("phase_not_advanceable")
828
1065
 
829
- responded_agents = {str(response["agent_id"]) for response in current.get("responses", [])}
830
- missing = sorted(set(active_agents) - responded_agents)
831
- if missing:
832
- raise ValueError(f"respond_incomplete:{','.join(missing)}")
833
-
834
- if not any(bool(response.get("still_disagree")) for response in current.get("responses", [])):
835
- board["round"]["phase"] = "closed"
836
- board["termination"] = {"reason": "converged"}
837
- _write_runtime_state(base_dir, board)
838
- _write_json(base_dir / "actions.json", _empty_actions(discussion_id))
839
- _append_audit(
840
- base_dir,
841
- "advance",
842
- {"round": current_round, "from": "respond", "to": "closed", "reason": "converged"},
843
- )
844
- return finalize_discussion(repo_root, discussion_id=discussion_id)
845
-
846
- if current_round >= int(board["round"]["max"]):
847
- board["round"]["phase"] = "closed"
848
- board["termination"] = {"reason": "max_rounds_unconverged"}
849
- _write_runtime_state(base_dir, board)
850
- _write_json(base_dir / "actions.json", _empty_actions(discussion_id))
851
- _append_audit(
852
- base_dir,
853
- "advance",
854
- {
855
- "round": current_round,
856
- "from": "respond",
857
- "to": "closed",
858
- "reason": "max_rounds_unconverged",
859
- },
860
- )
861
- return finalize_discussion(repo_root, discussion_id=discussion_id)
862
-
863
- next_round = current_round + 1
864
- config = get_party_mode_v2_config(repo_root)
865
- close_actions = []
866
- if bool(config.get("fresh_context_per_round", True)):
867
- close_actions = _build_close_actions(
868
- base_dir,
869
- _agent_lenses(base_dir),
870
- round_number=current_round,
871
- reason="fresh_context_per_round",
872
- )
873
- board["round"] = {"current": next_round, "max": board["round"]["max"], "phase": "publish"}
874
- board.setdefault("rounds", []).append(
875
- {"round": next_round, "posts": [], "responses": [], "moderator_events": []}
1066
+ return _advance_respond_phase(
1067
+ repo_root,
1068
+ base_dir=base_dir,
1069
+ discussion_id=discussion_id,
1070
+ board=board,
1071
+ current=current,
1072
+ active_agents=active_agents,
1073
+ current_round=current_round,
876
1074
  )
877
- _write_runtime_state(base_dir, board)
878
- _write_actions_for_phase(
879
- base_dir,
880
- discussion_id,
881
- next_round,
882
- "publish",
883
- leading_actions=close_actions,
884
- )
885
- _append_audit(
886
- base_dir,
887
- "advance",
888
- {"round": current_round, "from": "respond", "to": "publish", "next_round": next_round},
889
- )
890
- return monitor_discussion(repo_root, discussion_id=discussion_id)
891
1075
 
892
1076
 
893
1077
  def _set_all_agents_status(base_dir: Path, status: str) -> None:
@@ -905,6 +1089,132 @@ def _default_agent_status(action_type: str, outcome: str) -> str:
905
1089
  return "active"
906
1090
 
907
1091
 
1092
+ def _ensure_finalizable(
1093
+ base_dir: Path,
1094
+ discussion_id: str,
1095
+ board: dict[str, Any],
1096
+ *,
1097
+ manual_termination: bool,
1098
+ ) -> None:
1099
+ if board["round"]["phase"] == "closed":
1100
+ return
1101
+ if not manual_termination:
1102
+ raise ValueError("finalize_requires_closed_discussion")
1103
+ board["round"]["phase"] = "closed"
1104
+ board["termination"] = {"reason": "manual_terminated"}
1105
+ _write_runtime_state(base_dir, board)
1106
+ _write_json(base_dir / "actions.json", _empty_actions(discussion_id))
1107
+
1108
+
1109
+ def _discussion_posts(board: dict[str, Any]) -> list[dict[str, Any]]:
1110
+ return [post for item in board.get("rounds", []) for post in item.get("posts", [])]
1111
+
1112
+
1113
+ def _discussion_responses(board: dict[str, Any]) -> list[dict[str, Any]]:
1114
+ return [
1115
+ response
1116
+ for item in board.get("rounds", [])
1117
+ for response in item.get("responses", [])
1118
+ ]
1119
+
1120
+
1121
+ def _round_responses(board: dict[str, Any], round_number: int) -> list[dict[str, Any]]:
1122
+ return [
1123
+ response
1124
+ for item in board.get("rounds", [])
1125
+ if int(item.get("round", -1)) == round_number
1126
+ for response in item.get("responses", [])
1127
+ ]
1128
+
1129
+
1130
+ def _historical_disagreements(
1131
+ board: dict[str, Any],
1132
+ current_round: int,
1133
+ ) -> list[dict[str, Any]]:
1134
+ return [
1135
+ response
1136
+ for item in board.get("rounds", [])
1137
+ if int(item.get("round", -1)) < current_round
1138
+ for response in item.get("responses", [])
1139
+ if response.get("still_disagree")
1140
+ ]
1141
+
1142
+
1143
+ def _build_final_report(
1144
+ *,
1145
+ discussion_id: str,
1146
+ board: dict[str, Any],
1147
+ posts: list[dict[str, Any]],
1148
+ responses: list[dict[str, Any]],
1149
+ current_responses: list[dict[str, Any]],
1150
+ historical_disagreements: list[dict[str, Any]],
1151
+ stop_reason: str,
1152
+ ) -> dict[str, Any]:
1153
+ current_round = int(board["round"]["current"])
1154
+ current_unresolved = [
1155
+ response for response in current_responses if response.get("still_disagree")
1156
+ ]
1157
+ board_status = {
1158
+ "round": current_round,
1159
+ "phase": board["round"]["phase"],
1160
+ "max_rounds": board["round"]["max"],
1161
+ "termination_reason": stop_reason,
1162
+ }
1163
+ return {
1164
+ "schema_version": 1,
1165
+ "discussion_id": discussion_id,
1166
+ "terminal": True,
1167
+ "stop_reason": stop_reason,
1168
+ "board_status": board_status,
1169
+ "next_actions": [],
1170
+ "pro": _report_pro_posts(posts),
1171
+ "con": _report_con_responses(responses),
1172
+ "changed_positions": _responses_with_delta(responses, "changed"),
1173
+ "maintained_positions": _responses_with_delta(responses, "unchanged"),
1174
+ "revised_positions": _responses_with_decision(responses, "revise"),
1175
+ "current_unresolved_disagreements": current_unresolved,
1176
+ "historical_disagreements": historical_disagreements,
1177
+ "unresolved_disagreements": current_unresolved,
1178
+ }
1179
+
1180
+
1181
+ def _report_pro_posts(posts: list[dict[str, Any]]) -> list[dict[str, Any]]:
1182
+ return [
1183
+ {"agent_id": post["agent_id"], "claim": post["claim"], "evidence": post["evidence"]}
1184
+ for post in posts
1185
+ ]
1186
+
1187
+
1188
+ def _report_con_responses(responses: list[dict[str, Any]]) -> list[dict[str, Any]]:
1189
+ return [
1190
+ {
1191
+ "agent_id": response["agent_id"],
1192
+ "target_post_id": response["target_post_id"],
1193
+ "reasoning": response["reasoning"],
1194
+ }
1195
+ for response in responses
1196
+ if response.get("decision") == "maintain"
1197
+ ]
1198
+
1199
+
1200
+ def _responses_with_delta(
1201
+ responses: list[dict[str, Any]],
1202
+ position_delta: str,
1203
+ ) -> list[dict[str, Any]]:
1204
+ return [
1205
+ response for response in responses if response.get("position_delta") == position_delta
1206
+ ]
1207
+
1208
+
1209
+ def _responses_with_decision(
1210
+ responses: list[dict[str, Any]],
1211
+ decision: str,
1212
+ ) -> list[dict[str, Any]]:
1213
+ return [
1214
+ response for response in responses if response.get("decision") == decision
1215
+ ]
1216
+
1217
+
908
1218
  def finalize_discussion(
909
1219
  repo_root: Path,
910
1220
  *,
@@ -914,77 +1224,24 @@ def finalize_discussion(
914
1224
  base_dir = discussion_dir(repo_root, discussion_id)
915
1225
  with _state_lock(base_dir):
916
1226
  board = _read_json(base_dir / "board.json")
917
- if board["round"]["phase"] != "closed":
918
- if not manual_termination:
919
- raise ValueError("finalize_requires_closed_discussion")
920
- board["round"]["phase"] = "closed"
921
- board["termination"] = {"reason": "manual_terminated"}
922
- _write_runtime_state(base_dir, board)
923
- _write_json(base_dir / "actions.json", _empty_actions(discussion_id))
1227
+ _ensure_finalizable(
1228
+ base_dir,
1229
+ discussion_id,
1230
+ board,
1231
+ manual_termination=manual_termination,
1232
+ )
924
1233
  _set_all_agents_status(base_dir, "closed")
925
1234
  current_round = int(board["round"]["current"])
926
- posts = [post for item in board.get("rounds", []) for post in item.get("posts", [])]
927
- responses = [
928
- response
929
- for item in board.get("rounds", [])
930
- for response in item.get("responses", [])
931
- ]
932
- current_responses = [
933
- response
934
- for item in board.get("rounds", [])
935
- if int(item.get("round", -1)) == current_round
936
- for response in item.get("responses", [])
937
- ]
938
- historical_disagreements = [
939
- response
940
- for item in board.get("rounds", [])
941
- if int(item.get("round", -1)) < current_round
942
- for response in item.get("responses", [])
943
- if response.get("still_disagree")
944
- ]
945
1235
  stop_reason = board.get("termination", {}).get("reason") or "manual_finalize"
946
- current_unresolved = [
947
- response for response in current_responses if response.get("still_disagree")
948
- ]
949
- unresolved = current_unresolved
950
- report = {
951
- "schema_version": 1,
952
- "discussion_id": discussion_id,
953
- "terminal": True,
954
- "stop_reason": stop_reason,
955
- "board_status": {
956
- "round": current_round,
957
- "phase": board["round"]["phase"],
958
- "max_rounds": board["round"]["max"],
959
- "termination_reason": stop_reason,
960
- },
961
- "next_actions": [],
962
- "pro": [
963
- {"agent_id": post["agent_id"], "claim": post["claim"], "evidence": post["evidence"]}
964
- for post in posts
965
- ],
966
- "con": [
967
- {
968
- "agent_id": response["agent_id"],
969
- "target_post_id": response["target_post_id"],
970
- "reasoning": response["reasoning"],
971
- }
972
- for response in responses
973
- if response.get("decision") == "maintain"
974
- ],
975
- "changed_positions": [
976
- response for response in responses if response.get("position_delta") == "changed"
977
- ],
978
- "maintained_positions": [
979
- response for response in responses if response.get("position_delta") == "unchanged"
980
- ],
981
- "revised_positions": [
982
- response for response in responses if response.get("decision") == "revise"
983
- ],
984
- "current_unresolved_disagreements": current_unresolved,
985
- "historical_disagreements": historical_disagreements,
986
- "unresolved_disagreements": unresolved,
987
- }
1236
+ report = _build_final_report(
1237
+ discussion_id=discussion_id,
1238
+ board=board,
1239
+ posts=_discussion_posts(board),
1240
+ responses=_discussion_responses(board),
1241
+ current_responses=_round_responses(board, current_round),
1242
+ historical_disagreements=_historical_disagreements(board, current_round),
1243
+ stop_reason=stop_reason,
1244
+ )
988
1245
  reports_dir = base_dir / "reports"
989
1246
  reports_dir.mkdir(parents=True, exist_ok=True)
990
1247
  _write_json(reports_dir / "final.json", report)
@@ -1087,79 +1344,68 @@ def _read_payload(path: Path) -> dict[str, Any]:
1087
1344
  return payload
1088
1345
 
1089
1346
 
1347
+ def _command_result(repo_root: Path, args: argparse.Namespace) -> dict[str, Any]:
1348
+ if args.command == "init":
1349
+ return init_discussion(
1350
+ repo_root,
1351
+ discussion_id=args.discussion_id,
1352
+ topic=args.topic,
1353
+ agent_specs=args.agent,
1354
+ )
1355
+ if args.command == "view":
1356
+ return view_discussion(
1357
+ repo_root,
1358
+ discussion_id=args.discussion_id,
1359
+ agent_id=args.agent_id,
1360
+ )
1361
+ if args.command == "monitor":
1362
+ return monitor_discussion(repo_root, discussion_id=args.discussion_id)
1363
+ if args.command == "post":
1364
+ return _payload_submission_result(repo_root, args, responder=False)
1365
+ if args.command == "respond":
1366
+ return _payload_submission_result(repo_root, args, responder=True)
1367
+ if args.command == "advance":
1368
+ return advance_discussion(repo_root, discussion_id=args.discussion_id)
1369
+ if args.command == "finalize":
1370
+ return finalize_discussion(
1371
+ repo_root,
1372
+ discussion_id=args.discussion_id,
1373
+ manual_termination=args.manual_termination,
1374
+ )
1375
+ if args.command == "record-action-result":
1376
+ return record_action_result(
1377
+ repo_root,
1378
+ discussion_id=args.discussion_id,
1379
+ payload=_read_payload(args.file),
1380
+ )
1381
+ raise ValueError(f"unknown command: {args.command}")
1382
+
1383
+
1384
+ def _payload_submission_result(
1385
+ repo_root: Path,
1386
+ args: argparse.Namespace,
1387
+ *,
1388
+ responder: bool,
1389
+ ) -> dict[str, Any]:
1390
+ submit = respond_submission if responder else post_submission
1391
+ return submit(
1392
+ repo_root,
1393
+ discussion_id=args.discussion_id,
1394
+ agent_id=args.agent_id,
1395
+ payload=_read_payload(args.file),
1396
+ )
1397
+
1398
+
1090
1399
  def main(argv: list[str] | None = None) -> int:
1091
1400
  parser = build_parser()
1092
1401
  args = parser.parse_args(argv)
1093
1402
  repo_root = args.repo_root or get_repo_root()
1094
1403
  try:
1095
- if args.command == "init":
1096
- _print_json(
1097
- init_discussion(
1098
- repo_root,
1099
- discussion_id=args.discussion_id,
1100
- topic=args.topic,
1101
- agent_specs=args.agent,
1102
- )
1103
- )
1104
- return 0
1105
- if args.command == "view":
1106
- _print_json(
1107
- view_discussion(
1108
- repo_root,
1109
- discussion_id=args.discussion_id,
1110
- agent_id=args.agent_id,
1111
- )
1112
- )
1113
- return 0
1114
- if args.command == "monitor":
1115
- _print_json(monitor_discussion(repo_root, discussion_id=args.discussion_id))
1116
- return 0
1117
- if args.command == "post":
1118
- _print_json(
1119
- post_submission(
1120
- repo_root,
1121
- discussion_id=args.discussion_id,
1122
- agent_id=args.agent_id,
1123
- payload=_read_payload(args.file),
1124
- )
1125
- )
1126
- return 0
1127
- if args.command == "respond":
1128
- _print_json(
1129
- respond_submission(
1130
- repo_root,
1131
- discussion_id=args.discussion_id,
1132
- agent_id=args.agent_id,
1133
- payload=_read_payload(args.file),
1134
- )
1135
- )
1136
- return 0
1137
- if args.command == "advance":
1138
- _print_json(advance_discussion(repo_root, discussion_id=args.discussion_id))
1139
- return 0
1140
- if args.command == "finalize":
1141
- _print_json(
1142
- finalize_discussion(
1143
- repo_root,
1144
- discussion_id=args.discussion_id,
1145
- manual_termination=args.manual_termination,
1146
- )
1147
- )
1148
- return 0
1149
- if args.command == "record-action-result":
1150
- _print_json(
1151
- record_action_result(
1152
- repo_root,
1153
- discussion_id=args.discussion_id,
1154
- payload=_read_payload(args.file),
1155
- )
1156
- )
1157
- return 0
1404
+ _print_json(_command_result(repo_root, args))
1405
+ return 0
1158
1406
  except (OSError, ValueError, json.JSONDecodeError) as error:
1159
1407
  print(f"Error: {error}", file=sys.stderr)
1160
1408
  return 2
1161
- parser.print_usage()
1162
- return 2
1163
1409
 
1164
1410
 
1165
1411
  if __name__ == "__main__":