cowork-flow 0.0.39 → 0.0.41

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 (24) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/template/.cowork-flow/.version +1 -1
  4. package/template/.cowork-flow/scripts/application/task_archive.py +148 -21
  5. package/template/.cowork-flow/scripts/application/task_lifecycle.py +3 -1
  6. package/template/.cowork-flow/scripts/commands/add_session.py +137 -124
  7. package/template/.cowork-flow/scripts/commands/task.py +1 -0
  8. package/template/.cowork-flow/scripts/commands/task_archive_commands.py +135 -49
  9. package/template/.cowork-flow/scripts/commands/task_navigation.py +145 -81
  10. package/template/.cowork-flow/scripts/commands/task_parser.py +1 -0
  11. package/template/.cowork-flow/scripts/common/core/paths.py +4 -1
  12. package/template/.cowork-flow/scripts/common/gates/gates.py +114 -108
  13. package/template/.cowork-flow/scripts/common/gates/validate_implementation.py +50 -28
  14. package/template/.cowork-flow/scripts/common/git/git_snapshot.py +14 -0
  15. package/template/.cowork-flow/spec/protocols/decision-review.md +28 -1
  16. package/template/.cowork-flow/spec/protocols/spec-maintenance.md +19 -0
  17. package/template/.cowork-flow/spec/protocols/tdd.md +13 -0
  18. package/template/.cowork-flow/spec/references/definition-of-done.md +1 -1
  19. package/template/.cowork-flow/spec/runtime/host-assets.json +0 -2
  20. package/template/.cowork-flow/spec/runtime/skill-registry.json +29 -1
  21. package/template/.cowork-flow/workflow.md +6 -2
  22. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  23. package/template/.zcode/scaffold/.cowork-flow/workflow.md +6 -2
  24. package/template/skills/doubt-review/SKILL.md +125 -0
@@ -24,19 +24,18 @@ from common.core.paths import (
24
24
  get_repo_root,
25
25
  )
26
26
  from common.git.git_context import _run_git_command
27
+ from common.task.archive_utils import archive_directory_resumable
27
28
 
28
29
 
29
30
  DONE_STATUSES = ("completed", "done")
30
31
 
31
32
 
32
33
  def is_git_dirty(repo_root) -> bool:
33
- try:
34
- return _run_git_command(
35
- ["status", "--porcelain"],
36
- cwd=repo_root,
37
- )[0] != 0
38
- except (OSError, Exception):
39
- return False
34
+ rc, stdout, _ = _run_git_command(
35
+ ["status", "--porcelain"],
36
+ cwd=repo_root,
37
+ )
38
+ return rc != 0 or bool(stdout.strip())
40
39
 
41
40
 
42
41
  def linked_active_changes_for_task(repo_root, task_dir) -> list[str]:
@@ -62,9 +61,76 @@ def linked_changes_ready_for_archive(repo_root, slugs: list[str]) -> bool:
62
61
  return ready
63
62
 
64
63
 
64
+ def _linked_change_paths(repo_root, slug: str):
65
+ source = repo_root / DIR_WORKFLOW / DIR_CHANGES / slug
66
+ month = datetime.now().astimezone().strftime("%Y-%m")
67
+ destination = (
68
+ repo_root
69
+ / DIR_WORKFLOW
70
+ / DIR_CHANGES
71
+ / DIR_ARCHIVE
72
+ / month
73
+ / slug
74
+ )
75
+ return source, destination
76
+
77
+
78
+ def _restore_linked_changes(
79
+ repo_root,
80
+ slugs: list[str],
81
+ metadata: dict[str, bytes],
82
+ ) -> bool:
83
+ restored = True
84
+ for slug in reversed(slugs):
85
+ source, destination = _linked_change_paths(repo_root, slug)
86
+ if destination.is_dir():
87
+ result = archive_directory_resumable(destination, source)
88
+ if not result.ok:
89
+ print(
90
+ colored(
91
+ f"Error: Failed to restore linked change: {slug}: "
92
+ f"{result.message}",
93
+ Colors.RED,
94
+ ),
95
+ file=sys.stderr,
96
+ )
97
+ restored = False
98
+ continue
99
+ if source.is_dir():
100
+ try:
101
+ (source / "change.yaml").write_bytes(metadata[slug])
102
+ except OSError as error:
103
+ print(
104
+ colored(
105
+ f"Error: Failed to restore linked change metadata: "
106
+ f"{slug}: {error}",
107
+ Colors.RED,
108
+ ),
109
+ file=sys.stderr,
110
+ )
111
+ restored = False
112
+ return restored
113
+
114
+
65
115
  def archive_linked_changes(repo_root, slugs: list[str]) -> bool:
