cowork-flow 1.3.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 +44 -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 +100 -32
- package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +55 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +33 -7
- package/template/.cowork-flow/scripts/run.py +1 -0
- package/template/.cowork-flow/scripts/runtime/session_state.py +38 -2
- package/template/.cowork-flow/scripts/services/fact_view.py +1 -1
- package/template/.cowork-flow/spec/contracts/context-injection.md +19 -4
- 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,49 @@
|
|
|
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
|
+
|
|
35
|
+
## 1.4.0 - 2026-09-14
|
|
36
|
+
|
|
37
|
+
### Hook 会话身份绑定(fix(runtime))
|
|
38
|
+
|
|
39
|
+
实弹暴露的多窗口交叉读取问题:zcode hook 会话携带对话自身的 session id,而任务激活发生在 Bash CLI 的显式身份下,导致未绑定的 hook 会话回退到 newest-session 时可能**读到另一个窗口的任务**。本版把绑定与回退语义一起收紧:
|
|
40
|
+
|
|
41
|
+
- **激活即认领**:PostToolUse 在激活类命令(`task start` / `task next <dir> --run` / `resume <dir>`)之后,把 hook 会话自己的 `zcode_<sessionId>` 绑定认领下来(`session_state.claim_active_task`);查询类命令与不可信身份(process-fallback/缺失)永不认领。
|
|
42
|
+
- **回退语义收紧**:`fallback_for_unbound` 仅在**恰好存在一个** main-session 绑定时跟随最新的那个;存在多个绑定时,未绑定 hook 会话渲染 no_task + rebind 提示,绝不交叉读取其他窗口。
|
|
43
|
+
- **会话身份显式化**:workflow-state 头部新增 `session=<contextKey>` 属性,模型可将其作为 `COWORK_FLOW_CONTEXT_ID` 传给后续 CLI 调用,打通 hook 会话与 CLI 身份。
|
|
44
|
+
- **MCP 拒绝隐式冒认**:`task_state` / `task_scope` / `task_list` 在不可信身份下拒绝隐式解析(`identity-untrusted`),不再冒认 process-fallback 任务;CLI `FALLBACK_BINDING_BLOCKER` 语义不变(回归护栏锁定)。
|
|
45
|
+
- 契约文档 context-injection.md 同步记录 session 属性与回退语义;新增 pytest 396 行覆盖(active_task_runtime / inject_entry / mcp_state_server / workflow_state_hook)。
|
|
46
|
+
|
|
3
47
|
## 1.3.0 - 2026-09-09
|
|
4
48
|
|
|
5
49
|
### 跨宿主适配通用化(注入单源化 + MCP 重定位)
|
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,
|
|
@@ -239,7 +239,7 @@ def build_hook_context(
|
|
|
239
239
|
if contract_block:
|
|
240
240
|
blocks.append(contract_block)
|
|
241
241
|
blocks.append(
|
|
242
|
-
f"<workflow-state{_workflow_state_attrs(task_path, status, source)}>"
|
|
242
|
+
f"<workflow-state{_workflow_state_attrs(task_path, status, source, session=_resolve_session_identity(root, hook_input))}>"
|
|
243
243
|
f"\n{body}\n</workflow-state>"
|
|
244
244
|
)
|
|
245
245
|
context = "\n\n".join(blocks)
|
|
@@ -279,14 +279,38 @@ def _xml_attr(value: Any) -> str:
|
|
|
279
279
|
return xml_attr(value)
|
|
280
280
|
|
|
281
281
|
|
|
282
|
+
def _resolve_session_identity(
|
|
283
|
+
root: Path, hook_input: dict[str, Any]
|
|
284
|
+
) -> str | None:
|
|
285
|
+
"""The caller's resolved context key for the session attribute
|
|
286
|
+
(context-injection.md, stage 1). None when no identity is resolvable —
|
|
287
|
+
the attribute is omitted rather than guessed."""
|
|
288
|
+
_load_common(root)
|
|
289
|
+
try:
|
|
290
|
+
from runtime.session_state import resolve_context_key
|
|
291
|
+
except Exception:
|
|
292
|
+
return None
|
|
293
|
+
try:
|
|
294
|
+
return resolve_context_key(hook_input)
|
|
295
|
+
except Exception:
|
|
296
|
+
return None
|
|
297
|
+
|
|
298
|
+
|
|
282
299
|
def _workflow_state_attrs(
|
|
283
|
-
task_path: str | None,
|
|
300
|
+
task_path: str | None,
|
|
301
|
+
status: str,
|
|
302
|
+
source: str,
|
|
303
|
+
session: str | None = None,
|
|
284
304
|
) -> str:
|
|
285
305
|
"""Structured fact header (context-injection.md, stage 1): the machine
|
|
286
|
-
picks task/status/source off the attributes; humans read the
|
|
306
|
+
picks task/status/source/session off the attributes; humans read the
|
|
307
|
+
body. session is appended last and only when a session identity was
|
|
308
|
+
resolvable from the hook input or environment."""
|
|
287
309
|
attrs = [f'status="{_xml_attr(status)}"', f'source="{_xml_attr(source)}"']
|
|
288
310
|
if task_path:
|
|
289
311
|
attrs.insert(0, f'task="{_xml_attr(task_path)}"')
|
|
312
|
+
if session:
|
|
313
|
+
attrs.append(f'session="{_xml_attr(session)}"')
|
|
290
314
|
return "".join(f" {attr}" for attr in attrs)
|
|
291
315
|
|
|
292
316
|
|
|
@@ -411,6 +435,41 @@ def _stage_contract_block(
|
|
|
411
435
|
return None
|
|
412
436
|
|
|
413
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
|
+
|
|
414
473
|
def spec_edit_warning(
|
|
415
474
|
root: Path,
|
|
416
475
|
hook_input: dict[str, Any],
|
|
@@ -422,11 +481,12 @@ def spec_edit_warning(
|
|
|
422
481
|
reach them too: only an active in_progress/review task is required —
|
|
423
482
|
session scope is not consulted here (scope warnings stay main-only in
|
|
424
483
|
the zcode policy's edit_scope_warning). Hosts with
|
|
425
|
-
fallback_for_unbound (zcode) follow
|
|
426
|
-
never-bound hook sessions
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
484
|
+
fallback_for_unbound (zcode) follow another main session's binding for
|
|
485
|
+
their never-bound hook sessions only while exactly one main-session
|
|
486
|
+
binding exists (see _get_active_task_with_fallback); strict-identity
|
|
487
|
+
hosts (claude-code/codex) require the calling session's own binding.
|
|
488
|
+
no_task, planning, and completed stay silent. Empty string means
|
|
489
|
+
silent. Never raises — the editor path must not break edits.
|
|
430
490
|
"""
|
|
431
491
|
try:
|
|
432
492
|
task_path, status, _source = _get_active_task_with_fallback(
|
|
@@ -436,16 +496,17 @@ def spec_edit_warning(
|
|
|
436
496
|
return ""
|
|
437
497
|
if not task_path or status not in STAGE_CONTRACT_STATES:
|
|
438
498
|
return ""
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
tool_input.get("file_path") if isinstance(tool_input, dict) else None
|
|
442
|
-
)
|
|
443
|
-
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:
|
|
444
501
|
return ""
|
|
445
502
|
try:
|
|
446
503
|
from services.spec_check import run_edit_checks
|
|
447
504
|
|
|
448
|
-
|
|
505
|
+
for path in paths:
|
|
506
|
+
warning = run_edit_checks(root, path)
|
|
507
|
+
if warning:
|
|
508
|
+
return warning
|
|
509
|
+
return ""
|
|
449
510
|
except Exception:
|
|
450
511
|
return ""
|
|
451
512
|
|
|
@@ -605,16 +666,18 @@ def _get_active_task_with_fallback(
|
|
|
605
666
|
hook_input: dict[str, Any],
|
|
606
667
|
fallback_for_unbound: bool,
|
|
607
668
|
) -> tuple[str | None, str, str]:
|
|
608
|
-
"""zcode parity: a hook session that carries no binding follows
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
669
|
+
"""zcode parity: a hook session that carries no binding follows another
|
|
670
|
+
main session's binding — but only when exactly one main-session binding
|
|
671
|
+
exists. Two sources qualify for the fallback: no session identity at all
|
|
672
|
+
(missing-context), and a session id that resolves but was never bound
|
|
673
|
+
(empty-session) — the zcode hook always carries the conversation's own
|
|
674
|
+
session id, while task activation happens in the Bash CLI under a
|
|
675
|
+
separate explicit identity, so the hook session file may not exist yet.
|
|
676
|
+
Gated by the host policy's fallback_for_unbound flag; display/warning-only
|
|
677
|
+
— the CLI lifecycle commands keep their strict identity semantics. With
|
|
678
|
+
two or more main-session bindings the newest one belongs to some other
|
|
679
|
+
window, so the fallback refuses and the caller renders no_task plus the
|
|
680
|
+
rebind hints instead."""
|
|
618
681
|
task_path, status, source = _get_active_task(root, hook_input)
|
|
619
682
|
if (
|
|
620
683
|
not fallback_for_unbound
|
|
@@ -622,9 +685,10 @@ def _get_active_task_with_fallback(
|
|
|
622
685
|
or source not in {"missing-context", "empty-session"}
|
|
623
686
|
):
|
|
624
687
|
return task_path, status, source
|
|
625
|
-
|
|
626
|
-
if
|
|
688
|
+
candidates = _main_session_task_candidates(root)
|
|
689
|
+
if len(candidates) != 1:
|
|
627
690
|
return task_path, status, source
|
|
691
|
+
fallback = candidates[0]
|
|
628
692
|
task_json = root / fallback / "task.json"
|
|
629
693
|
try:
|
|
630
694
|
data = json.loads(task_json.read_text(encoding="utf-8"))
|
|
@@ -636,19 +700,22 @@ def _get_active_task_with_fallback(
|
|
|
636
700
|
return fallback, status.strip(), "session-fallback"
|
|
637
701
|
|
|
638
702
|
|
|
639
|
-
def
|
|
703
|
+
def _main_session_task_candidates(root: Path) -> list[str]:
|
|
704
|
+
"""All main-scope session bindings whose task directory is still valid,
|
|
705
|
+
newest first. Subagent scopes never qualify: delegated sessions follow
|
|
706
|
+
their runtime context, not another session's file."""
|
|
640
707
|
try:
|
|
641
708
|
from runtime.session_state import sessions_dir
|
|
642
709
|
except Exception:
|
|
643
|
-
return
|
|
710
|
+
return []
|
|
644
711
|
sessions = sessions_dir(root)
|
|
645
712
|
if not sessions.is_dir():
|
|
646
|
-
return
|
|
713
|
+
return []
|
|
647
714
|
entries: list[tuple[str, str]] = []
|
|
648
715
|
try:
|
|
649
716
|
paths = sorted(sessions.glob("*.json"))
|
|
650
717
|
except OSError:
|
|
651
|
-
return
|
|
718
|
+
return []
|
|
652
719
|
for path in paths:
|
|
653
720
|
try:
|
|
654
721
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
@@ -665,11 +732,12 @@ def _newest_session_task(root: Path) -> str | None:
|
|
|
665
732
|
(str(data.get("last_seen_at") or ""), task_path.strip())
|
|
666
733
|
)
|
|
667
734
|
entries.sort(reverse=True)
|
|
735
|
+
candidates: list[str] = []
|
|
668
736
|
for _seen_at, task_path in entries:
|
|
669
737
|
task_dir = root / task_path
|
|
670
738
|
if task_dir.is_dir() and (task_dir / "task.json").is_file():
|
|
671
|
-
|
|
672
|
-
return
|
|
739
|
+
candidates.append(task_path)
|
|
740
|
+
return candidates
|
|
673
741
|
|
|
674
742
|
|
|
675
743
|
def _session_scope(root: Path, hook_input: dict[str, Any]) -> str:
|
|
@@ -55,6 +55,60 @@ def is_lifecycle_bash(hook_input: dict[str, Any]) -> bool:
|
|
|
55
55
|
)
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
# Activation intent inside a lifecycle Bash command: `task next <dir> --run`,
|
|
59
|
+
# `task start <dir>`, `resume <dir>` (the run prefix is optional). Query
|
|
60
|
+
# forms (`task next --json`, `task next --run` without a dir) match nothing
|
|
61
|
+
# claimable — the captured token must be a task dir whose task.json exists.
|
|
62
|
+
ACTIVATION_COMMAND_RE = re.compile(
|
|
63
|
+
r"\btask\s+next\s+(?P<next>\S+)\s+--run\b"
|
|
64
|
+
r"|\btask\s+start\s+(?P<start>\S+?)(?=\s|$)"
|
|
65
|
+
r"|(?<![\w./-])resume\s+(?P<resume>\S+?)(?=\s|$)"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _resolvable_task_path(root: Path, token: str) -> str | None:
|
|
70
|
+
candidates = [token]
|
|
71
|
+
if not token.startswith(".cowork-flow/"):
|
|
72
|
+
candidates.append(f".cowork-flow/tasks/{token}")
|
|
73
|
+
for candidate in candidates:
|
|
74
|
+
normalized = candidate.replace("\\", "/").strip("/")
|
|
75
|
+
if (root / normalized / "task.json").is_file():
|
|
76
|
+
return normalized
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def claim_after_lifecycle_bash(root: Path, hook_input: dict[str, Any]) -> None:
|
|
81
|
+
"""Bind the activated task to this hook session's own identity.
|
|
82
|
+
|
|
83
|
+
The lifecycle Bash CLI resolves a process-fallback identity, so the
|
|
84
|
+
binding it writes is shared across windows; PostToolUse re-binds with the
|
|
85
|
+
hook input's session id via session_state.claim_active_task (explicit /
|
|
86
|
+
host_session provenance only). Commands without an activation intent or
|
|
87
|
+
with an unresolvable task dir claim nothing, and claim failure stays
|
|
88
|
+
silent — the refresh path below still renders the state."""
|
|
89
|
+
tool_input = hook_input.get("tool_input")
|
|
90
|
+
command = (
|
|
91
|
+
tool_input.get("command") if isinstance(tool_input, dict) else None
|
|
92
|
+
)
|
|
93
|
+
if not isinstance(command, str):
|
|
94
|
+
return
|
|
95
|
+
match = ACTIVATION_COMMAND_RE.search(command.replace("\\", "/"))
|
|
96
|
+
if match is None:
|
|
97
|
+
return
|
|
98
|
+
token = next(value for value in match.groupdict().values() if value)
|
|
99
|
+
if token.startswith("-"):
|
|
100
|
+
return
|
|
101
|
+
task_path = _resolvable_task_path(root, token)
|
|
102
|
+
if task_path is None:
|
|
103
|
+
return
|
|
104
|
+
try:
|
|
105
|
+
from runtime.session_state import claim_active_task
|
|
106
|
+
|
|
107
|
+
claim_active_task(root, task_path, hook_input)
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
|
|
58
112
|
def zcode_session_alias(data: dict[str, Any]) -> dict[str, Any]:
|
|
59
113
|
# Ported from the zcode hook's resolveSessionKey candidate order
|
|
60
114
|
# (env wins inside session_state; input aliases only fill the gap).
|
|
@@ -202,6 +256,7 @@ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
|
|
|
202
256
|
return merged_edit_warning(root, hook_input), 0
|
|
203
257
|
if not is_lifecycle_bash(hook_input):
|
|
204
258
|
return "", 0
|
|
259
|
+
claim_after_lifecycle_bash(root, hook_input)
|
|
205
260
|
from adapters.host.workflow_state_hook import build_hook_context
|
|
206
261
|
|
|
207
262
|
context = build_hook_context(
|
|
@@ -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
|
|
@@ -99,16 +99,36 @@ TOOLS = [
|
|
|
99
99
|
]
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
TRUSTED_PROVENANCE = ("explicit", "host_session")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _implicit_active_task(root: Path) -> tuple[str | None, str]:
|
|
106
|
+
"""Implicit resolution (no explicit `task` argument): the caller's own
|
|
107
|
+
session-bound active task. A process-fallback identity is shared across
|
|
108
|
+
every window on the host and a missing identity is no identity at all —
|
|
109
|
+
neither may impersonate "the current task", so both return no task with
|
|
110
|
+
an identity-untrusted reason. Returns (task_path, no_task_reason)."""
|
|
103
111
|
from runtime.session_state import get_active_task
|
|
112
|
+
|
|
113
|
+
active = get_active_task(root)
|
|
114
|
+
if active.provenance not in TRUSTED_PROVENANCE:
|
|
115
|
+
return None, (
|
|
116
|
+
f"no-active-task (identity-untrusted: {active.provenance})"
|
|
117
|
+
)
|
|
118
|
+
if not active.task_path:
|
|
119
|
+
return None, "no-active-task"
|
|
120
|
+
return active.task_path, "no-active-task"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _tool_task_state(root: Path, arguments: dict) -> dict:
|
|
104
124
|
from services.fact_view import build_fact_view
|
|
105
125
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
106
126
|
|
|
107
127
|
target = arguments.get("task")
|
|
108
128
|
if not target:
|
|
109
|
-
target =
|
|
129
|
+
target, reason = _implicit_active_task(root)
|
|
110
130
|
if not target:
|
|
111
|
-
return {"schemaVersion": 1, "task": None, "reason":
|
|
131
|
+
return {"schemaVersion": 1, "task": None, "reason": reason}
|
|
112
132
|
try:
|
|
113
133
|
task_dir = TaskRepository(root).resolve(target)
|
|
114
134
|
except TaskRepositoryError as error:
|
|
@@ -124,17 +144,23 @@ def _tool_task_list(root: Path, _arguments: dict) -> dict:
|
|
|
124
144
|
records, error = _list_task_records(root, mine=False, status=None)
|
|
125
145
|
if error:
|
|
126
146
|
return {"tasks": [], "count": 0, "error": error}
|
|
147
|
+
# records[].active is a per-session fact: only a trusted caller identity
|
|
148
|
+
# whose bound path matches the record may carry it.
|
|
149
|
+
trusted_active = _implicit_active_task(root)[0]
|
|
150
|
+
for record in records:
|
|
151
|
+
record["active"] = (
|
|
152
|
+
bool(trusted_active) and record.get("path") == trusted_active
|
|
153
|
+
)
|
|
127
154
|
return {"tasks": records, "count": len(records)}
|
|
128
155
|
|
|
129
156
|
|
|
130
157
|
def _resolve_task_dir(root: Path, target: str | None) -> Path:
|
|
131
|
-
from runtime.session_state import get_active_task
|
|
132
158
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
133
159
|
|
|
134
160
|
if not target:
|
|
135
|
-
target =
|
|
161
|
+
target, reason = _implicit_active_task(root)
|
|
136
162
|
if not target:
|
|
137
|
-
raise ValueError(
|
|
163
|
+
raise ValueError(reason)
|
|
138
164
|
try:
|
|
139
165
|
task_dir = TaskRepository(root).resolve(target)
|
|
140
166
|
except TaskRepositoryError as error:
|
|
@@ -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*$"
|
|
@@ -330,6 +331,41 @@ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
|
|
|
330
331
|
return active
|
|
331
332
|
|
|
332
333
|
|
|
334
|
+
def claim_active_task(
|
|
335
|
+
repo_root: Path,
|
|
336
|
+
task_path: str,
|
|
337
|
+
values: Mapping[str, object] | None = None,
|
|
338
|
+
) -> ActiveTask | None:
|
|
339
|
+
"""Bind the task to the caller's own resolved session identity.
|
|
340
|
+
|
|
341
|
+
Used by host PostToolUse hooks after a lifecycle Bash activation: the CLI
|
|
342
|
+
process often only carries a shared process-fallback identity, so the hook
|
|
343
|
+
re-binds with the session id parsed from the hook input. Only explicit /
|
|
344
|
+
host_session provenance may claim — process_fallback and missing
|
|
345
|
+
identities are shared or absent, and a main binding written with them
|
|
346
|
+
would hijack the binding for every other window. Claim failures return
|
|
347
|
+
None and write nothing.
|
|
348
|
+
"""
|
|
349
|
+
context_key, provenance = resolve_context_key_with_provenance(values)
|
|
350
|
+
if not context_key or provenance not in (
|
|
351
|
+
PROVENANCE_EXPLICIT,
|
|
352
|
+
PROVENANCE_HOST_SESSION,
|
|
353
|
+
):
|
|
354
|
+
return None
|
|
355
|
+
normalized = task_path.replace("\\", "/")
|
|
356
|
+
target = repo_root / normalized
|
|
357
|
+
if not target.is_dir():
|
|
358
|
+
return None
|
|
359
|
+
data: dict[str, object] = {
|
|
360
|
+
FIELD_ACTIVE_TASK_PATH: normalized,
|
|
361
|
+
FIELD_SCOPE: SCOPE_MAIN,
|
|
362
|
+
"platform": platform_from_context_key(context_key),
|
|
363
|
+
"last_seen_at": _now(),
|
|
364
|
+
}
|
|
365
|
+
_write_json(_session_path(repo_root, context_key), data)
|
|
366
|
+
return ActiveTask(normalized, context_key, "session", provenance=provenance)
|
|
367
|
+
|
|
368
|
+
|
|
333
369
|
def get_active_task(repo_root: Path, values: Mapping[str, object] | None = None) -> ActiveTask:
|
|
334
370
|
context_key, provenance = resolve_context_key_with_provenance(values)
|
|
335
371
|
if not context_key:
|
|
@@ -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.
|
|
@@ -85,8 +85,13 @@ The `<workflow-state>` open tag carries the machine-readable fact header as
|
|
|
85
85
|
XML attributes; the body keeps the human-readable breadcrumb prose.
|
|
86
86
|
|
|
87
87
|
- Attributes: `status` and `source` are always present; `task` (repo-relative
|
|
88
|
-
task path) appears whenever a task is bound
|
|
89
|
-
|
|
88
|
+
task path) appears whenever a task is bound; `session` (the caller's
|
|
89
|
+
resolved context key) appears whenever a session identity is resolvable
|
|
90
|
+
from the hook input or environment and is omitted otherwise (hosts that
|
|
91
|
+
cannot resolve an identity never guess one — the attribute is the machine
|
|
92
|
+
handle for re-binding with `COWORK_FLOW_CONTEXT_ID`). Attribute values are
|
|
93
|
+
XML-escaped (`& < > "`). The opencode JS mirror does not emit `session`
|
|
94
|
+
(delegated-only transport; documented behavioral difference, not drift).
|
|
90
95
|
- The legacy `Task:` / `Status:` / `Source:` label lines are gone — machines
|
|
91
96
|
parse the attributes, humans read the body. There is no bare `Scope:`
|
|
92
97
|
line in the body: the stage-contract block owns the scope declaration
|
|
@@ -94,11 +99,19 @@ XML attributes; the body keeps the human-readable breadcrumb prose.
|
|
|
94
99
|
as a read-only `[read-only]` reference of the parent task).
|
|
95
100
|
|
|
96
101
|
```text
|
|
97
|
-
<workflow-state task=".cowork-flow/tasks/08-28-demo" status="in_progress" source="runtime-session">
|
|
102
|
+
<workflow-state task=".cowork-flow/tasks/08-28-demo" status="in_progress" source="runtime-session" session="zcode_probe">
|
|
98
103
|
活动任务正在执行。...
|
|
99
104
|
</workflow-state>
|
|
100
105
|
```
|
|
101
106
|
|
|
107
|
+
- Unbound-session fallback (zcode only, display/warning-only): a hook
|
|
108
|
+
session with no binding follows another main session's binding only while
|
|
109
|
+
**exactly one** main-session binding exists (`source="session-fallback"`).
|
|
110
|
+
With two or more main-session bindings the newest one belongs to some
|
|
111
|
+
other window, so the fallback refuses: the injection renders
|
|
112
|
+
`status="no_task"` plus the rebind hints list. The CLI lifecycle commands
|
|
113
|
+
keep their strict identity semantics regardless (FALLBACK_BINDING_BLOCKER).
|
|
114
|
+
|
|
102
115
|
## Decision-anchor injection (stage 1)
|
|
103
116
|
|
|
104
117
|
For tasks whose status is `planning`, `in_progress`, or `review` (a delegated
|
|
@@ -167,7 +180,9 @@ Verify: npm run test:fast; python3 -m pytest tests/ -q
|
|
|
167
180
|
(`Bash|Edit|Write|MultiEdit`) is asserted by template tests but depends on
|
|
168
181
|
the ZCode runtime's tool names — there is no external runtime contract to
|
|
169
182
|
verify them against, so a runtime rename would silently disable the
|
|
170
|
-
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).
|
|
171
186
|
- Budget: the whole block stays ≤ 1200 characters. Over-budget inputs
|
|
172
187
|
degrade by dropping Verify, then Specs, then shrinking Scope entries
|
|
173
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)
|