cowork-flow 1.1.0 → 1.1.3
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 +38 -0
- package/package.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/adapters/claude-code/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/codex/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/dsh/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/opencode/adapter.yaml +1 -0
- package/template/.cowork-flow/adapters/zcode/adapter.yaml +1 -0
- package/template/.cowork-flow/scripts/adapters/cli/task.py +6 -4
- package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +90 -21
- package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +101 -2
- package/template/.cowork-flow/scripts/infra/git_snapshot.py +49 -10
- package/template/.cowork-flow/scripts/infra/skill_manifest.py +3 -1
- package/template/.cowork-flow/scripts/services/context_discovery.py +26 -0
- package/template/.cowork-flow/scripts/services/context_paths.py +79 -9
- package/template/.cowork-flow/scripts/services/fact_view.py +282 -5
- package/template/.cowork-flow/scripts/services/lifecycle_checks.py +34 -3
- package/template/.cowork-flow/scripts/services/task_context.py +2 -21
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +12 -0
- package/template/.cowork-flow/spec/contracts/context-injection.md +95 -8
- package/template/.cowork-flow/spec/contracts/decision-anchor.md +7 -0
- package/template/.cowork-flow/spec/runtime/host-assets.json +10 -5
- package/template/.cowork-flow/spec/runtime/scope-rules.json +16 -0
- package/template/.opencode/plugins/cowork-flow.js +225 -6
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/hooks.json +1 -1
- package/template/.zcode/hooks/inject-context.js +311 -13
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +202 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.3 - 2026-08-30
|
|
4
|
+
|
|
5
|
+
> **版本内容载体说明**:1.1.1(守卫修复批次)与 1.1.2(Review 基线 diff)章节内容随本 1.1.3 首次进入 npm 分发——三个批次同属一个发布周期,章节按批次序号记录,包内容以最新版本号为载体(与 1.1.0 承载 1.0.0 章节内容同一惯例)。
|
|
6
|
+
|
|
7
|
+
### 规则表数据化(scope-rules 单源)
|
|
8
|
+
|
|
9
|
+
- 新增 `.cowork-flow/spec/runtime/scope-rules.json`:scope 过滤规则(allowedTypes/wildcardChars/rejectedSegments/driveLetterPattern/trailingSlashRejectedTypes)与 stage-contract 限制(budget/scopeLimit/specLimit/verifyLimit)从三份复制实现下沉为单一数据文件。
|
|
10
|
+
- Python(context_paths/fact_view)与 zcode/opencode JS 镜像运行时消费同一文件;文件缺失/畸形降级到与默认内容逐字一致的内嵌默认(默认等价由 tests/test_scope_rules.py 与 selfcheck 锁定)。
|
|
11
|
+
- 规则可真实改变行为:wildcardChars 置空 → 通配条目进入 Scope;budget 调小 → 预算降级路径触发。
|
|
12
|
+
- 修复预算兜底 1 字节超限:裁剪切分与闭标签间的换行预留。
|
|
13
|
+
- **CI 修复(delegated 注入崩溃)**:zcode delegated 分支不再以空输入对象重新发现项目根(`findProjectRoot({})`),改为复用 main 已解析的工作流根——在无 `.cowork-flow/` 的目录(干净 checkout、非项目目录)触发 delegated prompt 不再 `join(null)` 崩溃;对应的注入测试显式指定 spawn 工作目录,消除对测试运行 cwd 的隐式依赖(干净 checkout 下此前必红,实测 dev 推送 CI 双平台失败)。
|
|
14
|
+
- **CI 修复(Windows git 降级)**:`git` 二进制不可用(PATH 缺失/未安装)时 `_run_git_command` 捕获 OSError 按 rc!=0 降级——`current_head` 视为无头(不写 baseline)、变更集收集降级 status-only,`task start` 不再崩溃(Windows 的 CreateProcess 在 PATH 缺失时不回退,清空环境的会话测试此前必红;macOS execvp 有默认 PATH 兜底故本地绿)。
|
|
15
|
+
|
|
16
|
+
## 1.1.2 - 2026-08-30
|
|
17
|
+
|
|
18
|
+
### Review 基线 diff(堵住提交绕行面)
|
|
19
|
+
|
|
20
|
+
- **基线记录**:`task start`(进入 in_progress)在 task.json `meta.baselineCommit` 记录当前 HEAD,且永不滑动(重复/幂等 start 不覆盖)——任务期间的审查窗口从激活时刻起固定。
|
|
21
|
+
- **变更集合并**:review 门禁以 `baseline..HEAD` diff 与 working-tree status 的并集去重作为变更集——agent 中途 `git commit` 越界文件后,review 的 `unlisted_changed_file` 仍会触发(此前提交即从 status 消失,形成无痕通道)。
|
|
22
|
+
- **降级语义**:无 git 仓库 / HEAD 不存在 / 基线缺失 / diff 失败(如 rebase 孤儿化)→ 降级 status-only,与旧行为一致,不产生错误 blocker。
|
|
23
|
+
- **契约重开**:task-review SKILL 输入语义写明基线变更集与降级行为;已审查任务的增量重审聚焦自基线以来的变化,证据要求不变。
|
|
24
|
+
|
|
25
|
+
## 1.1.1 - 2026-08-30
|
|
26
|
+
|
|
27
|
+
### 守卫修复批次(对抗评审后落地)
|
|
28
|
+
|
|
29
|
+
- **hook 崩溃修复**:implement.jsonl 含 `./` 前缀条目时 zcode/opencode 双宿主 hook 从 `TypeError: Assignment to constant variable` 崩溃(整个上下文注入丢失)改为正常输出——`const`→`let` 两处 + 回归 fixture。
|
|
30
|
+
- **stage-contract 预算降级**:超限输入不再产出未闭合的畸形块;按 Verify → Specs → Scope 条目(至少 1 条)逐级降级,收尾标签与 Gates 行恒在;三线同构算法。
|
|
31
|
+
- **MCP 路径隔离**:`task_scope`/`task_specs` 拒绝仓库外路径(`../`、绝对路径)返回 `task-outside-repo`;无 id 的 JSON-RPC 通知(含 initialize/ping/tools/list)不再响应。
|
|
32
|
+
- **JS 白名单语义对齐**:zcode/opencode 过滤规则与 Python `normalize_context_file_scope_entry` 一致(非法 type、`../`、绝对路径、盘符、通配符一律丢弃)——editScopeWarning 与 Scope 行不再对 gate 会标记越界的文件静默放行;spec 指针同规则过滤。
|
|
33
|
+
- **delegated 只读 scope**:子代理注入包删除 `Scope: subagent` 行;stage-contract 以父任务 scope `[read-only]` 变体呈现,Gates 话术同步(不再暗示子代理可自声明 scope)。
|
|
34
|
+
- **异常降级可诊断**:锚点文件非法 UTF-8 时 stage-contract 保留 Scope/Gates 仅丢 Verify(此前整块静默消失);非例行异常在 stderr 留痕。
|
|
35
|
+
- **review 门禁补洞**:implement.jsonl 缺失时 review 产生 `missing_implement_jsonl_file_scope` blocker(此前静默放行,删除 manifest 即绕过白名单)。
|
|
36
|
+
- **矩阵化跨端测试**:`test/fixtures/stage-contract-matrix.json` 单一数据文件驱动三线(python/zcode/opencode)逐字节相等断言,覆盖规范/`./` 前缀/非法边界/超限/emoji/缺锚点/空 scope/delegated 8 类用例;zcode hooks.json matcher(Bash|Edit|Write|MultiEdit)由模板测试锁定;新增 Python 侧矩阵断言。
|
|
37
|
+
- dev_type 畸形值(非字符串)在 task_specs 中按缺省降级;spec 指针忽略 directory 条目;normalizeScopePath 带 trim;zcode 生命周期刷新正则对齐 dsh(task|subagent|resume)。
|
|
38
|
+
- 契约文档如实化:context-injection.md 不再声称三线结构恒等/always emitted,改为差异表 + 矩阵锁定范围 + 残余缺口清单(matcher 依赖 ZCode 运行时工具名、JS 过滤为规则移植)。
|
|
39
|
+
|
|
3
40
|
## 1.1.0 - 2026-08-29
|
|
4
41
|
|
|
5
42
|
> **版本内容错位说明**:npm registry 上的 1.0.0 tarball 发布于 2026-08-26(仅含发版脚本修复之前的代码)。1.0.0 段下述的里程碑描述以本 1.1.0 为其实际发布载体——阶段 0-3 的全部内容自本版本起进入 npm 分发。
|
|
@@ -34,6 +71,7 @@
|
|
|
34
71
|
- 注入结构化(阶段 1b):`<workflow-state>` 升级为属性事实头(`task`/`status`/`source` 进开标签,body 保留人读面包屑),三线一致并在协议契约冻结;planning/in_progress/review 状态下三线注入紧凑 `<decision-anchor>` 决策要点块(Python 复用 fact_view 解析单源),completed 终态与缺文件不注入。
|
|
35
72
|
- 多执行者语义(阶段 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
73
|
- 生态适配(阶段 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 门禁链。
|
|
74
|
+
- 实现阶段守卫三件套:MCP `task_scope`/`task_specs` 只读工具(越界判定与规范清单,宿主无关、Python 单源);`<stage-contract>` 实现契约块三线注入(编辑白名单/规范入口/门禁预告/任务自声明验证命令,≤1200 字符,跨宿主逐字相等测试锁定);zcode 编辑越界实时警告(PostToolUse Edit/Write/MultiEdit 短路径,能力矩阵声明 `editScopeWarning`,其余宿主 fallback 到静态预告)。
|
|
37
75
|
- MCP 全局入口:npm CLI 新增 `cowork-flow mcp-state` 透传命令——从 cwd 向上定位最近 `.cowork-flow/` 并以继承 stdio exec 该项目的 `run mcp-state`;MCP 客户端全局注册一次(`cowork-flow mcp-state`)即可服务所有 cowork-flow 项目,无需逐项目配置。
|
|
38
76
|
|
|
39
77
|
## 0.0.52 - 2026-08-26
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.1.
|
|
1
|
+
1.1.3
|
|
@@ -12,15 +12,17 @@ if __package__:
|
|
|
12
12
|
else:
|
|
13
13
|
import _bootstrap # noqa: F401
|
|
14
14
|
|
|
15
|
+
from services.context_discovery import (
|
|
16
|
+
get_implement_backend,
|
|
17
|
+
get_implement_base,
|
|
18
|
+
get_implement_frontend,
|
|
19
|
+
get_implement_spec,
|
|
20
|
+
)
|
|
15
21
|
from services.task_context import (
|
|
16
22
|
detect_installed_platforms as _detect_installed_platforms,
|
|
17
23
|
discover_spec_files as _discover_spec_files,
|
|
18
24
|
get_check_context,
|
|
19
25
|
get_debug_context,
|
|
20
|
-
get_implement_backend,
|
|
21
|
-
get_implement_base,
|
|
22
|
-
get_implement_frontend,
|
|
23
|
-
get_implement_spec,
|
|
24
26
|
is_skill_path as _is_skill_path,
|
|
25
27
|
iter_jsonl_lines as _iter_jsonl_lines,
|
|
26
28
|
skill_path as _skill_path,
|
|
@@ -89,7 +89,6 @@ def build_hook_context(
|
|
|
89
89
|
source = f"runtime-context-invalid:{runtime_context_id}"
|
|
90
90
|
extra_lines = [
|
|
91
91
|
f"Runtime context: {runtime_context_id}",
|
|
92
|
-
"Scope: subagent",
|
|
93
92
|
(
|
|
94
93
|
"Runtime context is missing, closed, or invalid. "
|
|
95
94
|
"Do not run standalone lifecycle commands, resume, archive, commit, or spawn."
|
|
@@ -120,6 +119,9 @@ def build_hook_context(
|
|
|
120
119
|
blocks = [*preamble, digest_block]
|
|
121
120
|
if anchor_block:
|
|
122
121
|
blocks.append(anchor_block)
|
|
122
|
+
contract_block = _stage_contract_block(root, task_path, status)
|
|
123
|
+
if contract_block:
|
|
124
|
+
blocks.append(contract_block)
|
|
123
125
|
blocks.append(
|
|
124
126
|
f"<workflow-state{_workflow_state_attrs(task_path, status, source)}>"
|
|
125
127
|
f"\n{body}\n</workflow-state>"
|
|
@@ -148,13 +150,11 @@ def _session_has_started(root: Path, hook_input: dict[str, Any]) -> bool:
|
|
|
148
150
|
|
|
149
151
|
|
|
150
152
|
def _xml_attr(value: Any) -> str:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
.replace('"', """)
|
|
157
|
-
)
|
|
153
|
+
# Delegates to the fact-view implementation: the escaping rules must stay
|
|
154
|
+
# identical across the decision-anchor and stage-contract blocks.
|
|
155
|
+
from services.fact_view import xml_attr
|
|
156
|
+
|
|
157
|
+
return xml_attr(value)
|
|
158
158
|
|
|
159
159
|
|
|
160
160
|
def _workflow_state_attrs(
|
|
@@ -169,6 +169,24 @@ def _workflow_state_attrs(
|
|
|
169
169
|
|
|
170
170
|
|
|
171
171
|
DECISION_ANCHOR_STATES = ("planning", "in_progress", "review")
|
|
172
|
+
STAGE_CONTRACT_STATES = ("in_progress", "review")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _effective_task_status(
|
|
176
|
+
root: Path, task_path: str, status: str
|
|
177
|
+
) -> str | None:
|
|
178
|
+
"""Delegated subtasks read the underlying task's status; every other
|
|
179
|
+
status is already effective."""
|
|
180
|
+
if status != "delegated_subtask":
|
|
181
|
+
return status
|
|
182
|
+
try:
|
|
183
|
+
data = json.loads(
|
|
184
|
+
(root / task_path / "task.json").read_text(encoding="utf-8")
|
|
185
|
+
)
|
|
186
|
+
effective_status = data.get("status")
|
|
187
|
+
except (OSError, json.JSONDecodeError):
|
|
188
|
+
return None
|
|
189
|
+
return effective_status if isinstance(effective_status, str) else None
|
|
172
190
|
|
|
173
191
|
|
|
174
192
|
def _decision_anchor_block(
|
|
@@ -180,17 +198,7 @@ def _decision_anchor_block(
|
|
|
180
198
|
inject nothing."""
|
|
181
199
|
if not task_path:
|
|
182
200
|
return None
|
|
183
|
-
effective_status = status
|
|
184
|
-
if status == "delegated_subtask":
|
|
185
|
-
try:
|
|
186
|
-
data = json.loads(
|
|
187
|
-
(root / task_path / "task.json").read_text(encoding="utf-8")
|
|
188
|
-
)
|
|
189
|
-
effective_status = data.get("status")
|
|
190
|
-
except (OSError, json.JSONDecodeError):
|
|
191
|
-
return None
|
|
192
|
-
if not isinstance(effective_status, str):
|
|
193
|
-
return None
|
|
201
|
+
effective_status = _effective_task_status(root, task_path, status)
|
|
194
202
|
if effective_status not in DECISION_ANCHOR_STATES:
|
|
195
203
|
return None
|
|
196
204
|
try:
|
|
@@ -200,7 +208,13 @@ def _decision_anchor_block(
|
|
|
200
208
|
encoding="utf-8"
|
|
201
209
|
)
|
|
202
210
|
parsed = parse_decision_anchor(text)
|
|
203
|
-
except
|
|
211
|
+
except (OSError, UnicodeDecodeError):
|
|
212
|
+
# Absent or undecodable anchors are routine: no block, no noise.
|
|
213
|
+
return None
|
|
214
|
+
except Exception as error:
|
|
215
|
+
# Anything else leaves a trace so silent guard loss stays diagnosable
|
|
216
|
+
# (stdout is the injection channel, stderr is safe).
|
|
217
|
+
sys.stderr.write(f"decision-anchor degraded: {error}\n")
|
|
204
218
|
return None
|
|
205
219
|
if not parsed["goal"] and not parsed["acceptanceCriteria"]:
|
|
206
220
|
return None
|
|
@@ -219,6 +233,59 @@ def _decision_anchor_block(
|
|
|
219
233
|
return "\n".join(lines)
|
|
220
234
|
|
|
221
235
|
|
|
236
|
+
def _stage_contract_block(
|
|
237
|
+
root: Path, task_path: str | None, status: str
|
|
238
|
+
) -> str | None:
|
|
239
|
+
"""Implementation contract (edit scope, specs to read, gates preview,
|
|
240
|
+
declared verification commands) for states where it steers execution.
|
|
241
|
+
Data comes from the frozen task artifacts via services.fact_view — the
|
|
242
|
+
single source shared with the MCP task_scope tool. Delegated subtasks
|
|
243
|
+
render the parent scope as a read-only reference."""
|
|
244
|
+
if not task_path:
|
|
245
|
+
return None
|
|
246
|
+
effective_status = _effective_task_status(root, task_path, status)
|
|
247
|
+
if effective_status not in STAGE_CONTRACT_STATES:
|
|
248
|
+
return None
|
|
249
|
+
try:
|
|
250
|
+
from services.context_paths import load_scope_rules
|
|
251
|
+
from services.fact_view import (
|
|
252
|
+
build_stage_contract,
|
|
253
|
+
file_scope_whitelist,
|
|
254
|
+
parse_decision_anchor,
|
|
255
|
+
spec_pointer_files,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
task_dir = root / task_path
|
|
259
|
+
whitelist = file_scope_whitelist(root, task_dir)
|
|
260
|
+
spec_files = spec_pointer_files(task_dir)
|
|
261
|
+
rules = load_scope_rules(root)
|
|
262
|
+
anchor_path = task_dir / "decision-anchor.md"
|
|
263
|
+
try:
|
|
264
|
+
parsed = parse_decision_anchor(
|
|
265
|
+
anchor_path.read_text(encoding="utf-8")
|
|
266
|
+
)
|
|
267
|
+
except (OSError, UnicodeDecodeError):
|
|
268
|
+
# Absent or undecodable anchor: scope/gates still render; the
|
|
269
|
+
# verify line is dropped by the empty results.
|
|
270
|
+
parsed = {"validationCommands": []}
|
|
271
|
+
except Exception as error:
|
|
272
|
+
# Never silently kill the guard block: leave a degradation trace
|
|
273
|
+
# on stderr (stdout is the injection channel).
|
|
274
|
+
sys.stderr.write(f"stage-contract degraded: {error}\n")
|
|
275
|
+
return None
|
|
276
|
+
return build_stage_contract(
|
|
277
|
+
task_path,
|
|
278
|
+
whitelist,
|
|
279
|
+
spec_files,
|
|
280
|
+
parsed,
|
|
281
|
+
mutable=status != "delegated_subtask",
|
|
282
|
+
rules=rules,
|
|
283
|
+
)
|
|
284
|
+
except Exception as error:
|
|
285
|
+
sys.stderr.write(f"stage-contract degraded: {error}\n")
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
|
|
222
289
|
def _load_breadcrumbs(root: Path) -> dict[str, str]:
|
|
223
290
|
path = (
|
|
224
291
|
root
|
|
@@ -387,10 +454,12 @@ def _subagent_runtime_lines(context: dict[str, Any]) -> list[str]:
|
|
|
387
454
|
if isinstance(context.get("assignment"), dict)
|
|
388
455
|
else {}
|
|
389
456
|
)
|
|
457
|
+
# No "Scope: subagent" line here: the stage-contract block owns the scope
|
|
458
|
+
# declaration and renders the parent task's scope as a read-only reference
|
|
459
|
+
# for delegated sessions (build_stage_contract(mutable=False)).
|
|
390
460
|
lines = [
|
|
391
461
|
f"Runtime context: {context.get('runtime_context_id')}",
|
|
392
462
|
f"Agent: {context.get('agent_type') or 'unknown'}",
|
|
393
|
-
"Scope: subagent",
|
|
394
463
|
"Do not run standalone lifecycle commands, resume, archive, commit, or spawn.",
|
|
395
464
|
]
|
|
396
465
|
goal = assignment.get("goal")
|
|
@@ -56,6 +56,46 @@ TOOLS = [
|
|
|
56
56
|
),
|
|
57
57
|
"inputSchema": {"type": "object", "properties": {}},
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
"name": "task_specs",
|
|
61
|
+
"description": (
|
|
62
|
+
"Spec files the implementing agent should read for a task, "
|
|
63
|
+
"dispatched by its dev_type (base guides + domain skills + "
|
|
64
|
+
"backend/frontend/spec pointers). Read them before coding."
|
|
65
|
+
),
|
|
66
|
+
"inputSchema": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {
|
|
69
|
+
"task": {
|
|
70
|
+
"type": "string",
|
|
71
|
+
"description": "Task directory or name.",
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"name": "task_scope",
|
|
78
|
+
"description": (
|
|
79
|
+
"Edit-scope verdict for a task. Without `path`: the file-scope "
|
|
80
|
+
"whitelist summary (only file/planned-file/deleted-file entries "
|
|
81
|
+
"authorize edits; directories authorize nothing). With `path`: "
|
|
82
|
+
"an inScope verdict for that one file. Edits outside the scope "
|
|
83
|
+
"are review blockers."
|
|
84
|
+
),
|
|
85
|
+
"inputSchema": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"properties": {
|
|
88
|
+
"task": {
|
|
89
|
+
"type": "string",
|
|
90
|
+
"description": "Task directory or name.",
|
|
91
|
+
},
|
|
92
|
+
"path": {
|
|
93
|
+
"type": "string",
|
|
94
|
+
"description": "Repo-relative file path to check.",
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
59
99
|
]
|
|
60
100
|
|
|
61
101
|
|
|
@@ -87,17 +127,76 @@ def _tool_task_list(root: Path, _arguments: dict) -> dict:
|
|
|
87
127
|
return {"tasks": records, "count": len(records)}
|
|
88
128
|
|
|
89
129
|
|
|
130
|
+
def _resolve_task_dir(root: Path, target: str | None) -> Path:
|
|
131
|
+
from runtime.session_state import get_active_task
|
|
132
|
+
from services.task_repository import TaskRepository, TaskRepositoryError
|
|
133
|
+
|
|
134
|
+
if not target:
|
|
135
|
+
target = get_active_task(root).task_path
|
|
136
|
+
if not target:
|
|
137
|
+
raise ValueError("no-active-task")
|
|
138
|
+
try:
|
|
139
|
+
task_dir = TaskRepository(root).resolve(target)
|
|
140
|
+
except TaskRepositoryError as error:
|
|
141
|
+
raise ValueError(str(error)) from error
|
|
142
|
+
try:
|
|
143
|
+
resolved = Path(task_dir).resolve()
|
|
144
|
+
resolved.relative_to(Path(root).resolve())
|
|
145
|
+
except ValueError as error:
|
|
146
|
+
raise ValueError("task-outside-repo") from error
|
|
147
|
+
if not (Path(task_dir) / "task.json").is_file():
|
|
148
|
+
raise ValueError(f"task.json not found under {task_dir}")
|
|
149
|
+
return Path(task_dir)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _tool_task_scope(root: Path, arguments: dict) -> dict:
|
|
153
|
+
from services.fact_view import file_scope_whitelist, path_in_scope
|
|
154
|
+
|
|
155
|
+
task_dir = _resolve_task_dir(root, arguments.get("task"))
|
|
156
|
+
whitelist = file_scope_whitelist(root, task_dir)
|
|
157
|
+
candidate = arguments.get("path")
|
|
158
|
+
if candidate:
|
|
159
|
+
verdict = path_in_scope(whitelist, str(candidate))
|
|
160
|
+
return {"taskDir": task_dir.name, **verdict}
|
|
161
|
+
return {
|
|
162
|
+
"taskDir": task_dir.name,
|
|
163
|
+
"whitelist": whitelist,
|
|
164
|
+
"count": len(whitelist),
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _tool_task_specs(root: Path, arguments: dict) -> dict:
|
|
169
|
+
from services.context_discovery import implement_spec_entries
|
|
170
|
+
from services.fact_view import _read_json
|
|
171
|
+
|
|
172
|
+
task_dir = _resolve_task_dir(root, arguments.get("task"))
|
|
173
|
+
task = _read_json(task_dir / "task.json")
|
|
174
|
+
dev_type = task.get("dev_type") if isinstance(task, dict) else None
|
|
175
|
+
specs = implement_spec_entries(root, dev_type)
|
|
176
|
+
return {
|
|
177
|
+
"taskDir": task_dir.name,
|
|
178
|
+
"devType": dev_type,
|
|
179
|
+
"specs": specs,
|
|
180
|
+
"count": len(specs),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
90
184
|
TOOL_HANDLERS = {
|
|
91
185
|
"task_state": _tool_task_state,
|
|
92
186
|
"task_list": _tool_task_list,
|
|
187
|
+
"task_scope": _tool_task_scope,
|
|
188
|
+
"task_specs": _tool_task_specs,
|
|
93
189
|
}
|
|
94
190
|
|
|
95
191
|
|
|
96
192
|
def handle_request(root: Path, request: dict) -> dict | None:
|
|
97
|
-
"""Dispatch one decoded JSON-RPC message. Notifications (no id)
|
|
98
|
-
None and are never answered
|
|
193
|
+
"""Dispatch one decoded JSON-RPC message. Notifications (no id member)
|
|
194
|
+
yield None and are never answered — including initialize/ping/tools/list,
|
|
195
|
+
which are requests only when an id is present."""
|
|
99
196
|
method = request.get("method")
|
|
100
197
|
request_id = request.get("id")
|
|
198
|
+
if "id" not in request:
|
|
199
|
+
return None
|
|
101
200
|
if method == "initialize":
|
|
102
201
|
requested = (request.get("params") or {}).get("protocolVersion")
|
|
103
202
|
return {
|
|
@@ -58,17 +58,56 @@ def collect_changed_paths(repo_root: Path) -> list[str]:
|
|
|
58
58
|
return [changed.path for changed in collect_changed_files(repo_root)]
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
def collect_changed_paths_since(
|
|
62
|
+
repo_root: Path, base_commit: str | None
|
|
63
|
+
) -> tuple[list[str], bool]:
|
|
64
|
+
"""Merge the baseline..HEAD diff with the working-tree status:
|
|
65
|
+
(paths, degraded). Committing during a task no longer hides changed
|
|
66
|
+
files from review.
|
|
67
|
+
|
|
68
|
+
degraded=True when there is no baseline, HEAD does not exist, or the
|
|
69
|
+
diff fails (e.g. a rebase orphaned the baseline) — callers fall back to
|
|
70
|
+
status-only, which is exactly the pre-baseline behavior."""
|
|
71
|
+
status_paths = collect_changed_paths(repo_root)
|
|
72
|
+
if not base_commit:
|
|
73
|
+
return status_paths, True
|
|
74
|
+
rc, stdout, _ = _run_git_command(
|
|
75
|
+
["diff", "--name-only", f"{base_commit}..HEAD"],
|
|
76
|
+
cwd=repo_root,
|
|
71
77
|
)
|
|
78
|
+
if rc != 0:
|
|
79
|
+
return status_paths, True
|
|
80
|
+
diff_paths = [line.strip() for line in stdout.splitlines() if line.strip()]
|
|
81
|
+
merged = sorted(set(status_paths) | set(diff_paths))
|
|
82
|
+
return merged, False
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def current_head(repo_root: Path) -> str | None:
|
|
86
|
+
"""Full HEAD sha of the repo, or None when no commits exist yet."""
|
|
87
|
+
rc, stdout, _ = _run_git_command(["rev-parse", "HEAD"], cwd=repo_root)
|
|
88
|
+
if rc != 0:
|
|
89
|
+
return None
|
|
90
|
+
value = stdout.strip()
|
|
91
|
+
return value or None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[int, str, str]:
|
|
95
|
+
try:
|
|
96
|
+
completed = subprocess.run(
|
|
97
|
+
["git", *args],
|
|
98
|
+
cwd=cwd,
|
|
99
|
+
text=True,
|
|
100
|
+
encoding="utf-8",
|
|
101
|
+
errors="replace",
|
|
102
|
+
stdout=subprocess.PIPE,
|
|
103
|
+
stderr=subprocess.PIPE,
|
|
104
|
+
check=False,
|
|
105
|
+
)
|
|
106
|
+
except OSError:
|
|
107
|
+
# git unavailable (not installed, or PATH stripped): callers degrade
|
|
108
|
+
# through the existing rc!=0 paths — no HEAD means no baseline, no
|
|
109
|
+
# changes means status-only review.
|
|
110
|
+
return 127, "", "git unavailable"
|
|
72
111
|
return completed.returncode, completed.stdout, completed.stderr
|
|
73
112
|
|
|
74
113
|
|
|
@@ -414,7 +414,9 @@ def context_entries(
|
|
|
414
414
|
paths: tuple[str, ...] = (),
|
|
415
415
|
include_wildcard: bool = True,
|
|
416
416
|
) -> list[dict[str, str]]:
|
|
417
|
-
normalized_dev_type = (
|
|
417
|
+
normalized_dev_type = (
|
|
418
|
+
dev_type if isinstance(dev_type, str) else ""
|
|
419
|
+
).strip()
|
|
418
420
|
normalized_paths = tuple(
|
|
419
421
|
str(path).replace("\\", "/").removeprefix("./") for path in paths
|
|
420
422
|
)
|
|
@@ -110,6 +110,32 @@ def get_domain_skill_context(
|
|
|
110
110
|
)
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def implement_spec_entries(
|
|
114
|
+
repo_root: Path,
|
|
115
|
+
dev_type: str | None,
|
|
116
|
+
) -> list[dict]:
|
|
117
|
+
"""Single source of the implement-context dispatch (base + domain skills
|
|
118
|
+
+ dev-type spec pointers). Consumed by task context init and by the MCP
|
|
119
|
+
task_specs tool, so both agree on what an agent should read."""
|
|
120
|
+
entries = list(get_implement_base(repo_root))
|
|
121
|
+
entries.extend(
|
|
122
|
+
get_domain_skill_context(
|
|
123
|
+
repo_root,
|
|
124
|
+
dev_type=dev_type,
|
|
125
|
+
)
|
|
126
|
+
)
|
|
127
|
+
if dev_type in ("backend", "test"):
|
|
128
|
+
entries.extend(get_implement_backend())
|
|
129
|
+
elif dev_type == "frontend":
|
|
130
|
+
entries.extend(get_implement_frontend())
|
|
131
|
+
elif dev_type == "fullstack":
|
|
132
|
+
entries.extend(get_implement_backend())
|
|
133
|
+
entries.extend(get_implement_frontend())
|
|
134
|
+
elif dev_type == "spec":
|
|
135
|
+
entries.extend(get_implement_spec())
|
|
136
|
+
return entries
|
|
137
|
+
|
|
138
|
+
|
|
113
139
|
def is_skill_path(file_path: str) -> bool:
|
|
114
140
|
return (
|
|
115
141
|
file_path.startswith(f"{DIR_AGENTS}/skills/")
|