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
@@ -8,7 +8,7 @@ from collections.abc import Callable
8
8
  from dataclasses import dataclass
9
9
  from datetime import datetime
10
10
  from pathlib import Path
11
- from typing import Optional
11
+ from typing import Optional, Union
12
12
 
13
13
  from infra.storage.unit_of_work import (
14
14
  FaultInjector,
@@ -17,6 +17,12 @@ from infra.storage.unit_of_work import (
17
17
  )
18
18
  from runtime.session_state import build_active_task_session
19
19
  from services.lifecycle_checks import LifecycleCheckResult, LifecycleCheckRunner
20
+ from services.lifecycle_policy import (
21
+ LifecycleExecutionPolicy,
22
+ LifecyclePolicyFailure,
23
+ resolve_execution_policy,
24
+ start_readiness_failure,
25
+ )
20
26
  from kernel.task_state import transition_blockers
21
27
  from services.task_repository import TaskRepository, TaskRepositoryError
22
28
 
@@ -57,6 +63,15 @@ class LifecyclePreflightFailure:
57
63
  hint: str = ""
58
64
 
59
65
 
66
+ @dataclass(frozen=True)
67
+ class LifecycleTransition:
68
+ """Stable status transition facts for lifecycle results."""
69
+
70
+ previous_status: str | None
71
+ next_status: str
72
+ changed: bool
73
+
74
+
60
75
  @dataclass(frozen=True)
61
76
  class LifecycleResult:
62
77
  """Structured lifecycle outcome for delivery-layer rendering."""
@@ -71,9 +86,15 @@ class LifecycleResult:
71
86
  check_result: object | None = None
72
87
  active_task_path: str | None = None
73
88
  repository_error: TaskRepositoryError | None = None
89
+ transition: LifecycleTransition | None = None
90
+ execution_policy: LifecycleExecutionPolicy | None = None
91
+ emitted_events: tuple[str, ...] = ()
74
92
 
75
93
 
76
- Preflight = Callable[[Path], Optional[LifecyclePreflightFailure]]
94
+ Preflight = Callable[
95
+ [Path],
96
+ Optional[Union[LifecyclePreflightFailure, LifecyclePolicyFailure]],
97
+ ]
77
98
 
78
99
 
79
100
  class TaskLifecycleService:
@@ -104,12 +125,14 @@ class TaskLifecycleService:
104
125
  self,
105
126
  task: str | Path,
106
127
  *,
107
- allow_spec_file_modifications: bool = False,
128
+ allow_spec_file_modifications: bool | None = None,
129
+ execution_context: object | None = None,
108
130
  ) -> LifecycleResult:
109
131
  return self.execute(
110
132
  REVIEW_STAGE,
111
133
  task,
112
134
  allow_spec_file_modifications=allow_spec_file_modifications,
135
+ execution_context=execution_context,
113
136
  )
114
137
 
115
138
  def complete(
@@ -117,13 +140,15 @@ class TaskLifecycleService:
117
140
  task: str | Path,
118
141
  *,
119
142
  completed_at: str | None = None,
120
- allow_spec_file_modifications: bool = False,
143
+ allow_spec_file_modifications: bool | None = None,
144
+ execution_context: object | None = None,
121
145
  ) -> LifecycleResult:
122
146
  return self.execute(
123
147
  COMPLETE_STAGE,
124
148
  task,
125
149
  completed_at=completed_at,
126
150
  allow_spec_file_modifications=allow_spec_file_modifications,
151
+ execution_context=execution_context,
127
152
  )
128
153
 
129
154
  def execute(
@@ -132,20 +157,27 @@ class TaskLifecycleService:
132
157
  task: str | Path,
133
158
  *,
134
159
  preflight: Preflight | None = None,
135
- allow_spec_file_modifications: bool = False,
160
+ allow_spec_file_modifications: bool | None = None,
136
161
  completed_at: str | None = None,
162
+ execution_context: object | None = None,
137
163
  ) -> LifecycleResult:
138
164
  """Resolve, preflight, validate, check, and persist one transition."""
