cowork-flow 0.0.48 → 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 (43) hide show
  1. package/README.md +16 -1
  2. package/package.json +6 -3
  3. package/src/cli.js +6 -0
  4. package/src/commands/source-refresh.js +40 -0
  5. package/src/lib/copy-template.js +130 -1
  6. package/src/lib/host-assets.js +216 -71
  7. package/template/.codex/hooks.json +1 -1
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -2
  10. package/template/.cowork-flow/scripts/adapters/cli/task_archive_commands.py +12 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +18 -133
  12. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +82 -1
  13. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +98 -114
  14. package/template/.cowork-flow/scripts/infra/skill_manifest.py +23 -3
  15. package/template/.cowork-flow/scripts/infra/storage/operation_log.py +40 -0
  16. package/template/.cowork-flow/scripts/infra/storage/state_store.py +356 -1
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +156 -0
  18. package/template/.cowork-flow/scripts/services/context_jsonl.py +119 -0
  19. package/template/.cowork-flow/scripts/services/context_paths.py +186 -0
  20. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +23 -4
  21. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +128 -0
  22. package/template/.cowork-flow/scripts/services/plan_binding.py +146 -0
  23. package/template/.cowork-flow/scripts/services/task_archive.py +100 -14
  24. package/template/.cowork-flow/scripts/services/task_context.py +49 -479
  25. package/template/.cowork-flow/scripts/services/task_creation.py +31 -27
  26. package/template/.cowork-flow/scripts/services/task_lifecycle.py +155 -18
  27. package/template/.cowork-flow/spec/contracts/error-output-as-data.md +8 -0
  28. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  29. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +32 -6
  30. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  31. package/template/skills/batch-execution/scripts/batch_execution.py +111 -629
  32. package/template/skills/batch-execution/scripts/batch_recovery.py +94 -0
  33. package/template/skills/batch-execution/scripts/batch_state_machine.py +201 -0
  34. package/template/skills/batch-execution/scripts/batch_verification.py +326 -0
  35. package/template/skills/cowork-flow/SKILL.md +3 -0
  36. package/template/skills/party-mode/scripts/party_actions.py +95 -0
  37. package/template/skills/party-mode/scripts/party_mode_v2.py +129 -205
  38. package/template/skills/party-mode/scripts/party_reports.py +177 -0
  39. package/template/skills/party-mode/scripts/party_state_machine.py +189 -0
  40. package/template/skills/runtime-health/scripts/doctor.py +204 -15
  41. package/template/skills/task-review/scripts/review_check.py +135 -2
  42. package/template/skills/test-first/SKILL.md +18 -3
  43. package/template/skills/test-first/manifest.json +8 -4
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Pure Party Mode V2 final report projection helpers."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def discussion_posts(board: dict[str, Any]) -> list[dict[str, Any]]:
13
+ return [post for item in board.get("rounds", []) for post in item.get("posts", [])]
14
+
15
+
16
+ def discussion_responses(board: dict[str, Any]) -> list[dict[str, Any]]:
17
+ return [
18
+ response
19
+ for item in board.get("rounds", [])
20
+ for response in item.get("responses", [])
21
+ ]
22
+
23
+
24
+ def round_responses(board: dict[str, Any], round_number: int) -> list[dict[str, Any]]:
25
+ return [
26
+ response
27
+ for item in board.get("rounds", [])
28
+ if int(item.get("round", -1)) == round_number
29
+ for response in item.get("responses", [])
30
+ ]
31
+
32
+
33
+ def historical_disagreements(board: dict[str, Any], current_round: int) -> list[dict[str, Any]]:
34
+ return [
35
+ response
36
+ for item in board.get("rounds", [])
37
+ if int(item.get("round", -1)) < current_round
38
+ for response in item.get("responses", [])
39
+ if response.get("still_disagree")
40
+ ]
41
+
42
+
43
+ def round_summary_phase(board: dict[str, Any], round_number: int) -> str:
44
+ if round_number == int(board["round"]["current"]):
45
+ return str(board["round"]["phase"])
46
+ return "completed"
47
+
48
+
49
+ def rounds_summary(board: dict[str, Any]) -> list[dict[str, Any]]:
50
+ summaries: list[dict[str, Any]] = []
51
+ for item in board.get("rounds", []):
52
+ round_number = int(item.get("round", 0))
53
+ posts = item.get("posts", [])
54
+ responses = item.get("responses", [])
55
+ unresolved = [response for response in responses if response.get("still_disagree")]
56
+ summaries.append(
57
+ {
58
+ "round": round_number,
59
+ "phase": round_summary_phase(board, round_number),
60
+ "post_count": len(posts),
61
+ "response_count": len(responses),
62
+ "unresolved_count": len(unresolved),
63
+ }
64
+ )
65
+ return summaries
66
+
67
+
68
+ def action_results_summary(base_dir: Path) -> list[dict[str, Any]]:
69
+ history_path = base_dir / "action_history.jsonl"
70
+ if not history_path.is_file():
71
+ return []
72
+ results: list[dict[str, Any]] = []
73
+ with history_path.open("r", encoding="utf-8") as handle:
74
+ for line in handle:
75
+ if not line.strip():
76
+ continue
77
+ entry = json.loads(line)
78
+ if entry.get("event") != "action-result":
79
+ continue
80
+ payload = entry.get("payload", {})
81
+ summary: dict[str, Any] = {
82
+ "action_id": payload.get("action_id"),
83
+ "type": payload.get("type"),
84
+ "outcome": payload.get("outcome"),
85
+ }
86
+ if payload.get("agent_id"):
87
+ summary["agent_id"] = payload["agent_id"]
88
+ results.append(summary)
89
+ return results
90
+
91
+
92
+ def accepted_evidence_summary(responses: list[dict[str, Any]]) -> list[dict[str, Any]]:
93
+ summaries: list[dict[str, Any]] = []
94
+ for response in responses:
95
+ if response.get("decision") != "concede":
96
+ continue
97
+ evidence = response.get("accepted_evidence", [])
98
+ if not evidence:
99
+ continue
100
+ summaries.append(
101
+ {
102
+ "agent_id": response.get("agent_id"),
103
+ "target_post_id": response.get("target_post_id"),
104
+ "accepted_evidence": evidence,
105
+ }
106
+ )
107
+ return summaries
108
+
109
+
110
+ def report_pro_posts(posts: list[dict[str, Any]]) -> list[dict[str, Any]]:
111
+ return [
112
+ {"agent_id": post["agent_id"], "claim": post["claim"], "evidence": post["evidence"]}
113
+ for post in posts
114
+ ]
115
+
116
+
117
+ def report_con_responses(responses: list[dict[str, Any]]) -> list[dict[str, Any]]:
118
+ return [
119
+ {
120
+ "agent_id": response["agent_id"],
121
+ "target_post_id": response["target_post_id"],
122
+ "reasoning": response["reasoning"],
123
+ }
124
+ for response in responses
125
+ if response.get("decision") == "maintain"
126
+ ]
127
+
128
+
129
+ def responses_with_delta(responses: list[dict[str, Any]], position_delta: str) -> list[dict[str, Any]]:
130
+ return [response for response in responses if response.get("position_delta") == position_delta]
131
+
132
+
133
+ def responses_with_decision(responses: list[dict[str, Any]], decision: str) -> list[dict[str, Any]]:
134
+ return [response for response in responses if response.get("decision") == decision]
135
+
136
+
137
+ def build_final_report(
138
+ *,
139
+ discussion_id: str,
140
+ board: dict[str, Any],
141
+ action_results: list[dict[str, Any]],
142
+ stop_reason: str,
143
+ ) -> dict[str, Any]:
144
+ """Build a final advisory report from board facts without file writes."""
145
+
146
+ current_round = int(board["round"]["current"])
147
+ posts = discussion_posts(board)
148
+ responses = discussion_responses(board)
149
+ current_responses = round_responses(board, current_round)
150
+ current_unresolved = [
151
+ response for response in current_responses if response.get("still_disagree")
152
+ ]
153
+ board_status = {
154
+ "round": current_round,
155
+ "phase": board["round"]["phase"],
156
+ "max_rounds": board["round"]["max"],
157
+ "termination_reason": stop_reason,
158
+ }
159
+ return {
160
+ "schema_version": 1,
161
+ "discussion_id": discussion_id,
162
+ "terminal": True,
163
+ "stop_reason": stop_reason,
164
+ "board_status": board_status,
165
+ "rounds_summary": rounds_summary(board),
166
+ "action_results": list(action_results),
167
+ "accepted_evidence": accepted_evidence_summary(responses),
168
+ "next_actions": [],
169
+ "pro": report_pro_posts(posts),
170
+ "con": report_con_responses(responses),
171
+ "changed_positions": responses_with_delta(responses, "changed"),
172
+ "maintained_positions": responses_with_delta(responses, "unchanged"),
173
+ "revised_positions": responses_with_decision(responses, "revise"),
174
+ "current_unresolved_disagreements": current_unresolved,
175
+ "historical_disagreements": historical_disagreements(board, current_round),
176
+ "unresolved_disagreements": current_unresolved,
177
+ }
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Pure Party Mode V2 board state transitions and projections."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ CONFIDENCE_VALUES = {"low", "medium", "high"}
10
+
11
+
12
+ def round_empty_state(items: list[Any], phase: str, *, item_type: str) -> str | None:
13
+ if items:
14
+ return None
15
+ if item_type == "posts":
16
+ if phase == "publish":
17
+ return "waiting_for_current_round_posts"
18
+ if phase == "closed":
19
+ return "discussion_closed"
20
+ return "no_current_round_posts"
21
+ if phase == "publish":
22
+ return "responses_not_open_yet"
23
+ if phase == "respond":
24
+ return "waiting_for_current_round_responses"
25
+ if phase == "closed":
26
+ return "discussion_closed"
27
+ return "no_current_round_responses"
28
+
29
+
30
+ def build_public_round(board: dict[str, Any]) -> dict[str, Any]:
31
+ """Project the public current-round board without file IO."""
32
+
33
+ current_round_number = int(board["round"]["current"])
34
+ current = next(
35
+ (
36
+ item
37
+ for item in board.get("rounds", [])
38
+ if int(item.get("round", -1)) == current_round_number
39
+ ),
40
+ {"round": current_round_number, "posts": [], "responses": [], "moderator_events": []},
41
+ )
42
+ posts = list(current.get("posts", []))
43
+ responses = list(current.get("responses", []))
44
+ phase = board["round"]["phase"]
45
+ expected_next_action = {
46
+ "publish": "post",
47
+ "respond": "respond",
48
+ "closed": "finalized",
49
+ }.get(str(phase), "monitor")
50
+ return {
51
+ "schema_version": 1,
52
+ "discussion_id": board["discussion_id"],
53
+ "round": current_round_number,
54
+ "phase": phase,
55
+ "topic": board["topic"],
56
+ "visible_posts": posts,
57
+ "visible_responses": responses,
58
+ "moderator_events": list(current.get("moderator_events", [])),
59
+ "empty_state": {
60
+ "visible_posts": round_empty_state(posts, phase, item_type="posts"),
61
+ "visible_responses": round_empty_state(responses, phase, item_type="responses"),
62
+ },
63
+ "expected_next_action": expected_next_action,
64
+ }
65
+
66
+
67
+ def initial_board(
68
+ discussion_id: str,
69
+ topic: str,
70
+ max_rounds: int,
71
+ *,
72
+ host_id: str | None = None,
73
+ ) -> dict[str, Any]:
74
+ board: dict[str, Any] = {
75
+ "schema_version": 1,
76
+ "discussion_id": discussion_id,
77
+ "topic": topic,
78
+ "round": {"current": 1, "max": max_rounds, "phase": "publish"},
79
+ "rounds": [{"round": 1, "posts": [], "responses": [], "moderator_events": []}],
80
+ "termination": {"reason": None},
81
+ }
82
+ if host_id is not None:
83
+ board["host_id"] = host_id
84
+ return board
85
+
86
+
87
+ def initial_agents_state(discussion_id: str, agents: list[dict[str, str]]) -> dict[str, Any]:
88
+ return {
89
+ "schema_version": 1,
90
+ "discussion_id": discussion_id,
91
+ "agents": [
92
+ {
93
+ "agent_id": agent["agent_id"],
94
+ "lens": agent["lens"],
95
+ "status": "pending",
96
+ "drift_warnings": 0,
97
+ "host_child_id": None,
98
+ }
99
+ for agent in agents
100
+ ],
101
+ }
102
+
103
+
104
+ def current_round(board: dict[str, Any]) -> dict[str, Any]:
105
+ current_round_number = int(board["round"]["current"])
106
+ for item in board.get("rounds", []):
107
+ if int(item.get("round", -1)) == current_round_number:
108
+ return item
109
+ item = {"round": current_round_number, "posts": [], "responses": [], "moderator_events": []}
110
+ board.setdefault("rounds", []).append(item)
111
+ return item
112
+
113
+
114
+ def advance_board_state(
115
+ board: dict[str, Any],
116
+ *,
117
+ active_agent_ids: list[str],
118
+ fresh_context_per_round: bool,
119
+ ) -> dict[str, Any]:
120
+ """Advance the Party board in memory and return action/report facts."""
121
+
122
+ current = current_round(board)
123
+ current_round_number = int(board["round"]["current"])
124
+ phase = board["round"]["phase"]
125
+ if phase == "publish":
126
+ posted_agents = {str(post["agent_id"]) for post in current.get("posts", [])}
127
+ missing = sorted(set(active_agent_ids) - posted_agents)
128
+ if missing:
129
+ raise ValueError(f"publish_incomplete:{','.join(missing)}")
130
+ board["round"]["phase"] = "respond"
131
+ return {
132
+ "round": current_round_number,
133
+ "from": "publish",
134
+ "to": "respond",
135
+ "next_actions": {"round": current_round_number, "phase": "respond"},
136
+ }
137
+
138
+ if phase != "respond":
139
+ raise ValueError("phase_not_advanceable")
140
+
141
+ responded_agents = {str(response["agent_id"]) for response in current.get("responses", [])}
142
+ missing = sorted(set(active_agent_ids) - responded_agents)
143
+ if missing:
144
+ raise ValueError(f"respond_incomplete:{','.join(missing)}")
145
+ if not any(bool(response.get("still_disagree")) for response in current.get("responses", [])):
146
+ board["round"]["phase"] = "closed"
147
+ board["termination"] = {"reason": "converged"}
148
+ return {"round": current_round_number, "from": "respond", "to": "closed", "reason": "converged"}
149
+ if current_round_number >= int(board["round"]["max"]):
150
+ board["round"]["phase"] = "closed"
151
+ board["termination"] = {"reason": "max_rounds_unconverged"}
152
+ return {
153
+ "round": current_round_number,
154
+ "from": "respond",
155
+ "to": "closed",
156
+ "reason": "max_rounds_unconverged",
157
+ }
158
+
159
+ next_round_number = current_round_number + 1
160
+ board["round"] = {
161
+ "current": next_round_number,
162
+ "max": board["round"]["max"],
163
+ "phase": "publish",
164
+ }
165
+ board.setdefault("rounds", []).append(
166
+ {"round": next_round_number, "posts": [], "responses": [], "moderator_events": []}
167
+ )
168
+ transition: dict[str, Any] = {
169
+ "round": current_round_number,
170
+ "from": "respond",
171
+ "to": "publish",
172
+ "next_round": next_round_number,
173
+ "next_actions": {"round": next_round_number, "phase": "publish"},
174
+ }
175
+ if fresh_context_per_round:
176
+ transition["close_children_reason"] = "fresh_context_per_round"
177
+ return transition
178
+
179
+
180
+ def ensure_finalizable(board: dict[str, Any], *, manual_termination: bool) -> bool:
181
+ """Close a board in memory for manual finalization when allowed."""
182
+
183
+ if board["round"]["phase"] == "closed":
184
+ return False
185
+ if not manual_termination:
186
+ raise ValueError("finalize_requires_closed_discussion")
187
+ board["round"]["phase"] = "closed"
188
+ board["termination"] = {"reason": "manual_terminated"}
189
+ return True
@@ -29,6 +29,8 @@ from adapters.host.host_manifest import (
29
29
  )
