cowork-flow 1.2.0 → 1.3.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.
Files changed (33) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +4 -3
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/inject-workflow-state.py +12 -98
  5. package/template/.codex/hooks/inject-workflow-state.py +13 -89
  6. package/template/.cowork-flow/.version +1 -1
  7. package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
  8. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
  9. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +23 -0
  10. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
  11. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
  12. package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
  13. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +280 -35
  14. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +230 -0
  15. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +2 -5
  16. package/template/.cowork-flow/scripts/runtime/session_state.py +19 -9
  17. package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
  18. package/template/.cowork-flow/scripts/services/fact_view.py +14 -0
  19. package/template/.cowork-flow/scripts/services/spec_check.py +34 -12
  20. package/template/.cowork-flow/scripts/services/task_context.py +0 -1
  21. package/template/.cowork-flow/spec/contracts/context-injection.md +24 -20
  22. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
  23. package/template/.cowork-flow/spec/contracts/spec-checks.md +40 -8
  24. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
  25. package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
  26. package/template/.opencode/plugins/cowork-flow.js +12 -0
  27. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  28. package/template/.zcode/hooks/inject-context.js +95 -971
  29. package/template/skills/agent-dispatch/SKILL.md +15 -0
  30. package/template/skills/runtime-health/scripts/doctor.py +90 -0
  31. package/template/skills/task-planning/SKILL.md +1 -0
  32. package/template/skills/task-review/SKILL.md +1 -0
  33. package/template/.zcode/hooks/inject-context.selfcheck.mjs +0 -795
