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
@@ -70,3 +70,43 @@ class OperationLog:
70
70
  if record.get("phase") not in FINAL_PHASES:
71
71
  records.append(record)
72
72
  return tuple(records)
73
+ def pending_facts(self) -> tuple[dict, ...]:
74
+ """Return read-only facts about non-final operation records."""
75
+ if not self.operations_dir.is_dir():
76
+ return ()
77
+ facts: list[dict] = []
78
+ for path in sorted(self.operations_dir.glob("*.json")):
79
+ try:
80
+ record = self.state_store.load(path).data
81
+ except StateStoreError as error:
82
+ facts.append(
83
+ {
84
+ "path": str(path),
85
+ "phase": "unreadable",
86
+ "error": {
87
+ "code": error.code,
88
+ "path": str(error.path),
89
+ "detail": error.detail,
90
+ },
91
+ }
92
+ )
93
+ continue
94
+ phase = record.get("phase")
95
+ if phase == "committed":
96
+ continue
97
+ participants = record.get("participants")
98
+ facts.append(
99
+ {
100
+ "path": str(path),
101
+ "operation_id": str(record.get("operation_id") or path.stem),
102
+ "kind": str(record.get("kind") or "unknown"),
103
+ "phase": str(phase or "unknown"),
104
+ "participant_count": (
105
+ len(participants)
106
+ if isinstance(participants, list)
107
+ else 0
108
+ ),
109
+ "error": record.get("error"),
110
+ }
111
+ )
112
+ return tuple(facts)
@@ -3,16 +3,21 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import errno
6
7
  import json
8
+ import math
7
9
  import os
8
10
  import time
9
11
  from contextlib import contextmanager
10
12
  from dataclasses import dataclass
13
+ from datetime import datetime, timezone
11
14
  from pathlib import Path
12
15
  from typing import Iterator
13
16
 
14
17
 
15
18
  STATE_METADATA_KEY = "_state"
19
+ LOCK_METADATA_SCHEMA_VERSION = 1
20
+ DEFAULT_STALE_LOCK_SECONDS = 30 * 60
16
21
  ATOMIC_REPLACE_ATTEMPTS = 5
17
22
  ATOMIC_REPLACE_RETRY_SECONDS = 0.01
18
23
 
@@ -36,6 +41,46 @@ class StateSnapshot:
36
41
  exists: bool
37
42
 
38
43
 
44
+ @dataclass(frozen=True)
45
+ class LockInfo:
46
+ """Read-only facts about a StateStore lock file."""
47
+
48
+ lock_path: Path
49
+ target: Path
50
+ exists: bool
51
+ pid: int | None
52
+ created_at: str | None
53
+ age_seconds: float | None
54
+ owner_availability: str
55
+ status: str
56
+ detail: str
57
+
58
+ @property
59
+ def owner_available(self) -> bool | None:
60
+ if self.owner_availability == "available":
61
+ return True
62
+ if self.owner_availability == "missing":
63
+ return False
64
+ return None
65
+
66
+ def to_dict(self) -> dict[str, object]:
67
+ data: dict[str, object] = {
68
+ "lockPath": str(self.lock_path),
69
+ "target": str(self.target),
70
+ "exists": self.exists,
71
+ "ownerAvailability": self.owner_availability,
72
+ "status": self.status,
73
+ "detail": self.detail,
74
+ }
75
+ if self.pid is not None:
76
+ data["pid"] = self.pid
77
+ if self.created_at is not None:
78
+ data["createdAt"] = self.created_at
79
+ if self.age_seconds is not None:
80
+ data["ageSeconds"] = self.age_seconds
81
+ return data
82
+
83
+
39
84
  class StateStore:
40
85
  """Read and compare-and-swap JSON state documents."""
41
86
 
@@ -117,6 +162,113 @@ class StateStore:
117
162
  ) from error
118
163
  return True
119
164
 
