cowork-flow 0.0.47 → 0.0.49

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 (90) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +102 -23
  3. package/package.json +8 -3
  4. package/src/cli.js +6 -0
  5. package/src/commands/init.js +0 -83
  6. package/src/commands/source-refresh.js +40 -0
  7. package/src/commands/sync.js +6 -0
  8. package/src/commands/update.js +59 -6
  9. package/src/lib/copy-template.js +200 -50
  10. package/src/lib/host-assets.js +367 -64
  11. package/src/lib/plan-applier.js +47 -0
  12. package/template/.claude/hooks/inject-workflow-state.py +7 -0
  13. package/template/.codex/hooks/inject-workflow-state.py +7 -0
  14. package/template/.codex/hooks.json +1 -1
  15. package/template/.cowork-flow/.version +1 -1
  16. package/template/.cowork-flow/config.yaml +0 -11
  17. package/template/.cowork-flow/scripts/adapters/cli/init_developer.py +0 -1
  18. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +7 -4
  19. package/template/.cowork-flow/scripts/adapters/cli/task.py +8 -484
  20. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +15 -134
  21. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +7 -1
  22. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +388 -0
  23. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +163 -2
  24. package/template/.cowork-flow/scripts/adapters/git/git_context.py +16 -193
  25. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +170 -84
  26. package/template/.cowork-flow/scripts/infra/config.py +0 -17
  27. package/template/.cowork-flow/scripts/infra/developer_profile.py +0 -95
  28. package/template/.cowork-flow/scripts/infra/paths.py +0 -79
  29. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  30. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  31. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  32. package/template/.cowork-flow/scripts/kernel/workflow_route.py +15 -15
  33. package/template/.cowork-flow/scripts/run.py +0 -5
  34. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  35. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  36. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  37. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +186 -69
  38. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  39. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  40. package/template/.cowork-flow/scripts/services/readiness.py +3 -170
  41. package/template/.cowork-flow/scripts/services/task_archive.py +115 -24
  42. package/template/.cowork-flow/scripts/services/task_context.py +103 -365
  43. package/template/.cowork-flow/scripts/services/task_creation.py +40 -5
  44. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  45. package/template/.cowork-flow/scripts/services/task_routing.py +80 -67
  46. package/template/.cowork-flow/scripts/services/task_utils.py +0 -146
  47. package/template/.cowork-flow/scripts/services/workflow_runtime.py +1 -1
  48. package/template/.cowork-flow/spec/contracts/capabilities.md +23 -0
  49. package/template/.cowork-flow/spec/contracts/decision-anchor.md +2 -2
  50. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  51. package/template/.cowork-flow/spec/contracts/index.md +1 -0
  52. package/template/.cowork-flow/spec/contracts/party-mode-v2-board.md +20 -0
  53. package/template/.cowork-flow/spec/contracts/plan-binding.md +50 -0
  54. package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +31 -7
  55. package/template/.cowork-flow/spec/guides/index.md +3 -1
  56. package/template/.cowork-flow/spec/index.md +5 -4
  57. package/template/.cowork-flow/spec/references/definition-of-done.md +2 -3
  58. package/template/.cowork-flow/spec/references/orchestration-patterns.md +1 -1
  59. package/template/.cowork-flow/spec/runtime/contract-registry.json +14 -0
  60. package/template/.cowork-flow/spec/runtime/host-assets.json +73 -3
  61. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  62. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +150 -8
  63. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  64. package/template/skills/batch-execution/SKILL.md +37 -3
  65. package/template/skills/batch-execution/scripts/batch_action.py +4 -0
  66. package/template/skills/batch-execution/scripts/batch_execution.py +120 -499
  67. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  68. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  69. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  70. package/template/skills/brainstorming/SKILL.md +8 -7
  71. package/template/skills/cowork-flow/SKILL.md +3 -0
  72. package/template/skills/party-mode/scripts/party_action_contract.py +113 -0
  73. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  74. package/template/skills/party-mode/scripts/party_board_store.py +148 -0
  75. package/template/skills/party-mode/scripts/party_mode_v2.py +345 -317
  76. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  77. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  78. package/template/skills/runtime-health/SKILL.md +14 -2
  79. package/template/skills/runtime-health/scripts/doctor.py +278 -41
  80. package/template/skills/task-planning/SKILL.md +105 -61
  81. package/template/skills/task-review/SKILL.md +4 -4
  82. package/template/skills/task-review/scripts/review_check.py +277 -20
  83. package/template/skills/test-first/SKILL.md +18 -3
  84. package/template/skills/test-first/manifest.json +8 -4
  85. package/template/.cowork-flow/changes/archive/.gitkeep +0 -1
  86. package/template/.cowork-flow/scripts/adapters/cli/add_session.py +0 -465
  87. package/template/.cowork-flow/scripts/adapters/cli/change.py +0 -412
  88. package/template/.cowork-flow/workspace/index.md +0 -102
  89. package/template/.zcode/scaffold/AGENTS.md +0 -145
  90. package/template/.zcode/scaffold/CLAUDE.md +0 -5
@@ -10,11 +10,8 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import json
13
- import os
14
13
  import re
15
14
  import sys
16
- import threading
17
- from datetime import datetime, timezone
18
15
  from pathlib import Path
19
16
  from typing import Any
20
17
 
@@ -29,13 +26,49 @@ def _add_runtime_scripts_path() -> None:
29
26
 
30
27
 
31
28
  _add_runtime_scripts_path()
