cowork-flow 0.0.52 → 1.1.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 +36 -0
- package/README.md +12 -2
- package/package.json +1 -1
- package/presets/dsh/plugins/workflow-state.js +25 -15
- package/src/cli.js +6 -0
- package/src/commands/mcp-state.js +66 -0
- package/template/.claude/hooks/inject-workflow-state.py +6 -5
- package/template/.codex/hooks/inject-workflow-state.py +9 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/state.py +121 -0
- package/template/.cowork-flow/scripts/adapters/cli/subagent.py +28 -0
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +33 -2
- package/template/.cowork-flow/scripts/adapters/cli/task_navigation.py +15 -4
- package/template/.cowork-flow/scripts/adapters/cli/task_next_runner.py +20 -3
- package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +9 -0
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +137 -26
- package/template/.cowork-flow/scripts/adapters/mcp/_bootstrap.py +14 -0
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +206 -0
- package/template/.cowork-flow/scripts/run.py +3 -0
- package/template/.cowork-flow/scripts/runtime/session_state.py +44 -15
- package/template/.cowork-flow/scripts/services/fact_view.py +162 -0
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +197 -4
- package/template/.cowork-flow/scripts/services/workflow_runtime.py +45 -0
- package/template/.cowork-flow/spec/contracts/context-injection.md +121 -0
- package/template/.cowork-flow/spec/contracts/fact-layer-access.md +50 -0
- package/template/.opencode/plugins/cowork-flow.js +117 -9
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +136 -28
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +125 -19
- package/template/skills/runtime-health/scripts/doctor.py +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 - 2026-08-29
|
|
4
|
+
|
|
5
|
+
> **版本内容错位说明**:npm registry 上的 1.0.0 tarball 发布于 2026-08-26(仅含发版脚本修复之前的代码)。1.0.0 段下述的里程碑描述以本 1.1.0 为其实际发布载体——阶段 0-3 的全部内容自本版本起进入 npm 分发。
|
|
6
|
+
|
|
7
|
+
### 方向落地(阶段 0-3,详见 1.0.0 段与 docs/direction.md)
|
|
8
|
+
|
|
9
|
+
- 阶段 0:README 定位改写为「运行时上下文与协作事实层」;注入协议契约 `spec/contracts/context-injection.md`;契约指纹序列化三线统一 + slim 全覆盖 + 跨 host 一致性测试。
|
|
10
|
+
- 阶段 1:`run state [task] --json` 事实视图;`<workflow-state>` 属性事实头 + `<decision-anchor>` 决策要点注入(三线一致)。
|
|
11
|
+
- 阶段 2:`executor` 归属、冲突拦截与 `--takeover`、无会话 CI start、`subagent evidence` 证据位。
|
|
12
|
+
- 阶段 3:`run mcp-state` 无依赖 MCP stdio 只读服务(`task_state` / `task_list`)+ `spec/contracts/fact-layer-access.md` 接入契约。
|
|
13
|
+
- MCP 全局入口:`cowork-flow mcp-state` 透传命令——MCP 客户端全局注册一次即可服务所有 cowork-flow 项目。
|
|
14
|
+
|
|
15
|
+
## 1.0.0 - 2026-08-26
|
|
16
|
+
|
|
17
|
+
首个稳定主线发布:核心流程契约、会话模型与宿主矩阵在此版本冻结,后续改动进入语义化版本约束。
|
|
18
|
+
|
|
19
|
+
### 稳定性声明
|
|
20
|
+
|
|
21
|
+
- **宿主矩阵冻结**:`codex` / `opencode` / `claude-code` / `dsh` / `zcode` 五宿主经 host-assets 注册、adapter 一致性校验与 doctor 全链路覆盖;zcode 插件资产与工作区流程资产均为单源分发(`.zcode/` 走插件市场,技能落盘 `.cowork-flow/skills/` 供内核解析且不进入提示层)。
|
|
22
|
+
- **状态注入协议冻结**:`<workflow-state>` 块 + contract-digest(SessionStart 全量 / 逐消息指纹行)+ 生命周期快照(`.runtime/state-snapshot.json`,与状态转换同单元原子提交)构成宿主 hook 的标准输入;`build_hook_context` 共享协议具备直接单测覆盖。
|
|
23
|
+
- **会话模型冻结**:按会话身份(host session id / 显式 context id / hook sessionId)绑定;显式身份无绑定时判 `no_task` 并列可改绑任务,无身份请求才走全局最新有效兜底;进程级 fallback 绑定不再自动跟随。
|
|
24
|
+
|
|
25
|
+
### 自 0.0.52 以来的变更
|
|
26
|
+
|
|
27
|
+
- 会话绑定安全加固:进程 fallback 会话键(`ZCODE_PROCESS_LABEL`)带 provenance 标记,导航、`--run` 派发、review/complete 目标解析与命令行收尾拒绝自动跟随 fallback 绑定,要求显式任务目录;trusted 身份(显式 env、宿主 session env、hook sessionId)保持完整绑定语义。
|
|
28
|
+
- 对抗性审查修正批次:显式 `COWORK_FLOW_CONTEXT_ID` 按裸键解析与 CLI 对齐;PostToolUse 刷新过滤支持 `cd` + 裸 `run` 与 Windows `run.cmd` 命令形态;legacy cursor 宿主保留完整 contract-digest;改绑提示排除已完成的终态任务;doctor 会话卫生检查对无时区时间戳降级为告警而非崩溃。
|
|
29
|
+
- 交互式平台选择器与平台检测断言随五宿主矩阵更新。
|
|
30
|
+
- release.sh 容错:`--version` 精确模式在版本文件已全部就位(干净工作树)时,`git commit` 的 no-op 空提交不再中止脚本,继续 tag 与 publish;其余 commit 失败仍立即中止。新增回归测试覆盖两条路径。
|
|
31
|
+
- release.sh 容错:目标发布 tag 已存在且指向当前 HEAD 时跳过创建并继续 publish(上次运行 tag 后 publish 未完成的重跑场景);指向其它提交则中止报错。新增回归测试覆盖两条路径。
|
|
32
|
+
- 方向收敛(阶段 0):README 定位改为「运行时上下文与协作事实层」;新增注入协议契约 `spec/contracts/context-injection.md`(事件时机矩阵、digest 形态规则、序列化规范);契约指纹序列化三线统一(zcode/opencode 稳定排序 + Python 紧凑分隔符,跨 host 指纹一致性测试锁定);Python 线补 slim(SessionStart 全量 / 后续单行指纹,无事件 host 按会话文件首次判定);codex 事件名读取;opencode 首次全量后续单行;dsh 会话开始全量、生命周期命令后单行刷新。
|
|
33
|
+
- 事实层 API 化(阶段 1a):新增 `./.cowork-flow/run state [task] --json` 事实视图——聚合 task.json(含 `_state` 修订)、decision-anchor 结构化要点(目标/验收项/被拒方案名)、plan 绑定、绑定会话与受信快照;无绑定输出 `task: null` 供机器分支。
|
|
34
|
+
- 注入结构化(阶段 1b):`<workflow-state>` 升级为属性事实头(`task`/`status`/`source` 进开标签,body 保留人读面包屑),三线一致并在协议契约冻结;planning/in_progress/review 状态下三线注入紧凑 `<decision-anchor>` 决策要点块(Python 复用 fact_view 解析单源),completed 终态与缺文件不注入。
|
|
35
|
+
- 多执行者语义(阶段 2):task.json 增加 `executor` 归属(start 写入会话 key 或显式 `--executor`);执行者冲突 fail-closed(`LIFECYCLE-EXECUTOR-001`,幂等重跑同样拦截),`--takeover` 显式接管并覆写归属(含已激活任务的幂等接管);`--executor` 允许无会话 CI/无头 start(不建会话绑定);子代理运行时上下文新增 `evidence` 证据位(`subagent evidence <id> --note [--artifact]`,CAS 保护、closed 可补记、不影响任务状态机);`run state` 人读摘要透出 Executor。
|
|
36
|
+
- 生态适配(阶段 3):新增 `./.cowork-flow/run mcp-state`——无依赖 MCP stdio 只读服务(newline-delimited JSON-RPC 2.0),工具 `task_state`(事实视图)与 `task_list`(活动任务概览);接入契约 `spec/contracts/fact-layer-access.md` 冻结只读保证与"不自创跨 agent 协议、adapter 保持薄"立场,写路径仍独占于 CLI 门禁链。
|
|
37
|
+
- MCP 全局入口:npm CLI 新增 `cowork-flow mcp-state` 透传命令——从 cwd 向上定位最近 `.cowork-flow/` 并以继承 stdio exec 该项目的 `run mcp-state`;MCP 客户端全局注册一次(`cowork-flow mcp-state`)即可服务所有 cowork-flow 项目,无需逐项目配置。
|
|
38
|
+
|
|
3
39
|
## 0.0.52 - 2026-08-26
|
|
4
40
|
|
|
5
41
|
### ZCode 宿主与 hook 体验
|
package/README.md
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# cowork-flow
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> 多主机 Agent 协作的运行时上下文与协作事实层 — 让任何主机上的任何 agent,在任何会话、任何时刻,从同一份权威事实起步。
|
|
4
4
|
|
|
5
5
|
## 一句话
|
|
6
6
|
|
|
7
|
-
**cowork-flow
|
|
7
|
+
**cowork-flow 不写代码,它给 agent 喂状态。** 把 `template/` 复制到目标项目,立刻获得运行时状态注入、任务生命周期、决策记录、规格契约与跨宿主的协作事实一致性。
|
|
8
8
|
|
|
9
9
|
## 当前能力
|
|
10
10
|
|
|
11
11
|
| 领域 | 当前能力 |
|
|
12
12
|
|---|---|
|
|
13
13
|
| 任务流程 | `task next --json` 给出下一步 action,`task next --run` 只执行当前 action。 |
|
|
14
|
+
| 事实接入 | `run state [task] --json` 事实视图;`run mcp-state` 无依赖 MCP 只读服务(`task_state` / `task_list`)供任何 MCP 客户端查询。 |
|
|
14
15
|
| 运行健康 | `doctor` 诊断 runtime、host assets、Skill replica 和任务 hygiene,不推进生命周期。 |
|
|
15
16
|
| Host 分发 | Host Asset Manifest 驱动 Codex / OpenCode / Claude Code / ZCode / DeepSeek Harness 资产和 obsolete 清理。 |
|
|
16
17
|
| 批处理与讨论 | Batch 发布 Host action;Party Mode 只输出 advisory final facts。 |
|
|
@@ -24,6 +25,7 @@
|
|
|
24
25
|
| 理解任务如何流转 | [任务流程](#任务流程) |
|
|
25
26
|
| 处理故障或漂移 | [支持与故障诊断](#支持与故障诊断) |
|
|
26
27
|
| 做发布前检查 | [发布](#发布)、[`CHANGELOG.md`](CHANGELOG.md) |
|
|
28
|
+
| 了解项目方向与演进路线 | [`docs/direction.md`](docs/direction.md) |
|
|
27
29
|
| 接入到自己项目 | [接入原则](#接入原则) |
|
|
28
30
|
|
|
29
31
|
## 适用 / 不适用
|
|
@@ -64,6 +66,10 @@ cowork-flow install-dsh-preset
|
|
|
64
66
|
# 机器级安装实时 workflow-state 注入(推荐:不换预设,任意 DSH 会话生效)
|
|
65
67
|
cowork-flow install-dsh-hook
|
|
66
68
|
|
|
69
|
+
# MCP 客户端接入(可选):全局注册一次,任意项目查询任务事实
|
|
70
|
+
# 各客户端配置样例见 docs/mcp-client-setup.md
|
|
71
|
+
cowork-flow mcp-state
|
|
72
|
+
|
|
67
73
|
# 维护者发布前检查
|
|
68
74
|
npm run release:check
|
|
69
75
|
```
|
|
@@ -124,6 +130,7 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
|
|
|
124
130
|
| `install-dsh-preset` | 安装 DSH agent 预设到 `~/.dsh/.agent-presets/cowork-flow/`(整套 agent,可选) |
|
|
125
131
|
| `install-dsh-hook` | 机器级注册 workflow-state hook 组合行到 `$DSH_HOME/cordis.patch.yml`(当前 DSH 的 agent 提示不收集 host 层 section,实时注入请用预设方式) |
|
|
126
132
|
| `update [--dry-run]` | 升级 CLI 本身 |
|
|
133
|
+
| `mcp-state` | 全局 MCP 事实入口:从 cwd 向上定位项目运行时并透传 `run mcp-state`(全局注册一次,所有 cowork-flow 项目通用) |
|
|
127
134
|
|
|
128
135
|
### init 选项
|
|
129
136
|
|
|
@@ -314,6 +321,7 @@ npm run release:check # 发布信心门禁;当前等价于 test:all
|
|
|
314
321
|
```bash
|
|
315
322
|
npm run release # patch
|
|
316
323
|
npm run release -- minor # minor
|
|
324
|
+
npm run release -- --version 0.1.0 # 精确发布指定版本(跳过自动 bump)
|
|
317
325
|
```
|
|
318
326
|
|
|
319
327
|
**发布流程:**
|
|
@@ -324,6 +332,8 @@ npm run release -- minor # minor
|
|
|
324
332
|
5. `git commit` + `git tag`
|
|
325
333
|
6. `npm publish`
|
|
326
334
|
|
|
335
|
+
**CI 发布通道(推荐):** `scripts/release.sh --version <v>` 打好 tag 后,`gh release create v<v>` 触发 `.github/workflows/publish.yml`——Ubuntu/Windows 双平台全量门禁通过后自动 `npm publish`(需仓库 secret `NPM_TOKEN`,权限:publish)。
|
|
336
|
+
|
|
327
337
|
- 发布说明维护在 `CHANGELOG.md`;发布前更新当前版本段落,并保留 `release:check` 和 `git diff --check` 证据。
|
|
328
338
|
|
|
329
339
|
CI 的 PR 同时运行 Ubuntu core 与 Windows core;发布工作流要求 Ubuntu 与 Windows full verification 均成功后才执行 publish。测试 job 不接触 `NPM_TOKEN`,仅 publish job 使用该 secret。
|
package/package.json
CHANGED
|
@@ -51,6 +51,7 @@ import sys
|
|
|
51
51
|
from pathlib import Path
|
|
52
52
|
|
|
53
53
|
cwd = sys.argv[1]
|
|
54
|
+
full_digest = len(sys.argv) > 2 and sys.argv[2] != "0"
|
|
54
55
|
current = Path(cwd).resolve()
|
|
55
56
|
root = None
|
|
56
57
|
while True:
|
|
@@ -77,6 +78,7 @@ print(build_hook_context(
|
|
|
77
78
|
"runtime_context_identity: formal subagent sessions bind before workflow-state injection\\n"
|
|
78
79
|
"</dsh-runtime>",
|
|
79
80
|
),
|
|
81
|
+
session_start=full_digest,
|
|
80
82
|
))
|
|
81
83
|
`;
|
|
82
84
|
|
|
@@ -127,11 +129,11 @@ function discoverCandidates() {
|
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
|
|
130
|
-
function tryPython(candidate, cwd) {
|
|
132
|
+
function tryPython(candidate, cwd, full = true) {
|
|
131
133
|
return new Promise((resolve) => {
|
|
132
134
|
execFile(
|
|
133
135
|
candidate.command,
|
|
134
|
-
[...candidate.args, '-c', PYTHON_PROTOCOL, cwd],
|
|
136
|
+
[...candidate.args, '-c', PYTHON_PROTOCOL, cwd, full ? '1' : '0'],
|
|
135
137
|
EXEC_OPTIONS,
|
|
136
138
|
(error, stdout) => {
|
|
137
139
|
if (error) {
|
|
@@ -192,10 +194,12 @@ export async function findCoworkRoot(cwd) {
|
|
|
192
194
|
|
|
193
195
|
/**
|
|
194
196
|
* Produce the `<workflow-state>` hook context block for a workspace cwd.
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
+
* `full` selects the digest shape: true (default) for the full contract
|
|
198
|
+
* block on session-start-like refreshes, false for the single fingerprint
|
|
199
|
+
* line on intra-turn refreshes. Empty string means "contribute nothing"
|
|
200
|
+
* (no root, hooks disabled, no usable interpreter, or a broken runtime).
|
|
197
201
|
*/
|
|
198
|
-
export async function runWorkflowState(cwd) {
|
|
202
|
+
export async function runWorkflowState(cwd, full = true) {
|
|
199
203
|
if (hooksDisabled() || !cwd) {
|
|
200
204
|
return '';
|
|
201
205
|
}
|
|
@@ -212,14 +216,14 @@ export async function runWorkflowState(cwd) {
|
|
|
212
216
|
return '';
|
|
213
217
|
}
|
|
214
218
|
if (workingPython !== null) {
|
|
215
|
-
const result = await tryPython(workingPython, cwd);
|
|
219
|
+
const result = await tryPython(workingPython, cwd, full);
|
|
216
220
|
if (!result.missing) {
|
|
217
221
|
return normalize(result.output);
|
|
218
222
|
}
|
|
219
223
|
workingPython = null;
|
|
220
224
|
}
|
|
221
225
|
for (const candidate of discoverCandidates()) {
|
|
222
|
-
const result = await tryPython(candidate, cwd);
|
|
226
|
+
const result = await tryPython(candidate, cwd, full);
|
|
223
227
|
if (result.missing) {
|
|
224
228
|
continue;
|
|
225
229
|
}
|
|
@@ -231,11 +235,15 @@ export async function runWorkflowState(cwd) {
|
|
|
231
235
|
|
|
232
236
|
|
|
233
237
|
export function apply(ctx) {
|
|
234
|
-
// One cache entry per agent: { text, inflight, queued }.
|
|
235
|
-
//
|
|
238
|
+
// One cache entry per agent: { text, inflight, queued, full }.
|
|
239
|
+
// `full` records the digest shape the next refresh should use: session
|
|
240
|
+
// starts and inbox claims re-inject the full contract block, intra-turn
|
|
241
|
+
// refreshes after lifecycle commands only repeat the fingerprint line.
|
|
242
|
+
// The WeakMap key is the live Agent object the events and the assembly
|
|
243
|
+
// context both carry.
|
|
236
244
|
const states = new WeakMap();
|
|
237
245
|
|
|
238
|
-
const refresh = (agent) => {
|
|
246
|
+
const refresh = (agent, full = true) => {
|
|
239
247
|
const cwd = agent && agent.cwd;
|
|
240
248
|
if (!cwd) {
|
|
241
249
|
return;
|
|
@@ -245,6 +253,7 @@ export function apply(ctx) {
|
|
|
245
253
|
entry = { text: '', inflight: false, queued: false };
|
|
246
254
|
states.set(agent, entry);
|
|
247
255
|
}
|
|
256
|
+
entry.full = full;
|
|
248
257
|
if (entry.inflight) {
|
|
249
258
|
// A refresh arrived while one was running (e.g. several lifecycle
|
|
250
259
|
// commands back to back). Re-run once after it settles so the latest
|
|
@@ -253,7 +262,7 @@ export function apply(ctx) {
|
|
|
253
262
|
return;
|
|
254
263
|
}
|
|
255
264
|
entry.inflight = true;
|
|
256
|
-
runWorkflowState(cwd)
|
|
265
|
+
runWorkflowState(cwd, entry.full)
|
|
257
266
|
.then((text) => {
|
|
258
267
|
entry.text = text;
|
|
259
268
|
})
|
|
@@ -264,21 +273,22 @@ export function apply(ctx) {
|
|
|
264
273
|
entry.inflight = false;
|
|
265
274
|
if (entry.queued) {
|
|
266
275
|
entry.queued = false;
|
|
267
|
-
refresh(agent);
|
|
276
|
+
refresh(agent, entry.full);
|
|
268
277
|
}
|
|
269
278
|
});
|
|
270
279
|
};
|
|
271
280
|
|
|
272
|
-
ctx.on('agent/session-start', (payload) => refresh(payload && payload.agent));
|
|
273
|
-
ctx.on('agent/inbox/claimed', (payload) => refresh(payload && payload.agent));
|
|
281
|
+
ctx.on('agent/session-start', (payload) => refresh(payload && payload.agent, true));
|
|
282
|
+
ctx.on('agent/inbox/claimed', (payload) => refresh(payload && payload.agent, true));
|
|
274
283
|
// Intra-turn refresh: a lifecycle command just settled, so the next prompt
|
|
275
284
|
// assembly should already see the new state instead of the stale block.
|
|
285
|
+
// Same-session refresh, so only the fingerprint line is repeated.
|
|
276
286
|
ctx.on('tools/result', (exec) => {
|
|
277
287
|
const agent = exec && exec.agent;
|
|
278
288
|
if (!agent || !isLifecycleCommand(exec && exec.arguments)) {
|
|
279
289
|
return;
|
|
280
290
|
}
|
|
281
|
-
refresh(agent);
|
|
291
|
+
refresh(agent, false);
|
|
282
292
|
});
|
|
283
293
|
|
|
284
294
|
ctx.effect(() => ctx.systemPrompt.section({
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { runInit } from './commands/init.js';
|
|
|
6
6
|
import { runInstallDshHook } from './commands/install-dsh-hook.js';
|
|
7
7
|
import { runInstallDshPreset } from './commands/install-dsh-preset.js';
|
|
8
8
|
import { runInstallZCodePlugin } from './commands/install-zcode-plugin.js';
|
|
9
|
+
import { runMcpState } from './commands/mcp-state.js';
|
|
9
10
|
import { runSourceRefresh } from './commands/source-refresh.js';
|
|
10
11
|
import { runSync } from './commands/sync.js';
|
|
11
12
|
import { runUpdate } from './commands/update.js';
|
|
@@ -21,6 +22,7 @@ Usage:
|
|
|
21
22
|
cowork-flow update
|
|
22
23
|
cowork-flow sync [target] [--dry-run] [--force]
|
|
23
24
|
cowork-flow source-refresh [target] [--dry-run]
|
|
25
|
+
cowork-flow mcp-state
|
|
24
26
|
cowork-flow --version
|
|
25
27
|
cowork-flow --help
|
|
26
28
|
`;
|
|
@@ -199,6 +201,10 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|
|
199
201
|
return await runInstallDshHook(args);
|
|
200
202
|
}
|
|
201
203
|
|
|
204
|
+
if (command === 'mcp-state') {
|
|
205
|
+
return await runMcpState(args);
|
|
206
|
+
}
|
|
207
|
+
|
|
202
208
|
io.writeErr(`Unknown command: ${command}\n`);
|
|
203
209
|
return 1;
|
|
204
210
|
} catch (error) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DIR_WORKFLOW = '.cowork-flow';
|
|
6
|
+
|
|
7
|
+
function defaultIo() {
|
|
8
|
+
return {
|
|
9
|
+
writeOut(message) {
|
|
10
|
+
process.stdout.write(message);
|
|
11
|
+
},
|
|
12
|
+
writeErr(message) {
|
|
13
|
+
process.stderr.write(message);
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function findNearestWorkflowRoot(startDir) {
|
|
19
|
+
let current = resolve(startDir);
|
|
20
|
+
// Same semantics as the MCP server's root resolution (get_repo_root):
|
|
21
|
+
// walk up to the nearest directory containing .cowork-flow/, so a client
|
|
22
|
+
// launching the globally registered server inside any project subdirectory
|
|
23
|
+
// still lands on the right facts.
|
|
24
|
+
for (;;) {
|
|
25
|
+
if (existsSync(join(current, DIR_WORKFLOW))) {
|
|
26
|
+
return current;
|
|
27
|
+
}
|
|
28
|
+
const parent = resolve(current, '..');
|
|
29
|
+
if (parent === current) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
current = parent;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function runMcpState(args, options = {}) {
|
|
37
|
+
const io = options.io ?? defaultIo(); const root = findNearestWorkflowRoot(process.cwd());
|
|
38
|
+
if (root === null) {
|
|
39
|
+
io.writeErr(
|
|
40
|
+
'Error: no .cowork-flow/ found from the current directory; '
|
|
41
|
+
+ 'run this inside a cowork-flow project.\n'
|
|
42
|
+
);
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const runnerName = process.platform === 'win32' ? 'run.cmd' : 'run';
|
|
47
|
+
const runner = join(root, DIR_WORKFLOW, runnerName);
|
|
48
|
+
if (!existsSync(runner)) {
|
|
49
|
+
io.writeErr(`Error: workflow runner not found: ${runner}\n`);
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return await new Promise((resolveExit) => {
|
|
54
|
+
const child = spawn(runner, ['mcp-state', ...args], {
|
|
55
|
+
stdio: 'inherit',
|
|
56
|
+
cwd: root,
|
|
57
|
+
});
|
|
58
|
+
child.on('error', (error) => {
|
|
59
|
+
io.writeErr(`Error: failed to launch ${runner}: ${error.message}\n`);
|
|
60
|
+
resolveExit(1);
|
|
61
|
+
});
|
|
62
|
+
child.on('exit', (code) => {
|
|
63
|
+
resolveExit(code ?? 1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -63,6 +63,11 @@ def main() -> int:
|
|
|
63
63
|
sys.path.insert(0, str(scripts_dir))
|
|
64
64
|
from adapters.host.workflow_state_hook import build_hook_context
|
|
65
65
|
|
|
66
|
+
event_name = (
|
|
67
|
+
hook_input.get("hook_event_name")
|
|
68
|
+
or hook_input.get("hookEventName")
|
|
69
|
+
or "UserPromptSubmit"
|
|
70
|
+
)
|
|
66
71
|
context = build_hook_context(
|
|
67
72
|
root,
|
|
68
73
|
hook_input,
|
|
@@ -75,11 +80,7 @@ def main() -> int:
|
|
|
75
80
|
"</claude-code-runtime>"
|
|
76
81
|
),
|
|
77
82
|
),
|
|
78
|
-
|
|
79
|
-
event_name = (
|
|
80
|
-
hook_input.get("hook_event_name")
|
|
81
|
-
or hook_input.get("hookEventName")
|
|
82
|
-
or "UserPromptSubmit"
|
|
83
|
+
session_start=(event_name == "SessionStart"),
|
|
83
84
|
)
|
|
84
85
|
print(
|
|
85
86
|
json.dumps(
|
|
@@ -61,6 +61,11 @@ def main() -> int:
|
|
|
61
61
|
)
|
|
62
62
|
|
|
63
63
|
dispatch_mode = codex_dispatch_mode(root)
|
|
64
|
+
event_name = (
|
|
65
|
+
hook_input.get("hook_event_name")
|
|
66
|
+
or hook_input.get("hookEventName")
|
|
67
|
+
or "UserPromptSubmit"
|
|
68
|
+
)
|
|
64
69
|
context = build_hook_context(
|
|
65
70
|
root,
|
|
66
71
|
hook_input,
|
|
@@ -77,12 +82,15 @@ def main() -> int:
|
|
|
77
82
|
"</codex-runtime>"
|
|
78
83
|
),
|
|
79
84
|
),
|
|
85
|
+
# Codex registers UserPromptSubmit only, so event_name carries no
|
|
86
|
+
# session-start signal; build_hook_context falls back to the session
|
|
87
|
+
# state file probe (full on first injection, slim afterwards).
|
|
80
88
|
)
|
|
81
89
|
print(
|
|
82
90
|
json.dumps(
|
|
83
91
|
{
|
|
84
92
|
"hookSpecificOutput": {
|
|
85
|
-
"hookEventName":
|
|
93
|
+
"hookEventName": event_name,
|
|
86
94
|
"additionalContext": context,
|
|
87
95
|
}
|
|
88
96
|
},
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
1.1.0
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""`run state [task] [--json]` — the fact-layer read entry.
|
|
4
|
+
|
|
5
|
+
Assembles one machine-readable fact view per task (task.json, decision-anchor
|
|
6
|
+
essentials, plan binding, bound sessions, trusted snapshot). See
|
|
7
|
+
services/fact_view.py and docs/direction.md stage 1.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
if __package__:
|
|
18
|
+
from . import _bootstrap as _bootstrap # noqa: F401
|
|
19
|
+
else:
|
|
20
|
+
import _bootstrap # noqa: F401
|
|
21
|
+
|
|
22
|
+
from infra.paths import get_repo_root
|
|
23
|
+
from runtime.session_state import get_active_task
|
|
24
|
+
from services.fact_view import build_fact_view
|
|
25
|
+
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _human_summary(view: dict) -> str:
|
|
29
|
+
task = view.get("task") or {}
|
|
30
|
+
lines = [
|
|
31
|
+
f"Task: {view.get('taskPath')}",
|
|
32
|
+
f"Status: {task.get('status')}",
|
|
33
|
+
]
|
|
34
|
+
executor = task.get("executor")
|
|
35
|
+
if executor:
|
|
36
|
+
lines.append(f"Executor: {executor}")
|
|
37
|
+
anchor = view.get("decisionAnchor") or {}
|
|
38
|
+
if anchor.get("exists"):
|
|
39
|
+
goal = anchor.get("goal") or ""
|
|
40
|
+
lines.append(f"Goal: {goal.splitlines()[0] if goal else ''}")
|
|
41
|
+
criteria = anchor.get("acceptanceCriteria") or []
|
|
42
|
+
lines.append(f"Acceptance criteria: {len(criteria)}")
|
|
43
|
+
rejected = anchor.get("rejectedOptions") or []
|
|
44
|
+
if rejected:
|
|
45
|
+
lines.append(f"Rejected options: {len(rejected)}")
|
|
46
|
+
plan = view.get("plan") or {}
|
|
47
|
+
if plan.get("bound"):
|
|
48
|
+
lines.append(f"Plan: {plan.get('file')}")
|
|
49
|
+
sessions = view.get("sessions") or []
|
|
50
|
+
lines.append(f"Bound sessions: {len(sessions)}")
|
|
51
|
+
snapshot = view.get("snapshot")
|
|
52
|
+
lines.append(
|
|
53
|
+
f"Snapshot: {snapshot.get('status') if snapshot else 'none'}"
|
|
54
|
+
)
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main() -> int:
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
prog="state",
|
|
61
|
+
description="Show the fact view for a task (machine-readable with --json).",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"task",
|
|
65
|
+
nargs="?",
|
|
66
|
+
default=None,
|
|
67
|
+
help="Task directory or name; defaults to the session-bound active task.",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--json",
|
|
71
|
+
action="store_true",
|
|
72
|
+
dest="as_json",
|
|
73
|
+
help="Print the fact view as JSON.",
|
|
74
|
+
)
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
repo_root = get_repo_root()
|
|
78
|
+
target = args.task
|
|
79
|
+
if target is None:
|
|
80
|
+
active = get_active_task(repo_root)
|
|
81
|
+
target = active.task_path
|
|
82
|
+
if not target:
|
|
83
|
+
if args.as_json:
|
|
84
|
+
print(
|
|
85
|
+
json.dumps(
|
|
86
|
+
{
|
|
87
|
+
"schemaVersion": 1,
|
|
88
|
+
"task": None,
|
|
89
|
+
"reason": "no-active-task",
|
|
90
|
+
},
|
|
91
|
+
ensure_ascii=False,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
return 0
|
|
95
|
+
print(
|
|
96
|
+
"No active task bound to this session; pass a task directory "
|
|
97
|
+
"or name.",
|
|
98
|
+
file=sys.stderr,
|
|
99
|
+
)
|
|
100
|
+
return 1
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
task_dir = TaskRepository(repo_root).resolve(target)
|
|
104
|
+
except TaskRepositoryError as error:
|
|
105
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
if not (Path(task_dir) / "task.json").is_file():
|
|
109
|
+
print(f"Error: task.json not found under {task_dir}", file=sys.stderr)
|
|
110
|
+
return 1
|
|
111
|
+
|
|
112
|
+
view = build_fact_view(repo_root, Path(task_dir))
|
|
113
|
+
if args.as_json:
|
|
114
|
+
print(json.dumps(view, ensure_ascii=False, indent=2))
|
|
115
|
+
else:
|
|
116
|
+
print(_human_summary(view))
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(main())
|
|
@@ -273,6 +273,25 @@ def cmd_bind(args: argparse.Namespace) -> int:
|
|
|
273
273
|
return 0
|
|
274
274
|
|
|
275
275
|
|
|
276
|
+
def cmd_evidence(args: argparse.Namespace) -> int:
|
|
277
|
+
repo_root = get_repo_root()
|
|
278
|
+
try:
|
|
279
|
+
context = RuntimeContextService(repo_root).evidence(
|
|
280
|
+
args.subagent_id,
|
|
281
|
+
note=args.note,
|
|
282
|
+
artifact=getattr(args, "artifact", None),
|
|
283
|
+
)
|
|
284
|
+
except RuntimeContextError as error:
|
|
285
|
+
_print_runtime_error(error)
|
|
286
|
+
return 1
|
|
287
|
+
if context is None:
|
|
288
|
+
print(f"Error: subagent not found: {args.subagent_id}", file=sys.stderr)
|
|
289
|
+
return 1
|
|
290
|
+
entries = context.get("evidence") or []
|
|
291
|
+
print(f"subagent {args.subagent_id} evidence entries={len(entries)}")
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
|
|
276
295
|
def cmd_close(args: argparse.Namespace) -> int:
|
|
277
296
|
repo_root = get_repo_root()
|
|
278
297
|
try:
|
|
@@ -322,6 +341,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
322
341
|
bind.add_argument("context_key")
|
|
323
342
|
bind.set_defaults(func=cmd_bind)
|
|
324
343
|
|
|
344
|
+
evidence = subparsers.add_parser(
|
|
345
|
+
"evidence",
|
|
346
|
+
help="Append an evidence entry to a runtime context (does not move task status)",
|
|
347
|
+
)
|
|
348
|
+
evidence.add_argument("subagent_id")
|
|
349
|
+
evidence.add_argument("--note", required=True)
|
|
350
|
+
evidence.add_argument("--artifact", default=None)
|
|
351
|
+
evidence.set_defaults(func=cmd_evidence)
|
|
352
|
+
|
|
325
353
|
close = subparsers.add_parser("close", help="Close subagent runtime context")
|
|
326
354
|
close.add_argument("subagent_id")
|
|
327
355
|
close.set_defaults(func=cmd_close)
|
|
@@ -26,7 +26,11 @@ from infra.paths import (
|
|
|
26
26
|
FILE_TASK_JSON,
|
|
27
27
|
get_repo_root,
|
|
28
28
|
)
|
|
29
|
-
from runtime.session_state import
|
|
29
|
+
from runtime.session_state import (
|
|
30
|
+
PROVENANCE_PROCESS_FALLBACK,
|
|
31
|
+
clear_active_task,
|
|
32
|
+
get_active_task,
|
|
33
|
+
)
|
|
30
34
|
|
|
31
35
|
|
|
32
36
|
def _report_check_block(title: str, blockers: tuple[str, ...]) -> int:
|
|
@@ -71,6 +75,19 @@ def _resolve_status_task_dir(
|
|
|
71
75
|
file=sys.stderr,
|
|
72
76
|
)
|
|
73
77
|
return None
|
|
78
|
+
if (
|
|
79
|
+
active.task_path
|
|
80
|
+
and active.provenance == PROVENANCE_PROCESS_FALLBACK
|
|
81
|
+
):
|
|
82
|
+
print(
|
|
83
|
+
colored(
|
|
84
|
+
"Error: Session binding is process-fallback (shared); "
|
|
85
|
+
"pass a task dir explicitly.",
|
|
86
|
+
Colors.RED,
|
|
87
|
+
),
|
|
88
|
+
file=sys.stderr,
|
|
89
|
+
)
|
|
90
|
+
return None
|
|
74
91
|
if not active.task_path:
|
|
75
92
|
print(
|
|
76
93
|
colored(
|
|
@@ -249,7 +266,11 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
249
266
|
if getattr(args, "auto", False):
|
|
250
267
|
return _run_auto_start(args, full_path, service)
|
|
251
268
|
|
|
252
|
-
result = service.start(
|
|
269
|
+
result = service.start(
|
|
270
|
+
full_path,
|
|
271
|
+
executor=getattr(args, "executor", None),
|
|
272
|
+
takeover=bool(getattr(args, "takeover", False)),
|
|
273
|
+
)
|
|
253
274
|
if not result.ok:
|
|
254
275
|
return _report_start_failure(result)
|
|
255
276
|
|
|
@@ -342,6 +363,16 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
342
363
|
"""Clear the active task for this session."""
|
|
343
364
|
repo_root = get_repo_root()
|
|
344
365
|
active = get_active_task(repo_root)
|
|
366
|
+
if active.task_path and active.provenance == PROVENANCE_PROCESS_FALLBACK:
|
|
367
|
+
print(
|
|
368
|
+
colored(
|
|
369
|
+
"Error: Refusing to clear a session binding resolved from "
|
|
370
|
+
"process-fallback identity; archive the task to clear it.",
|
|
371
|
+
Colors.RED,
|
|
372
|
+
),
|
|
373
|
+
file=sys.stderr,
|
|
374
|
+
)
|
|
375
|
+
return 1
|
|
345
376
|
if not active.task_path:
|
|
346
377
|
print(
|
|
347
378
|
colored(
|
|
@@ -10,7 +10,11 @@ from services.task_context import TaskContextService
|
|
|
10
10
|
from adapters.cli.task_support import resolve_task_dir
|
|
11
11
|
from adapters.cli.execution_context_args import execution_context_from_namespace
|
|
12
12
|
from infra.paths import get_repo_root
|
|
13
|
-
from runtime.session_state import
|
|
13
|
+
from runtime.session_state import (
|
|
14
|
+
FALLBACK_BINDING_BLOCKER,
|
|
15
|
+
PROVENANCE_PROCESS_FALLBACK,
|
|
16
|
+
get_active_task,
|
|
17
|
+
)
|
|
14
18
|
from services.readiness import task_readiness_blockers
|
|
15
19
|
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
16
20
|
|
|
@@ -359,13 +363,20 @@ def _navigation_target(args, repo_root: Path, structured: bool):
|
|
|
359
363
|
|
|
360
364
|
active = get_active_task(repo_root)
|
|
361
365
|
source = f"{active.source}:{active.context_key or '-'}"
|
|
362
|
-
|
|
366
|
+
# 进程标签回退身份是同实例所有会话共享的;其绑定只作 advisory,
|
|
367
|
+
# 不得自动定位任务(否则他会话绑定的任务会被当作本会话任务)。
|
|
368
|
+
untrusted_binding = (
|
|
369
|
+
bool(active.task_path)
|
|
370
|
+
and active.provenance == PROVENANCE_PROCESS_FALLBACK
|
|
371
|
+
)
|
|
372
|
+
if active.task_path and not untrusted_binding:
|
|
363
373
|
return repo_root / active.task_path, active.task_path, source, True
|
|
374
|
+
blockers = [FALLBACK_BINDING_BLOCKER] if untrusted_binding else []
|
|
364
375
|
if structured:
|
|
365
376
|
payload = build_navigation_payload(
|
|
366
377
|
args=args,
|
|
367
378
|
status="no_task",
|
|
368
|
-
blockers=
|
|
379
|
+
blockers=blockers,
|
|
369
380
|
active_target=False,
|
|
370
381
|
task_path=None,
|
|
371
382
|
repo_root=repo_root,
|
|
@@ -379,7 +390,7 @@ def _navigation_target(args, repo_root: Path, structured: bool):
|
|
|
379
390
|
args=args,
|
|
380
391
|
status="no_task",
|
|
381
392
|
task_path=None,
|
|
382
|
-
blockers=
|
|
393
|
+
blockers=blockers,
|
|
383
394
|
active_target=False,
|
|
384
395
|
repo_root=repo_root,
|
|
385
396
|
)
|