package/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.3.0 - 2026-09-09
4
+
5
+ ### 跨宿主适配通用化(注入单源化 + MCP 重定位)
6
+
7
+ 注入事实逻辑收拢到单一 Python 源,MCP 定位收口为"外部/无 hook 宿主的通用 pull 通道":
8
+
9
+ - **宿主中立入口 `inject.py`**:zcode shim、claude-code、codex 全部走同一入口(`--host` 选择信封与形状),wrapper 瘦身为 ≤20 行委托。digest policy 措辞、registry-warning 抑制(zcode)、preamble、信封缩进全部按 host 参数化,仍由 context-injection.md 契约锁定。
10
+ - **zcode shim 化**:`inject-context.js` 从 ~1000 行事实镜像降为 ~140 行传输层——事件路由、PostToolUse(Bash) 廉价过滤(非生命周期命令零 spawn)、解释器定位(项目 runtime 优先、插件缓存副本兜底)、stdin/stdout 转发。删除 `inject-context.selfcheck.mjs`。matrix 逐字相等测试中的 zcode 线改为经 shim 驱动单源。
11
+ - **每宿主策略模块**:`adapters/host` 拆出 zcode/claude_code/codex policy(digest 措辞、警告静默、rebind 提示、essential-files、PostToolUse 传输、emit 格式化),共享层与入口零 `host ==` 分支;无模块宿主(dsh)走中立默认。字节级契约不变(fingerprint、stage-contract matrix 绿)。
12
+ - **JS-only 行为移植进 Python 单源**:`formatRebindHints`(zcode 无任务体补活动任务列表)、`editScopeWarning`(逐文件 scope 白名单警告,与 spec 警告合并进同一载荷)、missing-task 统一文案(原 "stale" 通用回退改为明确指向任务目录不存在 + 建任务指引,全宿主生效)、`checkEssentialFiles` 缺文件警告(zcode)、无会话身份时的 newest-session 显示回退(zcode,仅显示不改写绑定语义)。
13
+ - **MCP 重定位**:`task_scope` / `task_specs` 补 CLI 同源事实命令(`task scope` / `task specs`),MCP 不再有独占能力;contract-registry 注册 `FACT_LAYER_ACCESS_V1` 契约(digest 行注入"MCP 优先查询事实,CLI 兜底");doctor 新增 MCP 注册健康项(项目级 .mcp.json 存在性 / 全局+项目双重注册提示 / 缺失提示),只读 advisory 不阻断;README 增加两档注册说明(全局受支持默认 + 项目 opt-in 地图)。
14
+ - **修复(1.2.0 遗留,Windows)**:`spec_check._run_command` 三处——含引号命令经 list2cmdline 转义后 cmd.exe 解析失败;`text=True` 未显式 `encoding="utf-8", errors="replace"`,中文 Windows cmd GBK 输出致解码线程崩溃、输出丢失;命令入口缺失在 Windows 被误分类为 violation(现经 `shutil.which` 归 unchecked,契约三态语义恢复一致)。
15
+ - **修复**:legacy runtime context 文件(缺 `runtime_context_id` 字段)注入显示回退到检测到的 id。
16
+
17
+ **行为变更**:delegated 绑定写语义统一为 Python(zcode 注入时新增 bind 写,原 JS 为只读);zcode missing-task 文案与全宿主统一;digest/fingerprint 因注册 `FACT_LAYER_ACCESS_V1` 而变化(预期内,change guard 允许)。
18
+
19
+ ### 实弹修正与 delegated 自检协议化(fix)
20
+
21
+ 实弹验证(zcode 插件 live proof)暴露四个缺陷并修复:
22
+
23
+ - **未绑定会话的编辑期警告可达**:zcode hook 会话携带对话自身 id、从不绑定(激活发生在 Bash CLI 的显式身份下),empty-session 此前跳过 newest-session 回退——主会话的编辑期 spec/scope 警告完全静默。回退现覆盖 empty-session,`spec_edit_warning` 的 zcode 路由经它放行(claude-code/codex 保持严格身份)。
24
+ - **scope 路径相对化**:`edit_scope_warning` 此前拿原始绝对路径与仓库相对白名单比对,所有编辑都被误标越界;现与 spec 路径同样归一为仓库相对,警告行显示相对路径。
25
+ - **编辑期预算与节流加固**:edit 钳制 3s→2.5s(子进程预算余量);节流只在跑完一次后写入(执行器崩溃不再吞掉节流窗口);`files` 多值列表逐 token 剥引号。
26
+ - **delegated 子代理收到 spec 反馈**:编辑期 spec 警告的静默范围此前在 Python/JS 两侧不一致,现统一为 delegated 会话可达(scope 警告保持仅主会话)。
27
+
28
+ 配套协议化(文档与 skill):实弹两轮证实宿主 PostToolUse 只覆盖主会话流——Agent 子代理收不到编辑期反馈。按已验证的 CLI 拉取路径固化:子代理报告前跑 `run spec-check` 自检修复(subagent-dispatch.md、agent-dispatch SKILL);父侧验收把 violation 作为验收阻断项(task-review SKILL,delegated 工作最早的可靠检查点);task-planning 的实现 Verify 命令默认含 spec-check 自检;spec-checks.md 记录缺口、四部分补偿与治理方向(向宿主请求;原生的那一环降级为冗余保险)。
29
+
30
+ **已知合法副作用**:contract fingerprint 更新为 `d1d0e2536e8fa150`(内容派生;Python 与 JS 镜像重算一致)。
31
+
32
+ ### 发布门禁加固(release.sh)
33
+
34
+ - 同步 `--force` 自实例镜像时带 AGENTS.md 定制守卫(脏树预检、sync 后从 HEAD 还原);pytest services 门禁前置到 `test:all` 之前;`.gitattributes` 将 `*.version` 钉为 LF(fingerprint 字节比对前提)。
35
+
3
36
  ## 1.2.0 - 2026-09-08
4
37
 
5
38
  ### 规范挂命令(spec 约束前移)