66
116
  from commands.change import archive_change_by_slug
67
117
 
118
+ metadata: dict[str, bytes] = {}
119
+ try:
120
+ for slug in slugs:
121
+ source, _ = _linked_change_paths(repo_root, slug)
122
+ metadata[slug] = (source / "change.yaml").read_bytes()
123
+ except OSError as error:
124
+ print(
125
+ colored(
126
+ f"Error: Failed to snapshot linked change metadata: {error}",
127
+ Colors.RED,
128
+ ),
129
+ file=sys.stderr,
130
+ )
131
+ return False
132
+
133
+ archived: list[str] = []
68
134
  for slug in slugs:
69
135
  if archive_change_by_slug(repo_root, slug) is None:
70
136
  print(
@@ -74,7 +140,11 @@ def archive_linked_changes(repo_root, slugs: list[str]) -> bool:
74
140
  ),
75
141
  file=sys.stderr,
76
142
  )
143
+ _restore_linked_changes(repo_root, slugs, metadata)
77
144
  return False
145
+ archived.append(slug)
146
+
147
+ for slug in archived:
78
148
  print(
79
149
  colored(f"Archived linked change: {slug}", Colors.GREEN),
80
150
  file=sys.stderr,
@@ -111,15 +181,13 @@ def auto_commit_archive(task_name: str, repo_root) -> None:
111
181
  print(f"[WARN] Auto-commit failed: {error.strip()}", file=sys.stderr)
112
182
 
113
183
 
114
- def cmd_archive(args) -> int:
115
- repo_root = get_repo_root()
116
- task_name = args.name
184
+ def _resolve_archive_task(repo_root, task_name: str):
117
185
  if not task_name:
118
186
  print(
119
187
  colored("Error: Task name is required", Colors.RED),
120
188
  file=sys.stderr,
121
189
  )
122
- return 1
190
+ return None
123
191
 
124
192
  task_dir = resolve_task_dir(task_name, repo_root)
125
193
  if not task_dir.is_dir():
@@ -129,46 +197,40 @@ def cmd_archive(args) -> int:
129
197
  )
130
198
  print("Active tasks:", file=sys.stderr)
131
199
  cmd_list(argparse.Namespace(mine=False, status=None))
132
- return 1
200
+ return None
201
+ return task_dir
133
202
 
134
- linked_changes = linked_active_changes_for_task(repo_root, task_dir)
135
- if linked_changes and not linked_changes_ready_for_archive(
136
- repo_root,
137
- linked_changes,
138
- ):
139
- return 1
140
203
 