165
+ def inspect_lock(
166
+ self,
167
+ path: str | Path,
168
+ *,
169
+ stale_after_seconds: float = DEFAULT_STALE_LOCK_SECONDS,
170
+ ) -> LockInfo:
171
+ target = Path(path)
172
+ return self.inspect_lock_path(
173
+ self.lock_path_for(target),
174
+ target=target,
175
+ stale_after_seconds=stale_after_seconds,
176
+ )
177
+
178
+ def inspect_lock_path(
179
+ self,
180
+ lock_path: str | Path,
181
+ *,
182
+ target: str | Path | None = None,
183
+ stale_after_seconds: float = DEFAULT_STALE_LOCK_SECONDS,
184
+ ) -> LockInfo:
185
+ stale_after_seconds = self._validated_stale_threshold(
186
+ stale_after_seconds
187
+ )
188
+ lock = Path(lock_path)
189
+ target_path = (
190
+ Path(target)
191
+ if target is not None
192
+ else self.target_from_lock_path(lock)
193
+ )
194
+ try:
195
+ raw_text = lock.read_text(encoding="utf-8")
196
+ except FileNotFoundError:
197
+ return LockInfo(
198
+ lock_path=lock,
199
+ target=target_path,
200
+ exists=False,
201
+ pid=None,
202
+ created_at=None,
203
+ age_seconds=None,
204
+ owner_availability="unknown",
205
+ status="absent",
206
+ detail="state lock is absent",
207
+ )
208
+ except (OSError, UnicodeDecodeError) as error:
209
+ return LockInfo(
210
+ lock_path=lock,
211
+ target=target_path,
212
+ exists=True,
213
+ pid=None,
214
+ created_at=None,
215
+ age_seconds=None,
216
+ owner_availability="unknown",
217
+ status="unknown",
218
+ detail=f"state lock metadata could not be read: {error}",
219
+ )
220
+ return self._lock_info_from_text(
221
+ lock,
222
+ target_path,
223
+ raw_text,
224
+ stale_after_seconds=stale_after_seconds,
225
+ )
226
+
227
+ def remove_stale_lock(
228
+ self,
229
+ path: str | Path,
230
+ *,
231
+ stale_after_seconds: float = DEFAULT_STALE_LOCK_SECONDS,
232
+ ) -> bool:
233
+ stale_after_seconds = self._validated_stale_threshold(
234
+ stale_after_seconds
235
+ )
236
+ target = Path(path)
237
+ lock_path = self.lock_path_for(target)
238
+ try:
239
+ original = lock_path.read_text(encoding="utf-8")
240
+ except FileNotFoundError:
241
+ return False
242
+ except (OSError, UnicodeDecodeError):
243
+ return False
244
+ info = self._lock_info_from_text(
245
+ lock_path,
246
+ target,
247
+ original,
248
+ stale_after_seconds=stale_after_seconds,
249
+ )
250
+ if info.status != "recoverable":
251
+ return False
252
+ try:
253
+ current = lock_path.read_text(encoding="utf-8")
254
+ except FileNotFoundError:
255
+ return False
256
+ except (OSError, UnicodeDecodeError):
257
+ return False
258
+ if current != original:
259
+ return False
260
+ try:
261
+ lock_path.unlink()
262
+ except FileNotFoundError:
263
+ return False
264
+ except OSError as error:
265
+ raise StateStoreError(
266
+ "STATE-LOCK-002",
267
+ target,
268
+ "stale state lock could not be removed",
269
+ ) from error
270
+ return True
271
+
120
272
  def _load_unlocked(
121
273
  self,
122
274
  path: Path,
@@ -249,12 +401,13 @@ class StateStore:
249
401
  @contextmanager
250
402
  def _lock(self, path: Path) -> Iterator[None]:
251
403
  path.parent.mkdir(parents=True, exist_ok=True)
252
- lock_path = path.with_name(f"{path.name}.lock")
404
+ lock_path = self.lock_path_for(path)
253
405
  deadline = time.monotonic() + self.lock_timeout_seconds
254
406
  lock_handle = None
255
407
  while lock_handle is None:
256
408
  try:
257
409
  lock_handle = lock_path.open("x", encoding="utf-8")
410
+ self._write_lock_payload(lock_handle, path)
258
411
  except FileExistsError as error:
259
412
  if time.monotonic() >= deadline:
260
413
  raise StateStoreError(
@@ -264,6 +417,15 @@ class StateStore:
264
417
  ) from error
265
418
  time.sleep(self.lock_poll_seconds)
266
419
  except OSError as error:
420
+ if lock_handle is not None:
421
+ try:
422
+ lock_handle.close()
423
+ except OSError:
424
+ pass
425
+ try:
426
+ lock_path.unlink(missing_ok=True)
427
+ except OSError:
428
+ pass
267
429
  raise StateStoreError(
268
430
  "STATE-LOCK-001",
269
431
  path,
@@ -277,3 +439,196 @@ class StateStore:
277
439
  lock_path.unlink(missing_ok=True)
278
440
  except OSError:
279
441
  pass
442
+
443
+ @staticmethod
444
+ def lock_path_for(path: str | Path) -> Path:
445
+ target = Path(path)
446
+ return target.with_name(f"{target.name}.lock")
447
+
448
+ @staticmethod
449
+ def target_from_lock_path(lock_path: str | Path) -> Path:
450
+ lock = Path(lock_path)
451
+ if lock.name.endswith(".lock"):
452
+ return lock.with_name(lock.name[:-5])
453
+ return lock
454
+
455
+ @staticmethod
456
+ def _write_lock_file(
457
+ lock_path: Path,
458
+ target: Path,
459
+ *,
460
+ pid: int | None = None,
461
+ created_at: str | None = None,
462
+ ) -> None:
463
+ payload = StateStore._lock_payload(
464
+ target,
465
+ pid=pid,
466
+ created_at=created_at,
467
+ )
468
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
469
+ lock_path.write_text(
470
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
471
+ encoding="utf-8",
472
+ )
473
+
474
+ @staticmethod
475
+ def _write_lock_payload(stream: object, target: Path) -> None:
476
+ payload = StateStore._lock_payload(target)
477
+ stream.write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
478
+ stream.flush()
479
+
480
+ @staticmethod
481
+ def _lock_payload(
482
+ target: Path,
483
+ *,
484
+ pid: int | None = None,
485
+ created_at: str | None = None,
486
+ ) -> dict[str, object]:
487
+ return {
488
+ "schemaVersion": LOCK_METADATA_SCHEMA_VERSION,
489
+ "pid": os.getpid() if pid is None else pid,
490
+ "createdAt": created_at or StateStore._now_iso(),
491
+ "target": str(target),
492
+ }
493
+
494
+ @staticmethod
495
+ def _validated_stale_threshold(value: float) -> float:
496
+ threshold = float(value)
497
+ if not math.isfinite(threshold) or threshold < 0:
498
+ raise ValueError("stale_after_seconds must be a non-negative finite number")
499
+ return threshold
500
+
501
+ @staticmethod
502
+ def _now_iso() -> str:
503
+ return datetime.now(timezone.utc).isoformat(
504
+ timespec="seconds"
505
+ ).replace("+00:00", "Z")
506
+
507
+ @staticmethod
508
+ def _parse_created_at(value: object) -> datetime | None:
509
+ if not isinstance(value, str) or not value.strip():
510
+ return None
511
+ try:
512
+ parsed = datetime.fromisoformat(
513
+ value.strip().replace("Z", "+00:00")
514
+ )
515
+ except ValueError:
516
+ return None
517
+ if parsed.tzinfo is None:
518
+ parsed = parsed.replace(tzinfo=timezone.utc)
519
+ return parsed.astimezone(timezone.utc)
520
+
521
+ @staticmethod
522
+ def _pid_exists(pid: int) -> bool | None:
523
+ if pid <= 0:
524
+ return False
525
+ if pid == os.getpid():
526
+ return True
527
+ if os.name == "nt":
528
+ return StateStore._windows_pid_exists(pid)
529
+ try:
530
+ os.kill(pid, 0)
531
+ except ProcessLookupError:
532
+ return False
533
+ except PermissionError:
534
+ return True
535
+ except OSError as error:
536
+ if getattr(error, "errno", None) == errno.ESRCH:
537
+ return False
538
+ if getattr(error, "errno", None) == errno.EPERM:
539
+ return True
540
+ return None
541
+ return True
542
+
543
+ @staticmethod
544
+ def _windows_pid_exists(pid: int) -> bool | None:
545
+ try:
546
+ import ctypes
547
+ except ImportError:
548
+ return None
549
+ process_query_limited_information = 0x1000
550
+ handle = ctypes.windll.kernel32.OpenProcess(
551
+ process_query_limited_information,
552
+ False,
553
+ pid,
554
+ )
555
+ if handle:
556
+ ctypes.windll.kernel32.CloseHandle(handle)
557
+ return True
558
+ error = ctypes.windll.kernel32.GetLastError()
559
+ if error == 87: # ERROR_INVALID_PARAMETER: PID does not exist.
560
+ return False
561
+ if error == 5: # ERROR_ACCESS_DENIED: process exists but is protected.
562
+ return True
563
+ return None
564
+
565
+ def _lock_info_from_text(
566
+ self,
567
+ lock_path: Path,
568
+ target: Path,
569
+ raw_text: str,
570
+ *,
571
+ stale_after_seconds: float,
572
+ ) -> LockInfo:
573
+ try:
574
+ raw = json.loads(raw_text) if raw_text.strip() else {}
575
+ except json.JSONDecodeError:
576
+ raw = {}
577
+ if not isinstance(raw, dict):
578
+ raw = {}
579
+ pid = raw.get("pid")
580
+ pid_value = pid if isinstance(pid, int) and not isinstance(pid, bool) else None
581
+ created_at = raw.get("createdAt")
582
+ created_at_value = created_at if isinstance(created_at, str) else None
583
+ created = self._parse_created_at(created_at_value)
584
+ age_seconds = None
585
+ if created is not None:
586
+ age_seconds = max(
587
+ 0.0,
588
+ (datetime.now(timezone.utc) - created).total_seconds(),
589
+ )
590
+ else:
591
+ try:
592
+ age_seconds = max(0.0, time.time() - lock_path.stat().st_mtime)
593
+ except OSError:
594
+ age_seconds = None
595
+ target_value = raw.get("target")
596
+ if isinstance(target_value, str) and target_value.strip():
597
+ target = Path(target_value)
598
+
599
+ if pid_value is None:
600
+ availability = "unknown"
601
+ status = "unknown"
602
+ detail = "state lock owner pid is unknown"
603
+ else:
604
+ exists = self._pid_exists(pid_value)
605
+ if exists is True:
606
+ availability = "available"
607
+ status = "active"
608
+ detail = "state lock owner process is still available"
609
+ elif exists is False:
610
+ availability = "missing"
611
+ if age_seconds is not None and age_seconds >= stale_after_seconds:
612
+ status = "recoverable"
613
+ detail = "state lock owner process is missing and lock is stale"
614
+ else:
615
+ status = "stale-but-unsafe"
616
+ detail = (
617
+ "state lock owner process is missing but lock age "
618
+ "does not satisfy the stale threshold"
619
+ )
620
+ else:
621
+ availability = "unknown"
622
+ status = "unknown"
623
+ detail = "state lock owner process availability is unknown"
624
+ return LockInfo(
625
+ lock_path=lock_path,
626
+ target=target,
627
+ exists=True,
628
+ pid=pid_value,
629
+ created_at=created_at_value,
630
+ age_seconds=age_seconds,
631
+ owner_availability=availability,
632
+ status=status,
633
+ detail=detail,
634
+ )
@@ -38,20 +38,20 @@ INTENT_OPERATIONS = {
38
38
 
39
39
  # These are state-transition facts, not Skill metadata.
40
40
  ACTION_TRANSITIONS = {
41
- "answer_questions": {"runtimeGate": None, "mutatesState": False},
42
- "debug_failure": {"runtimeGate": None, "mutatesState": False},
43
- "discuss_options": {"runtimeGate": None, "mutatesState": False},
44
- "doubt_review": {"runtimeGate": None, "mutatesState": False},
45
- "batch_execute": {"runtimeGate": "task_start", "mutatesState": True},
46
- "create_task": {"runtimeGate": None, "mutatesState": True},
47
- "edit_planning_artifacts": {"runtimeGate": "task_start", "mutatesState": False},
48
- "start_task": {"runtimeGate": "task_start", "mutatesState": True},
49
- "implement_change": {"runtimeGate": None, "mutatesState": False},
50
- "request_review": {"runtimeGate": "task_review", "mutatesState": True},
51
- "complete_task": {"runtimeGate": "task_complete", "mutatesState": True},
52
- "archive_task": {"runtimeGate": "task_archive", "mutatesState": True},
53
- "execute_delegated_work": {"runtimeGate": None, "mutatesState": False},
54
- "repair_workflow_state": {"runtimeGate": None, "mutatesState": False},
41
+ "answer_questions": {"lifecycleCheck": None, "mutatesState": False},
42
+ "debug_failure": {"lifecycleCheck": None, "mutatesState": False},
43
+ "discuss_options": {"lifecycleCheck": None, "mutatesState": False},
44
+ "doubt_review": {"lifecycleCheck": None, "mutatesState": False},
45
+ "batch_execute": {"lifecycleCheck": "task_start", "mutatesState": True},
46
+ "create_task": {"lifecycleCheck": None, "mutatesState": True},
47
+ "edit_planning_artifacts": {"lifecycleCheck": "task_start", "mutatesState": False},
48
+ "start_task": {"lifecycleCheck": "task_start", "mutatesState": True},
49
+ "implement_change": {"lifecycleCheck": None, "mutatesState": False},
50
+ "request_review": {"lifecycleCheck": "task_review", "mutatesState": True},
51
+ "complete_task": {"lifecycleCheck": "task_complete", "mutatesState": True},
52
+ "archive_task": {"lifecycleCheck": "task_archive", "mutatesState": True},
53
+ "execute_delegated_work": {"lifecycleCheck": None, "mutatesState": False},
54
+ "repair_workflow_state": {"lifecycleCheck": None, "mutatesState": False},
55
55
  }
56
56
  RUNNABLE_ACTIONS = {
57
57
  action_id
@@ -158,7 +158,7 @@ def _action_contract(
158
158
  return {
159
159
  "id": action_id,
160
160
  "mutatesState": spec["mutatesState"],
161
- "runtimeGate": spec["runtimeGate"],
161
+ "lifecycleCheck": spec["lifecycleCheck"],
162
162
  "runnable": action_id in RUNNABLE_ACTIONS and not action_blockers,
163
163
  "blockers": action_blockers,
164
164
  }
@@ -22,15 +22,12 @@ configure_cli_encoding()
22
22
  COMMAND_SCRIPTS = {
23
23
  "resume": "adapters/cli/resume.py",
24
24
  "task": "adapters/cli/task.py",
25
- "change": "adapters/cli/change.py",
26
25
  "get-context": "adapters/cli/get_context.py",
27
26
  "get_context": "adapters/cli/get_context.py",
28
27
  "get-developer": "adapters/cli/get_developer.py",
29
28
  "get_developer": "adapters/cli/get_developer.py",
30
29
  "init-developer": "adapters/cli/init_developer.py",
31
30
  "init_developer": "adapters/cli/init_developer.py",
32
- "add-session": "adapters/cli/add_session.py",
33
- "add_session": "adapters/cli/add_session.py",
34
31
  "subagent": "adapters/cli/subagent.py",
35
32
  }
36
33
  RESERVED_COMMAND_NAMES = (*COMMAND_SCRIPTS, "python", "help", "-h", "--help")
@@ -49,11 +46,9 @@ def print_usage() -> None:
49
46
  Common commands:
50
47
  resume
51
48
  task
52
- change
53
49
  get-context
54
50
  get-developer
55
51
  init-developer
56
- add-session
57
52
  subagent
58
53
  doctor
59
54
  party-v2
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env python3
2
+ """Task context discovery for specs, Skills, and installed platforms."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+ from infra.quality_sources import quality_source_entries
9
+ from infra.skill_manifest import context_entries
10
+ from infra.paths import (
11
+ DIR_AGENTS,
12
+ DIR_SPEC,
13
+ DIR_WORKFLOW,
14
+ get_repo_root,
15
+ )
16
+
17
+
18
+ def get_implement_base(repo_root: Path | None = None) -> list[dict]:
19
+ root = Path(repo_root) if repo_root is not None else get_repo_root()
20
+ entries = [
21
+ {
22
+ "file": "AGENTS.md",
23
+ "reason": "Project collaboration rules and workflow checks",
24
+ },
25
+ {
26
+ "file": f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/index.md",
27
+ "reason": "Pre-implementation thinking guides",
28
+ },
29
+ {
30
+ "file": (
31
+ f"{DIR_WORKFLOW}/{DIR_SPEC}/guides/"
32
+ "pre-implementation-checklist.md"
33
+ ),
34
+ "reason": "Mandatory pre-coding checklist",
35
+ },
36
+ ]
37
+ entries.extend(context_entries(root, context="implement"))
38
+ return entries
39
+
40
+
41
+ def get_implement_backend() -> list[dict]:
42
+ return [
43
+ {
44
+ "file": f"{DIR_WORKFLOW}/{DIR_SPEC}/backend/index.md",
45
+ "reason": "Backend development guide",
46
+ }
47
+ ]
48
+
49
+
50
+ def get_implement_frontend() -> list[dict]:
51
+ return [
52
+ {
53
+ "file": f"{DIR_WORKFLOW}/{DIR_SPEC}/frontend/index.md",
54
+ "reason": "Frontend development guide",
55
+ }
56
+ ]
57
+
58
+
59
+ def get_implement_spec() -> list[dict]:
60
+ return [
61
+ {
62
+ "file": f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md",
63
+ "reason": "Spec index — read before modifying spec/",
64
+ },
65
+ {
66
+ "file": f"{DIR_WORKFLOW}/{DIR_SPEC}/contracts/index.md",
67
+ "reason": "Contract definitions",
68
+ },
69
+ {
70
+ "file": f"{DIR_WORKFLOW}/{DIR_SPEC}/schemas/index.md",
71
+ "reason": "Schema definitions",
72
+ },
73
+ ]
74
+
75
+
76
+ def detect_installed_platforms(repo_root: Path | None = None) -> list[str]:
77
+ repo_root = Path(repo_root) if repo_root is not None else get_repo_root()
78
+ platforms: list[str] = []
79
+ if (repo_root / ".codex").is_dir():
80
+ platforms.append("codex")
81
+ if (repo_root / ".opencode").is_dir():
82
+ platforms.append("opencode")
83
+ if (repo_root / ".claude").is_dir() or (repo_root / "CLAUDE.md").is_file():
84
+ platforms.append("claude-code")
85
+ return platforms
86
+
87
+
88
+ def use_claude_skill_context(repo_root: Path | None = None) -> bool:
89
+ return detect_installed_platforms(repo_root) == ["claude-code"]
90
+
91
+
92
+ def skill_path(name: str, repo_root: Path | None = None) -> str:
93
+ if use_claude_skill_context(repo_root):
94
+ return f".claude/skills/{name}/SKILL.md"
95
+ return f"{DIR_AGENTS}/skills/{name}/SKILL.md"
96
+
97
+
98
+ def get_domain_skill_context(
99
+ repo_root: Path,
100
+ *,
101
+ dev_type: str | None = None,
102
+ paths: tuple[str, ...] = (),
103
+ ) -> list[dict]:
104
+ return context_entries(
105
+ repo_root,
106
+ context="implement",
107
+ dev_type=dev_type,
108
+ paths=paths,
109
+ include_wildcard=False,
110
+ )
111
+
112
+
113
+ def is_skill_path(file_path: str) -> bool:
114
+ return (
115
+ file_path.startswith(f"{DIR_AGENTS}/skills/")
116
+ or file_path.startswith(".claude/skills/")
117
+ or ".skills/" in file_path
118
+ or "/skills/" in file_path
119
+ )
120
+
121
+
122
+ def discover_spec_files(repo_root: Path, dev_type: str) -> list[str]:
123
+ if dev_type == "spec":
124
+ return [f"{DIR_WORKFLOW}/{DIR_SPEC}/index.md"]
125
+
126
+ spec_dir = repo_root / DIR_WORKFLOW / DIR_SPEC / dev_type
127
+ if not spec_dir.is_dir():
128
+ return []
129
+ return sorted(
130
+ f"{DIR_WORKFLOW}/{DIR_SPEC}/{dev_type}/{path.name}"
131
+ for path in spec_dir.glob("*.md")
132
+ if path.is_file()
133
+ )
134
+
135
+
136
+ def get_check_context(repo_root: Path, dev_type: str) -> list[dict]:
137
+ entries = context_entries(repo_root, context="check", dev_type=dev_type)
138
+ entries.sort(key=lambda entry: entry["file"])
139
+ if dev_type == "spec":
140
+ entries.extend(
141
+ {
142
+ "file": spec_file,
143
+ "reason": f"Verify {Path(spec_file).name} compliance",
144
+ }
145
+ for spec_file in discover_spec_files(repo_root, dev_type)
146
+ )
147
+ entries.extend(quality_source_entries(repo_root, dev_type))
148
+ return entries
149
+
150
+
151
+ def get_debug_context(
152
+ dev_type: str,
153
+ repo_root: Path | None = None,
154
+ ) -> list[dict]:
155
+ root = Path(repo_root) if repo_root is not None else get_repo_root()
156
+ return context_entries(root, context="debug", dev_type=dev_type)