30
30
  from infra.paths import get_repo_root
31
31
  from infra.skill_manifest import SkillManifestError, action_owners, load_skill_manifests
32
+ from infra.storage.operation_log import OperationLog
33
+ from infra.storage.state_store import DEFAULT_STALE_LOCK_SECONDS, StateStore
32
34
 
33
35
 
34
36
  def _issue(
@@ -145,13 +147,30 @@ def _distribution_files(root: Path) -> tuple[Path, ...]:
145
147
  return tuple(sorted(files))
146
148
 
147
149
 
150
+ def _source_checkout_protected_path(relative: Path) -> bool:
151
+ value = relative.as_posix()
152
+ if value in {".cowork-flow/config.yaml", ".cowork-flow/.developer"}:
153
+ return True
154
+ return value.startswith(".cowork-flow/tasks/") or value.startswith(
155
+ ".cowork-flow/plans/"
156
+ ) or value.startswith(".cowork-flow/.runtime/")
157
+
158
+
159
+ def _source_checkout_live_files(template: Path) -> tuple[Path, ...]:
160
+ runtime_root = template / ".cowork-flow"
161
+ return tuple(
162
+ source
163
+ for source in _distribution_files(runtime_root)
164
+ if not _source_checkout_protected_path(source.relative_to(template))
165
+ )
166
+
167
+
148
168
  def check_distribution(repo_root: Path) -> list[str]:
149
169
  errors: list[str] = []
150
170
  template = repo_root / "template"
151
171
  if _distribution_root(repo_root) == repo_root:
152
172
  return errors
153
- runtime_root = template / ".cowork-flow" / "scripts"
154
- for source in _distribution_files(runtime_root):
173
+ for source in _source_checkout_live_files(template):
155
174
  relative = source.relative_to(template)
156
175
  target = repo_root / relative
157
176
  if target.is_file():
@@ -161,11 +180,6 @@ def check_distribution(repo_root: Path) -> list[str]:
161
180
  errors,
162
181
  drift="local live runtime drift",
163
182
  )