29
+ _SCRIPT_DIR = Path(__file__).resolve().parent
30
+ if str(_SCRIPT_DIR) not in sys.path:
31
+ sys.path.insert(0, str(_SCRIPT_DIR))
32
+
33
+ from party_board_store import (
34
+ append_action_history as _append_action_history,
35
+ append_audit as _append_audit,
36
+ load_board,
37
+ read_json as _read_json,
38
+ save_board,
39
+ state_lock as _state_lock,
40
+ write_json as _write_json,
41
+ )
42
+ from party_action_contract import validate_actions_document
43
+ from party_actions import (
44
+ actions_document as _project_actions_document,
45
+ child_action as _project_child_action,
46
+ close_actions as _project_close_actions,
47
+ empty_actions_document as _project_empty_actions,
48
+ fallback_actions_document as _project_fallback_actions,
49
+ )
50
+ from party_reports import (
51
+ action_results_summary as _project_action_results_summary,
52
+ build_final_report as _project_final_report,
53
+ )
54
+ from party_state_machine import (
55
+ advance_board_state as _advance_board_state,
56
+ build_public_round as _project_public_round,
57
+ current_round as _project_current_round,
58
+ ensure_finalizable as _ensure_finalizable_state,
59
+ initial_agents_state as _project_initial_agents_state,
60
+ initial_board as _project_initial_board,
61
+ )
32
62
  from infra.config import get_party_mode_v2_config
33
63
  from infra.paths import DIR_WORKFLOW, get_repo_root
64
+ from adapters.host.host_manifest import load_host_manifest
34
65
 
35
66
  RUNTIME_DIR = ".runtime"
36
67
  MODE_DIR = "party-mode-v2"
37
68
  SAFE_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
38
69
  CONFIDENCE_VALUES = {"low", "medium", "high"}
70
+ PARTY_BOARD_ACTION_CAPABILITY = "party_board_action"
71
+ SUPPORTED_HOST_CAPABILITY_STATUSES = {"native", "shim", "plugin", "external", "experimental"}
39
72
  HOST_FORBIDDEN_TERMS = (
40
73
  "spawn_agent",
41
74
  "wait_agent",
@@ -44,12 +77,6 @@ HOST_FORBIDDEN_TERMS = (
44
77
  "Claude Task",
45
78
  "OpenCode task",
46
79
  )
47
- _LOCKS_GUARD = threading.Lock()
48
- _STATE_LOCKS: dict[str, dict[str, Any]] = {}
49
-
50
-
51
- def _now() -> str:
52
- return datetime.now(timezone.utc).isoformat()
53
80
 
54
81
 
55
82
  def _validate_identifier(value: str, *, label: str) -> str:
@@ -71,117 +98,6 @@ def discussion_dir(repo_root: Path, discussion_id: str) -> Path:
71
98
  return path
72
99
 
73
100
 
74
- def _lock_file(handle: Any) -> None:
75
- if os.name == "nt":
76
- import msvcrt
77
-
78
- handle.seek(0)
79
- msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
80
- return
81
-
82
- import fcntl
83
-
84
- fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
85
-
86
-
87
- def _unlock_file(handle: Any) -> None:
88
- if os.name == "nt":
89
- import msvcrt
90
-
91
- handle.seek(0)
92
- msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
93
- return
94
-
95
- import fcntl
96
-
97
- fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
98
-
99
-
100
- class _StateLock:
101
- def __init__(self, base_dir: Path, state: dict[str, Any]) -> None:
102
- self.base_dir = base_dir
103
- self.state = state
104
-
105
- def __enter__(self) -> "_StateLock":
106
- thread_lock = self.state["thread_lock"]
107
- thread_lock.acquire()
108
- current_thread = threading.get_ident()
109
- if self.state.get("owner_thread") == current_thread:
110
- self.state["depth"] += 1
111
- return self
112
-
113
- self.base_dir.mkdir(parents=True, exist_ok=True)
114
- lock_path = self.base_dir / ".state.lock"
115
- handle = lock_path.open("a+b")
116
- handle.seek(0)
117
- handle.write(b"0")
118
- handle.flush()
119
- _lock_file(handle)
120
- self.state["owner_thread"] = current_thread
121
- self.state["depth"] = 1
122
- self.state["handle"] = handle
123
- return self
124
-
125
- def __exit__(self, exc_type: Any, exc: Any, traceback: Any) -> None:
126
- thread_lock = self.state["thread_lock"]
127
- try:
128
- current_thread = threading.get_ident()
129
- if self.state.get("owner_thread") == current_thread:
130
- self.state["depth"] -= 1
131
- if self.state["depth"] == 0:
132
- handle = self.state.pop("handle", None)
133
- self.state["owner_thread"] = None
134
- if handle is not None:
135
- try:
136
- _unlock_file(handle)
137
- finally:
138
- handle.close()
139
- finally:
140
- thread_lock.release()
141
-
142
-
143
- def _state_lock(base_dir: Path) -> _StateLock:
144
- key = str(base_dir.resolve())
145
- with _LOCKS_GUARD:
146
- state = _STATE_LOCKS.get(key)
147
- if state is None:
148
- state = {
149
- "thread_lock": threading.RLock(),
150
- "owner_thread": None,
151
- "depth": 0,
152
- }
153
- _STATE_LOCKS[key] = state
154
- return _StateLock(base_dir, state)
155
-
156
-
157
- def _write_json(path: Path, data: dict[str, Any]) -> None:
158
- path.parent.mkdir(parents=True, exist_ok=True)
159
- temp_path = path.with_name(f".{path.name}.{threading.get_ident()}.tmp")
160
- temp_path.write_text(
161
- json.dumps(data, ensure_ascii=False, indent=2) + "\n",
162
- encoding="utf-8",
163
- )
164
- os.replace(temp_path, path)
165
-
166
-
167
- def _read_json(path: Path) -> dict[str, Any]:
168
- return json.loads(path.read_text(encoding="utf-8"))
169
-
170
-
171
- def _append_audit(base_dir: Path, event: str, payload: dict[str, Any]) -> None:
172
- audit_path = base_dir / "audit.jsonl"
173
- entry = {"timestamp": _now(), "event": event, "payload": payload}
174
- with audit_path.open("a", encoding="utf-8") as handle:
175
- handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
176
-
177
-
178
- def _append_action_history(base_dir: Path, event: str, payload: dict[str, Any]) -> None:
179
- history_path = base_dir / "action_history.jsonl"
180
- entry = {"timestamp": _now(), "event": event, "payload": payload}
181
- with history_path.open("a", encoding="utf-8") as handle:
182
- handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
183
-
184
-
185
101
  def _parse_agent_spec(spec: str) -> dict[str, str]:
186
102
  agent_id, separator, lens = spec.partition(":")
187
103
  agent_id = agent_id.strip()
@@ -309,40 +225,7 @@ def _round_empty_state(items: list[Any], phase: str, *, item_type: str) -> str |
309
225
 
310
226
 
311
227
  def _build_public_round(board: dict[str, Any]) -> dict[str, Any]:
312
- current_round = int(board["round"]["current"])
313
- current = next(
314
- (
315
- item
316
- for item in board.get("rounds", [])
317
- if int(item.get("round", -1)) == current_round
318
- ),
319
- {"round": current_round, "posts": [], "responses": [], "moderator_events": []},
320
- )
321
- posts = list(current.get("posts", []))
322
- responses = list(current.get("responses", []))
323
- phase = board["round"]["phase"]
324
- expected_next_action = {
325
- "publish": "post",
326
- "respond": "respond",
327
- "closed": "finalized",
328
- }.get(str(phase), "monitor")
329
- return {
330
- "schema_version": 1,
331
- "discussion_id": board["discussion_id"],
332
- "round": current_round,
333
- "phase": phase,
334
- "topic": board["topic"],
335
- "visible_posts": posts,
336
- "visible_responses": responses,
337
- "moderator_events": list(current.get("moderator_events", [])),
338
- "empty_state": {
339
- "visible_posts": _round_empty_state(posts, phase, item_type="posts"),
340
- "visible_responses": _round_empty_state(responses, phase, item_type="responses"),
341
- },
342
- "expected_next_action": expected_next_action,
343
- }
344
-
345
-
228
+ return _project_public_round(board)
346
229
  def _agent_prompt_file(
347
230
  base_dir: Path,
348
231
  discussion_id: str,
@@ -362,6 +245,81 @@ def _agent_prompt_file(
362
245
  return prompt_name, str(prompt_file)
363
246
 
364
247
 
248
+ def _host_manifest_root(repo_root: Path) -> Path:
249
+ repo_manifest = repo_root / DIR_WORKFLOW / "spec" / "runtime" / "host-assets.json"
250
+ if repo_manifest.is_file():
251
+ return repo_root
252
+ for parent in _SCRIPT_DIR.parents:
253
+ if (parent / DIR_WORKFLOW / "spec" / "runtime" / "host-assets.json").is_file():
254
+ return parent
255
+ return repo_root
256
+
257
+
258
+ def _normalize_host_id(host_id: str | None) -> str | None:
259
+ if host_id is None or not str(host_id).strip():
260
+ return None
261
+ return _validate_identifier(str(host_id).strip(), label="host_id")
262
+
263
+
264
+ def _party_board_capability(repo_root: Path, host_id: str | None) -> Any | None:
265
+ if host_id is None:
266
+ return None
267
+ manifest = load_host_manifest(_host_manifest_root(repo_root))
268
+ return manifest.host_capability(host_id, PARTY_BOARD_ACTION_CAPABILITY)
269
+
270
+
271
+ def _party_board_action_is_supported(repo_root: Path, host_id: str | None) -> bool:
272
+ capability = _party_board_capability(repo_root, host_id)
273
+ if capability is None:
274
+ return True
275
+ return capability.status in SUPPORTED_HOST_CAPABILITY_STATUSES
276
+
277
+
278
+ def _party_board_fallback_actions(
279
+ repo_root: Path,
280
+ base_dir: Path,
281
+ discussion_id: str,
282
+ agents: list[dict[str, str]],
283
+ *,
284
+ round_number: int,
285
+ phase: str,
286
+ host_id: str,
287
+ ) -> dict[str, Any]:
288
+ capability = _party_board_capability(repo_root, host_id)
289
+ fallback = capability.fallback if capability is not None else "inline_or_manual"
290
+ (base_dir / "prompts").mkdir(parents=True, exist_ok=True)
291
+ prompt_files: list[str] = []
292
+ for agent in agents:
293
+ _prompt_name, prompt_file = _agent_prompt_file(
294
+ base_dir,
295
+ discussion_id,
296
+ agent,
297
+ round_number=round_number,
298
+ phase=phase,
299
+ )
300
+ prompt_files.append(prompt_file)
301
+ document = _project_fallback_actions(
302
+ discussion_id,
303
+ round_number=round_number,
304
+ phase=phase,
305
+ host_id=host_id,
306
+ fallback=fallback,
307
+ )
308
+ action = document["next_actions"][0]
309
+ _append_action_history(base_dir, "action-issued", action)
310
+ _append_audit(
311
+ base_dir,
312
+ "fallback",
313
+ {
314
+ "host_id": host_id,
315
+ "capability": PARTY_BOARD_ACTION_CAPABILITY,
316
+ "fallback": fallback,
317
+ "round": round_number,
318
+ "phase": phase,
319
+ "prompt_files": prompt_files,
320
+ },
321
+ )
322
+ return document
365
323
  def _child_action(
366
324
  base_dir: Path,
367
325
  discussion_id: str,
@@ -370,7 +328,6 @@ def _child_action(
370
328
  round_number: int,
371
329
  phase: str,
372
330
  ) -> dict[str, Any]:
373
- agent_id = agent["agent_id"]
374
331
  _prompt_name, prompt_file = _agent_prompt_file(
375
332
  base_dir,
376
333
  discussion_id,
@@ -378,48 +335,55 @@ def _child_action(
378
335
  round_number=round_number,
379
336
  phase=phase,
380
337
  )
381
- return {
382
- "action_id": f"r{round_number}-{phase}-{agent_id}",
383
- "type": "dispatch_child" if phase == "publish" else "send_control_message",
384
- "agent_id": agent_id,
385
- "agent_kind": "advisory",
386
- "lens": agent["lens"],
387
- "message_kind": f"board_{phase}",
388
- "prompt_file": prompt_file,
389
- }
390
-
391
-
338
+ return _project_child_action(
339
+ agent=agent,
340
+ round_number=round_number,
341
+ phase=phase,
342
+ prompt_file=prompt_file,
343
+ )
392
344
  def _build_actions(
345
+ repo_root: Path,
393
346
  base_dir: Path,
394
347
  discussion_id: str,
395
348
  agents: list[dict[str, str]],
396
349
  *,
397
350
  round_number: int,
398
351
  phase: str,
352
+ host_id: str | None = None,
399
353
  ) -> dict[str, Any]:
400
- actions = []
354
+ host_id = _normalize_host_id(host_id)
355
+ if host_id is not None and not _party_board_action_is_supported(repo_root, host_id):
356
+ return _party_board_fallback_actions(
357
+ repo_root,
358
+ base_dir,
359
+ discussion_id,
360
+ agents,
361
+ round_number=round_number,
362
+ phase=phase,
363
+ host_id=host_id,
364
+ )
401
365
  prompts_dir = base_dir / "prompts"
402
366
  prompts_dir.mkdir(parents=True, exist_ok=True)
403
- for agent in agents:
404
- action = _child_action(
367
+ child_actions = [
368
+ _child_action(
405
369
  base_dir,
406
370
  discussion_id,
407
371
  agent,
408
372
  round_number=round_number,
409
373
  phase=phase,
410
374
  )
411
- actions.append(action)
375
+ for agent in agents
376
+ ]
377
+ document = _project_actions_document(
378
+ discussion_id,
379
+ child_actions=child_actions,
380
+ agent_ids=[agent["agent_id"] for agent in agents],
381
+ round_number=round_number,
382
+ phase=phase,
383
+ )
384
+ for action in document["next_actions"]:
412
385
  _append_action_history(base_dir, "action-issued", action)
413
- wait_action = {
414
- "action_id": f"r{round_number}-{phase}-wait",
415
- "type": "wait_children",
416
- "agent_ids": [agent["agent_id"] for agent in agents],
417
- }
418
- actions.append(wait_action)
419
- _append_action_history(base_dir, "action-issued", wait_action)
420
- return {"schema_version": 1, "discussion_id": discussion_id, "next_actions": actions}
421
-
422
-
386
+ return document
423
387
  def _build_close_actions(
424
388
  base_dir: Path,
425
389
  agents: list[dict[str, str]],
@@ -427,30 +391,27 @@ def _build_close_actions(
427
391
  round_number: int,
428
392
  reason: str,
429
393
  ) -> list[dict[str, Any]]:
430
- actions = []
431
- for agent in agents:
432
- action = {
433
- "action_id": f"r{round_number}-close-{agent['agent_id']}",
434
- "type": "close_child",
435
- "agent_id": agent["agent_id"],
436
- "reason": reason,
437
- }
438
- actions.append(action)
394
+ actions = _project_close_actions(
395
+ agents,
396
+ round_number=round_number,
397
+ reason=reason,
398
+ )
399
+ for action in actions:
439
400
  _append_action_history(base_dir, "action-issued", action)
440
401
  _append_audit(
441
402
  base_dir,
442
403
  "close",
443
404
  {
444
- "agent_id": agent["agent_id"],
405
+ "agent_id": action["agent_id"],
445
406
  "round": round_number,
446
407
  "reason": reason,
447
408
  },
448
409
  )
449
410
  return actions
450
-
451
-
452
411
  def _empty_actions(discussion_id: str) -> dict[str, Any]:
453
- return {"schema_version": 1, "discussion_id": discussion_id, "next_actions": []}
412
+ return _project_empty_actions(discussion_id)
413
+ def _write_actions_json(base_dir: Path, actions: dict[str, Any]) -> None:
414
+ _write_json(base_dir / "actions.json", validate_actions_document(actions))
454
415
 
455
416
 
456
417
  def _with_terminal_state(data: dict[str, Any], terminal: bool) -> dict[str, Any]:
@@ -458,55 +419,52 @@ def _with_terminal_state(data: dict[str, Any], terminal: bool) -> dict[str, Any]
458
419
  return data
459
420
 
460
421
 
461
- def _initial_board(discussion_id: str, topic: str, max_rounds: int) -> dict[str, Any]:
462
- return {
463
- "schema_version": 1,
464
- "discussion_id": discussion_id,
465
- "topic": topic,
466
- "round": {"current": 1, "max": max_rounds, "phase": "publish"},
467
- "rounds": [{"round": 1, "posts": [], "responses": [], "moderator_events": []}],
468
- "termination": {"reason": None},
469
- }
470
-
471
-
422
+ def _initial_board(
423
+ discussion_id: str,
424
+ topic: str,
425
+ max_rounds: int,
426
+ *,
427
+ host_id: str | None = None,
428
+ ) -> dict[str, Any]:
429
+ return _project_initial_board(
430
+ discussion_id,
431
+ topic,
432
+ max_rounds,
433
+ host_id=host_id,
434
+ )
472
435
  def _initial_agents_state(discussion_id: str, agents: list[dict[str, str]]) -> dict[str, Any]:
473
- return {
474
- "schema_version": 1,
475
- "discussion_id": discussion_id,
476
- "agents": [
477
- {
478
- "agent_id": agent["agent_id"],
479
- "lens": agent["lens"],
480
- "status": "pending",
481
- "drift_warnings": 0,
482
- "host_child_id": None,
483
- }
484
- for agent in agents
485
- ],
486
- }
487
-
488
-
436
+ return _project_initial_agents_state(discussion_id, agents)
489
437
  def init_discussion(
490
438
  repo_root: Path,
491
439
  *,
492
440
  discussion_id: str,
493
441
  topic: str,
494
442
  agent_specs: list[str],
443
+ host_id: str | None = None,
495
444
  ) -> dict[str, Any]:
496
445
  _validate_identifier(discussion_id, label="discussion_id")
497
446
  config = get_party_mode_v2_config(repo_root)
498
447
  agents = [_parse_agent_spec(spec) for spec in agent_specs]
499
448
  _validate_agents(agents, config)
449
+ host_id = _normalize_host_id(host_id)
500
450
  base_dir = discussion_dir(repo_root, discussion_id)
501
451
  with _state_lock(base_dir):
502
452
  max_rounds = int(config["max_rounds"])
503
- board = _initial_board(discussion_id, topic, max_rounds)
453
+ board = _initial_board(discussion_id, topic, max_rounds, host_id=host_id)
504
454
  agents_state = _initial_agents_state(discussion_id, agents)
505
- actions = _build_actions(base_dir, discussion_id, agents, round_number=1, phase="publish")
455
+ actions = _build_actions(
456
+ repo_root,
457
+ base_dir,
458
+ discussion_id,
459
+ agents,
460
+ round_number=1,
461
+ phase="publish",
462
+ host_id=host_id,
463
+ )
506
464
  public_round = _build_public_round(board)
507
- _write_json(base_dir / "board.json", board)
465
+ save_board(base_dir, board)
508
466
  _write_json(base_dir / "agents.json", agents_state)
509
- _write_json(base_dir / "actions.json", actions)
467
+ _write_actions_json(base_dir, actions)
510
468
  _write_json(base_dir / "public_round.json", public_round)
511
469
  _append_audit(
512
470
  base_dir,
@@ -530,7 +488,7 @@ def view_discussion(
530
488
  if agent_id:
531
489
  _validate_identifier(agent_id, label="agent_id")
532
490
  with _state_lock(base_dir):
533
- board = _read_json(base_dir / "board.json")
491
+ board = load_board(base_dir)
534
492
  public_round = _build_public_round(board)
535
493
  if agent_id:
536
494
  public_round["agent_id"] = agent_id
@@ -545,9 +503,9 @@ def view_discussion(
545
503
 
546
504
  def monitor_discussion(repo_root: Path, *, discussion_id: str) -> dict[str, Any]:
547
505
  base_dir = discussion_dir(repo_root, discussion_id)
548
- board = _read_json(base_dir / "board.json")
506
+ board = load_board(base_dir)
549
507
  agents = _read_json(base_dir / "agents.json")
550
- actions = _read_json(base_dir / "actions.json")
508
+ actions = validate_actions_document(_read_json(base_dir / "actions.json"))
551
509
  active_agents = [
552
510
  agent
553
511
  for agent in agents.get("agents", [])
@@ -569,15 +527,7 @@ def monitor_discussion(repo_root: Path, *, discussion_id: str) -> dict[str, Any]
569
527
 
570
528
 
571
529
  def _current_round(board: dict[str, Any]) -> dict[str, Any]:
572
- current_round = int(board["round"]["current"])
573
- for item in board.get("rounds", []):
574
- if int(item.get("round", -1)) == current_round:
575
- return item
576
- item = {"round": current_round, "posts": [], "responses": [], "moderator_events": []}
577
- board.setdefault("rounds", []).append(item)
578
- return item
579
-
580
-
530
+ return _project_current_round(board)
581
531
  def _active_agent_ids(base_dir: Path) -> list[str]:
582
532
  agents = _read_json(base_dir / "agents.json")
583
533
  return [
@@ -638,7 +588,7 @@ def _build_post_payload(
638
588
 
639
589
 
640
590
  def _write_runtime_state(base_dir: Path, board: dict[str, Any]) -> None:
641
- _write_json(base_dir / "board.json", board)
591
+ save_board(base_dir, board)
642
592
  _write_json(base_dir / "public_round.json", _build_public_round(board))
643
593
 
644
594
 
@@ -653,7 +603,7 @@ def post_submission(
653
603
  base_dir = discussion_dir(repo_root, discussion_id)
654
604
  config = get_party_mode_v2_config(repo_root)
655
605
  with _state_lock(base_dir):
656
- board = _read_json(base_dir / "board.json")
606
+ board = load_board(base_dir)
657
607
  if board["round"]["phase"] != "publish":
658
608
  raise ValueError("phase_not_publish")
659
609
  if agent_id not in _active_agent_ids(base_dir):
@@ -869,7 +819,7 @@ def respond_submission(
869
819
  base_dir = discussion_dir(repo_root, discussion_id)
870
820
  config = get_party_mode_v2_config(repo_root)
871
821
  with _state_lock(base_dir):
872
- board = _read_json(base_dir / "board.json")
822
+ board = load_board(base_dir)
873
823
  if board["round"]["phase"] != "respond":
874
824
  raise ValueError("phase_not_respond")
875
825
  if agent_id not in _active_agent_ids(base_dir):
@@ -905,23 +855,27 @@ def respond_submission(
905
855
 
906
856
 
907
857
  def _write_actions_for_phase(
858
+ repo_root: Path,
908
859
  base_dir: Path,
909
860
  discussion_id: str,
910
861
  round_number: int,
911
862
  phase: str,
912
863
  *,
864
+ host_id: str | None = None,
913
865
  leading_actions: list[dict[str, Any]] | None = None,
914
866
  ) -> None:
915
867
  actions = _build_actions(
868
+ repo_root,
916
869
  base_dir,
917
870
  discussion_id,
918
871
  _agent_lenses(base_dir),
919
872
  round_number=round_number,
920
873
  phase=phase,
874
+ host_id=host_id,
921
875
  )
922
876
  if leading_actions:
923
877
  actions["next_actions"] = leading_actions + actions["next_actions"]
924
- _write_json(base_dir / "actions.json", actions)
878
+ _write_actions_json(base_dir, actions)
925
879
 
926
880
 
927
881
  def _advance_publish_phase(
@@ -934,21 +888,26 @@ def _advance_publish_phase(
934
888
  active_agents: list[str],
935
889
  current_round: int,
936
890
  ) -> dict[str, Any]:
937
- posted_agents = {str(post["agent_id"]) for post in current.get("posts", [])}
938
- missing = sorted(set(active_agents) - posted_agents)
939
- if missing:
940
- raise ValueError(f"publish_incomplete:{','.join(missing)}")
941
- board["round"]["phase"] = "respond"
891
+ transition = _advance_board_state(
892
+ board,
893
+ active_agent_ids=active_agents,
894
+ fresh_context_per_round=True,
895
+ )
942
896
  _write_runtime_state(base_dir, board)
943
- _write_actions_for_phase(base_dir, discussion_id, current_round, "respond")
897
+ _write_actions_for_phase(
898
+ repo_root,
899
+ base_dir,
900
+ discussion_id,
901
+ transition["next_actions"]["round"],
902
+ transition["next_actions"]["phase"],
903
+ host_id=board.get("host_id"),
904
+ )
944
905
  _append_audit(
945
906
  base_dir,
946
907
  "advance",
947
- {"round": current_round, "from": "publish", "to": "respond"},
908
+ {"round": current_round, "from": transition["from"], "to": transition["to"]},
948
909
  )
949
910
  return monitor_discussion(repo_root, discussion_id=discussion_id)
950
-
951
-
952
911
  def _close_discussion(
953
912
  repo_root: Path,
954
913
  *,
@@ -961,7 +920,7 @@ def _close_discussion(
961
920
  board["round"]["phase"] = "closed"
962
921
  board["termination"] = {"reason": reason}
963
922
  _write_runtime_state(base_dir, board)
964
- _write_json(base_dir / "actions.json", _empty_actions(discussion_id))
923
+ _write_actions_json(base_dir, _empty_actions(discussion_id))
965
924
  _append_audit(
966
925
  base_dir,
967
926
  "advance",
@@ -994,10 +953,12 @@ def _advance_next_round(
994
953
  )
995
954
  _write_runtime_state(base_dir, board)
996
955
  _write_actions_for_phase(
956
+ repo_root,
997
957
  base_dir,
998
958
  discussion_id,
999
959
  next_round,
1000
960
  "publish",
961
+ host_id=board.get("host_id"),
1001
962
  leading_actions=close_actions,
1002
963
  )
1003
964
  _append_audit(
@@ -1018,41 +979,63 @@ def _advance_respond_phase(
1018
979
  active_agents: list[str],
1019
980
  current_round: int,
1020
981
  ) -> dict[str, Any]:
1021
- responded_agents = {str(response["agent_id"]) for response in current.get("responses", [])}
1022
- missing = sorted(set(active_agents) - responded_agents)
1023
- if missing:
1024
- raise ValueError(f"respond_incomplete:{','.join(missing)}")
1025
- if not any(bool(response.get("still_disagree")) for response in current.get("responses", [])):
1026
- return _close_discussion(
1027
- repo_root,
1028
- base_dir=base_dir,
1029
- discussion_id=discussion_id,
1030
- board=board,
1031
- current_round=current_round,
1032
- reason="converged",
982
+ config = get_party_mode_v2_config(repo_root)
983
+ transition = _advance_board_state(
984
+ board,
985
+ active_agent_ids=active_agents,
986
+ fresh_context_per_round=bool(config.get("fresh_context_per_round", True)),
987
+ )
988
+ if transition["to"] == "closed":
989
+ _write_runtime_state(base_dir, board)
990
+ _write_actions_json(base_dir, _empty_actions(discussion_id))
991
+ _append_audit(
992
+ base_dir,
993
+ "advance",
994
+ {
995
+ "round": current_round,
996
+ "from": transition["from"],
997
+ "to": transition["to"],
998
+ "reason": transition["reason"],
999
+ },
1033
1000
  )
1034
- if current_round >= int(board["round"]["max"]):
1035
- return _close_discussion(
1036
- repo_root,
1037
- base_dir=base_dir,
1038
- discussion_id=discussion_id,
1039
- board=board,
1040
- current_round=current_round,
1041
- reason="max_rounds_unconverged",
1001
+ return finalize_discussion(repo_root, discussion_id=discussion_id)
1002
+
1003
+ if transition["to"] != "publish":
1004
+ raise ValueError("phase_not_advanceable")
1005
+ close_actions = []
1006
+ close_reason = transition.get("close_children_reason")
1007
+ if close_reason:
1008
+ close_actions = _build_close_actions(
1009
+ base_dir,
1010
+ _agent_lenses(base_dir),
1011
+ round_number=current_round,
1012
+ reason=str(close_reason),
1042
1013
  )
1043
- return _advance_next_round(
1014
+ _write_runtime_state(base_dir, board)
1015
+ _write_actions_for_phase(
1044
1016
  repo_root,
1045
- base_dir=base_dir,
1046
- discussion_id=discussion_id,
1047
- board=board,
1048
- current_round=current_round,
1017
+ base_dir,
1018
+ discussion_id,
1019
+ transition["next_actions"]["round"],
1020
+ transition["next_actions"]["phase"],
1021
+ host_id=board.get("host_id"),
1022
+ leading_actions=close_actions,
1049
1023
  )
1050
-
1051
-
1024
+ _append_audit(
1025
+ base_dir,
1026
+ "advance",
1027
+ {
1028
+ "round": current_round,
1029
+ "from": transition["from"],
1030
+ "to": transition["to"],
1031
+ "next_round": transition["next_round"],
1032
+ },
1033
+ )
1034
+ return monitor_discussion(repo_root, discussion_id=discussion_id)
1052
1035
  def advance_discussion(repo_root: Path, *, discussion_id: str) -> dict[str, Any]:
1053
1036
  base_dir = discussion_dir(repo_root, discussion_id)
1054
1037
  with _state_lock(base_dir):
1055
- board = _read_json(base_dir / "board.json")
1038
+ board = load_board(base_dir)
1056
1039
  current = _current_round(board)
1057
1040
  active_agents = _active_agent_ids(base_dir)
1058
1041
  current_round = int(board["round"]["current"])
@@ -1103,16 +1086,13 @@ def _ensure_finalizable(
1103
1086
  *,
1104
1087
  manual_termination: bool,
1105
1088
  ) -> None:
1106
- if board["round"]["phase"] == "closed":
1107
- return
1108
- if not manual_termination:
1109
- raise ValueError("finalize_requires_closed_discussion")
1110
- board["round"]["phase"] = "closed"
1111
- board["termination"] = {"reason": "manual_terminated"}
1112
- _write_runtime_state(base_dir, board)
1113
- _write_json(base_dir / "actions.json", _empty_actions(discussion_id))
1114
-
1115
-
1089
+ changed = _ensure_finalizable_state(
1090
+ board,
1091
+ manual_termination=manual_termination,
1092
+ )
1093
+ if changed:
1094
+ _write_runtime_state(base_dir, board)
1095
+ _write_actions_json(base_dir, _empty_actions(discussion_id))
1116
1096
  def _discussion_posts(board: dict[str, Any]) -> list[dict[str, Any]]:
1117
1097
  return [post for item in board.get("rounds", []) for post in item.get("posts", [])]
1118
1098
 
@@ -1147,8 +1127,75 @@ def _historical_disagreements(
1147
1127
  ]
1148
1128
 
1149
1129
 
1130
+ def _round_summary_phase(board: dict[str, Any], round_number: int) -> str:
1131
+ if round_number == int(board["round"]["current"]):
1132
+ return str(board["round"]["phase"])
1133
+ return "completed"
1134
+
1135
+
1136
+ def _rounds_summary(board: dict[str, Any]) -> list[dict[str, Any]]:
1137
+ summaries: list[dict[str, Any]] = []
1138
+ for item in board.get("rounds", []):
1139
+ round_number = int(item.get("round", 0))
1140
+ posts = item.get("posts", [])
1141
+ responses = item.get("responses", [])
1142
+ unresolved = [r for r in responses if r.get("still_disagree")]
1143
+ summaries.append(
1144
+ {
1145
+ "round": round_number,
1146
+ "phase": _round_summary_phase(board, round_number),
1147
+ "post_count": len(posts),
1148
+ "response_count": len(responses),
1149
+ "unresolved_count": len(unresolved),
1150
+ }
1151
+ )
1152
+ return summaries
1153
+
1154
+
1155
+ def _action_results_summary(base_dir: Path) -> list[dict[str, Any]]:
1156
+ history_path = base_dir / "action_history.jsonl"
1157
+ if not history_path.is_file():
1158
+ return []
1159
+ results: list[dict[str, Any]] = []
1160
+ with history_path.open("r", encoding="utf-8") as handle:
1161
+ for line in handle:
1162
+ if not line.strip():
1163
+ continue
1164
+ entry = json.loads(line)
1165
+ if entry.get("event") != "action-result":
1166
+ continue
1167
+ payload = entry.get("payload", {})
1168
+ summary: dict[str, Any] = {
1169
+ "action_id": payload.get("action_id"),
1170
+ "type": payload.get("type"),
1171
+ "outcome": payload.get("outcome"),
1172
+ }
1173
+ if payload.get("agent_id"):
1174
+ summary["agent_id"] = payload["agent_id"]
1175
+ results.append(summary)
1176
+ return results
1177
+
1178
+
1179
+ def _accepted_evidence_summary(responses: list[dict[str, Any]]) -> list[dict[str, Any]]:
1180
+ summaries: list[dict[str, Any]] = []
1181
+ for response in responses:
1182
+ if response.get("decision") != "concede":
1183
+ continue
1184
+ evidence = response.get("accepted_evidence", [])
1185
+ if not evidence:
1186
+ continue
1187
+ summaries.append(
1188
+ {
1189
+ "agent_id": response.get("agent_id"),
1190
+ "target_post_id": response.get("target_post_id"),
1191
+ "accepted_evidence": evidence,
1192
+ }
1193
+ )
1194
+ return summaries
1195
+
1150
1196
  def _build_final_report(
1151
1197
  *,
1198
+ base_dir: Path,
1152
1199
  discussion_id: str,
1153
1200
  board: dict[str, Any],
1154
1201
  posts: list[dict[str, Any]],
@@ -1157,34 +1204,12 @@ def _build_final_report(
1157
1204
  historical_disagreements: list[dict[str, Any]],
1158
1205
  stop_reason: str,
1159
1206
  ) -> dict[str, Any]:
1160
- current_round = int(board["round"]["current"])
1161
- current_unresolved = [
1162
- response for response in current_responses if response.get("still_disagree")
1163
- ]
1164
- board_status = {
1165
- "round": current_round,
1166
- "phase": board["round"]["phase"],
1167
- "max_rounds": board["round"]["max"],
1168
- "termination_reason": stop_reason,
1169
- }
1170
- return {
1171
- "schema_version": 1,
1172
- "discussion_id": discussion_id,
1173
- "terminal": True,
1174
- "stop_reason": stop_reason,
1175
- "board_status": board_status,
1176
- "next_actions": [],
1177
- "pro": _report_pro_posts(posts),
1178
- "con": _report_con_responses(responses),
1179
- "changed_positions": _responses_with_delta(responses, "changed"),
1180
- "maintained_positions": _responses_with_delta(responses, "unchanged"),
1181
- "revised_positions": _responses_with_decision(responses, "revise"),
1182
- "current_unresolved_disagreements": current_unresolved,
1183
- "historical_disagreements": historical_disagreements,
1184
- "unresolved_disagreements": current_unresolved,
1185
- }
1186
-
1187
-
1207
+ return _project_final_report(
1208
+ discussion_id=discussion_id,
1209
+ board=board,
1210
+ action_results=_project_action_results_summary(base_dir),
1211
+ stop_reason=stop_reason,
1212
+ )
1188
1213
  def _report_pro_posts(posts: list[dict[str, Any]]) -> list[dict[str, Any]]:
1189
1214
  return [
1190
1215
  {"agent_id": post["agent_id"], "claim": post["claim"], "evidence": post["evidence"]}
@@ -1230,7 +1255,7 @@ def finalize_discussion(
1230
1255
  ) -> dict[str, Any]:
1231
1256
  base_dir = discussion_dir(repo_root, discussion_id)
1232
1257
  with _state_lock(base_dir):
1233
- board = _read_json(base_dir / "board.json")
1258
+ board = load_board(base_dir)
1234
1259
  _ensure_finalizable(
1235
1260
  base_dir,
1236
1261
  discussion_id,
@@ -1241,6 +1266,7 @@ def finalize_discussion(
1241
1266
  current_round = int(board["round"]["current"])
1242
1267
  stop_reason = board.get("termination", {}).get("reason") or "manual_finalize"
1243
1268
  report = _build_final_report(
1269
+ base_dir=base_dir,
1244
1270
  discussion_id=discussion_id,
1245
1271
  board=board,
1246
1272
  posts=_discussion_posts(board),
@@ -1312,6 +1338,7 @@ def build_parser() -> argparse.ArgumentParser:
1312
1338
  init_parser.add_argument("--discussion-id", required=True)
1313
1339
  init_parser.add_argument("--topic", required=True)
1314
1340
  init_parser.add_argument("--agent", action="append", required=True)
1341
+ init_parser.add_argument("--host-id", default=None)
1315
1342
 
1316
1343
  view_parser = subcommands.add_parser("view")
1317
1344
  view_parser.add_argument("--discussion-id", required=True)
@@ -1358,6 +1385,7 @@ def _command_result(repo_root: Path, args: argparse.Namespace) -> dict[str, Any]
1358
1385
  discussion_id=args.discussion_id,
1359
1386
  topic=args.topic,
1360
1387
  agent_specs=args.agent,
1388
+ host_id=args.host_id,
1361
1389
  )
1362
1390
  if args.command == "view":
1363
1391
  return view_discussion(