cowork-flow 0.0.45 → 0.0.47

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 (27) hide show
  1. package/README.md +11 -3
  2. package/package.json +1 -1
  3. package/src/cli.js +1 -1
  4. package/src/commands/install-zcode-plugin.js +165 -21
  5. package/template/.cowork-flow/.version +1 -1
  6. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +3 -0
  7. package/template/.cowork-flow/scripts/adapters/cli/task.py +24 -185
  8. package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +70 -1
  9. package/template/.cowork-flow/scripts/adapters/cli/task_next_runner.py +229 -0
  10. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +1 -1
  11. package/template/.cowork-flow/scripts/adapters/cli/task_tree_commands.py +68 -0
  12. package/template/.cowork-flow/scripts/infra/skill_manifest.py +25 -2
  13. package/template/.cowork-flow/scripts/runtime/session_state.py +2 -0
  14. package/template/.cowork-flow/scripts/services/task_routing.py +11 -0
  15. package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +14 -5
  16. package/template/.zcode/.zcode-plugin/plugin.json +3 -1
  17. package/template/.zcode/agents/cowork-check.md +27 -0
  18. package/template/.zcode/agents/cowork-implement.md +28 -0
  19. package/template/.zcode/agents/cowork-research.md +21 -0
  20. package/template/.zcode/hooks/hooks.json +12 -6
  21. package/template/.zcode/hooks/inject-context.js +40 -13
  22. package/template/.zcode/hooks/inject-context.selfcheck.mjs +8 -2
  23. package/template/skills/adversarial-review/SKILL.md +17 -0
  24. package/template/skills/runtime-health/SKILL.md +6 -1
  25. package/template/skills/runtime-health/scripts/doctor.py +174 -8
  26. package/template/skills/task-review/SKILL.md +5 -0
  27. package/template/skills/task-review/manifest.json +4 -2
package/README.md CHANGED
@@ -39,7 +39,7 @@ template/
39
39
  ├── .codex/ # Codex agents / hooks / config
40
40
  ├── .claude/ # Claude Code settings / agents / hooks
41
41
  ├── .opencode/ # OpenCode agents / commands / plugins
42
- ├── .zcode/ # ⭐ ZCode 插件(hooks + skills + scaffold instructions)
42
+ ├── .zcode/ # ⭐ ZCode 插件(hooks + skills + agents + scaffold instructions)
43
43
  └── .cowork-flow/
44
44
  ├── config.yaml # 项目配置
45
45
  ├── scripts/ # Python 运行时
@@ -103,17 +103,25 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
103
103
  ```bash
104
104
  cowork-flow install-zcode-plugin # 安装
105
105
  cowork-flow install-zcode-plugin --force # 覆盖已安装
106
+ cowork-flow install-zcode-plugin --force --prune-old # 覆盖并清理旧版本缓存
106
107
  ```
107
108
 
108
- 安装到 `~/.zcode/cli/plugins/cache/zcode-plugins-official/cowork-flow/<version>/`
109
+ 安装到 `~/.zcode/cli/plugins/cache/cowork-flow-local/cowork-flow/<version>/`。安装器会同时写入稳定 marketplace source:`~/.zcode/cli/plugins/cache/marketplaces/cowork-flow-local/marketplace.json`,以及 ZCode 当前使用的活动副本:`~/.zcode/cli/plugins/marketplaces/cowork-flow-local/marketplace.json`。`known_marketplaces.json` 指向稳定 source 目录,避免 ZCode 刷新活动副本时删除自己的 source。
109
110
 
110
- ZCode 插件只安装 hook、skills 和轻量说明文件;`.cowork-flow/` 流程文件仍由显式 `cowork-flow init` / `cowork-flow sync` 在项目根目录管理。插件不会通过 scaffold 创建 `.cowork-flow/`,因此不会在多模块项目的模块目录重复落盘流程文件。
111
+ 安装新版本时,marketplace 中只保留一个 `cowork-flow` entry 并指向最新版本目录;旧版本缓存默认保留,避免正在运行的 ZCode session 仍引用旧插件根目录。需要清理旧版本时显式传 `--prune-old`。
112
+
113
+ ZCode 插件只安装 hook、skills、agents 和轻量说明文件;`.cowork-flow/` 流程文件仍由显式 `cowork-flow init` / `cowork-flow sync` 在项目根目录管理。插件不会通过 scaffold 创建 `.cowork-flow/`,因此不会在多模块项目的模块目录重复落盘流程文件。
111
114
 
112
115
  **Hook 注入内容:**
113
116
  - `workflow-state` — 当前任务状态
114
117
  - `contract-digest` — 合同摘要(SHA256 fingerprint)
115
118
  - `delegated_subtask` — 子代理运行时上下文
116
119
 
120
+ **插件子代理:**
121
+ - `cowork-implement` — 绑定 runtime context 后执行计划内实现
122
+ - `cowork-check` — 绑定 runtime context 后做独立检查
123
+ - `cowork-research` — 绑定 runtime context 后做只读调研
124
+
117
125
  ## 任务流程
118
126
 
119
127
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -12,7 +12,7 @@ const HELP = `cowork-flow
12
12
 
13
13
  Usage:
14
14
  cowork-flow init [target] --platform <codex|opencode|claude-code|all> [--developer <name>] [--dry-run] [--force]
15
- cowork-flow install-zcode-plugin [--force]
15
+ cowork-flow install-zcode-plugin [--dry-run] [--force] [--prune-old]
16
16
  cowork-flow update
17
17
  cowork-flow sync [target] [--dry-run] [--force]
18
18
  cowork-flow --version
@@ -1,17 +1,20 @@
1
- import { cp, mkdir, readFile, writeFile, access, rm } from 'node:fs/promises';
2
- import { join, resolve } from 'node:path';
1
+ import { cp, mkdir, readFile, writeFile, access, rm, readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
5
  import { readPackageInfo } from '../lib/package-info.js';
6
6
  import { templateRoot } from '../lib/paths.js';
7
7
 
8
- const ZCODE_MARKETPLACE = 'zcode-plugins-official';
8
+ const ZCODE_MARKETPLACE = 'cowork-flow-local';
9
+ const LEGACY_ZCODE_MARKETPLACE = 'zcode-plugins-official';
9
10
  const PLUGIN_NAME = 'cowork-flow';
11
+ const LOCAL_MARKETPLACE_DESCRIPTION = 'Local marketplace registration for cowork-flow during local development.';
10
12
 
11
13
  function parseArgs(args) {
12
14
  return {
13
15
  dryRun: args.includes('--dry-run'),
14
- force: args.includes('--force')
16
+ force: args.includes('--force'),
17
+ pruneOld: args.includes('--prune-old')
15
18
  };
16
19
  }
17
20
 
@@ -24,9 +27,13 @@ async function pathExists(target) {
24
27
  }
25
28
  }
26
29
 
27
- async function getZCodeCacheDir() {
30
+ function getZCodePluginsRoot() {
28
31
  const base = process.env.ZCODE_HOME || join(homedir(), '.zcode');
29
- return join(base, 'cli', 'plugins', 'cache', ZCODE_MARKETPLACE, PLUGIN_NAME);
32
+ return join(base, 'cli', 'plugins');
33
+ }
34
+
35
+ async function getZCodeCacheDir() {
36
+ return join(getZCodePluginsRoot(), 'cache', ZCODE_MARKETPLACE, PLUGIN_NAME);
30
37
  }
31
38
 
32
39
  async function readJsonSafe(path) {
@@ -46,14 +53,46 @@ function dirname(path) {
46
53
  return idx >= 0 ? path.slice(0, idx) : path;
47
54
  }
48
55
 
49
- async function updateMarketplace(cacheRoot, version) {
50
- const marketPath = resolve(cacheRoot, '..', '..', '..', 'marketplaces', ZCODE_MARKETPLACE, 'marketplace.json');
51
- const market = (await readJsonSafe(marketPath)) || { name: ZCODE_MARKETPLACE, plugins: [], version: 1 };
56
+ function normalizedPath(path) {
57
+ return path.split('\\').join('/');
58
+ }
59
+
60
+ function marketplacePaths(pluginsRoot) {
61
+ return {
62
+ activeMarketplacePath: join(pluginsRoot, 'marketplaces', ZCODE_MARKETPLACE, 'marketplace.json'),
63
+ sourceMarketplacePath: join(pluginsRoot, 'cache', 'marketplaces', ZCODE_MARKETPLACE, 'marketplace.json')
64
+ };
65
+ }
66
+
67
+ async function updateMarketplace(pluginsRoot, cacheRoot, version, manifest = {}) {
68
+ const { activeMarketplacePath, sourceMarketplacePath } = marketplacePaths(pluginsRoot);
69
+ const market = (await readJsonSafe(sourceMarketplacePath))
70
+ || (await readJsonSafe(activeMarketplacePath))
71
+ || {
72
+ name: ZCODE_MARKETPLACE,
73
+ description: LOCAL_MARKETPLACE_DESCRIPTION,
74
+ plugins: [],
75
+ version: 1
76
+ };
77
+ const pluginPath = normalizedPath(join(cacheRoot, version));
78
+
79
+ market.name = ZCODE_MARKETPLACE;
80
+ market.description = market.description || LOCAL_MARKETPLACE_DESCRIPTION;
81
+ market.version = 1;
82
+ if (!Array.isArray(market.plugins)) {
83
+ market.plugins = [];
84
+ }
52
85
 
53
86
  const entry = {
54
- cachePath: join(cacheRoot, version).split('\\').join('/'),
87
+ author: manifest.author || { name: 'fisklikewater' },
88
+ category: 'developer-tools',
89
+ description: manifest.description || 'cowork-flow task lifecycle, hooks, skills, and fixed subagents for ZCode.',
90
+ license: manifest.license || 'MIT',
55
91
  name: PLUGIN_NAME,
56
- source: 'filesystem',
92
+ source: {
93
+ source: 'directory',
94
+ path: pluginPath
95
+ },
57
96
  version
58
97
  };
59
98
 
@@ -64,15 +103,99 @@ async function updateMarketplace(cacheRoot, version) {
64
103
  market.plugins.push(entry);
65
104
  }
66
105
 
67
- await mkdir(dirname(marketPath), { recursive: true });
68
- await writeJsonAtomic(marketPath, market);
106
+ for (const targetPath of [sourceMarketplacePath, activeMarketplacePath]) {
107
+ await mkdir(dirname(targetPath), { recursive: true });
108
+ await writeJsonAtomic(targetPath, market);
109
+ }
110
+ return { activeMarketplacePath, sourceMarketplacePath };
111
+ }
112
+
113
+ async function updateKnownMarketplaces(pluginsRoot) {
114
+ const knownPath = join(pluginsRoot, 'known_marketplaces.json');
115
+ const known = (await readJsonSafe(knownPath)) || {
116
+ version: 1,
117
+ marketplaces: []
118
+ };
119
+ const now = new Date().toISOString();
120
+ const marketplaceDir = join(pluginsRoot, 'cache', 'marketplaces', ZCODE_MARKETPLACE);
121
+ const existingIdx = Array.isArray(known.marketplaces)
122
+ ? known.marketplaces.findIndex((marketplace) => marketplace.id === ZCODE_MARKETPLACE)
123
+ : -1;
124
+ const existing = existingIdx >= 0 ? known.marketplaces[existingIdx] : {};
125
+ const entry = {
126
+ id: ZCODE_MARKETPLACE,
127
+ source: {
128
+ source: 'directory',
129
+ path: normalizedPath(marketplaceDir)
130
+ },
131
+ name: ZCODE_MARKETPLACE,
132
+ description: LOCAL_MARKETPLACE_DESCRIPTION,
133
+ addedAt: existing.addedAt || now,
134
+ pluginCount: 1,
135
+ lastUpdated: now
136
+ };
137
+
138
+ known.version = 1;
139
+ if (!Array.isArray(known.marketplaces)) {
140
+ known.marketplaces = [];
141
+ }
142
+ if (existingIdx >= 0) {
143
+ known.marketplaces[existingIdx] = entry;
144
+ } else {
145
+ known.marketplaces.push(entry);
146
+ }
147
+
148
+ await mkdir(dirname(knownPath), { recursive: true });
149
+ await writeJsonAtomic(knownPath, known);
150
+ return knownPath;
151
+ }
152
+
153
+ async function removeLegacyMarketplaceEntry(pluginsRoot) {
154
+ const legacyPath = join(
155
+ pluginsRoot,
156
+ 'marketplaces',
157
+ LEGACY_ZCODE_MARKETPLACE,
158
+ 'marketplace.json'
159
+ );
160
+ const legacy = await readJsonSafe(legacyPath);
161
+ if (!legacy || !Array.isArray(legacy.plugins)) {
162
+ return false;
163
+ }
164
+
165
+ const filteredPlugins = legacy.plugins.filter((plugin) => plugin.name !== PLUGIN_NAME);
166
+ if (filteredPlugins.length === legacy.plugins.length) {
167
+ return false;
168
+ }
169
+
170
+ legacy.plugins = filteredPlugins;
171
+ await writeJsonAtomic(legacyPath, legacy);
172
+ return true;
173
+ }
174
+
175
+ async function pruneOldVersions(cacheRoot, currentVersion) {
176
+ if (!(await pathExists(cacheRoot))) {
177
+ return [];
178
+ }
179
+
180
+ const removed = [];
181
+ const entries = await readdir(cacheRoot, { withFileTypes: true });
182
+ for (const entry of entries) {
183
+ if (!entry.isDirectory() || entry.name === currentVersion) {
184
+ continue;
185
+ }
186
+ const target = join(cacheRoot, entry.name);
187
+ await rm(target, { recursive: true, force: true });
188
+ removed.push(entry.name);
189
+ }
190
+ return removed;
69
191
  }
70
192
 
71
193
  export async function runInstallZCodePlugin(args = []) {
72
- const { dryRun, force } = parseArgs(args);
194
+ const { dryRun, force, pruneOld } = parseArgs(args);
73
195
  const pluginSrc = join(templateRoot, ".zcode");
74
196
  const skillsSrc = join(templateRoot, "skills");
75
197
  const { version } = await readPackageInfo();
198
+ const manifest = (await readJsonSafe(join(pluginSrc, '.zcode-plugin', 'plugin.json'))) || {};
76
199
 
77
200
  if (!(await pathExists(pluginSrc))) {
78
201
  throw new Error(`ZCode plugin source missing at ${pluginSrc}. Reinstall cowork-flow.`);
@@ -81,19 +204,32 @@ export async function runInstallZCodePlugin(args = []) {
81
204
  throw new Error(`Skills source missing at ${skillsSrc}. Reinstall cowork-flow.`);
82
205
  }
83
206
 
207
+ const pluginsRoot = getZCodePluginsRoot();
84
208
  const cacheRoot = await getZCodeCacheDir();
85
209
  const destDir = join(cacheRoot, version);
86
210
 
87
- if (!force && (await pathExists(destDir))) {
88
- console.log(`cowork-flow ZCode plugin already installed at ${destDir}`);
89
- console.log('Use --force to overwrite.');
90
- return;
91
- }
92
-
93
211
  if (dryRun) {
94
212
  console.log(`[dry-run] Would install ZCode plugin:`);
95
213
  console.log(` Plugin: ${pluginSrc} -> ${destDir}`);
96
214
  console.log(` Skills: ${skillsSrc} -> ${join(destDir, 'skills')}`);
215
+ console.log(` Marketplace source: ${join(pluginsRoot, 'cache', 'marketplaces', ZCODE_MARKETPLACE, 'marketplace.json')}`);
216
+ console.log(` Active marketplace: ${join(pluginsRoot, 'marketplaces', ZCODE_MARKETPLACE, 'marketplace.json')}`);
217
+ console.log(` Known marketplaces: ${join(pluginsRoot, 'known_marketplaces.json')}`);
218
+ return;
219
+ }
220
+
221
+ if (!force && (await pathExists(destDir))) {
222
+ await updateMarketplace(pluginsRoot, cacheRoot, version, manifest);
223
+ await updateKnownMarketplaces(pluginsRoot);
224
+ await removeLegacyMarketplaceEntry(pluginsRoot);
225
+ if (pruneOld) {
226
+ const removed = await pruneOldVersions(cacheRoot, version);
227
+ if (removed.length > 0) {
228
+ console.log(`Pruned old cowork-flow ZCode plugin versions: ${removed.join(', ')}`);
229
+ }
230
+ }
231
+ console.log(`cowork-flow ZCode plugin already installed at ${destDir}`);
232
+ console.log('Use --force to overwrite.');
97
233
  return;
98
234
  }
99
235
 
@@ -121,7 +257,15 @@ export async function runInstallZCodePlugin(args = []) {
121
257
  // Copy canonical skills into the plugin cache so plugin.json "skills" resolves.
122
258
  await cp(skillsSrc, join(destDir, "skills"), { recursive: true, force: true });
123
259
 
124
- await updateMarketplace(cacheRoot, version);
260
+ await updateMarketplace(pluginsRoot, cacheRoot, version, manifest);
261
+ await updateKnownMarketplaces(pluginsRoot);
262
+ await removeLegacyMarketplaceEntry(pluginsRoot);
263
+ if (pruneOld) {
264
+ const removed = await pruneOldVersions(cacheRoot, version);
265
+ if (removed.length > 0) {
266
+ console.log(`Pruned old cowork-flow ZCode plugin versions: ${removed.join(', ')}`);
267
+ }
268
+ }
125
269
 
126
270
  // Write seed file for install tracking
127
271
  await writeJsonAtomic(join(destDir, ".zcode-plugin-seed.json"), {
@@ -1 +1 @@
1
- 0.0.45
1
+ 0.0.47
@@ -72,6 +72,8 @@ def _detect_host() -> str:
72
72
  return "claude-code"
73
73
  if env.get("CODEX_SESSION_ID") or env.get("CODEX_THREAD_ID"):
74
74
  return "codex"
75
+ if env.get("ZCODE_SESSION_ID"):
76
+ return "zcode"
75
77
  if env.get("OPENCODE_SESSION_ID"):
76
78
  return "opencode"
77
79
  return "codex"
@@ -82,6 +84,7 @@ def _detect_adapter(host: str) -> str:
82
84
  "claude-code": "claude-code.hooks",
83
85
  "codex": "codex.spawn_agent",
84
86
  "opencode": "opencode.task",
87
+ "zcode": "zcode.plugin",
85
88
  }
86
89
  return adapters.get(host, "codex.spawn_agent")
87
90
 
@@ -5,7 +5,6 @@
5
5
  from __future__ import annotations
6
6
 
7
7
  import argparse
8
- import json
9
8
  import sys
10
9
  from datetime import datetime
11
10
  from pathlib import Path
@@ -48,6 +47,11 @@ from adapters.cli.task_context_commands import (
48
47
  from adapters.cli.task_create_command import cmd_create, ensure_tasks_dir
49
48
  from adapters.cli import task_navigation
50
49
  from adapters.cli.task_parser import build_parser, show_usage
50
+ from adapters.cli.task_next_runner import (
51
+ NextActionHandlers,
52
+ namespace_with as _args_with,
53
+ run_next_action,
54
+ )
51
55
  from adapters.cli.task_support import (
52
56
  Colors,
53
57
  colored,
@@ -57,6 +61,7 @@ from adapters.cli.task_support import (
57
61
  from adapters.cli.task_tree_commands import (
58
62
  cmd_add_subtask,
59
63
  cmd_list,
64
+ cmd_list_json,
60
65
  cmd_list_archive,
61
66
  cmd_remove_subtask,
62
67
  )
@@ -537,189 +542,6 @@ def cmd_current(args: argparse.Namespace) -> int:
537
542
  return 0
538
543
 
539
544
 
540
- def _next_target_for_run(args: argparse.Namespace, repo_root: Path):
541
- target_input = getattr(args, "dir", None)
542
- if target_input:
543
- task_dir = _resolve_task_dir(target_input, repo_root)
544
- return task_dir, _display_task_path(repo_root, task_dir), False
545
-
546
- active = get_active_task(repo_root)
547
- if active.task_path:
548
- task_dir = repo_root / active.task_path
549
- return task_dir, active.task_path, True
550
- return None, None, False
551
-
552
-
553
- def _next_payload_for_run(args: argparse.Namespace, repo_root: Path) -> dict[str, object]:
554
- task_dir, task_path, active_target = _next_target_for_run(args, repo_root)
555
- if task_dir is None or task_path is None:
556
- return task_navigation.build_navigation_payload(
557
- args=args,
558
- status="no_task",
559
- blockers=[],
560
- active_target=False,
561
- task_path=None,
562
- )
563
- if not task_dir.is_dir():
564
- return task_navigation.build_navigation_payload(
565
- args=args,
566
- status="stale",
567
- blockers=[f"task directory not found: {task_path}"],
568
- active_target=active_target,
569
- task_path=task_path,
570
- )
571
- status = task_navigation._status(repo_root, task_dir)
572
- blockers = task_navigation._blockers(repo_root, task_dir) if status == "planning" else []
573
- return task_navigation.build_navigation_payload(
574
- args=args,
575
- status=status,
576
- blockers=blockers,
577
- active_target=active_target,
578
- task_path=task_path,
579
- )
580
-
581
-
582
- def _args_with(args: argparse.Namespace, **overrides) -> argparse.Namespace:
583
- values = vars(args).copy()
584
- values.update(overrides)
585
- return argparse.Namespace(**values)
586
-
587
-
588
- def _run_create_action(args: argparse.Namespace, action: dict[str, object]) -> int:
589
- if not getattr(args, "title", None):
590
- print(
591
- colored("Error: create_task requires --title", Colors.RED),
592
- file=sys.stderr,
593
- )
594
- print(f"Command: {action.get('command')}", file=sys.stderr)
595
- return 1
596
- return cmd_create(
597
- _args_with(
598
- args,
599
- title=args.title,
600
- slug=getattr(args, "slug", None),
601
- assignee=getattr(args, "assignee", None),
602
- priority=getattr(args, "priority", "P2"),
603
- description=getattr(args, "description", None),
604
- parent=getattr(args, "parent", None),
605
- from_plan=getattr(args, "from_plan", None),
606
- )
607
- )
608
-
609
-
610
- def _create_input_names(args: argparse.Namespace) -> list[str]:
611
- names = (
612
- "title",
613
- "slug",
614
- "assignee",
615
- "description",
616
- "parent",
617
- "from_plan",
618
- )
619
- return [name for name in names if getattr(args, name, None)]
620
-
621
-
622
- def _validated_next_action(args: argparse.Namespace) -> tuple[dict[str, object] | None, int]:
623
- payload = _next_payload_for_run(args, get_repo_root())
624
- action = payload.get("action")
625
- if not isinstance(action, dict):
626
- print(
627
- colored("Error: task next did not return an action", Colors.RED),
628
- file=sys.stderr,
629
- )
630
- return None, 1
631
- create_inputs = _create_input_names(args)
632
- action_id = action.get("id")
633
- if create_inputs and action_id != "create_task":
634
- print(
635
- colored(
636
- f"Error: create_task inputs cannot run {action_id}",
637
- Colors.RED,
638
- ),
639
- file=sys.stderr,
640
- )
641
- print(
642
- "Hint: archive or finish the current task first, then rerun "
643
- "`task next --run --title ... --slug ... --assignee <name>`.",
644
- file=sys.stderr,
645
- )
646
- return None, 1
647
- if payload.get("blockers") or action.get("blockers"):
648
- print(colored("Error: next action is blocked", Colors.RED), file=sys.stderr)
649
- for blocker in payload.get("blockers", []) or action.get("blockers", []):
650
- print(f" - {blocker}", file=sys.stderr)
651
- return None, 1
652
- if not action.get("runnable"):
653
- action_id = action.get("id")
654
- print(
655
- colored(f"Error: next action is not executable: {action_id}", Colors.RED),
656
- file=sys.stderr,
657
- )
658
- if action.get("command"):
659
- print(f"Command: {action['command']}", file=sys.stderr)
660
- return None, 1
661
- return action, 0
662
-
663
-
664
- def _resolved_next_action_task(args: argparse.Namespace, action_id: object) -> str | None:
665
- if action_id == "create_task":
666
- return None
667
- _, task_path_value, _ = _next_target_for_run(args, get_repo_root())
668
- if task_path_value is None:
669
- print(colored("Error: no task target for action", Colors.RED), file=sys.stderr)
670
- return None
671
- return task_path_value
672
-
673
-
674
- def _dispatch_next_lifecycle_action(
675
- args: argparse.Namespace,
676
- action_id: object,
677
- task_path: str | None,
678
- action: dict[str, object],
679
- ) -> int:
680
- if action_id == "create_task":
681
- return _run_create_action(args, action)
682
- if task_path is None:
683
- return 1
684
- if action_id == "start_task":
685
- return cmd_start(_args_with(
686
- args,
687
- dir=task_path,
688
- auto=bool(getattr(args, "auto", False)),
689
- approved=bool(getattr(args, "approved", False)),
690
- ))
691
- if action_id == "request_review":
692
- return cmd_review(_args_with(args, dir=task_path))
693
- if action_id == "complete_task":
694
- return cmd_complete(_args_with(args, dir=task_path))
695
- if action_id == "archive_task":
696
- return cmd_archive(_args_with(
697
- args,
698
- name=Path(str(task_path)).name,
699
- commit=bool(getattr(args, "commit", False)),
700
- ))
701
- if action_id == "batch_execute":
702
- return cmd_start(_args_with(
703
- args,
704
- dir=task_path,
705
- auto=True,
706
- approved=bool(getattr(args, "approved", False)),
707
- ))
708
- print(
709
- colored(f"Error: unsupported next action: {action_id}", Colors.RED),
710
- file=sys.stderr,
711
- )
712
- return 1
713
-
714
-
715
- def _run_next_action(args: argparse.Namespace) -> int:
716
- action, error_code = _validated_next_action(args)
717
- if action is None:
718
- return error_code
719
- action_id = action.get("id")
720
- task_path = _resolved_next_action_task(args, action_id)
721
- return _dispatch_next_lifecycle_action(args, action_id, task_path, action)
722
-
723
545
  def cmd_next(args: argparse.Namespace) -> int:
724
546
  """Show or run the next workflow action."""
725
547
  if getattr(args, "list_tasks", False):
@@ -729,6 +551,14 @@ def cmd_next(args: argparse.Namespace) -> int:
729
551
  file=sys.stderr,
730
552
  )
731
553
  return 1
554
+ if getattr(args, "json", False):
555
+ return cmd_list_json(
556
+ _args_with(
557
+ args,
558
+ mine=bool(getattr(args, "mine", False)),
559
+ status=getattr(args, "status", None),
560
+ )
561
+ )
732
562
  return cmd_list(
733
563
  _args_with(
734
564
  args,
@@ -751,7 +581,16 @@ def cmd_next(args: argparse.Namespace) -> int:
751
581
  return 1
752
582
  return cmd_validate(_args_with(args, dir=args.dir))
753
583
  if getattr(args, "run", False):
754
- return _run_next_action(args)
584
+ return run_next_action(
585
+ args,
586
+ NextActionHandlers(
587
+ create=cmd_create,
588
+ start=cmd_start,
589
+ review=cmd_review,
590
+ complete=cmd_complete,
591
+ archive=cmd_archive,
592
+ ),
593
+ )
755
594
  return task_navigation.cmd_next(args)
756
595
 
757
596
 
@@ -18,6 +18,13 @@ from services.task_repository import TaskRepository, TaskRepositoryError
18
18
  from services.task_routing import _default_intent, route_request
19
19
 
20
20
 
21
+ RECOVERABLE_TASK_STATUSES = {
22
+ "in_progress": "active_unbound",
23
+ "review": "active_unbound",
24
+ "completed": "completed_unarchived",
25
+ }
26
+
27
+
21
28
 
22
29
  def _display(repo_root: Path, task_dir: Path) -> str:
23
30
  try:
@@ -41,6 +48,61 @@ def _blockers(repo_root: Path, task_dir: Path) -> list[str]:
41
48
  return blockers
42
49
 
43
50
 
51
+ def repository_recovery_signals(repo_root: Path) -> list[dict[str, str]]:
52
+ tasks_dir = repo_root / ".cowork-flow" / "tasks"
53
+ if not tasks_dir.is_dir():
54
+ return []
55
+ signals: list[dict[str, str]] = []
56
+ for task_dir in sorted(tasks_dir.iterdir()):
57
+ if not task_dir.is_dir() or task_dir.name == "archive":
58
+ continue
59
+ status = _status(repo_root, task_dir)
60
+ kind = RECOVERABLE_TASK_STATUSES.get(status)
61
+ if kind is None:
62
+ continue
63
+ task = _display(repo_root, task_dir)
64
+ hint = f"./.cowork-flow/run task next {task} --run"
65
+ if kind == "completed_unarchived":
66
+ hint = f"{hint} --intent archive"
67
+ signals.append(
68
+ {
69
+ "kind": kind,
70
+ "task": task,
71
+ "status": status,
72
+ "hint": hint,
73
+ }
74
+ )
75
+ return signals
76
+
77
+
78
+ def _attach_recovery(payload: dict[str, object], repo_root: Path) -> dict[str, object]:
79
+ signals = repository_recovery_signals(repo_root)
80
+ if signals:
81
+ payload["recovery"] = {
82
+ "signals": signals,
83
+ "listCommand": "./.cowork-flow/run task next --list --json",
84
+ }
85
+ return payload
86
+
87
+
88
+ def _print_recovery(payload: dict[str, object]) -> None:
89
+ recovery = payload.get("recovery")
90
+ if not isinstance(recovery, dict):
91
+ return
92
+ signals = recovery.get("signals")
93
+ if not isinstance(signals, list) or not signals:
94
+ return
95
+ print("Recovery:")
96
+ for signal in signals:
97
+ if isinstance(signal, dict):
98
+ print(
99
+ f" - {signal.get('kind')}: {signal.get('task')} "
100
+ f"({signal.get('status')})"
101
+ )
102
+ print(f" Hint: {signal.get('hint')}")
103
+ print(f"List: {recovery.get('listCommand')}")
104
+
105
+
44
106
  def _print_blockers(blockers: list[str]) -> None:
45
107
  if not blockers:
46
108
  print("Blockers: none")
@@ -105,6 +167,9 @@ def _print_text_payload(payload: dict[str, object]) -> None:
105
167
  print(f"Next action: {action['label']}")
106
168
  print(f"Skill: {action['activatedSkill'] or 'none'}")
107
169
  print(f"Command: {action['command'] or 'none'}")
170
+ diagnostics_command = action.get("diagnosticsCommand")
171
+ if diagnostics_command:
172
+ print(f"Diagnostics: {diagnostics_command}")
108
173
  blockers = payload["blockers"]
109
174
  if not isinstance(blockers, list):
110
175
  raise TypeError("navigation payload blockers must be a list")
@@ -123,13 +188,15 @@ def _navigation_target(args, repo_root: Path, structured: bool):
123
188
  if active.task_path:
124
189
  return repo_root / active.task_path, active.task_path, source, True
125
190
  if structured:
126
- _print_json_route(
191
+ payload = build_navigation_payload(
127
192
  args=args,
128
193
  status="no_task",
129
194
  blockers=[],
130
195
  active_target=False,
131
196
  task_path=None,
132
197
  )
198
+ _attach_recovery(payload, repo_root)
199
+ print(json.dumps(payload, ensure_ascii=False, sort_keys=False))
133
200
  else:
134
201
  print("Status: no_task")
135
202
  print(f"Source: {source}")
@@ -140,7 +207,9 @@ def _navigation_target(args, repo_root: Path, structured: bool):
140
207
  blockers=[],
141
208
  active_target=False,
142
209
  )
210
+ _attach_recovery(payload, repo_root)
143
211
  _print_text_payload(payload)
212
+ _print_recovery(payload)
144
213
  return None
145
214
 
146
215