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 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
- | 了解项目方向与演进路线 | [`docs/direction.md`](docs/direction.md) |
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`,各客户端配置样例见 docs/mcp-client-setup.md
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 级组合后可直接生效(见 `docs/dsh-upstream-proposal.md`)。
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 --version <v>` 打好 tag 后,`gh release create v<v>` 触发 `.github/workflows/publish.yml`——Ubuntu/Windows 双平台全量门禁通过后自动 `npm publish`(需仓库 secret `NPM_TOKEN`,权限:publish)。
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
- console.log('Use --force to overwrite.');
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.3.0
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 docs/direction.md stage 1.
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
- "pass a task dir explicitly.",
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, status: str, source: str
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 body."""
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 the newest main session for their
426
- never-bound hook sessions; strict-identity hosts (claude-code/codex)
427
- require the calling session's own binding. no_task, planning, and
428
- completed stay silent. Empty string means silent. Never raises — the
429
- editor path must not break edits.
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
- tool_input = hook_input.get("tool_input")
440
- file_path = (
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
- return run_edit_checks(root, file_path)
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 the
609
- newest valid main session's binding. Two sources qualify: no session
610
- identity at all (missing-context), and a session id that resolves but
611
- was never bound (empty-session) — the zcode hook always carries the
612
- conversation's own session id, while task activation happens in the
613
- Bash CLI under a separate explicit identity, so the hook session file
614
- never exists. Gated by the host policy's fallback_for_unbound flag;
615
- display/warning-only — the CLI lifecycle commands keep their strict
616
- identity semantics. Multi-window setups may cross-read another window's
617
- binding; accepted for advisory output."""
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
- fallback = _newest_session_task(root)
626
- if fallback is None:
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 _newest_session_task(root: Path) -> str | None:
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 None
710
+ return []
644
711
  sessions = sessions_dir(root)
645
712
  if not sessions.is_dir():
646
- return None
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 None
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
- return task_path
672
- return None
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
- Stage 3 of docs/direction.md: any MCP client can query task facts without
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
- def _tool_task_state(root: Path, arguments: dict) -> dict:
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 = get_active_task(root).task_path
129
+ target, reason = _implicit_active_task(root)
110
130
  if not target:
111
- return {"schemaVersion": 1, "task": None, "reason": "no-active-task"}
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 = get_active_task(root).task_path
161
+ target, reason = _implicit_active_task(root)
136
162
  if not target:
137
- raise ValueError("no-active-task")
163
+ raise ValueError(reason)
138
164
  try:
139
165
  task_dir = TaskRepository(root).resolve(target)
140
166
  except TaskRepositoryError as error:
@@ -50,6 +50,7 @@ Common commands:
50
50
  resume
51
51
  task
52
52
  state
53
+ spec-check
53
54
  get-context
54
55
  get-developer
55
56
  init-developer
@@ -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); pass <task-dir> explicitly "
29
- "or set COWORK_FLOW_CONTEXT_ID to a trusted session id"
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 (docs/direction.md, stage 1): task.json,
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. Attribute values are XML-escaped
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
@@ -2,7 +2,7 @@
2
2
 
3
3
  How external tools (MCP clients today, other ecosystem protocols as they
4
4
  converge) and agents on hosts without injection hooks read cowork-flow task
5
- facts. Stage 3 of `docs/direction.md`.
5
+ facts. Fact-layer stage 3.
6
6
 
7
7
  ## Three channels, one fact layer
8
8
 
@@ -67,10 +67,16 @@ spec 为无检查并进 doctor 报告,不炸流程。
67
67
 
68
68
  ## 归属与 sync 策略
69
69
 
70
- - **模板自带 spec**(`spec/backend/` 等随包分发)的 check 声明由
71
- cowork-flow 上游维护,属 sync safe 资产——本地修改会被 sync 覆盖。
72
- - **用户自定义规范**请放自建 spec 文件(如 `spec/team-xxx.md` 或自建
73
- 子目录),sync 不触碰;check 声明随规范正文一起由用户维护。
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
- | opencode | 有 | **降级:无**(插件无 PostToolUse 短路径) | 有 |
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 只在主会话工具流上派发(事件模型七事件均挂主会话;子代理是独立内部
@@ -289,6 +289,7 @@
289
289
  ".cowork-flow/.version",
290
290
  ".cowork-flow/run",
291
291
  ".cowork-flow/run.cmd",
292
+ ".cowork-flow/spec/contracts/spec-checks.md",
292
293
  ".cowork-flow/spec/contracts/workflow-state-templates.md"
293
294
  ],
294
295
  "safePrefixes": [
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"
@@ -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)