package/README.md CHANGED
@@ -11,7 +11,7 @@
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
+ | 事实接入 | `run state [task] --json` 事实视图;`run mcp-state` 无依赖 MCP 只读服务(`task_state` / `task_list` / `task_scope` / `task_specs`)供任何 MCP 客户端查询;`task scope` / `task specs` 为 CLI 同源事实命令。 |
15
15
  | 运行健康 | `doctor` 诊断 runtime、host assets、Skill replica 和任务 hygiene,不推进生命周期。 |
16
16
  | Host 分发 | Host Asset Manifest 驱动 Codex / OpenCode / Claude Code / ZCode / DeepSeek Harness 资产和 obsolete 清理。 |
17
17
  | 批处理与讨论 | Batch 发布 Host action;Party Mode 只输出 advisory final facts。 |
@@ -66,8 +66,9 @@ cowork-flow install-dsh-preset
66
66
  # 机器级安装实时 workflow-state 注入(推荐:不换预设,任意 DSH 会话生效)
67
67
  cowork-flow install-dsh-hook
68
68
 
69
- # MCP 客户端接入(可选):全局注册一次,任意项目查询任务事实
70
- # 各客户端配置样例见 docs/mcp-client-setup.md
69
+ # MCP 客户端接入(可选):全局注册一次,任意项目查询任务事实;
70
+ # 项目级 opt-in(如 claude-code 的 .mcp.json)与注册健康检测见
71
+ # `run doctor`,各客户端配置样例见 docs/mcp-client-setup.md
71
72
  cowork-flow mcp-state
72
73
 
73
74
  # 维护者发布前检查
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,114 +1,28 @@
1
1
  #!/usr/bin/env python3
2
- """Emit cowork-flow workflow context for Claude Code hooks."""
2
+ """Claude Code hook: thin delegate to the host-neutral injection entry.
3
+
4
+ All workflow facts and host shaping (preamble, envelope, exit codes) live in
5
+ adapters/host/inject.py — this file only locates the project runtime next to
6
+ itself (.claude/hooks/ → project root) and forwards with --host claude-code.
7
+ """
3
8
 
4
9
  from __future__ import annotations
5
10
 
6
- import json
7
- import os
8
11
  import sys
9
12
  from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- def _configure_stdio() -> None:
14
- for stream in (sys.stdout, sys.stderr):
15
- if hasattr(stream, "reconfigure"):
16
- stream.reconfigure(encoding="utf-8", errors="replace")
17
-
18
-
19
- def _read_input() -> dict[str, Any]:
20
- raw = sys.stdin.buffer.read()
21
- if not raw.strip():
22
- return {}
23
- try:
24
- data = json.loads(raw.decode("utf-8-sig"))
25
- except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
26
- return {}
27
- if not isinstance(data, dict):
28
- return {}
29
- if "claude_session_id" not in data:
30
- session_id = data.get("session_id")
31
- if isinstance(session_id, str) and session_id.strip():
32
- data["claude_session_id"] = session_id
33
- return data
34
-
35
-
36
- def _find_root(start: Path) -> Path | None:
37
- current = start.resolve()
38
- while True:
39
- if (current / ".cowork-flow").is_dir():
40
- return current
41
- if current == current.parent:
42
- return None
43
- current = current.parent
44
13
 
45
14
 
46
15
  def main() -> int:
47
- if (
48
- os.environ.get("COWORK_FLOW_HOOKS") == "0"
49
- or os.environ.get("COWORK_FLOW_DISABLE_HOOKS") == "1"
50
- ):
51
- return 0
52
- hook_input = _read_input()
53
- cwd_value = hook_input.get("cwd")
54
- cwd = (
55
- Path(cwd_value)
56
- if isinstance(cwd_value, str) and cwd_value.strip()
57
- else Path.cwd()
58
- )
59
- root = _find_root(cwd)
60
- if root is None:
16
+ project_root = Path(__file__).resolve().parents[2]
17
+ scripts_dir = project_root / ".cowork-flow" / "scripts"
18
+ if not scripts_dir.is_dir():
19
+ # Outside any cowork-flow project: stay silent like the entry does.
61
20
  return 0
62
- scripts_dir = root / ".cowork-flow" / "scripts"
63
21
  sys.path.insert(0, str(scripts_dir))
22
+ from adapters.host.inject import main as inject_main
64
23
 
65
- event_name = (
66
- hook_input.get("hook_event_name")
67
- or hook_input.get("hookEventName")
68
- or "UserPromptSubmit"
69
- )
70
- if event_name == "PostToolUse":
71
- # Editor-phase spec-check short path: at most one advisory line on
72
- # stderr (exit 2 surfaces it to the model; the edit itself has
73
- # already happened). Nothing to report exits 0 silently.
74
- from adapters.host.workflow_state_hook import spec_edit_warning
75
-
76
- warning = spec_edit_warning(root, hook_input)
77
- if warning:
78
- print(warning, file=sys.stderr)
79
- return 2
80
- return 0
81
-
82
- from adapters.host.workflow_state_hook import build_hook_context
83
-
84
- context = build_hook_context(
85
- root,
86
- hook_input,
87
- host="claude-code",
88
- adapter="claude-code.hooks",
89
- preamble=(
90
- (
91
- "<claude-code-runtime>\n"
92
- "hooks: UserPromptSubmit, SessionStart, PostToolUse\n"
93
- "</claude-code-runtime>"
94
- ),
95
- ),
96
- session_start=(event_name == "SessionStart"),
97
- )
98
- print(
99
- json.dumps(
100
- {
101
- "hookSpecificOutput": {
102
- "hookEventName": event_name,
103
- "additionalContext": context,
104
- }
105
- },
106
- ensure_ascii=False,
107
- )
108
- )
109
- return 0
24
+ return inject_main(["--host", "claude-code"])
110
25
 
111
26
 
112
27
  if __name__ == "__main__":
113
- _configure_stdio()
114
28
  raise SystemExit(main())
@@ -1,105 +1,29 @@
1
1
  #!/usr/bin/env python3
2
- """Emit cowork-flow workflow context for Codex hooks."""
2
+ """Codex hook: thin delegate to the host-neutral injection entry.
3
+
4
+ All workflow facts and host shaping (dispatch-mode preamble, envelope,
5
+ session-probe digest semantics) live in adapters/host/inject.py — this file
6
+ only locates the project runtime next to itself (.codex/hooks/ → project
7
+ root) and forwards with --host codex.
8
+ """
3
9
 
4
10
  from __future__ import annotations
5
11
 
6
- import json
7
- import os
8
12
  import sys
9
13
  from pathlib import Path
10
- from typing import Any
11
-
12
-
13
- def _configure_stdio() -> None:
14
- for stream in (sys.stdout, sys.stderr):
15
- if hasattr(stream, "reconfigure"):
16
- stream.reconfigure(encoding="utf-8", errors="replace")
17
-
18
-
19
- def _read_input() -> dict[str, Any]:
20
- raw = sys.stdin.buffer.read()
21
- if not raw.strip():
22
- return {}
23
- try:
24
- data = json.loads(raw.decode("utf-8-sig"))
25
- except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
26
- return {}
27
- return data if isinstance(data, dict) else {}
28
-
29
-
30
- def _find_root(start: Path) -> Path | None:
31
- current = start.resolve()
32
- while True:
33
- if (current / ".cowork-flow").is_dir():
34
- return current
35
- if current == current.parent:
36
- return None
37
- current = current.parent
38
14
 
39
15
 
40
16
  def main() -> int:
41
- if (
42
- os.environ.get("COWORK_FLOW_HOOKS") == "0"
43
- or os.environ.get("COWORK_FLOW_DISABLE_HOOKS") == "1"
44
- ):
45
- return 0
46
- hook_input = _read_input()
47
- cwd_value = hook_input.get("cwd")
48
- cwd = (
49
- Path(cwd_value)
50
- if isinstance(cwd_value, str) and cwd_value.strip()
51
- else Path.cwd()
52
- )
53
- root = _find_root(cwd)
54
- if root is None:
17
+ project_root = Path(__file__).resolve().parents[2]
18
+ scripts_dir = project_root / ".cowork-flow" / "scripts"
19
+ if not scripts_dir.is_dir():
20
+ # Outside any cowork-flow project: stay silent like the entry does.
55
21
  return 0
56
- scripts_dir = root / ".cowork-flow" / "scripts"
57
22
  sys.path.insert(0, str(scripts_dir))
58
- from adapters.host.workflow_state_hook import (
59
- build_hook_context,
60
- codex_dispatch_mode,
61
- )
23
+ from adapters.host.inject import main as inject_main
62
24
 
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
- )
69
- context = build_hook_context(
70
- root,
71
- hook_input,
72
- host="codex",
73
- adapter="codex.spawn_agent",
74
- preamble=(
75
- f"<codex-dispatch-mode>{dispatch_mode}</codex-dispatch-mode>",
76
- (
77
- "<codex-runtime>\n"
78
- "dispatch_mode_meaning: workflow dispatch hint, not current "
79
- "thread role\n"
80
- "runtime_context_identity: formal subagent sessions bind "
81
- "before workflow-state injection\n"
82
- "</codex-runtime>"
83
- ),
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).
88
- )
89
- print(
90
- json.dumps(
91
- {
92
- "hookSpecificOutput": {
93
- "hookEventName": event_name,
94
- "additionalContext": context,
95
- }
96
- },
97
- ensure_ascii=False,
98
- )
99
- )
100
- return 0
25
+ return inject_main(["--host", "codex"])
101
26
 
102
27
 
103
28
  if __name__ == "__main__":
104
- _configure_stdio()
105
29
  raise SystemExit(main())
@@ -1 +1 @@
1
- 1.2.0
1
+ 1.3.0
@@ -26,7 +26,6 @@ from services.task_context import (
26
26
  is_skill_path as _is_skill_path,
27
27
  iter_jsonl_lines as _iter_jsonl_lines,
28
28
  skill_path as _skill_path,
29
- use_claude_skill_context as _use_claude_skill_context,
30
29
  write_jsonl as _write_jsonl,
31
30
  )
32
31
  from adapters.cli.task_archive_commands import cmd_archive
@@ -35,6 +34,8 @@ from adapters.cli.task_context_commands import (
35
34
  cmd_add_context,
36
35
  cmd_init_context,
37
36
  cmd_list_context,
37
+ cmd_scope,
38
+ cmd_specs,
38
39
  cmd_validate,
39
40
  )
40
41
  from adapters.cli.task_create_command import cmd_create, ensure_tasks_dir
@@ -124,6 +125,8 @@ WORKER_BLOCKED_COMMANDS = frozenset()
124
125
 
125
126
  COMMANDS = {
126
127
  "next": cmd_next,
128
+ "scope": cmd_scope,
129
+ "specs": cmd_specs,
127
130
  }
128
131
 
129
132
 
@@ -4,6 +4,7 @@
4
4
  from __future__ import annotations
5
5
 
6
6
  import argparse
7
+ import json
7
8
  from pathlib import Path
8
9
 
9
10
  from services.task_context import (
@@ -244,3 +245,67 @@ def cmd_list_context(args: argparse.Namespace) -> int:
244
245
  print(f" {colored('->', Colors.YELLOW)} {reason}")
245
246
  print()
246
247
  return 0
248
+
249
+
250
+ def _resolve_fact_task_dir(
251
+ task_dir_arg: str | None, repo_root: Path
252
+ ) -> Path | None:
253
+ """Explicit dir wins; otherwise the session-bound active task. Returns
254
+ None when nothing is resolvable (the caller reports no-active-task)."""
255
+ if task_dir_arg:
256
+ return resolve_task_dir(task_dir_arg, repo_root)
257
+ from runtime.session_state import get_active_task
258
+
259
+ active = get_active_task(repo_root)
260
+ if not active.task_path:
261
+ return None
262
+ return repo_root / active.task_path
263
+
264
+
265
+ def _print_fact_json(payload: dict) -> None:
266
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
267
+
268
+
269
+ def cmd_scope(args: argparse.Namespace) -> int:
270
+ """File-scope whitelist for a task (read-only fact query, MCP task_scope
271
+ parity). With --path, prints the per-file inScope verdict instead."""
272
+ repo_root = get_repo_root()
273
+ task_dir = _resolve_fact_task_dir(getattr(args, "dir", None), repo_root)
274
+ if task_dir is None or not task_dir.is_dir():
275
+ _print_fact_json({"error": "no-active-task"})
276
+ return 1
277
+ from services.fact_view import file_scope_whitelist, path_in_scope
278
+
279
+ whitelist = file_scope_whitelist(repo_root, task_dir)
280
+ if getattr(args, "path", None):
281
+ payload = {"taskDir": task_dir.name, **path_in_scope(whitelist, args.path)}
282
+ else:
283
+ payload = {
284
+ "taskDir": task_dir.name,
285
+ "whitelist": whitelist,
286
+ "count": len(whitelist),
287
+ }
288
+ _print_fact_json(payload)
289
+ return 0
290
+
291
+
292
+ def cmd_specs(args: argparse.Namespace) -> int:
293
+ """Spec/skill reading list dispatched by task dev_type (read-only fact
294
+ query, MCP task_specs parity)."""
295
+ repo_root = get_repo_root()
296
+ task_dir = _resolve_fact_task_dir(getattr(args, "dir", None), repo_root)
297
+ if task_dir is None or not task_dir.is_dir():
298
+ _print_fact_json({"error": "no-active-task"})
299
+ return 1
300
+ from services.fact_view import spec_entries_for_task
301
+
302
+ dev_type, specs = spec_entries_for_task(repo_root, task_dir)
303
+ _print_fact_json(
304
+ {
305
+ "taskDir": task_dir.name,
306
+ "devType": dev_type,
307
+ "specs": specs,
308
+ "count": len(specs),
309
+ }
310
+ )
311
+ return 0
@@ -168,6 +168,27 @@ def _add_next_command(subparsers: argparse._SubParsersAction) -> None:
168
168
  _add_next_create_inputs(next_parser)
169
169
  _add_next_runtime_inputs(next_parser)
170
170
 
171
+
172
+ def _add_scope_command(subparsers: argparse._SubParsersAction) -> None:
173
+ scope_parser = subparsers.add_parser(
174
+ "scope",
175
+ help="File-scope whitelist for a task (read-only fact query)",
176
+ )
177
+ _add_optional_task_dir(scope_parser)
178
+ scope_parser.add_argument(
179
+ "--path",
180
+ help="Check one repo-relative path and print the inScope verdict",
181
+ )
182
+
183
+
184
+ def _add_specs_command(subparsers: argparse._SubParsersAction) -> None:
185
+ specs_parser = subparsers.add_parser(
186
+ "specs",
187
+ help="Spec/skill reading list dispatched by task dev_type "
188
+ "(read-only fact query)",
189
+ )
190
+ _add_optional_task_dir(specs_parser)
191
+
171
192
  def build_parser() -> argparse.ArgumentParser:
172
193
  parser = argparse.ArgumentParser(
173
194
  description="Task workflow router for cowork-flow",
@@ -179,6 +200,8 @@ def build_parser() -> argparse.ArgumentParser:
179
200
  help="Commands",
180
201
  )
181
202
  _add_next_command(subparsers)
203
+ _add_scope_command(subparsers)
204
+ _add_specs_command(subparsers)
182
205
  return parser
183
206
 
184
207
 
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """claude-code host policy: the claude-code-specific behaviors of the
3
+ workflow hook.
4
+
5
+ Rendering stays in workflow_state_hook.py; this module owns only the
6
+ claude-code transport specifics — the session alias ported from the claude
7
+ wrapper, the runtime preamble block, and the PostToolUse spec-advisory path
8
+ (stderr + exit 2, shared with codex via the neutral helper).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from adapters.host.workflow_state_hook import HostPolicy
17
+
18
+ CLAUDE_PREAMBLE = (
19
+ (
20
+ "<claude-code-runtime>\n"
21
+ "hooks: UserPromptSubmit, SessionStart, PostToolUse\n"
22
+ "</claude-code-runtime>"
23
+ ),
24
+ )
25
+
26
+
27
+ def claude_session_alias(data: dict[str, Any]) -> dict[str, Any]:
28
+ # Ported from the claude-code wrapper: bare session_id must resolve as a
29
+ # claude session, not fall through to the codex session_id rule.
30
+ if "claude_session_id" not in data:
31
+ session_id = data.get("session_id")
32
+ if isinstance(session_id, str) and session_id.strip():
33
+ data["claude_session_id"] = session_id
34
+ return data
35
+
36
+
37
+ def preamble(root: Path) -> tuple[str, ...]:
38
+ return CLAUDE_PREAMBLE
39
+
40
+
41
+ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
42
+ from adapters.host.workflow_state_hook import spec_only_post_tool_use
43
+
44
+ return spec_only_post_tool_use(root, hook_input, POLICY)
45
+
46
+
47
+ POLICY = HostPolicy(
48
+ host="claude-code",
49
+ session_alias=claude_session_alias,
50
+ preamble=preamble,
51
+ post_tool_use=post_tool_use,
52
+ )
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """codex host policy: the codex-specific behaviors of the workflow hook.
3
+
4
+ Rendering stays in workflow_state_hook.py; this module owns only the codex
5
+ transport specifics — the dispatch-mode preamble (config-driven, the codex
6
+ wrapper surfaces it above every injection) and the probe-based session-start
7
+ detection (codex registers UserPromptSubmit only, so the digest shape derives
8
+ from the session state file probe). PostToolUse reuses the neutral spec-only
9
+ stderr path shared with claude-code.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from adapters.host.workflow_state_hook import HostPolicy
19
+
20
+
21
+ def codex_dispatch_mode(root: Path) -> str:
22
+ from adapters.host.workflow_state_hook import _load_common
23
+
24
+ _load_common(root)
25
+ try:
26
+ from infra.config import get_codex_dispatch_mode
27
+ except Exception:
28
+ return "sub-agent"
29
+ try:
30
+ return get_codex_dispatch_mode(root)
31
+ except Exception:
32
+ return "sub-agent"
33
+
34
+
35
+ def preamble(root: Path) -> tuple[str, ...]:
36
+ dispatch_mode = codex_dispatch_mode(root)
37
+ return (
38
+ f"<codex-dispatch-mode>{dispatch_mode}</codex-dispatch-mode>",
39
+ (
40
+ "<codex-runtime>\n"
41
+ "dispatch_mode_meaning: workflow dispatch hint, not current "
42
+ "thread role\n"
43
+ "runtime_context_identity: formal subagent sessions bind "
44
+ "before workflow-state injection\n"
45
+ "</codex-runtime>"
46
+ ),
47
+ )
48
+
49
+
50
+ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
51
+ from adapters.host.workflow_state_hook import spec_only_post_tool_use
52
+
53
+ return spec_only_post_tool_use(root, hook_input, POLICY)
54
+
55
+
56
+ POLICY = HostPolicy(
57
+ host="codex",
58
+ session_start_event=None, # probe the session state file instead
59
+ preamble=preamble,
60
+ post_tool_use=post_tool_use,
61
+ )