141
- if is_git_dirty(repo_root):
142
- print(
143
- colored(
144
- "Warning: Uncommitted changes detected. Archive the task first, "
145
- "then commit the archived result.",
146
- Colors.YELLOW,
147
- ),
148
- file=sys.stderr,
204
+ def _archive_error_message(task_name: str, error: TaskArchiveError) -> str:
205
+ if error.code == "TASK-ARCHIVE-LOAD-001":
206
+ return f"Task '{task_name}' task.json is unreadable — refusing archive."
207
+ if error.code == "TASK-ARCHIVE-STATUS-001":
208
+ status = error.detail.removeprefix("task status is ")
209
+ return (
210
+ f"Task '{task_name}' is in status '{status}', not in "
211
+ f"{DONE_STATUSES}. Run `task complete` first, then retry archive."
149
212
  )
213
+ return f"Failed to archive task: {error.detail}"
214
+
150
215
 
216
+ def _archive_task(repo_root, task_name: str, task_dir, linked_changes: list[str]):
151
217
  try:
152
- result = TaskArchiveService(repo_root).archive(
218
+ return TaskArchiveService(repo_root).archive(
153
219
  task_dir,
154
220
  archived_at=datetime.now().strftime("%Y-%m-%d"),
221
+ finalize=(
222
+ lambda: archive_linked_changes(repo_root, linked_changes)
223
+ )
224
+ if linked_changes
225
+ else None,
155
226
  )
156
227
  except TaskArchiveError as error:
157
- if error.code == "TASK-ARCHIVE-LOAD-001":
158
- message = (
159
- f"Task '{task_name}' task.json is unreadable — refusing archive."
160
- )
161
- elif error.code == "TASK-ARCHIVE-STATUS-001":
162
- status = error.detail.removeprefix("task status is ")
163
- message = (
164
- f"Task '{task_name}' is in status '{status}', not in "
165
- f"{DONE_STATUSES}. Run `task complete` first, then retry archive."
166
- )
167
- else:
168
- message = f"Failed to archive task: {error.detail}"
228
+ message = _archive_error_message(task_name, error)
169
229
  print(colored(f"Error: {message}", Colors.RED), file=sys.stderr)
170
- return 1
230
+ return None
231
+
171
232
 
233
+ def _print_archive_result(result, repo_root) -> None:
172
234
  archive_dest = result.destination
173
235
  year_month = archive_dest.parent.name
174
236
  print(
@@ -178,18 +240,42 @@ def cmd_archive(args) -> int:
178
240
  ),
179
241
  file=sys.stderr,
180
242
  )
181
- if linked_changes and not archive_linked_changes(repo_root, linked_changes):
182
- return 1
183
- if getattr(args, "commit", False):
184
- auto_commit_archive(result.task_name, repo_root)
185
-
186
243
  print(
187
244
  f"{DIR_WORKFLOW}/{DIR_TASKS}/{DIR_ARCHIVE}/"
188
245
  f"{year_month}/{result.task_name}"
189
246
  )
190
- run_hooks(
191
- "after_archive",
192
- archive_dest / FILE_TASK_JSON,
247
+ run_hooks("after_archive", archive_dest / FILE_TASK_JSON, repo_root)
248
+
249
+
250
+ def cmd_archive(args) -> int:
251
+ repo_root = get_repo_root()
252
+ task_name = args.name
253
+ task_dir = _resolve_archive_task(repo_root, task_name)
254
+ if task_dir is None:
255
+ return 1
256
+
257
+ linked_changes = linked_active_changes_for_task(repo_root, task_dir)
258
+ if linked_changes and not linked_changes_ready_for_archive(
193
259
  repo_root,
194
- )
260
+ linked_changes,
261
+ ):
262
+ return 1
263
+
264
+ if is_git_dirty(repo_root):
265
+ print(
266
+ colored(
267
+ "Warning: Uncommitted changes detected. Archive the task first, "
268
+ "then commit the archived result.",
269
+ Colors.YELLOW,
270
+ ),
271
+ file=sys.stderr,
272
+ )
273
+
274
+ result = _archive_task(repo_root, task_name, task_dir, linked_changes)
275
+ if result is None:
276
+ return 1
277
+
278
+ if getattr(args, "commit", False):
279
+ auto_commit_archive(result.task_name, repo_root)
280
+ _print_archive_result(result, repo_root)
195
281
  return 0
@@ -25,6 +25,7 @@ USER_INTENTS = (
25
25
  "clarify",
26
26
  "plan",
27
27
  "implement",
28
+ "archive",
28
29
  "review",
29
30
  "debug",
30
31
  "discuss",
@@ -35,6 +36,7 @@ INTENT_REGISTRY_KEYS = {
35
36
  "clarify": "clarify_requirement",
36
37
  "plan": "write_plan",
37
38
  "implement": "route_workflow",
39
+ "archive": "route_workflow",
38
40
  "review": "route_workflow",
39
41
  "debug": "analyze_repeated_failure",
40
42
  "discuss": "discuss_options",
@@ -45,11 +47,10 @@ INTENT_OPERATIONS = {
45
47
  "clarify": {"edit_planning_artifacts"},
46
48
  "plan": {"edit_planning_artifacts"},
47
49
  "implement": {
48
- "create_task",
49
- "start_task",
50
50
  "implement_change",
51
51
  "execute_delegated_work",
52
52
  },
53
+ "archive": {"archive_task"},
53
54
  "review": {"request_review", "verify_change", "complete_task"},
54
55
  "debug": {"debug_failure"},
55
56
  "discuss": {"discuss_options"},
@@ -204,12 +205,39 @@ def route_request(
204
205
  if context not in {"main", "delegated"}:
205
206
  raise ValueError(f"unsupported workflow context: {context}")
206
207
 
208
+ route_blockers, operations, intent_allowed, matches = _resolve_route(
209
+ registry,
210
+ status=status,
211
+ intent=intent,
212
+ context=context,
213
+ blockers=blockers,
214
+ active_target=active_target,
215
+ )
216
+ protocols = _route_protocols(status, intent, intent_allowed)
217
+ return {
218
+ "status": status,
219
+ "allowedOperations": operations,
220
+ "requiredArtifacts": _required_artifacts(status),
221
+ "recommendedSkill": matches[0] if len(matches) == 1 else None,
222
+ "internalProtocols": protocols,
223
+ "blockers": route_blockers,
224
+ }
225
+
226
+
227
+ def _resolve_route(
228
+ registry: SkillRegistry,
229
+ *,
230
+ status: str,
231
+ intent: str,
232
+ context: str,
233
+ blockers: tuple[str, ...] | list[str],
234
+ active_target: bool,
235
+ ) -> tuple[list[str], list[str], bool, list[str]]:
207
236
  route_blockers = [str(blocker) for blocker in blockers]
208
- blocker_tuple = tuple(route_blockers)
209
237
  operations = _allowed_operations(
210
238
  status,
211
239
  context,
212
- blocker_tuple,
240
+ tuple(route_blockers),
213
241
  active_target,
214
242
  )
215
243
  intent_allowed = _intent_is_allowed(intent, operations)
@@ -233,24 +261,22 @@ def route_request(
233
261
  )
234
262
  if intent == "batch" and not matches:
235
263
  route_blockers.append("batch-mode is disabled")
264
+ return route_blockers, operations, intent_allowed, matches
236
265
 
237
- protocols: list[str] = []
266
+
267
+ def _route_protocols(
268
+ status: str,
269
+ intent: str,
270
+ intent_allowed: bool,
271
+ ) -> list[str]:
238
272
  if intent_allowed and intent == "implement" and status in {
239
273
  "in_progress",
240
274
  "delegated_subtask",
241
275
  }:
242
- protocols.append("tdd")
243
- elif intent_allowed and intent == "review" and status in CHECK_STATUSES:
244
- protocols.append("check")
245
-
246
- return {
247
- "status": status,
248
- "allowedOperations": operations,
249
- "requiredArtifacts": _required_artifacts(status),
250
- "recommendedSkill": matches[0] if len(matches) == 1 else None,
251
- "internalProtocols": protocols,
252
- "blockers": route_blockers,
253
- }
276
+ return ["tdd"]
277
+ if intent_allowed and intent == "review" and status in CHECK_STATUSES:
278
+ return ["check"]
279
+ return []
254
280
 
255
281
 
256
282
  def _default_intent(
@@ -266,6 +292,8 @@ def _default_intent(
266
292
  return "implement"
267
293
  if status in CHECK_STATUSES:
268
294
  return "review"
295
+ if status in DONE_STATUSES:
296
+ return "archive"
269
297
  if status == "delegated_subtask":
270
298
  return "implement"
271
299
  if active_target:
@@ -308,70 +336,60 @@ def _print_json_route(
308
336
  print(json.dumps(payload, ensure_ascii=False, sort_keys=False))
309
337
 
310
338
 
311
- def cmd_next(args) -> int:
312
- repo_root = get_repo_root()
339
+ def _navigation_target(args, repo_root: Path, structured: bool):
313
340
  target = getattr(args, "dir", None)
314
- active_target = False
315
- structured = bool(getattr(args, "json", False))
316
- if not structured:
317
- print("Workflow Next")
318
341
  if target:
319
342
  task_dir = resolve_task_dir(target, repo_root)
320
343
  task_path = _display(repo_root, task_dir)
321
- source = "argument"
322
- else:
323
- active = get_active_task(repo_root)
324
- source = f"{active.source}:{active.context_key or '-'}"
325
- if not active.task_path:
326
- if structured:
327
- _print_json_route(
328
- args=args,
329
- status="no_task",
330
- blockers=[],
331
- active_target=False,
332
- )
333
- return 0
334
- print("Status: no_task")
335
- print(f"Source: {source}")
336
- print("Next action: create or start a task before repository changes")
337
- print('Command: ./.cowork-flow/run task create "<title>" --slug <task-name>')
338
- print("Then: ./.cowork-flow/run task start <task-dir>")
339
- print("Runtime-context subagent state is injected by hook/plugin; do not infer it from prompt text.")
340
- return 0
341
- task_path = active.task_path
342
- task_dir = repo_root / task_path
343
- active_target = True
344
+ return task_dir, task_path, "argument", False
344
345
 
345
- if not structured:
346
- print(f"Task: {task_path}")
347
- if not task_dir.is_dir():
348
- if structured:
349
- _print_json_route(
350
- args=args,
351
- status="stale",
352
- blockers=[f"task directory not found: {task_path}"],
353
- active_target=active_target,
354
- )
355
- return 0
356
- print("Status: stale")
346
+ active = get_active_task(repo_root)
347
+ source = f"{active.source}:{active.context_key or '-'}"
348
+ if active.task_path:
349
+ return repo_root / active.task_path, active.task_path, source, True
350
+ if structured:
351
+ _print_json_route(
352
+ args=args,
353
+ status="no_task",
354
+ blockers=[],
355
+ active_target=False,
356
+ )
357
+ else:
358
+ print("Status: no_task")
357
359
  print(f"Source: {source}")
358
- print("Next action: clear or replace the missing active task")
359
- print("Command: ./.cowork-flow/run task list")
360
- print("Blockers:")
361
- print(f" - task directory not found: {task_path}")
362
- return 0
360
+ print("Next action: create or start a task before repository changes")
361
+ print('Command: ./.cowork-flow/run task create "<title>" --slug <task-name>')
362
+ print("Then: ./.cowork-flow/run task start <task-dir>")
363
+ print("Runtime-context subagent state is injected by hook/plugin; do not infer it from prompt text.")
364
+ return None
363
365
 
364
- status = _status(repo_root, task_dir)
365
- blockers = _blockers(repo_root, task_dir)
366
- if structured:
366
+
367
+ def _print_stale_route(args, task_path: str, source: str, active_target: bool) -> None:
368
+ if bool(getattr(args, "json", False)):
367
369
  _print_json_route(
368
370
  args=args,
369
- status=status,
370
- blockers=blockers,
371
+ status="stale",
372
+ blockers=[f"task directory not found: {task_path}"],
371
373
  active_target=active_target,
372
374
  )
373
- return 0
375
+ return
376
+ print("Status: stale")
377
+ print(f"Source: {source}")
378
+ print("Next action: clear or replace the missing active task")
379
+ print("Command: ./.cowork-flow/run task list")
380
+ print("Blockers:")
381
+ print(f" - task directory not found: {task_path}")
382
+
374
383
 
384
+ def _print_text_route(
385
+ repo_root: Path,
386
+ task_path: str,
387
+ task_dir: Path,
388
+ status: str,
389
+ source: str,
390
+ blockers: list[str],
391
+ active_target: bool,
392
+ ) -> None:
375
393
  print(f"Status: {status}")
376
394
  print(f"Source: {source}")
377
395
  if status == "planning":
@@ -387,21 +405,67 @@ def cmd_next(args) -> int:
387
405
  elif status == "in_progress":
388
406
  _implementation(task_path)
389
407
  elif status in CHECK_STATUSES:
390
- print("Next action: verify implementation")
391
- print(f"Command: ./.cowork-flow/run subagent init --role check --agent-type cowork-check --execution-task-dir {task_path} --title \"Check {Path(task_path).name}\"")
392
- print("Then: pass cowork_runtime_context_id and cowork_host_context_key through the active Host Adapter or run equivalent inline check")
393
- print("Then: child first step runs ./.cowork-flow/run subagent bind <runtime_context_id> <host_context_key>")
394
- print("Then: verify status=bound and bound_context_key before accepting output")
395
- print(f"Then: ./.cowork-flow/run task complete {task_path}")
408
+ _print_check_route(task_path)
396
409
  elif status in DONE_STATUSES:
397
- print("Next action: finalize, archive, commit, and record session")
398
- print("Command: git status --short")
399
- changes = linked_active_changes_for_task(repo_root, task_dir)
400
- print(f"Then: ./.cowork-flow/run task archive {Path(task_path).name}")
401
- for slug in changes:
402
- print(f"Then: ./.cowork-flow/run change archive {slug} (handled by task archive)")
410
+ _print_archive_route(repo_root, task_path, task_dir)
403
411
  else:
404
412
  print("Next action: inspect task status and repair workflow state")
405
413
  print(f"Command: ./.cowork-flow/run task validate {task_path}")
406
414
  _print_blockers(blockers)
415
+
416
+
417
+ def _print_check_route(task_path: str) -> None:
418
+ print("Next action: verify implementation")
419
+ print(f"Command: ./.cowork-flow/run subagent init --role check --agent-type cowork-check --execution-task-dir {task_path} --title \"Check {Path(task_path).name}\"")
420
+ print("Then: pass cowork_runtime_context_id and cowork_host_context_key through the active Host Adapter or run equivalent inline check")
421
+ print("Then: child first step runs ./.cowork-flow/run subagent bind <runtime_context_id> <host_context_key>")
422
+ print("Then: verify status=bound and bound_context_key before accepting output")
423
+ print(f"Then: ./.cowork-flow/run task complete {task_path}")
424
+
425
+
426
+ def _print_archive_route(repo_root: Path, task_path: str, task_dir: Path) -> None:
427
+ print("Next action: finalize, archive, commit, and record session")
428
+ print("Command: git status --short")
429
+ changes = linked_active_changes_for_task(repo_root, task_dir)
430
+ print(f"Then: ./.cowork-flow/run task archive {Path(task_path).name}")
431
+ for slug in changes:
432
+ print(f"Then: ./.cowork-flow/run change archive {slug} (handled by task archive)")
433
+
434
+
435
+ def cmd_next(args) -> int:
436
+ repo_root = get_repo_root()
437
+ structured = bool(getattr(args, "json", False))
438
+ if not structured:
439
+ print("Workflow Next")
440
+ target = _navigation_target(args, repo_root, structured)
441
+ if target is None:
442
+ return 0
443
+ task_dir, task_path, source, active_target = target
444
+
445
+ if not structured:
446
+ print(f"Task: {task_path}")
447
+ if not task_dir.is_dir():
448
+ _print_stale_route(args, task_path, source, active_target)
449
+ return 0
450
+
451
+ status = _status(repo_root, task_dir)
452
+ blockers = _blockers(repo_root, task_dir) if status == "planning" else []
453
+ if structured:
454
+ _print_json_route(
455
+ args=args,
456
+ status=status,
457
+ blockers=blockers,
458
+ active_target=active_target,
459
+ )
460
+ return 0
461
+
462
+ _print_text_route(
463
+ repo_root,
464
+ task_path,
465
+ task_dir,
466
+ status,
467
+ source,
468
+ blockers,
469
+ active_target,
470
+ )
407
471
  return 0
@@ -245,6 +245,7 @@ def _add_next_command(subparsers: argparse._SubParsersAction) -> None:
245
245
  "clarify",
246
246
  "plan",
247
247
  "implement",
248
+ "archive",
248
249
  "review",
249
250
  "debug",
250
251
  "discuss",
@@ -89,7 +89,10 @@ def get_developer(repo_root: Path | None = None) -> str | None:
89
89
  content = dev_file.read_text(encoding="utf-8")
90
90
  for line in content.splitlines():
91
91
  if line.startswith("name="):
92
- return line.split("=", 1)[1].strip()
92
+ name = line.split("=", 1)[1].strip()
93
+ if not name or (name.startswith("<") and name.endswith(">")):
94
+ return None
95
+ return name
93
96
  except (OSError, IOError):
94
97
  pass
95
98