cowork-flow 1.4.0 → 1.5.0
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/CHANGELOG.md +32 -0
- package/README.md +62 -5
- package/package.json +1 -1
- package/presets/dsh/plugins/workflow-state.js +152 -0
- package/src/commands/install-dsh-preset.js +36 -2
- package/template/.codex/hooks.json +12 -0
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/state.py +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +5 -2
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +42 -6
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +1 -1
- package/template/.cowork-flow/scripts/run.py +1 -0
- package/template/.cowork-flow/scripts/runtime/session_state.py +3 -2
- package/template/.cowork-flow/scripts/services/fact_view.py +1 -1
- package/template/.cowork-flow/spec/contracts/context-injection.md +3 -1
- package/template/.cowork-flow/spec/contracts/fact-layer-access.md +1 -1
- package/template/.cowork-flow/spec/contracts/spec-checks.md +15 -5
- package/template/.cowork-flow/spec/runtime/host-assets.json +1 -0
- package/template/.opencode/plugins/cowork-flow.js +86 -0
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/AGENTS.md +1 -0
- package/template/skills/runtime-health/scripts/doctor.py +79 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0 - 2026-09-18
|
|
4
|
+
|
|
5
|
+
### 发版开关 `--no-publish`
|
|
6
|
+
|
|
7
|
+
- `scripts/release.sh` 新增 `--no-publish`:刷新、self-instance 镜像、双套测试门禁、版本 bump、changelog 校验、提交、tag 全部照常执行,只跳过最后的 `npm publish`,并提示 tag 仍是本地(后续走 `npm publish` 或 `gh release create v<v>` 触发 CI 通道)。不带该 flag 时行为逐项不变。
|
|
8
|
+
- 参数解析改为逐个消费:`--no-publish` 位置无关且可重复;release-type 与 `--version` 仍互斥、最多出现一次,重复被拒。
|
|
9
|
+
- 动机是既有的 CI 发布通道——tag 落地后由 `gh release create` 触发 publish.yml,而脚本此前只能一路 publish 到底。
|
|
10
|
+
|
|
11
|
+
### 编辑期覆盖扩展(codex / opencode / dsh)
|
|
12
|
+
|
|
13
|
+
- **codex**:`hooks.json` 注册 `PostToolUse`(matcher `apply_patch|Write|Edit`),编辑后违规经既有 `spec_only_post_tool_use` 通道(stderr + exit 2)反馈;运行时无需新增分支。
|
|
14
|
+
- **opencode**:插件新增 `tool.execute.after` 钩子——`edit`/`write` 命中时调用 `run spec-check --phase edit --file <path> --throttled`,把非空单行结果追加到工具结果文本;2.5 秒超时,运行时不存在的静默且不阻断编辑。
|
|
15
|
+
- **dsh**:预设插件注册 `tools/post-execute` waterfall——`write`/`edit` 命中时调同一 Python 协议(`run_edit_checks`),把单行告警作为 `additionalContexts` 附件送入下一轮请求;2.5 秒超时,无根/无解释器/协议异常一律静默。DSH 的 `tools/result` 只能观察不能回写,故走 post-execute 而非复用刷新监听。
|
|
16
|
+
- **路径提取多形态**:PostToolUse 的路径提取兼容 `file_path`/`filePath`/`path` 与 codex `apply_patch` 补丁文本(`*** Update/Add/Delete File:` 行);同一路径去重,避免重复消耗节流窗口。
|
|
17
|
+
- **契约诚实化**:`spec-checks.md` 矩阵与 README 从"opencode 降级:无"改为准确表述——五个宿主全部覆盖,其中 zcode 仅主会话、dsh 经预设注入;此前的"降级"是项目未接线,不是宿主能力缺失。
|
|
18
|
+
- **实弹验收项(未在本地执行)**:codex 会话编辑后 stderr 反馈可见(需宿主 hook 信任批准);opencode 会话 edit 后工具结果含单行警告;dsh 会话编辑后下一轮上下文含 spec-check 通知。
|
|
19
|
+
|
|
20
|
+
### DSH 预设过期检测
|
|
21
|
+
|
|
22
|
+
- **版本标记**:`install-dsh-preset` 安装后在预设目录写入 `.cowork-flow-preset.json`(`version` + `installedAt`);已安装且版本不同时,不带 `--force` 的重复执行输出过期提示与更新命令,而不是仅报 "already installed"。
|
|
23
|
+
- **doctor 检测**:新增只读 advisory 检查 `check_dsh_preset`——预设缺失时不报告;无标记或标记不可读 → `PRESET-UNKNOWN-VERSION`;与项目 `.cowork-flow/.version` 不一致 → `PRESET-STALE`;两者都附 `install-dsh-preset --force` 提示,且不进入 doctor 的 error 集合(`ok` 不受影响)。
|
|
24
|
+
- **背景**:预设不随 `sync`/npm 更新,此前升级路径只存在于 README 散文,用户可能长期停留在旧注入逻辑且没有任何提示;协议失败时该宿主的注入会静默降级为空输出。
|
|
25
|
+
|
|
26
|
+
### 下游可发现性与文档权威性(spec-check)
|
|
27
|
+
|
|
28
|
+
- **发现路径补齐**:`run` 帮助列出 `spec-check`;`AGENTS.md` 的 managed block(模板与根同块)加指针行;README 新增「规范挂命令」与「MCP 客户端接入」两节,能力表与常用命令同步。
|
|
29
|
+
- **契约修正**:`spec/contracts/spec-checks.md` 的「归属与 sync 策略」改为与实际一致——`spec/` 是 sync 保护前缀,已安装项目不会被同步改写;具名例外是 `syncPolicy.safeFiles` 中的契约文件。该文件加入 `safeFiles`(同 `workflow-state-templates.md` 先例),使契约修正能到达已安装下游。
|
|
30
|
+
- **fallback 自救路径**:`FALLBACK_BINDING_BLOCKER` 与两处 lifecycle 拒绝文案改为指向 workflow-state 头部的 `session` 属性(`COWORK_FLOW_CONTEXT_ID`),不再给出无任务可传的 `pass <task-dir>` 单一出路。
|
|
31
|
+
- **悬空引用清理**:README 与模板脚本/spec 中指向已 untrack 的 `docs/` 的引用全部改写或内联(MCP 客户端配置要点保留在 README)。
|
|
32
|
+
- **测试**:新增根/模板 managed block 字节相等与指针断言、fallback blocker 文案可执行性断言。
|
|
33
|
+
- **已知合法副作用**:contract fingerprint 由 `d1d0e2536e8fa150` 更新为 `2dc34375d28f8103`(内容派生;`host-assets.json` 的 `safeFiles` 条目变更)。
|
|
34
|
+
|
|
3
35
|
## 1.4.0 - 2026-09-14
|
|
4
36
|
|
|
5
37
|
### Hook 会话身份绑定(fix(runtime))
|
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|---|---|
|
|
13
13
|
| 任务流程 | `task next --json` 给出下一步 action,`task next --run` 只执行当前 action。 |
|
|
14
14
|
| 事实接入 | `run state [task] --json` 事实视图;`run mcp-state` 无依赖 MCP 只读服务(`task_state` / `task_list` / `task_scope` / `task_specs`)供任何 MCP 客户端查询;`task scope` / `task specs` 为 CLI 同源事实命令。 |
|
|
15
|
+
| 规范挂命令 | spec 首部 frontmatter 声明 `checks:` 命令,按编辑期/收口期执行;`run spec-check` 输出三态门禁(pass / violation / unchecked)。 |
|
|
15
16
|
| 运行健康 | `doctor` 诊断 runtime、host assets、Skill replica 和任务 hygiene,不推进生命周期。 |
|
|
16
17
|
| Host 分发 | Host Asset Manifest 驱动 Codex / OpenCode / Claude Code / ZCode / DeepSeek Harness 资产和 obsolete 清理。 |
|
|
17
18
|
| 批处理与讨论 | Batch 发布 Host action;Party Mode 只输出 advisory final facts。 |
|
|
@@ -25,7 +26,7 @@
|
|
|
25
26
|
| 理解任务如何流转 | [任务流程](#任务流程) |
|
|
26
27
|
| 处理故障或漂移 | [支持与故障诊断](#支持与故障诊断) |
|
|
27
28
|
| 做发布前检查 | [发布](#发布)、[`CHANGELOG.md`](CHANGELOG.md) |
|
|
28
|
-
|
|
|
29
|
+
| 了解项目演进与版本内容 | [`CHANGELOG.md`](CHANGELOG.md) |
|
|
29
30
|
| 接入到自己项目 | [接入原则](#接入原则) |
|
|
30
31
|
|
|
31
32
|
## 适用 / 不适用
|
|
@@ -68,7 +69,7 @@ cowork-flow install-dsh-hook
|
|
|
68
69
|
|
|
69
70
|
# MCP 客户端接入(可选):全局注册一次,任意项目查询任务事实;
|
|
70
71
|
# 项目级 opt-in(如 claude-code 的 .mcp.json)与注册健康检测见
|
|
71
|
-
# `run doctor
|
|
72
|
+
# `run doctor`;各客户端配置样例见下方「MCP 客户端接入」一节
|
|
72
73
|
cowork-flow mcp-state
|
|
73
74
|
|
|
74
75
|
# 维护者发布前检查
|
|
@@ -164,6 +165,32 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
|
|
|
164
165
|
- 默认查询 npm latest,发现新版本时执行 `npm install -g cowork-flow@latest`
|
|
165
166
|
- `--dry-run` 只输出当前版本、最新版本和 `readiness=<json>`,其中 `update.wouldInstall` 表示是否会执行全局安装,不调用安装命令
|
|
166
167
|
|
|
168
|
+
## MCP 客户端接入
|
|
169
|
+
|
|
170
|
+
事实层以只读 MCP 服务(工具 `task_state` / `task_list`)提供给任意客户端。两种入口:
|
|
171
|
+
|
|
172
|
+
- **全局(推荐)**:`cowork-flow mcp-state`(npm 全局 CLI 透传)。注册一次,所有 cowork-flow 项目通用——项目根由客户端启动时的 cwd 向上解析。
|
|
173
|
+
- **项目级**:`<project>/.cowork-flow/run mcp-state`(不依赖全局安装,每项目一份配置)。
|
|
174
|
+
|
|
175
|
+
stdio 注册样例:
|
|
176
|
+
|
|
177
|
+
```toml
|
|
178
|
+
# Codex(~/.codex/config.toml)
|
|
179
|
+
[mcp_servers.cowork-flow]
|
|
180
|
+
command = "cowork-flow"
|
|
181
|
+
args = ["mcp-state"]
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
```json
|
|
185
|
+
// OpenCode(~/.config/opencode/opencode.json)
|
|
186
|
+
{"mcp": {"cowork-flow": {"type": "local", "command": ["cowork-flow", "mcp-state"], "enabled": true}}}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- **Claude Code**:`claude mcp add -s user cowork-flow -- cowork-flow mcp-state`(项目级 `.mcp.json` 写同构条目)。
|
|
190
|
+
- **ZCode**:客户端设置的 MCP 服务器中添加同构 stdio 条目(命令 `cowork-flow`、参数 `mcp-state`)。
|
|
191
|
+
|
|
192
|
+
项目级注册的健康检查由 `./.cowork-flow/run doctor` 报告。
|
|
193
|
+
|
|
167
194
|
## ZCode 插件
|
|
168
195
|
|
|
169
196
|
```bash
|
|
@@ -195,7 +222,7 @@ cowork-flow init ./my-project --platform dsh # 项目资产:AGENTS.md + .age
|
|
|
195
222
|
cowork-flow install-dsh-hook # 机器级:注册 hook 组合行(实时注入见下方说明)
|
|
196
223
|
```
|
|
197
224
|
|
|
198
|
-
`install-dsh-hook` 把 `workflow-state.js` 插件作为 `insert:` patch 注册到 `$DSH_HOME/cordis.patch.yml`(未设置 `DSH_HOME` 时默认 `~/.dsh`),组合层面可被 `dsh --dump-config` 验证。经实测(DSH 0.1.1-rc.1),**agent 提示组装不收集 host 层 section**:该组合行不会在会话系统提示中产生 `<workflow-state>` 块。当前 DSH 版本下实时注入仍需预设方式(`install-dsh-preset`);本命令保留为组合层面的幂等注册能力(卸载见下),待 DSH 支持 agent-scope patch / workspace
|
|
225
|
+
`install-dsh-hook` 把 `workflow-state.js` 插件作为 `insert:` patch 注册到 `$DSH_HOME/cordis.patch.yml`(未设置 `DSH_HOME` 时默认 `~/.dsh`),组合层面可被 `dsh --dump-config` 验证。经实测(DSH 0.1.1-rc.1),**agent 提示组装不收集 host 层 section**:该组合行不会在会话系统提示中产生 `<workflow-state>` 块。当前 DSH 版本下实时注入仍需预设方式(`install-dsh-preset`);本命令保留为组合层面的幂等注册能力(卸载见下),待 DSH 支持 agent-scope patch / workspace 级组合后可直接生效。
|
|
199
226
|
|
|
200
227
|
在未安装 cowork-flow 的项目里 hook 完全无感:JS 侧根目录预检直接短路——不注入内容、不启动 Python 进程。全局开关(环境变量):`COWORK_FLOW_HOOKS=0` / `COWORK_FLOW_DISABLE_HOOKS=1`。卸载:`cowork-flow install-dsh-hook --uninstall`(`--force` 同时删除插件文件)。
|
|
201
228
|
|
|
@@ -213,6 +240,8 @@ cowork-flow install-dsh-preset --dry-run # 预览不写入
|
|
|
213
240
|
|
|
214
241
|
安装到 `~/.dsh/.agent-presets/cowork-flow/`(`DSH_HOME` 存在时以其为准):`agent.cordis.yml` + `preset.yml` + 全部流程技能。安装后在 DeepSeek Harness 中新建会话并选择 **Cowork Flow** 预设即可使用:persona 携带流程门禁规则,技能目录随预设挂载,不依赖项目本地副本。
|
|
215
242
|
|
|
243
|
+
预设是**一次性安装的机器级资产**:它不随 `sync` 或 npm 更新。升级 cowork-flow 后需要重跑 `cowork-flow install-dsh-preset --force` 才会刷新(不带 `--force` 的重复执行是空操作,安装器会在版本不同时给出提示)。安装时会在预设目录写入 `.cowork-flow-preset.json` 版本标记;`./.cowork-flow/run doctor` 比对标记与项目 runtime 版本,过期或缺失时输出 warning 与更新命令。
|
|
244
|
+
|
|
216
245
|
预设组合是部署 `standard` 预设的拷贝 + 最小改动(persona 流程规则、`skill-filesystem` 指向预设自带 `skills/`);`cowork-flow init --platform dsh` 仍负责项目级资产(`AGENTS.md`、`.agents/skills/`、`.dsh/` 标记)。
|
|
217
246
|
|
|
218
247
|
预设内置 **workflow-state hook**(`plugins/workflow-state.js`):DSH 原生等效于 Codex/Claude hook,向系统提示末尾注入与其它宿主同构的 `<workflow-state>` 块,每条用户消息刷新一次,并在生命周期命令(`task`/`subagent`/`resume`)执行完成后立即轮内刷新(替换语义,不累积)。项目无 `.cowork-flow` 根、缺少 Python 或设 `COWORK_FLOW_HOOKS=0` / `COWORK_FLOW_DISABLE_HOOKS=1` 时静默降级,由 AGENTS.md 门禁的运行导航器兜底。
|
|
@@ -254,6 +283,29 @@ flowchart TD
|
|
|
254
283
|
|
|
255
284
|
Batch 使用任务图和持久化 Host action:运行 `task next <parent-task> --run --intent batch --auto --approved` 获取 `next_action`;Host 完成真实生命周期动作后继续通过 `task next` 导航,不暴露独立 batch 子命令。
|
|
256
285
|
|
|
286
|
+
## 规范挂命令(spec-check)
|
|
287
|
+
|
|
288
|
+
`.cowork-flow/spec/` 下的规范可在文件首部 frontmatter 声明检查命令;机制只执行声明、不解析规范正文——规则随规范同文件更新,天然同步。
|
|
289
|
+
|
|
290
|
+
```markdown
|
|
291
|
+
---
|
|
292
|
+
checks:
|
|
293
|
+
- cmd: npm run lint --silent
|
|
294
|
+
files: "src/" # 可选:目录前缀或扩展名("*.ts"),逗号分隔
|
|
295
|
+
timeout: 60 # 可选:秒,默认 30,硬顶 120
|
|
296
|
+
when: both # 可选:edit | lifecycle | both(默认 both)
|
|
297
|
+
---
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
唯一执行器是 `./.cowork-flow/run spec-check`:
|
|
301
|
+
|
|
302
|
+
- **三态语义**:`pass`(退出码 0);`violation` 阻断 `task complete`;`unchecked`(命令缺失、解释器缺失、超时)同样阻断,需显式 `--allow-unchecked` 放行,豁免留痕进 `task.json`。unchecked 永不冒充 pass。
|
|
303
|
+
- **两个相位**:`when: edit` 在编辑期就地执行(超时钳制 2.5 秒,违规输出单行提示;五个宿主均已覆盖,其中 zcode 仅主会话),是 best-effort 提示不是门禁;收口期全量执行,未过项阻断状态推进。
|
|
304
|
+
- **扫描范围**:`.cowork-flow/spec/` 下的 markdown;`contracts/`、`runtime/`、`schemas/` 三个机器自有子树不参与。
|
|
305
|
+
- **模板不带生效声明**:模板无法预知项目命令,而命令缺失会归 `unchecked` 并阻断收口;请把声明写进自建 spec 文件(如 `spec/team-xxx.md`)。
|
|
306
|
+
|
|
307
|
+
`task start` 后,绑定 spec 的章节索引(h2 标题树)随 stage-contract 注入,规范条目名常驻注意力。完整契约见 `.cowork-flow/spec/contracts/spec-checks.md`。
|
|
308
|
+
|
|
257
309
|
## 常用命令
|
|
258
310
|
|
|
259
311
|
```bash
|
|
@@ -274,6 +326,10 @@ Batch 使用任务图和持久化 Host action:运行 `task next <parent-task>
|
|
|
274
326
|
./.cowork-flow/run task next <dir> --run --intent review
|
|
275
327
|
./.cowork-flow/run task next <dir> --run --intent archive
|
|
276
328
|
|
|
329
|
+
# 规范检查
|
|
330
|
+
./.cowork-flow/run spec-check
|
|
331
|
+
./.cowork-flow/run spec-check --phase lifecycle --json
|
|
332
|
+
|
|
277
333
|
# 子代理
|
|
278
334
|
./.cowork-flow/run subagent init --role implement --agent-type cowork-implement --execution-task-dir <dir> --title "<title>"
|
|
279
335
|
./.cowork-flow/run subagent bind <runtime_context_id> <host_context_key>
|
|
@@ -323,6 +379,7 @@ npm run release:check # 发布信心门禁;当前等价于 test:all
|
|
|
323
379
|
npm run release # patch
|
|
324
380
|
npm run release -- minor # minor
|
|
325
381
|
npm run release -- --version 0.1.0 # 精确发布指定版本(跳过自动 bump)
|
|
382
|
+
npm run release -- minor --no-publish # 完整流程但跳过 npm publish(tag 留在本地)
|
|
326
383
|
```
|
|
327
384
|
|
|
328
385
|
**发布流程:**
|
|
@@ -331,9 +388,9 @@ npm run release -- --version 0.1.0 # 精确发布指定版本(跳过自动 bu
|
|
|
331
388
|
3. `npm version` 升级版本
|
|
332
389
|
4. 同步版本到 `template/.cowork-flow/.version` 和 `template/.zcode/.zcode-plugin/plugin.json`
|
|
333
390
|
5. `git commit` + `git tag`
|
|
334
|
-
6. `npm publish`
|
|
391
|
+
6. `npm publish`——走 CI 发布通道时改用 `--no-publish` 在此止步,交由下一步触发
|
|
335
392
|
|
|
336
|
-
**CI 发布通道(推荐):** `scripts/release.sh --
|
|
393
|
+
**CI 发布通道(推荐):** `scripts/release.sh <release-type> --no-publish` 完成提交与打 tag(不本地 publish)后,先 `git push` 分支并 `git push origin v<v>` 把 tag 推上远端,再 `gh release create v<v>` 触发 `.github/workflows/publish.yml`——远端尚无该 tag 时,`gh release create` 会从默认分支最新提交自动建 tag,使门禁与发布落在错误的提交上。Ubuntu/Windows 双平台全量门禁通过后自动 `npm publish`(需仓库 secret `NPM_TOKEN`,权限:publish)。`--no-publish` 只是跳过最后一步,前置的镜像、门禁与版本同步与默认路径完全一致。
|
|
337
394
|
|
|
338
395
|
- 发布说明维护在 `CHANGELOG.md`;发布前更新当前版本段落,并保留 `release:check` 和 `git diff --check` 证据。
|
|
339
396
|
|
package/package.json
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
// falls back to running the navigator manually.
|
|
23
23
|
|
|
24
24
|
import { execFile } from 'node:child_process';
|
|
25
|
+
import { randomUUID } from 'node:crypto';
|
|
25
26
|
import { stat } from 'node:fs/promises';
|
|
26
27
|
import { dirname, join, resolve } from 'node:path';
|
|
27
28
|
|
|
@@ -82,6 +83,44 @@ print(build_hook_context(
|
|
|
82
83
|
))
|
|
83
84
|
`;
|
|
84
85
|
|
|
86
|
+
// The edit path sits on the finished tool result, so it gets a short leash:
|
|
87
|
+
// a slow checker is dropped rather than delaying the edit the model already
|
|
88
|
+
// made.
|
|
89
|
+
const EDIT_TIMEOUT_MS = 2500;
|
|
90
|
+
const EDIT_EXEC_OPTIONS = {
|
|
91
|
+
timeout: EDIT_TIMEOUT_MS,
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
maxBuffer: 1024 * 1024,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// Editor-phase spec-check protocol: same root discovery, then the shared
|
|
97
|
+
// edit-check entry so DSH sessions receive the single-line violation hint
|
|
98
|
+
// the other hosts get from their PostToolUse hook. Always exits 0.
|
|
99
|
+
const PYTHON_EDIT_PROTOCOL = `\
|
|
100
|
+
import sys
|
|
101
|
+
from pathlib import Path
|
|
102
|
+
|
|
103
|
+
cwd = sys.argv[1]
|
|
104
|
+
file_path = sys.argv[2]
|
|
105
|
+
current = Path(cwd).resolve()
|
|
106
|
+
root = None
|
|
107
|
+
while True:
|
|
108
|
+
if (current / ".cowork-flow").is_dir():
|
|
109
|
+
root = current
|
|
110
|
+
break
|
|
111
|
+
if current == current.parent:
|
|
112
|
+
break
|
|
113
|
+
current = current.parent
|
|
114
|
+
if root is None:
|
|
115
|
+
raise SystemExit(0)
|
|
116
|
+
sys.path.insert(0, str(root / ".cowork-flow" / "scripts"))
|
|
117
|
+
try:
|
|
118
|
+
from services.spec_check import run_edit_checks
|
|
119
|
+
print(run_edit_checks(root, file_path))
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
`;
|
|
123
|
+
|
|
85
124
|
// The first interpreter that ran the protocol successfully; a missing one is
|
|
86
125
|
// dropped so a later refresh can rediscover (e.g. after a PATH change).
|
|
87
126
|
let workingPython = null;
|
|
@@ -234,6 +273,90 @@ export async function runWorkflowState(cwd, full = true) {
|
|
|
234
273
|
}
|
|
235
274
|
|
|
236
275
|
|
|
276
|
+
const EDIT_TOOL_NAMES = new Set(['write', 'edit']);
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Path of the file a finished tool call edited, or '' for anything else.
|
|
281
|
+
* DSH's fs tools expose `file_path`; other tool surfaces are ignored.
|
|
282
|
+
*/
|
|
283
|
+
export function editedFilePath(exec) {
|
|
284
|
+
const name = exec && typeof exec.name === 'string' ? exec.name : '';
|
|
285
|
+
if (!EDIT_TOOL_NAMES.has(name)) {
|
|
286
|
+
return '';
|
|
287
|
+
}
|
|
288
|
+
const args = exec && exec.arguments;
|
|
289
|
+
const value = args && typeof args === 'object' ? args.file_path : null;
|
|
290
|
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Editor-phase spec check for one edited file. Returns the single-line
|
|
296
|
+
* warning, or '' when there is nothing to say: no root, no interpreter, no
|
|
297
|
+
* declaration, a clean run, or a timeout. Never throws.
|
|
298
|
+
*/
|
|
299
|
+
export async function runEditSpecCheck(cwd, filePath) {
|
|
300
|
+
if (hooksDisabled() || !cwd || !filePath) {
|
|
301
|
+
return '';
|
|
302
|
+
}
|
|
303
|
+
const resolved = resolve(cwd);
|
|
304
|
+
if ((await findCoworkRoot(resolved)) === null) {
|
|
305
|
+
return '';
|
|
306
|
+
}
|
|
307
|
+
const attempt = (candidate) => new Promise((resolvePromise) => {
|
|
308
|
+
execFile(
|
|
309
|
+
candidate.command,
|
|
310
|
+
[...candidate.args, '-c', PYTHON_EDIT_PROTOCOL, cwd, filePath],
|
|
311
|
+
EDIT_EXEC_OPTIONS,
|
|
312
|
+
(error, stdout) => {
|
|
313
|
+
if (error) {
|
|
314
|
+
resolvePromise({ missing: error.code === 'ENOENT', output: '' });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
resolvePromise({ missing: false, output: stdout.trim() });
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
if (workingPython !== null) {
|
|
322
|
+
const result = await attempt(workingPython);
|
|
323
|
+
if (!result.missing) {
|
|
324
|
+
return result.output;
|
|
325
|
+
}
|
|
326
|
+
workingPython = null;
|
|
327
|
+
}
|
|
328
|
+
for (const candidate of discoverCandidates()) {
|
|
329
|
+
const result = await attempt(candidate);
|
|
330
|
+
if (result.missing) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
workingPython = candidate;
|
|
334
|
+
return result.output;
|
|
335
|
+
}
|
|
336
|
+
return '';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* One identified user message carrying a plugin notice. DSH's own
|
|
342
|
+
* createUserMessage lives in a package this preset deliberately does not
|
|
343
|
+
* import, so the shape is built here with node's uuid.
|
|
344
|
+
*/
|
|
345
|
+
function specNoticeMessage(text) {
|
|
346
|
+
return {
|
|
347
|
+
id: randomUUID(),
|
|
348
|
+
role: 'user',
|
|
349
|
+
content: [{ type: 'text', text }],
|
|
350
|
+
source: {
|
|
351
|
+
kind: 'plugin',
|
|
352
|
+
plugin: 'cowork-flow',
|
|
353
|
+
form: 'notice',
|
|
354
|
+
summary: 'spec-check',
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
|
|
237
360
|
export function apply(ctx) {
|
|
238
361
|
// One cache entry per agent: { text, inflight, queued, full }.
|
|
239
362
|
// `full` records the digest shape the next refresh should use: session
|
|
@@ -291,6 +414,35 @@ export function apply(ctx) {
|
|
|
291
414
|
refresh(agent, false);
|
|
292
415
|
});
|
|
293
416
|
|
|
417
|
+
// Editor-phase spec-check: the tool already ran, so this only attaches
|
|
418
|
+
// context for the next request and never blocks the call. Waterfall order
|
|
419
|
+
// requires awaiting next() before merging downstream decisions.
|
|
420
|
+
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
|
421
|
+
const downstream = await next();
|
|
422
|
+
const agent = exec && exec.agent;
|
|
423
|
+
const filePath = editedFilePath(exec);
|
|
424
|
+
if (!agent || !agent.cwd || !filePath) {
|
|
425
|
+
return downstream;
|
|
426
|
+
}
|
|
427
|
+
let warning = '';
|
|
428
|
+
try {
|
|
429
|
+
warning = await runEditSpecCheck(agent.cwd, filePath);
|
|
430
|
+
} catch {
|
|
431
|
+
warning = '';
|
|
432
|
+
}
|
|
433
|
+
if (!warning) {
|
|
434
|
+
return downstream;
|
|
435
|
+
}
|
|
436
|
+
const contexts = [
|
|
437
|
+
specNoticeMessage(warning),
|
|
438
|
+
...((downstream && downstream.additionalContexts) || []),
|
|
439
|
+
];
|
|
440
|
+
if (downstream && downstream.kind === 'block') {
|
|
441
|
+
return { ...downstream, additionalContexts: contexts };
|
|
442
|
+
}
|
|
443
|
+
return { ...(downstream || { kind: 'accept' }), additionalContexts: contexts };
|
|
444
|
+
});
|
|
445
|
+
|
|
294
446
|
ctx.effect(() => ctx.systemPrompt.section({
|
|
295
447
|
name: SECTION_NAME,
|
|
296
448
|
order: SECTION_ORDER,
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { cp, mkdir, rm, access } from 'node:fs/promises';
|
|
1
|
+
import { cp, mkdir, rm, access, readFile, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
5
|
import { packageRoot, templateRoot } from '../lib/paths.js';
|
|
6
|
+
import { readPackageInfo } from '../lib/package-info.js';
|
|
6
7
|
|
|
7
8
|
const PRESET_ID = 'cowork-flow';
|
|
9
|
+
const MARKER_FILE = '.cowork-flow-preset.json';
|
|
8
10
|
const PRESET_SRC = join(packageRoot, 'presets', 'dsh');
|
|
9
11
|
const SKILLS_SRC = join(templateRoot, 'skills');
|
|
10
12
|
|
|
@@ -33,6 +35,17 @@ function getDshPresetRoot() {
|
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
|
|
38
|
+
async function readInstalledVersion(destDir) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readFile(join(destDir, MARKER_FILE), 'utf8');
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
return typeof parsed.version === 'string' ? parsed.version : null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
36
49
|
export async function runInstallDshPreset(args = []) {
|
|
37
50
|
const { dryRun, force } = parseArgs(args);
|
|
38
51
|
|
|
@@ -53,9 +66,25 @@ export async function runInstallDshPreset(args = []) {
|
|
|
53
66
|
return;
|
|
54
67
|
}
|
|
55
68
|
|
|
69
|
+
const { version } = await readPackageInfo();
|
|
70
|
+
|
|
56
71
|
if (!force && (await pathExists(destDir))) {
|
|
72
|
+
const installedVersion = await readInstalledVersion(destDir);
|
|
57
73
|
console.log(`cowork-flow DSH preset already installed at ${destDir}`);
|
|
58
|
-
|
|
74
|
+
if (installedVersion === null) {
|
|
75
|
+
console.log(
|
|
76
|
+
`Installed version unknown (no ${MARKER_FILE}); current version is ${version}.`
|
|
77
|
+
);
|
|
78
|
+
console.log('Refresh it with: cowork-flow install-dsh-preset --force');
|
|
79
|
+
} else if (installedVersion !== version) {
|
|
80
|
+
console.log(
|
|
81
|
+
`Installed version ${installedVersion} differs from current version ${version}.`
|
|
82
|
+
);
|
|
83
|
+
console.log('The preset does not update with sync or npm; refresh it with:');
|
|
84
|
+
console.log(' cowork-flow install-dsh-preset --force');
|
|
85
|
+
} else {
|
|
86
|
+
console.log('Use --force to overwrite.');
|
|
87
|
+
}
|
|
59
88
|
return;
|
|
60
89
|
}
|
|
61
90
|
|
|
@@ -66,6 +95,11 @@ export async function runInstallDshPreset(args = []) {
|
|
|
66
95
|
|
|
67
96
|
await cp(PRESET_SRC, destDir, { recursive: true });
|
|
68
97
|
await cp(SKILLS_SRC, skillsDest, { recursive: true, force: true });
|
|
98
|
+
await writeFile(
|
|
99
|
+
join(destDir, MARKER_FILE),
|
|
100
|
+
`${JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2)}\n`,
|
|
101
|
+
'utf8'
|
|
102
|
+
);
|
|
69
103
|
|
|
70
104
|
console.log(`✓ cowork-flow DSH preset installed to ${destDir}`);
|
|
71
105
|
console.log(' Start a new DSH session and pick the "Cowork Flow" preset.');
|
|
@@ -10,6 +10,18 @@
|
|
|
10
10
|
}
|
|
11
11
|
]
|
|
12
12
|
}
|
|
13
|
+
],
|
|
14
|
+
"PostToolUse": [
|
|
15
|
+
{
|
|
16
|
+
"matcher": "apply_patch|Write|Edit",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": ".cowork-flow/run python .codex/hooks/inject-workflow-state.py",
|
|
21
|
+
"timeout": 5
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
13
25
|
]
|
|
14
26
|
}
|
|
15
27
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.5.0
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
Assembles one machine-readable fact view per task (task.json, decision-anchor
|
|
6
6
|
essentials, plan binding, bound sessions, trusted snapshot). See
|
|
7
|
-
services/fact_view.py and
|
|
7
|
+
services/fact_view.py and the fact-layer stage 1 design.
|
|
8
8
|
"""
|
|
9
9
|
|
|
10
10
|
from __future__ import annotations
|
|
@@ -82,7 +82,9 @@ def _resolve_status_task_dir(
|
|
|
82
82
|
print(
|
|
83
83
|
colored(
|
|
84
84
|
"Error: Session binding is process-fallback (shared); "
|
|
85
|
-
"
|
|
85
|
+
"set COWORK_FLOW_CONTEXT_ID to the session value shown "
|
|
86
|
+
"in the workflow-state header, or pass a task dir "
|
|
87
|
+
"explicitly.",
|
|
86
88
|
Colors.RED,
|
|
87
89
|
),
|
|
88
90
|
file=sys.stderr,
|
|
@@ -368,7 +370,8 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
368
370
|
print(
|
|
369
371
|
colored(
|
|
370
372
|
"Error: Refusing to clear a session binding resolved from "
|
|
371
|
-
"process-fallback identity; archive the task to clear it
|
|
373
|
+
"process-fallback identity; archive the task to clear it, or "
|
|
374
|
+
"set COWORK_FLOW_CONTEXT_ID to your own session identity.",
|
|
372
375
|
Colors.RED,
|
|
373
376
|
),
|
|
374
377
|
file=sys.stderr,
|
|
@@ -435,6 +435,41 @@ def _stage_contract_block(
|
|
|
435
435
|
return None
|
|
436
436
|
|
|
437
437
|
|
|
438
|
+
_APPLY_PATCH_FILE_RE = re.compile(
|
|
439
|
+
r"^\*\*\* (?:Update|Add|Delete) File: (.+?)\s*$", re.MULTILINE
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _extract_edited_paths(tool_input: Any) -> list[str]:
|
|
444
|
+
"""Best-effort edited file paths from a PostToolUse tool_input payload.
|
|
445
|
+
|
|
446
|
+
Hosts differ: claude-code and zcode pass file_path, while codex's
|
|
447
|
+
apply_patch carries the paths inside the patch text. Returning every
|
|
448
|
+
extractable path keeps a multi-file patch from silently skipping
|
|
449
|
+
checks; deduplication keeps one edit from burning the throttle window
|
|
450
|
+
on the same file twice."""
|
|
451
|
+
if not isinstance(tool_input, dict):
|
|
452
|
+
return []
|
|
453
|
+
paths: list[str] = []
|
|
454
|
+
for key in ("file_path", "filePath", "path"):
|
|
455
|
+
value = tool_input.get(key)
|
|
456
|
+
if isinstance(value, str) and value.strip():
|
|
457
|
+
paths.append(value.strip())
|
|
458
|
+
for key in ("input", "patch", "patchText"):
|
|
459
|
+
text = tool_input.get(key)
|
|
460
|
+
if isinstance(text, str):
|
|
461
|
+
paths.extend(
|
|
462
|
+
match.strip() for match in _APPLY_PATCH_FILE_RE.findall(text)
|
|
463
|
+
)
|
|
464
|
+
seen: set[str] = set()
|
|
465
|
+
unique: list[str] = []
|
|
466
|
+
for path in paths:
|
|
467
|
+
if path not in seen:
|
|
468
|
+
seen.add(path)
|
|
469
|
+
unique.append(path)
|
|
470
|
+
return unique
|
|
471
|
+
|
|
472
|
+
|
|
438
473
|
def spec_edit_warning(
|
|
439
474
|
root: Path,
|
|
440
475
|
hook_input: dict[str, Any],
|
|
@@ -461,16 +496,17 @@ def spec_edit_warning(
|
|
|
461
496
|
return ""
|
|
462
497
|
if not task_path or status not in STAGE_CONTRACT_STATES:
|
|
463
498
|
return ""
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
467
|
-
)
|
|
468
|
-
if not isinstance(file_path, str) or not file_path.strip():
|
|
499
|
+
paths = _extract_edited_paths(hook_input.get("tool_input"))
|
|
500
|
+
if not paths:
|
|
469
501
|
return ""
|
|
470
502
|
try:
|
|
471
503
|
from services.spec_check import run_edit_checks
|
|
472
504
|
|
|
473
|
-
|
|
505
|
+
for path in paths:
|
|
506
|
+
warning = run_edit_checks(root, path)
|
|
507
|
+
if warning:
|
|
508
|
+
return warning
|
|
509
|
+
return ""
|
|
474
510
|
except Exception:
|
|
475
511
|
return ""
|
|
476
512
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
"""Read-only MCP stdio server exposing the cowork-flow fact layer.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Fact-layer stage 3: any MCP client can query task facts without
|
|
6
6
|
pulling the workflow into a prompt. Deliberately dependency-free — the MCP
|
|
7
7
|
stdio transport is newline-delimited JSON-RPC 2.0, small enough to serve on
|
|
8
8
|
the standard library while the ecosystem settles (see
|
|
@@ -25,8 +25,9 @@ PROVENANCE_HOST_SESSION = "host_session"
|
|
|
25
25
|
PROVENANCE_PROCESS_FALLBACK = "process_fallback"
|
|
26
26
|
PROVENANCE_MISSING = "missing"
|
|
27
27
|
FALLBACK_BINDING_BLOCKER = (
|
|
28
|
-
"session identity is process-fallback (shared);
|
|
29
|
-
"
|
|
28
|
+
"session identity is process-fallback (shared); set COWORK_FLOW_CONTEXT_ID "
|
|
29
|
+
"to the session value shown in the workflow-state header (or pass "
|
|
30
|
+
"<task-dir> explicitly when acting on an existing task)"
|
|
30
31
|
)
|
|
31
32
|
RUNTIME_CONTEXT_PROMPT_RE = re.compile(
|
|
32
33
|
r"(?im)^\s*cowork_runtime_context_id\s*:\s*([A-Za-z0-9._-]+)\s*$"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"""Task fact view: one machine-readable aggregate of everything a session
|
|
4
4
|
needs to know about a task.
|
|
5
5
|
|
|
6
|
-
This is the fact-layer read entry (
|
|
6
|
+
This is the fact-layer read entry (fact-layer stage 1): task.json,
|
|
7
7
|
decision-anchor essentials, plan binding, bound sessions, and the trusted
|
|
8
8
|
state snapshot — assembled once so hosts, hooks, and tools stop re-deriving
|
|
9
9
|
them from file conventions.
|
|
@@ -180,7 +180,9 @@ Verify: npm run test:fast; python3 -m pytest tests/ -q
|
|
|
180
180
|
(`Bash|Edit|Write|MultiEdit`) is asserted by template tests but depends on
|
|
181
181
|
the ZCode runtime's tool names — there is no external runtime contract to
|
|
182
182
|
verify them against, so a runtime rename would silently disable the
|
|
183
|
-
warning; the matcher is the single switch.
|
|
183
|
+
warning; the matcher is the single switch. This bullet covers the scope
|
|
184
|
+
warning only; the spec-check violation line is a separate channel that
|
|
185
|
+
also reaches codex and opencode sessions (see spec-checks.md).
|
|
184
186
|
- Budget: the whole block stays ≤ 1200 characters. Over-budget inputs
|
|
185
187
|
degrade by dropping Verify, then Specs, then shrinking Scope entries
|
|
186
188
|
(min 1) — the closing tag and the Gates row always survive; tests assert
|
|
@@ -67,10 +67,16 @@ spec 为无检查并进 doctor 报告,不炸流程。
|
|
|
67
67
|
|
|
68
68
|
## 归属与 sync 策略
|
|
69
69
|
|
|
70
|
-
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
- **`spec/` 整体是 sync 保护前缀**:已存在的 spec 文件不会被 `sync`
|
|
71
|
+
覆盖——模板里 spec 的更新只对 `init` 新装生效,已安装项目的本地
|
|
72
|
+
内容不会被同步任务改写。
|
|
73
|
+
- **具名例外**:`spec/contracts/` 下列入 `spec/runtime/host-assets.json`
|
|
74
|
+
的 `syncPolicy.safeFiles` 条目(本文件与 `workflow-state-templates.md`)
|
|
75
|
+
随 sync 更新,使契约修正能到达已安装项目;其余 spec 文件保持保护。
|
|
76
|
+
- **声明由使用方维护**:模板自带的 spec 文件不含生效的 `checks:` 声明
|
|
77
|
+
——模板无法预知项目命令,而命令缺失会归 `unchecked` 并阻断
|
|
78
|
+
`complete`。请把声明写进自建 spec 文件(如 `spec/team-xxx.md` 或
|
|
79
|
+
自建子目录),sync 不触碰,声明随规范正文一起由使用方维护。
|
|
74
80
|
|
|
75
81
|
## 宿主能力矩阵
|
|
76
82
|
|
|
@@ -78,7 +84,11 @@ spec 为无检查并进 doctor 报告,不炸流程。
|
|
|
78
84
|
| --- | --- | --- | --- |
|
|
79
85
|
| zcode | 有(Specs 行 h2 digest) | 有(PostToolUse 短路径;**仅主会话**) | 有 |
|
|
80
86
|
| claude-code | 有(Python 单源) | 有(PostToolUse,exit 2 反馈) | 有 |
|
|
81
|
-
|
|
|
87
|
+
| codex | 有 | 有(PostToolUse,`apply_patch\|Write\|Edit` → exit 2 stderr) | 有 |
|
|
88
|
+
| opencode | 有 | 有(插件 `tool.execute.after` 单行追加到工具结果) | 有 |
|
|
89
|
+
| dsh | 有(预设注入) | 有(预设插件 `tools/post-execute` → `additionalContexts`) | 有 |
|
|
90
|
+
|
|
91
|
+
zcode / codex / claude-code 经 Python 单源共享 `run_edit_checks`;opencode 在插件内以 CLI 拉取同一执行器(`run spec-check --phase edit --throttled`),dsh 经预设插件的 `tools/post-execute` 调同一 Python 协议——五家共享同一节流状态与三态语义,不各自实现检查逻辑。
|
|
82
92
|
|
|
83
93
|
**delegated 子代理编辑期缺口(zcode 实测,2026-09-09 两轮)**:ZCode 的插件
|
|
84
94
|
hook 只在主会话工具流上派发(事件模型七事件均挂主会话;子代理是独立内部
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawn } from "node:child_process"
|
|
1
2
|
import { createHash } from "node:crypto"
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
3
4
|
import { dirname, resolve } from "node:path"
|
|
@@ -721,6 +722,81 @@ function buildInjectedDigest(input) {
|
|
|
721
722
|
return `<contract-fingerprint value="${contractFingerprint(root, contracts)}"/>`
|
|
722
723
|
}
|
|
723
724
|
|
|
725
|
+
// Editor-phase spec-check: opencode sessions get the same single-line
|
|
726
|
+
// violation hint zcode and claude-code already receive. Best-effort — a
|
|
727
|
+
// missing runtime, a timeout, or any error stays silent and never blocks
|
|
728
|
+
// the edit that already happened.
|
|
729
|
+
const EDIT_TOOL_NAMES = new Set(["edit", "write"])
|
|
730
|
+
const EDIT_CHECK_TIMEOUT_MS = 2500
|
|
731
|
+
|
|
732
|
+
function editedFilePath(input) {
|
|
733
|
+
const tool = typeof input?.tool === "string" ? input.tool : ""
|
|
734
|
+
if (!EDIT_TOOL_NAMES.has(tool)) {
|
|
735
|
+
return ""
|
|
736
|
+
}
|
|
737
|
+
const args = input?.args
|
|
738
|
+
const candidate = args && typeof args === "object" ? args.filePath : null
|
|
739
|
+
return typeof candidate === "string" && candidate.trim() ? candidate.trim() : ""
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function runEditSpecCheck(root, filePath) {
|
|
743
|
+
return new Promise((resolvePromise) => {
|
|
744
|
+
let settled = false
|
|
745
|
+
const finish = (value) => {
|
|
746
|
+
if (!settled) {
|
|
747
|
+
settled = true
|
|
748
|
+
resolvePromise(value)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
const entry = resolve(
|
|
752
|
+
root,
|
|
753
|
+
".cowork-flow",
|
|
754
|
+
process.platform === "win32" ? "run.cmd" : "run"
|
|
755
|
+
)
|
|
756
|
+
if (!existsSync(entry)) {
|
|
757
|
+
finish("")
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
let child
|
|
761
|
+
try {
|
|
762
|
+
child = spawn(
|
|
763
|
+
entry,
|
|
764
|
+
["spec-check", "--phase", "edit", "--file", filePath, "--throttled"],
|
|
765
|
+
{
|
|
766
|
+
cwd: root,
|
|
767
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
768
|
+
shell: process.platform === "win32",
|
|
769
|
+
}
|
|
770
|
+
)
|
|
771
|
+
} catch {
|
|
772
|
+
finish("")
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
const timer = setTimeout(() => {
|
|
776
|
+
try {
|
|
777
|
+
child.kill()
|
|
778
|
+
} catch {
|
|
779
|
+
// the edit must not be held up by a stuck checker
|
|
780
|
+
}
|
|
781
|
+
finish("")
|
|
782
|
+
}, EDIT_CHECK_TIMEOUT_MS)
|
|
783
|
+
let stdout = ""
|
|
784
|
+
if (child.stdout) {
|
|
785
|
+
child.stdout.on("data", (chunk) => {
|
|
786
|
+
stdout += String(chunk)
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
child.on("error", () => {
|
|
790
|
+
clearTimeout(timer)
|
|
791
|
+
finish("")
|
|
792
|
+
})
|
|
793
|
+
child.on("close", () => {
|
|
794
|
+
clearTimeout(timer)
|
|
795
|
+
finish(stdout.trim())
|
|
796
|
+
})
|
|
797
|
+
})
|
|
798
|
+
}
|
|
799
|
+
|
|
724
800
|
export const CoworkFlowPlugin = async () => {
|
|
725
801
|
return {
|
|
726
802
|
"shell.env": async (input, output) => {
|
|
@@ -729,6 +805,16 @@ export const CoworkFlowPlugin = async () => {
|
|
|
729
805
|
"experimental.chat.system.transform": async (input, output) => {
|
|
730
806
|
output.system.push([buildInjectedDigest(input), buildRuntimeWorkflowState(input)].filter(Boolean).join("\n\n"))
|
|
731
807
|
},
|
|
808
|
+
"tool.execute.after": async (input, output) => {
|
|
809
|
+
const filePath = editedFilePath(input)
|
|
810
|
+
if (!filePath) {
|
|
811
|
+
return
|
|
812
|
+
}
|
|
813
|
+
const warning = await runEditSpecCheck(findRepoRoot(input), filePath)
|
|
814
|
+
if (warning) {
|
|
815
|
+
output.output = [output.output, warning].filter(Boolean).join("\n")
|
|
816
|
+
}
|
|
817
|
+
},
|
|
732
818
|
}
|
|
733
819
|
}
|
|
734
820
|
|
package/template/AGENTS.md
CHANGED
|
@@ -143,4 +143,5 @@
|
|
|
143
143
|
|
|
144
144
|
<!-- COWORK-FLOW:START -->
|
|
145
145
|
项目流程以 `task next`、runtime gate、Skill command manifests 和 `.cowork-flow/spec/` 硬契约为准;不要维护第二套流程文档或 Skill 注册控制面。
|
|
146
|
+
规范声明的检查以 `.cowork-flow/spec/contracts/spec-checks.md` 为准;交付或收口前运行 `./.cowork-flow/run spec-check`。
|
|
146
147
|
<!-- COWORK-FLOW:END -->
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import json
|
|
8
|
+
import os
|
|
8
9
|
import sys
|
|
9
10
|
from datetime import datetime, timezone
|
|
10
11
|
from pathlib import Path
|
|
@@ -28,7 +29,7 @@ from adapters.host.host_manifest import (
|
|
|
28
29
|
load_host_manifest,
|
|
29
30
|
validate_host_assets,
|
|
30
31
|
)
|
|
31
|
-
from infra.paths import get_repo_root
|
|
32
|
+
from infra.paths import DIR_WORKFLOW, get_repo_root
|
|
32
33
|
from infra.skill_manifest import SkillManifestError, action_owners, load_skill_manifests
|
|
33
34
|
from infra.storage.operation_log import OperationLog
|
|
34
35
|
from infra.storage.state_store import DEFAULT_STALE_LOCK_SECONDS, StateStore
|
|
@@ -727,6 +728,77 @@ def check_mcp_registration(repo_root: Path) -> list[dict[str, str]]:
|
|
|
727
728
|
return issues
|
|
728
729
|
|
|
729
730
|
|
|
731
|
+
def _dsh_preset_dir() -> Path:
|
|
732
|
+
base = os.environ.get("DSH_HOME") or str(Path.home() / ".dsh")
|
|
733
|
+
return Path(base) / ".agent-presets" / "cowork-flow"
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def check_dsh_preset(repo_root: Path) -> list[dict[str, str]]:
|
|
737
|
+
"""DSH preset freshness. Advisory only, and silent when the preset is
|
|
738
|
+
absent: it is a machine-level asset installed once, so it does not
|
|
739
|
+
update with npm or sync. Report an unknown or stale installed version
|
|
740
|
+
instead of assuming the injection logic matches this project."""
|
|
741
|
+
preset_dir = _dsh_preset_dir()
|
|
742
|
+
if not preset_dir.is_dir():
|
|
743
|
+
return []
|
|
744
|
+
marker = preset_dir / ".cowork-flow-preset.json"
|
|
745
|
+
if not marker.is_file():
|
|
746
|
+
return [
|
|
747
|
+
_issue(
|
|
748
|
+
code="PRESET-UNKNOWN-VERSION",
|
|
749
|
+
severity="warning",
|
|
750
|
+
path=str(marker),
|
|
751
|
+
message=(
|
|
752
|
+
"DSH preset is installed without a version marker; its "
|
|
753
|
+
"injection logic may predate the current release"
|
|
754
|
+
),
|
|
755
|
+
command_hint="cowork-flow install-dsh-preset --force",
|
|
756
|
+
contract="runtime-health:dsh-preset",
|
|
757
|
+
)
|
|
758
|
+
]
|
|
759
|
+
recorded: object = None
|
|
760
|
+
try:
|
|
761
|
+
recorded = json.loads(marker.read_text(encoding="utf-8")).get("version")
|
|
762
|
+
except (OSError, json.JSONDecodeError, ValueError, AttributeError):
|
|
763
|
+
recorded = None
|
|
764
|
+
if not isinstance(recorded, str) or not recorded:
|
|
765
|
+
return [
|
|
766
|
+
_issue(
|
|
767
|
+
code="PRESET-UNKNOWN-VERSION",
|
|
768
|
+
severity="warning",
|
|
769
|
+
path=str(marker),
|
|
770
|
+
message=(
|
|
771
|
+
"DSH preset version marker is unreadable; its injection "
|
|
772
|
+
"logic may predate the current release"
|
|
773
|
+
),
|
|
774
|
+
command_hint="cowork-flow install-dsh-preset --force",
|
|
775
|
+
contract="runtime-health:dsh-preset",
|
|
776
|
+
)
|
|
777
|
+
]
|
|
778
|
+
try:
|
|
779
|
+
project_version = (
|
|
780
|
+
repo_root / DIR_WORKFLOW / ".version"
|
|
781
|
+
).read_text(encoding="utf-8").strip()
|
|
782
|
+
except OSError:
|
|
783
|
+
return []
|
|
784
|
+
if not project_version or recorded == project_version:
|
|
785
|
+
return []
|
|
786
|
+
return [
|
|
787
|
+
_issue(
|
|
788
|
+
code="PRESET-STALE",
|
|
789
|
+
severity="warning",
|
|
790
|
+
path=str(marker),
|
|
791
|
+
message=(
|
|
792
|
+
f"DSH preset was installed from {recorded} but this project "
|
|
793
|
+
f"runs {project_version}; the preset does not update with "
|
|
794
|
+
"sync or npm, so injection may lag the project runtime"
|
|
795
|
+
),
|
|
796
|
+
command_hint="cowork-flow install-dsh-preset --force",
|
|
797
|
+
contract="runtime-health:dsh-preset",
|
|
798
|
+
)
|
|
799
|
+
]
|
|
800
|
+
|
|
801
|
+
|
|
730
802
|
def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
731
803
|
host_issues = _host_issues(repo_root)
|
|
732
804
|
runtime_errors = check_runtime(repo_root)
|
|
@@ -736,6 +808,7 @@ def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
|
736
808
|
session_hygiene_issues = check_session_hygiene(repo_root)
|
|
737
809
|
spec_check_issues = check_spec_checks(repo_root)
|
|
738
810
|
mcp_issues = check_mcp_registration(repo_root)
|
|
811
|
+
dsh_preset_issues = check_dsh_preset(repo_root)
|
|
739
812
|
errors: list[dict[str, object]] = []
|
|
740
813
|
for issue in host_issues:
|
|
741
814
|
errors.append({"kind": "host_adapter", **issue})
|
|
@@ -758,6 +831,7 @@ def _all_check_result(repo_root: Path) -> dict[str, object]:
|
|
|
758
831
|
"sessionHygiene": session_hygiene_issues,
|
|
759
832
|
"specChecks": spec_check_issues,
|
|
760
833
|
"mcpRegistration": mcp_issues,
|
|
834
|
+
"dshPreset": dsh_preset_issues,
|
|
761
835
|
},
|
|
762
836
|
}
|
|
763
837
|
|
|
@@ -773,6 +847,10 @@ def _run_checks(repo_root: Path, *, structured: bool = False) -> int:
|
|
|
773
847
|
_print_session_hygiene_issues(result["issues"]["sessionHygiene"])
|
|
774
848
|
for issue in result["issues"]["mcpRegistration"]:
|
|
775
849
|
print(f"MCP ({issue['status']}): {issue['message']}")
|
|
850
|
+
for issue in result["issues"]["dshPreset"]:
|
|
851
|
+
print(f"DSH preset ({issue['code']}): {issue['message']}")
|
|
852
|
+
if issue.get("commandHint"):
|
|
853
|
+
print(f" fix: {issue['commandHint']}")
|
|
776
854
|
if errors:
|
|
777
855
|
for error in errors:
|
|
778
856
|
print(f"ERROR: {error['message']}", file=sys.stderr)
|