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.
- package/README.md +11 -3
- package/package.json +1 -1
- package/src/cli.js +1 -1
- package/src/commands/install-zcode-plugin.js +165 -21
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/subagent.py +3 -0
- package/template/.cowork-flow/scripts/adapters/cli/task.py +24 -185
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +70 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_next_runner.py +229 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_tree_commands.py +68 -0
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +25 -2
- package/template/.cowork-flow/scripts/runtime/session_state.py +2 -0
- package/template/.cowork-flow/scripts/services/task_routing.py +11 -0
- package/template/.cowork-flow/spec/contracts/skill-owned-actions.md +14 -5
- package/template/.zcode/.zcode-plugin/plugin.json +3 -1
- package/template/.zcode/agents/cowork-check.md +27 -0
- package/template/.zcode/agents/cowork-implement.md +28 -0
- package/template/.zcode/agents/cowork-research.md +21 -0
- package/template/.zcode/hooks/hooks.json +12 -6
- package/template/.zcode/hooks/inject-context.js +40 -13
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +8 -2
- package/template/skills/adversarial-review/SKILL.md +17 -0
- package/template/skills/runtime-health/SKILL.md +6 -1
- package/template/skills/runtime-health/scripts/doctor.py +174 -8
- package/template/skills/task-review/SKILL.md +5 -0
- 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/
|
|
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
|
-
|
|
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
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
|
|
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 = '
|
|
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
|
-
|
|
30
|
+
function getZCodePluginsRoot() {
|
|
28
31
|
const base = process.env.ZCODE_HOME || join(homedir(), '.zcode');
|
|
29
|
-
return join(base, 'cli', 'plugins'
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
68
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|