164
- for relative in (Path(".cowork-flow/run"), Path(".cowork-flow/run.cmd")):
165
- source = template / relative
166
- target = repo_root / relative
167
- if source.is_file() and target.is_file():
168
- _compare_file(source, target, errors, drift="local live runtime drift")
169
183
  try:
170
184
  host_manifest = load_host_manifest(template)
171
185
  except HostManifestError as error:
@@ -301,6 +315,151 @@ def _print_task_hygiene_issues(issues: list[dict[str, str]]) -> None:
301
315
  print(f"Hint: {issue['hint']}", file=sys.stderr)
302
316
 
303
317
 
318
+ def _diagnostic_path(repo_root: Path, path: Path) -> str:
319
+ try:
320
+ return path.resolve().relative_to(repo_root.resolve()).as_posix()
321
+ except ValueError:
322
+ return str(path)
323
+
324
+
325
+ def _state_lock_code(status: str) -> str:
326
+ return f"STATE-RECOVERY-LOCK-{status.upper().replace('_', '-')}"
327
+
328
+
329
+ def _state_lock_command_hint(info) -> str:
330
+ if info.status != "recoverable":
331
+ return "Inspect the lock owner facts; do not delete unless the owner PID is missing and the lock is older than the stale threshold."
332
+ return (
333
+ "Use an explicit recovery action only after verification, for example "
334
+ "StateStore().remove_stale_lock(Path(<target>), stale_after_seconds="
335
+ f"{DEFAULT_STALE_LOCK_SECONDS})"
336
+ )
337
+
338
+
339
+ def _state_lock_issue(repo_root: Path, info) -> dict[str, object]:
340
+ fields = info.to_dict()
341
+ fields.update(
342
+ {
343
+ "kind": "state_lock",
344
+ "lockPath": _diagnostic_path(repo_root, info.lock_path),
345
+ "target": str(info.target),
346
+ }
347
+ )
348
+ if info.age_seconds is not None:
349
+ fields["ageSeconds"] = round(info.age_seconds, 3)
350
+ return _issue(
351
+ code=_state_lock_code(info.status),
352
+ severity="warning",
353
+ path=_diagnostic_path(repo_root, info.lock_path),
354
+ message=info.detail,
355
+ command_hint=_state_lock_command_hint(info),
356
+ contract="runtime-health:state-recovery",
357
+ **fields,
358
+ )
359
+
360
+
361
+ def _operation_command_hint() -> str:
362
+ return (
363
+ "Inspect the pending operation record; a trusted recovery command may "
364
+ "call UnitOfWork.recover_all(repo_root) without interpreting error "
365
+ "output as instructions."
366
+ )
367
+
368
+
369
+ def _pending_operation_issue(repo_root: Path, fact: dict) -> dict[str, object]:
370
+ error = fact.get("error")
371
+ if fact.get("phase") == "unreadable" and isinstance(error, dict):
372
+ return _issue(
373
+ code="STATE-RECOVERY-OPERATION-UNREADABLE",
374
+ severity="warning",
375
+ path=_diagnostic_path(repo_root, Path(str(fact.get("path") or ""))),
376
+ message=str(error.get("detail") or "operation record is unreadable"),
377
+ command_hint="Inspect the operation record manually; Doctor does not mutate recovery state.",
378
+ contract="runtime-health:state-recovery",
379
+ kind="pending_operation",
380
+ operationId=str(fact.get("operation_id") or ""),
381
+ phase=str(fact.get("phase") or "unreadable"),
382
+ errorCode=str(error.get("code") or ""),
383
+ )
384
+ operation_id = str(fact.get("operation_id") or "")
385
+ phase = str(fact.get("phase") or "unknown")
386
+ record_error = fact.get("error")
387
+ if phase == "conflicted":
388
+ error_detail = ""
389
+ error_code = ""
390
+ if isinstance(record_error, dict):
391
+ error_code = str(record_error.get("code") or "")
392
+ error_detail = str(record_error.get("detail") or "")
393
+ suffix = ": ".join(value for value in (error_code, error_detail) if value)
394
+ message = (
395
+ f"conflicted UnitOfWork operation {operation_id}"
396
+ + (f": {suffix}" if suffix else "")
397
+ )
398
+ return _issue(
399
+ code="STATE-RECOVERY-CONFLICTED-OPERATION",
400
+ severity="warning",
401
+ path=_diagnostic_path(repo_root, Path(str(fact.get("path") or ""))),
402
+ message=message,
403
+ command_hint=(
404
+ "Resolve the recorded conflict manually; Doctor does not retry "
405
+ "or mutate conflicted operation state."
406
+ ),
407
+ contract="runtime-health:state-recovery",
408
+ kind="pending_operation",
409
+ operationId=operation_id,
410
+ operationKind=str(fact.get("kind") or "unknown"),
411
+ phase=phase,
412
+ participantCount=fact.get("participant_count", 0),
413
+ )
414
+ return _issue(
415
+ code="STATE-RECOVERY-PENDING-OPERATION",
416
+ severity="warning",
417
+ path=_diagnostic_path(repo_root, Path(str(fact.get("path") or ""))),
418
+ message=f"pending UnitOfWork operation {operation_id} is in phase {phase}",
419
+ command_hint=_operation_command_hint(),
420
+ contract="runtime-health:state-recovery",
421
+ kind="pending_operation",
422
+ operationId=operation_id,
423
+ operationKind=str(fact.get("kind") or "unknown"),
424
+ phase=phase,
425
+ participantCount=fact.get("participant_count", 0),
426
+ )
427
+
428
+
429
+ def check_state_recovery(repo_root: Path) -> list[dict[str, object]]:
430
+ workflow = repo_root / ".cowork-flow"
431
+ if not workflow.is_dir():
432
+ return []
433
+ store = StateStore()
434
+ issues: list[dict[str, object]] = []
435
+ for lock_path in sorted(workflow.rglob("*.lock")):
436
+ if not lock_path.is_file():
437
+ continue
438
+ info = store.inspect_lock_path(
439
+ lock_path,
440
+ stale_after_seconds=DEFAULT_STALE_LOCK_SECONDS,
441
+ )
442
+ if info.status != "absent":
443
+ issues.append(_state_lock_issue(repo_root, info))
444
+ operation_log = OperationLog(repo_root, state_store=store)
445
+ for fact in operation_log.pending_facts():
446
+ issues.append(_pending_operation_issue(repo_root, fact))
447
+ return issues
448
+
449
+
450
+ def _print_state_recovery_issues(issues: list[dict[str, object]]) -> None:
451
+ for issue in issues:
452
+ print(
453
+ "WARNING: "
454
+ f"{issue['kind']}: {issue['path']}: "
455
+ f"{issue['message']}",
456
+ file=sys.stderr,
457
+ )
458
+ hint = issue.get("commandHint")
459
+ if hint:
460
+ print(f"Hint: {hint}", file=sys.stderr)
461
+
462
+
304
463
  def check_runtime(repo_root: Path) -> list[str]:
305
464
  errors: list[str] = []
306
465
  try:
@@ -330,15 +489,45 @@ def check_runtime(repo_root: Path) -> list[str]:
330
489
  return errors
331
490
 
332
491
 
333
- def _run_checks(repo_root: Path) -> int:
334
- errors = []
335
- errors.extend(_host_errors(repo_root))
336
- errors.extend(check_runtime(repo_root))
337
- errors.extend(check_distribution(repo_root))
338
- _print_task_hygiene_issues(check_task_hygiene(repo_root))
492
+ def _all_check_result(repo_root: Path) -> dict[str, object]:
493
+ host_issues = _host_issues(repo_root)
494
+ runtime_errors = check_runtime(repo_root)
495
+ distribution_errors = check_distribution(repo_root)
496
+ task_hygiene_issues = check_task_hygiene(repo_root)
497
+ state_recovery_issues = check_state_recovery(repo_root)
498
+ errors: list[dict[str, object]] = []
499
+ for issue in host_issues:
500
+ errors.append({"kind": "host_adapter", **issue})
501
+ errors.extend(
502
+ {"kind": "runtime", "message": error}
503
+ for error in runtime_errors
504
+ )
505
+ errors.extend(
506
+ {"kind": "distribution", "message": error}
507
+ for error in distribution_errors
508
+ )
509
+ return {
510
+ "ok": not errors,
511
+ "errors": errors,
512
+ "issues": {
513
+ "hostAdapters": host_issues,
514
+ "taskHygiene": task_hygiene_issues,
515
+ "stateRecovery": state_recovery_issues,
516
+ },
517
+ }
518
+
519
+
520
+ def _run_checks(repo_root: Path, *, structured: bool = False) -> int:
521
+ result = _all_check_result(repo_root)
522
+ errors = result["errors"]
523
+ if structured:
524
+ print(json.dumps(result, ensure_ascii=False))
525
+ return 1 if errors else 0
526
+ _print_task_hygiene_issues(result["issues"]["taskHygiene"])
527
+ _print_state_recovery_issues(result["issues"]["stateRecovery"])
339
528
  if errors:
340
529
  for error in errors:
341
- print(f"ERROR: {error}", file=sys.stderr)
530
+ print(f"ERROR: {error['message']}", file=sys.stderr)
342
531
  return 1
343
532
  print("runtime health checks passed")
344
533
  return 0
@@ -394,7 +583,7 @@ def main() -> int:
394
583
  args = build_parser().parse_args()
395
584
  repo_root = get_repo_root()
396
585
  if args.all:
397
- return _run_checks(repo_root)
586
+ return _run_checks(repo_root, structured=bool(args.json))
398
587
  if args.host_adapters:
399
588
  return _run_host_checks(repo_root, structured=bool(args.json))
400
589
  if args.subagent_safety: