cowork-flow 1.1.4 → 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 (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +4 -3
  3. package/package.json +1 -1
  4. package/template/.claude/hooks/inject-workflow-state.py +12 -85
  5. package/template/.claude/settings.json +12 -0
  6. package/template/.codex/hooks/inject-workflow-state.py +13 -89
  7. package/template/.cowork-flow/.version +1 -1
  8. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +121 -0
  9. package/template/.cowork-flow/scripts/adapters/cli/task.py +4 -1
  10. package/template/.cowork-flow/scripts/adapters/cli/task_context_commands.py +65 -0
  11. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +1 -0
  12. package/template/.cowork-flow/scripts/adapters/cli/task_parser.py +30 -0
  13. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +52 -0
  14. package/template/.cowork-flow/scripts/adapters/host/codex_policy.py +61 -0
  15. package/template/.cowork-flow/scripts/adapters/host/inject.py +249 -0
  16. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +305 -25
  17. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +230 -0
  18. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +2 -5
  19. package/template/.cowork-flow/scripts/run.py +1 -0
  20. package/template/.cowork-flow/scripts/runtime/session_state.py +19 -9
  21. package/template/.cowork-flow/scripts/services/context_discovery.py +18 -5
  22. package/template/.cowork-flow/scripts/services/fact_view.py +69 -2
  23. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +75 -2
  24. package/template/.cowork-flow/scripts/services/lifecycle_policy.py +9 -2
  25. package/template/.cowork-flow/scripts/services/spec_check.py +515 -0
  26. package/template/.cowork-flow/scripts/services/task_context.py +0 -1
  27. package/template/.cowork-flow/scripts/services/task_lifecycle.py +33 -1
  28. package/template/.cowork-flow/spec/backend/error-handling.md +14 -0
  29. package/template/.cowork-flow/spec/contracts/context-injection.md +24 -20
  30. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +43 -8
  31. package/template/.cowork-flow/spec/contracts/index.md +1 -0
  32. package/template/.cowork-flow/spec/contracts/spec-checks.md +106 -0
  33. package/template/.cowork-flow/spec/contracts/subagent-dispatch.md +21 -0
  34. package/template/.cowork-flow/spec/runtime/contract-registry.json +12 -0
  35. package/template/.opencode/plugins/cowork-flow.js +55 -1
  36. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  37. package/template/.zcode/hooks/inject-context.js +106 -864
  38. package/template/skills/agent-dispatch/SKILL.md +15 -0
  39. package/template/skills/runtime-health/scripts/doctor.py +139 -0
  40. package/template/skills/task-planning/SKILL.md +1 -0
  41. package/template/skills/task-review/SKILL.md +1 -0
  42. package/template/.zcode/hooks/inject-context.selfcheck.mjs +0 -795
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
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
+
36
+ ## 1.2.0 - 2026-09-08
37
+
38
+ ### 规范挂命令(spec 约束前移)
39
+
40
+ 用户在 `.cowork-flow/spec/` 定义的行为规范,从"review 才发现"前移为"编码时即约束":
41
+
42
+ - **spec frontmatter `checks:` 声明**:每条规范可声明检查命令(`cmd`/`files`/`timeout`/`when`),由唯一执行器 `./.cowork-flow/run spec-check` 按相位执行;机制只消费声明、不解析规范正文,规则变化随 spec 同文件天然同步。`files` 只支持目录前缀与扩展名两种形式(完整 glob 解析为错误且声明不执行,doctor 报告)。契约文档见 `spec/contracts/spec-checks.md`。
43
+ - **三态门禁语义**:`pass` / `violation`(complete 阻断、状态不推进)/ `unchecked`(命令缺失、解释器缺失、超时——阻断并要求显式 `--allow-unchecked`,豁免留痕进 `task.json` `meta.specCheckExempt` 供 review 可见)。unchecked 永不冒充 pass。
44
+ - **收口遥测**:complete 时全量结果写 `meta.specCheckSummary`,为"返工前移"效果提供可对比基线。
45
+ - **三线 digest 注入**:进入 in_progress 后,stage-contract 的 Specs 行升级为 h2 标题树索引(每 spec 最多 6 条、每条截断 24 字符),规范条目名随契约常驻注意力;spec 正文改动不影响 digest,超预算整行让位(Scope/Gates 永不丢)。python/zcode/opencode 输出由 matrix 用例锁定逐字相等,claude 线经 Python 单源自动获得。
46
+ - **编辑期快跑**:zcode(PostToolUse 短路径,spawn 单源 CLI、3.8s 上限)与 claude(PostToolUse 新增 hook 资产,exit 2 单行 stderr 反馈)在编辑命中 `files` 的声明时就地执行,违规单行警告、同文件 10 秒节流(状态在 `.cowork-flow/.runtime/`)、执行器缺失静默不阻断编辑流;opencode 降级为仅 digest + 收口。
47
+ - **doctor 健康项**:`specChecks` 报告声明解析错误与命令入口不存在——"跑不了的声明"在收口前暴露。
48
+
49
+ 配套:spec-check CLI(`--phase/--file/--json/--verbose/--throttled`)、`--allow-unchecked` 收口旗标、架构拓扑保持 services 层无 CLI 关注点。
50
+
3
51
  ## 1.1.4 - 2026-08-31
4
52
 
5
53
  ### 修复
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.1.4",
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,101 +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))
64
- from adapters.host.workflow_state_hook import build_hook_context
22
+ from adapters.host.inject import main as inject_main
65
23
 
66
- event_name = (
67
- hook_input.get("hook_event_name")
68
- or hook_input.get("hookEventName")
69
- or "UserPromptSubmit"
70
- )
71
- context = build_hook_context(
72
- root,
73
- hook_input,
74
- host="claude-code",
75
- adapter="claude-code.hooks",
76
- preamble=(
77
- (
78
- "<claude-code-runtime>\n"
79
- "hooks: UserPromptSubmit, SessionStart\n"
80
- "</claude-code-runtime>"
81
- ),
82
- ),
83
- session_start=(event_name == "SessionStart"),
84
- )
85
- print(
86
- json.dumps(
87
- {
88
- "hookSpecificOutput": {
89
- "hookEventName": event_name,
90
- "additionalContext": context,
91
- }
92
- },
93
- ensure_ascii=False,
94
- )
95
- )
96
- return 0
24
+ return inject_main(["--host", "claude-code"])
97
25
 
98
26
 
99
27
  if __name__ == "__main__":
100
- _configure_stdio()
101
28
  raise SystemExit(main())
@@ -19,6 +19,18 @@
19
19
  }
20
20
  ]
21
21
  }
22
+ ],
23
+ "PostToolUse": [
24
+ {
25
+ "matcher": "Edit|Write|MultiEdit",
26
+ "hooks": [
27
+ {
28
+ "type": "command",
29
+ "command": "\"${CLAUDE_PROJECT_DIR:-.}/.cowork-flow/run\" python \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/inject-workflow-state.py\"",
30
+ "timeout": 30
31
+ }
32
+ ]
33
+ }
22
34
  ]
23
35
  }
24
36
  }
@@ -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.1.4
1
+ 1.3.0
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """`run spec-check [--phase edit|lifecycle] [--file <path>...] [--json]`.
4
+
5
+ Executes user-declared spec checks (frontmatter `checks:`) and prints a
6
+ normalized one-line summary unless `--json`/`--verbose` asks for more. See
7
+ services/spec_check.py for the three-state contract (pass/violation/unchecked).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+
16
+ if __package__:
17
+ from . import _bootstrap as _bootstrap # noqa: F401
18
+ else:
19
+ import _bootstrap # noqa: F401
20
+
21
+ from infra.paths import get_repo_root
22
+ from services.spec_check import SpecCheckError, run_checks, summary_has
23
+
24
+
25
+ def main() -> int:
26
+ parser = argparse.ArgumentParser(
27
+ prog="spec-check",
28
+ description=(
29
+ "Run user-declared spec checks (frontmatter checks:) for one phase."
30
+ ),
31
+ )
32
+ parser.add_argument(
33
+ "--phase",
34
+ choices=("edit", "lifecycle"),
35
+ default="lifecycle",
36
+ help="edit: only declarations matching the given files (3s clamp); "
37
+ "lifecycle: every declaration honoring its files scope.",
38
+ )
39
+ parser.add_argument(
40
+ "--file",
41
+ action="append",
42
+ default=None,
43
+ help="Changed file path to match against declaration files filters; "
44
+ "repeatable. Drives the edit-phase filter and single-file queries.",
45
+ )
46
+ parser.add_argument(
47
+ "--verbose",
48
+ action="store_true",
49
+ help="Print full command output per result (query mode only).",
50
+ )
51
+ parser.add_argument(
52
+ "--throttled",
53
+ action="store_true",
54
+ help="Editor-phase mode: single-line report for one file, throttled "
55
+ "per file (empty output when nothing to report).",
56
+ )
57
+ parser.add_argument(
58
+ "--json",
59
+ action="store_true",
60
+ dest="as_json",
61
+ help="Print the machine-readable report.",
62
+ )
63
+ args = parser.parse_args()
64
+
65
+ repo_root = get_repo_root()
66
+
67
+ if args.throttled:
68
+ from services.spec_check import run_edit_checks
69
+
70
+ files = args.file or []
71
+ if len(files) != 1:
72
+ print("Error: --throttled requires exactly one --file", file=sys.stderr)
73
+ return 2
74
+ print(run_edit_checks(repo_root, files[0]))
75
+ return 0
76
+
77
+ try:
78
+ report = run_checks(
79
+ repo_root,
80
+ phase=args.phase,
81
+ changed_files=args.file or [],
82
+ )
83
+ except SpecCheckError as error:
84
+ print(f"Error: {error}", file=sys.stderr)
85
+ return 2
86
+
87
+ if args.as_json:
88
+ print(json.dumps(report, ensure_ascii=False, indent=2))
89
+ return 0
90
+
91
+ if args.verbose:
92
+ for result in report.get("results", []):
93
+ print(f"--- {result.get('spec')} :: {result.get('cmd')}")
94
+ for line in result.get("output", []):
95
+ print(line)
96
+ if result.get("reason"):
97
+ print(f"reason: {result['reason']}")
98
+
99
+ from services.spec_check import normalized_one_line
100
+
101
+ line = normalized_one_line(report)
102
+ if line:
103
+ print(line)
104
+ else:
105
+ summary = report.get("summary", {})
106
+ print(
107
+ "spec-check: {pass} passed, {violation} violations, "
108
+ "{unchecked} unchecked".format(
109
+ **{**{"pass": 0, "violation": 0, "unchecked": 0}, **summary}
110
+ )
111
+ )
112
+
113
+ if summary_has(report, "violation"):
114
+ return 1
115
+ if summary_has(report, "unchecked"):
116
+ return 2
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ sys.exit(main())
@@ -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
@@ -329,6 +329,7 @@ def cmd_complete(args: argparse.Namespace) -> int:
329
329
  result = service.complete(
330
330
  task_dir,
331
331
  execution_context=execution_context,
332
+ allow_unchecked_specs=bool(getattr(args, "allow_unchecked", False)),
332
333
  )
333
334
  if not result.ok:
334
335
  if result.code == "LIFECYCLE-TRANSITION-001":
@@ -84,6 +84,13 @@ def _add_next_output_options(parser: argparse.ArgumentParser) -> None:
84
84
  action="store_true",
85
85
  help="Validate task context JSONL without changing task state",
86
86
  )
87
+ parser.add_argument(
88
+ "--allow-unchecked",
89
+ dest="allow_unchecked",
90
+ action="store_true",
91
+ help="With task completion: allow unchecked spec-check commands "
92
+ "(missing/timeout) and record the exemption in task.json meta",
93
+ )
87
94
  parser.add_argument(
88
95
  "--list",
89
96
  dest="list_tasks",
@@ -161,6 +168,27 @@ def _add_next_command(subparsers: argparse._SubParsersAction) -> None:
161
168
  _add_next_create_inputs(next_parser)
162
169
  _add_next_runtime_inputs(next_parser)
163
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
+
164
192
  def build_parser() -> argparse.ArgumentParser:
165
193
  parser = argparse.ArgumentParser(
166
194
  description="Task workflow router for cowork-flow",
@@ -172,6 +200,8 @@ def build_parser() -> argparse.ArgumentParser:
172
200
  help="Commands",
173
201
  )
174
202
  _add_next_command(subparsers)
203
+ _add_scope_command(subparsers)
204
+ _add_specs_command(subparsers)
175
205
  return parser
176
206
 
177
207