139
165
  task_dir = self.repository.resolve(task)
166
+ execution_policy = resolve_execution_policy(
167
+ self.repo_root,
168
+ execution_context,
169
+ allow_spec_file_modifications=allow_spec_file_modifications,
170
+ )
140
171
  prepared = self._prepare_transition(stage, task_dir, preflight)
141
172
  if isinstance(prepared, LifecycleResult):
142
173
  return prepared
143
- task_data, already_at_target = prepared
174
+ task_data, already_at_target, transition = prepared
144
175
 
145
176
  checked = self._run_stage_checks(
146
177
  stage,
147
178
  task_dir,
148
- allow_spec_file_modifications=allow_spec_file_modifications,
179
+ transition=transition,
180
+ execution_policy=execution_policy,
149
181
  )
150
182
  if isinstance(checked, LifecycleResult):
151
183
  return checked
@@ -156,6 +188,8 @@ class TaskLifecycleService:
156
188
  stage,
157
189
  task_dir,
158
190
  check_result,
191
+ transition=transition,
192
+ execution_policy=execution_policy,
159
193
  )
160
194
 
161
195
  return self._persist_transition_result(
@@ -164,6 +198,8 @@ class TaskLifecycleService:
164
198
  task_data,
165
199
  check_result,
166
200
  completed_at,
201
+ transition=transition,
202
+ execution_policy=execution_policy,
167
203
  )
168
204
 
169
205
  def _prepare_transition(
@@ -171,35 +207,50 @@ class TaskLifecycleService:
171
207
  stage: LifecycleStage,
172
208
  task_dir: Path,
173
209
  preflight: Preflight | None,
174
- ) -> tuple[dict, bool] | LifecycleResult:
210
+ ) -> tuple[dict, bool, LifecycleTransition] | LifecycleResult:
175
211
  task_data_or_failure = self._load_transition_task(stage, task_dir)
176
212
  if isinstance(task_data_or_failure, LifecycleResult):
177
213
  return task_data_or_failure
178
214
  task_data = task_data_or_failure
179
215
 
180
- already_at_target = task_data.get("status") == stage.target_status
216
+ transition = self._transition_for(stage, task_data)
217
+ already_at_target = not transition.changed
181
218
  idempotent = self._early_idempotent_result(
182
219
  stage,
183
220
  task_dir,
184
221
  already_at_target,
222
+ transition,
185
223
  )
186
224
  if idempotent is not None:
187
225
  return idempotent
188
226
 
189
- preflight_failure = self._run_preflight(stage, task_dir, preflight)
227
+ preflight_failure = self._run_preflight(
228
+ stage,
229
+ task_dir,
230
+ preflight,
231
+ transition,
232
+ )
190
233
  if preflight_failure is not None:
191
234
  return preflight_failure
192
235
 
193
- transition_failure = self._validate_transition(stage, task_dir, task_data)
236
+ transition_failure = self._validate_transition(
237
+ stage,
238
+ task_dir,
239
+ task_data,
240
+ transition,
241
+ )
194
242
  if transition_failure is not None:
195
243
  return transition_failure
196
- return task_data, already_at_target
244
+ return task_data, already_at_target, transition
197
245
 
198
246
  def _validated_idempotent_result(
199
247
  self,
200
248
  stage: LifecycleStage,
201
249
  task_dir: Path,
202
250
  check_result: object,
251
+ *,
252
+ transition: LifecycleTransition,
253
+ execution_policy: LifecycleExecutionPolicy,
203
254
  ) -> LifecycleResult:
204
255
  return LifecycleResult(
205
256
  ok=True,
@@ -207,6 +258,9 @@ class TaskLifecycleService:
207
258
  stage=stage,
208
259
  task_dir=task_dir,
209
260
  check_result=check_result,
261
+ transition=transition,
262
+ execution_policy=execution_policy,
263
+ emitted_events=self._success_events(stage),
210
264
  )
211
265
 
212
266
  def _persist_transition_result(
@@ -216,11 +270,15 @@ class TaskLifecycleService:
216
270
  task_data: dict,
217
271
  check_result: object,
218
272
  completed_at: str | None,
273
+ *,
274
+ transition: LifecycleTransition,
275
+ execution_policy: LifecycleExecutionPolicy,
219
276
  ) -> LifecycleResult:
220
277
  session_state_or_failure = self._build_session_state(
221
278
  stage,
222
279
  task_dir,
223
280
  check_result,
281
+ transition,
224
282
  )
225
283
  if isinstance(session_state_or_failure, LifecycleResult):
226
284
  return session_state_or_failure
@@ -244,6 +302,9 @@ class TaskLifecycleService:
244
302
  task_dir=task_dir,
245
303
  check_result=check_result,
246
304
  active_task_path=active_task_path,
305
+ transition=transition,
306
+ execution_policy=execution_policy,
307
+ emitted_events=self._success_events(stage),
247
308
  )
248
309
 
249
310
  def _load_transition_task(
@@ -277,6 +338,7 @@ class TaskLifecycleService:
277
338
  stage: LifecycleStage,
278
339
  task_dir: Path,
279
340
  already_at_target: bool,
341
+ transition: LifecycleTransition,
280
342
  ) -> LifecycleResult | None:
281
343
  if not already_at_target or stage.name != START_STAGE.name:
282
344
  return None
@@ -290,6 +352,8 @@ class TaskLifecycleService:
290
352
  if stage.activates_session
291
353
  else None
292
354
  ),
355
+ transition=transition,
356
+ emitted_events=self._success_events(stage),
293
357
  )
294
358
 
295
359
  def _run_preflight(
@@ -297,10 +361,14 @@ class TaskLifecycleService:
297
361
  stage: LifecycleStage,
298
362
  task_dir: Path,
299
363
  preflight: Preflight | None,
364
+ transition: LifecycleTransition,
300
365
  ) -> LifecycleResult | None:
301
- if preflight is None:
302
- return None
303
- failure = preflight(task_dir)
366
+ if preflight is None and stage.name == START_STAGE.name:
367
+ failure = start_readiness_failure(self.repo_root, task_dir)
368
+ elif preflight is None:
369
+ failure = None
370
+ else:
371
+ failure = preflight(task_dir)
304
372
  if failure is None:
305
373
  return None
306
374
  return self._failure(
@@ -310,6 +378,7 @@ class TaskLifecycleService:
310
378
  title=failure.title,
311
379
  blockers=failure.blockers,
312
380
  hint=failure.hint,
381
+ transition=transition,
313
382
  )
314
383
 
315
384
  def _validate_transition(
@@ -317,6 +386,7 @@ class TaskLifecycleService:
317
386
  stage: LifecycleStage,
318
387
  task_dir: Path,
319
388
  task_data: dict,
389
+ transition: LifecycleTransition,
320
390
  ) -> LifecycleResult | None:
321
391
  blockers = transition_blockers(
322
392
  task_data.get("status"),
@@ -329,6 +399,7 @@ class TaskLifecycleService:
329
399
  task_dir,
330
400
  "LIFECYCLE-TRANSITION-001",
331
401
  blockers=tuple(blockers),
402
+ transition=transition,
332
403
  )
333
404
 
334
405
  def _run_stage_checks(
@@ -336,19 +407,26 @@ class TaskLifecycleService:
336
407
  stage: LifecycleStage,
337
408
  task_dir: Path,
338
409
  *,
339
- allow_spec_file_modifications: bool,
410
+ transition: LifecycleTransition,
411
+ execution_policy: LifecycleExecutionPolicy,
340
412
  ) -> object | LifecycleResult:
341
413
  if stage.name == START_STAGE.name:
342
414
  check_result = LifecycleCheckResult(stage=stage.name)
343
415
  elif stage.name == REVIEW_STAGE.name:
344
416
  check_result = self.check_runner.review(
345
417
  task_dir,
346
- allow_spec_file_modifications=allow_spec_file_modifications,
418
+ allow_spec_file_modifications=(
419
+ execution_policy.allow_spec_file_modifications
420
+ ),
421
+ execution_policy=execution_policy,
347
422
  )
348
423
  elif stage.name == COMPLETE_STAGE.name:
349
424
  check_result = self.check_runner.complete(
350
425
  task_dir,
351
- allow_spec_file_modifications=allow_spec_file_modifications,
426
+ allow_spec_file_modifications=(
427
+ execution_policy.allow_spec_file_modifications
428
+ ),
429
+ execution_policy=execution_policy,
352
430
  )
353
431
  else:
354
432
  check_result = LifecycleCheckResult(
@@ -363,6 +441,8 @@ class TaskLifecycleService:
363
441
  "LIFECYCLE-CHECK-001",
364
442
  blockers=tuple(check_result.blockers),
365
443
  check_result=check_result,
444
+ transition=transition,
445
+ execution_policy=execution_policy,
366
446
  )
367
447
 
368
448
  def _build_session_state(
@@ -370,6 +450,7 @@ class TaskLifecycleService:
370
450
  stage: LifecycleStage,
371
451
  task_dir: Path,
372
452
  check_result: object,
453
+ transition: LifecycleTransition,
373
454
  ) -> tuple[object | None, str | None] | LifecycleResult:
374
455
  if not stage.activates_session:
375
456
  return None, None
@@ -384,6 +465,7 @@ class TaskLifecycleService:
384
465
  task_dir,
385
466
  "LIFECYCLE-CONTEXT-001",
386
467
  check_result=check_result,
468
+ transition=transition,
387
469
  )
388
470
  return session_state, session_state[2].task_path
389
471
 
@@ -435,6 +517,52 @@ class TaskLifecycleService:
435
517
  )
436
518
  return None
437
519
 
520
+ def start_readiness(self, task: str | Path) -> LifecycleResult:
521
+ """Check start readiness without mutating task or session state."""
522
+ task_dir = self.repository.resolve(task)
523
+ task_data_or_failure = self._load_transition_task(START_STAGE, task_dir)
524
+ transition = None
525
+ if isinstance(task_data_or_failure, LifecycleResult):
526
+ return task_data_or_failure
527
+ transition = self._transition_for(START_STAGE, task_data_or_failure)
528
+ failure = start_readiness_failure(self.repo_root, task_dir)
529
+ if failure is not None:
530
+ return self._failure(
531
+ START_STAGE,
532
+ task_dir,
533
+ failure.code,
534
+ title=failure.title,
535
+ blockers=failure.blockers,
536
+ hint=failure.hint,
537
+ transition=transition,
538
+ )
539
+ return LifecycleResult(
540
+ ok=True,
541
+ code="TASK-READINESS-OK",
542
+ stage=START_STAGE,
543
+ task_dir=task_dir,
544
+ transition=transition,
545
+ )
546
+
547
+ def _transition_for(
548
+ self,
549
+ stage: LifecycleStage,
550
+ task_data: dict,
551
+ ) -> LifecycleTransition:
552
+ previous = task_data.get("status")
553
+ previous_status = previous if isinstance(previous, str) else None
554
+ return LifecycleTransition(
555
+ previous_status=previous_status,
556
+ next_status=stage.target_status,
557
+ changed=previous_status != stage.target_status,
558
+ )
559
+
560
+ @staticmethod
561
+ def _success_events(stage: LifecycleStage) -> tuple[str, ...]:
562
+ if stage.name == START_STAGE.name:
563
+ return ("after_start",)
564
+ return ()
565
+
438
566
  def _operation_id(
439
567
  self,
440
568
  stage: LifecycleStage,
@@ -472,6 +600,9 @@ class TaskLifecycleService:
472
600
  title: str = "",
473
601
  hint: str = "",
474
602
  check_result: object | None = None,
603
+ transition: LifecycleTransition | None = None,
604
+ execution_policy: LifecycleExecutionPolicy | None = None,
605
+ emitted_events: tuple[str, ...] = (),
475
606
  ) -> LifecycleResult:
476
607
  return LifecycleResult(
477
608
  ok=False,
@@ -482,6 +613,9 @@ class TaskLifecycleService:
482
613
  title=title,
483
614
  hint=hint,
484
615
  check_result=check_result,
616
+ transition=transition,
617
+ execution_policy=execution_policy,
618
+ emitted_events=emitted_events,
485
619
  )
486
620
 
487
621
  @staticmethod
@@ -491,6 +625,9 @@ class TaskLifecycleService:
491
625
  error: TaskRepositoryError,
492
626
  *,
493
627
  check_result: object | None = None,
628
+ transition: LifecycleTransition | None = None,
629
+ execution_policy: LifecycleExecutionPolicy | None = None,
630
+ emitted_events: tuple[str, ...] = (),
494
631
  ) -> LifecycleResult:
495
632
  return LifecycleResult(
496
633
  ok=False,
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from dataclasses import dataclass
5
6
  from pathlib import Path
6
7
 
7
8
  from infra.skill_manifest import SkillManifestError, action_metadata
@@ -19,38 +20,61 @@ from kernel.workflow_route import (
19
20
  )
20
21
 
21
22
 
22
- def _run_command(
23
- task_path: str | None,
24
- *,
25
- intent: str | None = None,
26
- create: bool = False,
27
- commit: bool = False,
28
- ) -> str:
29
- parts = ["./.cowork-flow/run", "task", "next"]
30
- if task_path:
31
- parts.append(task_path)
32
- parts.append("--run")
33
- if intent:
34
- parts.extend(["--intent", intent])
35
- if create:
36
- parts.extend(["--title", '"<title>"', "--slug", "<task-name>", "--assignee", "<name>"])
37
- if commit:
38
- parts.append("--commit")
39
- return " ".join(parts)
40
-
41
-
42
- def _action_command(action_id: str, task_path: str | None, template: str | None) -> str | None:
43
- if template:
44
- return template.replace("<task-dir>", task_path or "<task-dir>")
45
- if action_id in {"start_task", "implement_change"}:
46
- return _run_command(task_path)
47
- return None
48
-
49
-
50
- def _diagnostics_command(task_path: str | None, template: str | None) -> str | None:
51
- if template:
52
- return template.replace("<task-dir>", task_path or "<task-dir>")
53
- return None
23
+ @dataclass(frozen=True)
24
+ class ActionContract:
25
+ """Internal action facts before adapter compatibility aliases are added."""
26
+
27
+ action_id: str
28
+ label: str
29
+ activated_skill: str | None
30
+ command: str | None
31
+ diagnostics_command: str | None
32
+ mutates_state: bool
33
+ lifecycle_check: str | None
34
+ runnable: bool
35
+ blockers: tuple[str, ...]
36
+
37
+ def to_payload(self) -> dict[str, object]:
38
+ return {
39
+ "id": self.action_id,
40
+ "label": self.label,
41
+ "activatedSkill": self.activated_skill,
42
+ "command": self.command,
43
+ "diagnosticsCommand": self.diagnostics_command,
44
+ "mutatesState": self.mutates_state,
45
+ "lifecycleCheck": self.lifecycle_check,
46
+ "runnable": self.runnable,
47
+ "blockers": list(self.blockers),
48
+ }
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RouteContract:
53
+ """Internal route facts before CLI adapter rendering."""
54
+
55
+ status: str
56
+ allowed_operations: tuple[str, ...]
57
+ required_artifacts: tuple[str, ...]
58
+ recommended_skill: str | None
59
+ blockers: tuple[str, ...]
60
+ action: ActionContract
61
+
62
+ def to_payload(self) -> dict[str, object]:
63
+ action = self.action.to_payload()
64
+ return {
65
+ "status": self.status,
66
+ "allowedOperations": list(self.allowed_operations),
67
+ "requiredArtifacts": list(self.required_artifacts),
68
+ "recommendedSkill": self.recommended_skill,
69
+ "blockers": list(self.blockers),
70
+ "nextAction": self.action.action_id,
71
+ "activatedSkill": self.action.activated_skill,
72
+ "actionCommand": self.action.command,
73
+ "diagnosticsCommand": self.action.diagnostics_command,
74
+ "mutatesState": self.action.mutates_state,
75
+ "lifecycleCheck": self.action.lifecycle_check,
76
+ "action": action,
77
+ }
54
78
 
55
79
 
56
80
  def _action_contract(
@@ -61,7 +85,7 @@ def _action_contract(
61
85
  active_target: bool,
62
86
  intent: str,
63
87
  repo_root: Path | None = None,
64
- ) -> dict[str, object]:
88
+ ) -> ActionContract:
65
89
  action = _kernel_action_contract(
66
90
  status=status,
67
91
  blockers=blockers,
@@ -87,25 +111,21 @@ def _action_contract(
87
111
  if owner is not None:
88
112
  if owner.mutates_state != bool(action["mutatesState"]):
89
113
  action_blockers.append(f"Skill owner transition mismatch: {action_id}")
90
- if owner.lifecycle_check != action["runtimeGate"]:
114
+ if owner.lifecycle_check != action["lifecycleCheck"]:
91
115
  action_blockers.append(f"Skill owner lifecycle mismatch: {action_id}")
92
116
 
93
- lifecycle_check = owner.lifecycle_check if owner is not None else action["runtimeGate"]
94
- return {
95
- "id": action_id,
96
- "label": owner.label if owner is not None else action_id,
97
- "activatedSkill": owner.skill if owner is not None else None,
98
- "command": _action_command(action_id, task_path, owner.command if owner else None),
99
- "diagnosticsCommand": _diagnostics_command(
100
- task_path,
101
- owner.diagnostics_command if owner else None,
102
- ),
103
- "mutatesState": action["mutatesState"],
104
- "lifecycleCheck": lifecycle_check,
105
- "runtimeGate": lifecycle_check,
106
- "runnable": bool(action["runnable"]) and not action_blockers,
107
- "blockers": action_blockers,
108
- }
117
+ lifecycle_check = owner.lifecycle_check if owner is not None else action["lifecycleCheck"]
118
+ return ActionContract(
119
+ action_id=action_id,
120
+ label=owner.label if owner is not None else action_id,
121
+ activated_skill=owner.skill if owner is not None else None,
122
+ command=owner.command if owner is not None else None,
123
+ diagnostics_command=owner.diagnostics_command if owner is not None else None,
124
+ mutates_state=bool(action["mutatesState"]),
125
+ lifecycle_check=lifecycle_check,
126
+ runnable=bool(action["runnable"]) and not action_blockers,
127
+ blockers=tuple(action_blockers),
128
+ )
109
129
 
110
130
 
111
131
  def route_request(
@@ -140,20 +160,13 @@ def route_request(
140
160
  intent=intent,
141
161
  repo_root=repo_root,
142
162
  )
143
- route_blockers = list(action["blockers"])
144
- recommended = action["activatedSkill"] if intent_allowed and not route_blockers else None
145
- return {
146
- "status": status,
147
- "allowedOperations": operations,
148
- "requiredArtifacts": _required_artifacts(status),
149
- "recommendedSkill": recommended,
150
- "blockers": route_blockers,
151
- "nextAction": action["id"],
152
- "activatedSkill": action["activatedSkill"],
153
- "actionCommand": action["command"],
154
- "diagnosticsCommand": action["diagnosticsCommand"],
155
- "mutatesState": action["mutatesState"],
156
- "lifecycleCheck": action["lifecycleCheck"],
157
- "runtimeGate": action["runtimeGate"],
158
- "action": action,
159
- }
163
+ route_blockers = action.blockers
164
+ recommended = action.activated_skill if intent_allowed and not route_blockers else None
165
+ return RouteContract(
166
+ status=status,
167
+ allowed_operations=tuple(operations),
168
+ required_artifacts=tuple(_required_artifacts(status)),
169
+ recommended_skill=recommended,
170
+ blockers=route_blockers,
171
+ action=action,
172
+ ).to_payload()