cowork-flow 0.0.52 → 1.0.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 +18 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +28 -1
- 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/runtime/session_state.py +44 -15
- package/template/.cowork-flow/scripts/services/task_lifecycle.py +7 -1
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
- package/template/.zcode/hooks/inject-context.js +20 -10
- package/template/.zcode/hooks/inject-context.selfcheck.mjs +53 -0
- package/template/skills/runtime-health/scripts/doctor.py +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.0.0 - 2026-08-26
|
|
4
|
+
|
|
5
|
+
首个稳定主线发布:核心流程契约、会话模型与宿主矩阵在此版本冻结,后续改动进入语义化版本约束。
|
|
6
|
+
|
|
7
|
+
### 稳定性声明
|
|
8
|
+
|
|
9
|
+
- **宿主矩阵冻结**:`codex` / `opencode` / `claude-code` / `dsh` / `zcode` 五宿主经 host-assets 注册、adapter 一致性校验与 doctor 全链路覆盖;zcode 插件资产与工作区流程资产均为单源分发(`.zcode/` 走插件市场,技能落盘 `.cowork-flow/skills/` 供内核解析且不进入提示层)。
|
|
10
|
+
- **状态注入协议冻结**:`<workflow-state>` 块 + contract-digest(SessionStart 全量 / 逐消息指纹行)+ 生命周期快照(`.runtime/state-snapshot.json`,与状态转换同单元原子提交)构成宿主 hook 的标准输入;`build_hook_context` 共享协议具备直接单测覆盖。
|
|
11
|
+
- **会话模型冻结**:按会话身份(host session id / 显式 context id / hook sessionId)绑定;显式身份无绑定时判 `no_task` 并列可改绑任务,无身份请求才走全局最新有效兜底;进程级 fallback 绑定不再自动跟随。
|
|
12
|
+
|
|
13
|
+
### 自 0.0.52 以来的变更
|
|
14
|
+
|
|
15
|
+
- 会话绑定安全加固:进程 fallback 会话键(`ZCODE_PROCESS_LABEL`)带 provenance 标记,导航、`--run` 派发、review/complete 目标解析与命令行收尾拒绝自动跟随 fallback 绑定,要求显式任务目录;trusted 身份(显式 env、宿主 session env、hook sessionId)保持完整绑定语义。
|
|
16
|
+
- 对抗性审查修正批次:显式 `COWORK_FLOW_CONTEXT_ID` 按裸键解析与 CLI 对齐;PostToolUse 刷新过滤支持 `cd` + 裸 `run` 与 Windows `run.cmd` 命令形态;legacy cursor 宿主保留完整 contract-digest;改绑提示排除已完成的终态任务;doctor 会话卫生检查对无时区时间戳降级为告警而非崩溃。
|
|
17
|
+
- 交互式平台选择器与平台检测断言随五宿主矩阵更新。
|
|
18
|
+
- release.sh 容错:`--version` 精确模式在版本文件已全部就位(干净工作树)时,`git commit` 的 no-op 空提交不再中止脚本,继续 tag 与 publish;其余 commit 失败仍立即中止。新增回归测试覆盖两条路径。
|
|
19
|
+
- release.sh 容错:目标发布 tag 已存在且指向当前 HEAD 时跳过创建并继续 publish(上次运行 tag 后 publish 未完成的重跑场景);指向其它提交则中止报错。新增回归测试覆盖两条路径。
|
|
20
|
+
|
|
3
21
|
## 0.0.52 - 2026-08-26
|
|
4
22
|
|
|
5
23
|
### ZCode 宿主与 hook 体验
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0
|
|
1
|
+
1.0.0
|
|
@@ -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(
|
|
@@ -342,6 +359,16 @@ def cmd_finish(args: argparse.Namespace) -> int:
|
|
|
342
359
|
"""Clear the active task for this session."""
|
|
343
360
|
repo_root = get_repo_root()
|
|
344
361
|
active = get_active_task(repo_root)
|
|
362
|
+
if active.task_path and active.provenance == PROVENANCE_PROCESS_FALLBACK:
|
|
363
|
+
print(
|
|
364
|
+
colored(
|
|
365
|
+
"Error: Refusing to clear a session binding resolved from "
|
|
366
|
+
"process-fallback identity; archive the task to clear it.",
|
|
367
|
+
Colors.RED,
|
|
368
|
+
),
|
|
369
|
+
file=sys.stderr,
|
|
370
|
+
)
|
|
371
|
+
return 1
|
|
345
372
|
if not active.task_path:
|
|
346
373
|
print(
|
|
347
374
|
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
|
)
|
|
@@ -12,7 +12,11 @@ from typing import Callable
|
|
|
12
12
|
from adapters.cli import task_navigation
|
|
13
13
|
from adapters.cli.task_support import Colors, colored, resolve_task_dir
|
|
14
14
|
from infra.paths import get_repo_root
|
|
15
|
-
from runtime.session_state import
|
|
15
|
+
from runtime.session_state import (
|
|
16
|
+
FALLBACK_BINDING_BLOCKER,
|
|
17
|
+
PROVENANCE_PROCESS_FALLBACK,
|
|
18
|
+
get_active_task,
|
|
19
|
+
)
|
|
16
20
|
from services.plan_binding import PlanBindingError, bind_task_plan
|
|
17
21
|
|
|
18
22
|
|
|
@@ -48,19 +52,32 @@ def _next_target_for_run(args: argparse.Namespace, repo_root: Path):
|
|
|
48
52
|
return task_dir, _display_task_path(repo_root, task_dir), False
|
|
49
53
|
|
|
50
54
|
active = get_active_task(repo_root)
|
|
51
|
-
|
|
55
|
+
# 进程标签回退身份是共享的,绑定只作 advisory,不自动定位任务。
|
|
56
|
+
if active.task_path and active.provenance != PROVENANCE_PROCESS_FALLBACK:
|
|
52
57
|
task_dir = repo_root / active.task_path
|
|
53
58
|
return task_dir, active.task_path, True
|
|
54
59
|
return None, None, False
|
|
55
60
|
|
|
56
61
|
|
|
62
|
+
def _fallback_binding_blockers(
|
|
63
|
+
args: argparse.Namespace,
|
|
64
|
+
repo_root: Path,
|
|
65
|
+
) -> list[str]:
|
|
66
|
+
if getattr(args, "dir", None):
|
|
67
|
+
return []
|
|
68
|
+
active = get_active_task(repo_root)
|
|
69
|
+
if active.task_path and active.provenance == PROVENANCE_PROCESS_FALLBACK:
|
|
70
|
+
return [FALLBACK_BINDING_BLOCKER]
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
57
74
|
def _next_payload_for_run(args: argparse.Namespace, repo_root: Path) -> dict[str, object]:
|
|
58
75
|
task_dir, task_path, active_target = _next_target_for_run(args, repo_root)
|
|
59
76
|
if task_dir is None or task_path is None:
|
|
60
77
|
return task_navigation.build_navigation_payload(
|
|
61
78
|
args=args,
|
|
62
79
|
status="no_task",
|
|
63
|
-
blockers=
|
|
80
|
+
blockers=_fallback_binding_blockers(args, repo_root),
|
|
64
81
|
active_target=False,
|
|
65
82
|
task_path=None,
|
|
66
83
|
)
|
|
@@ -17,8 +17,17 @@ DIR_SUBAGENTS = "subagents"
|
|
|
17
17
|
FIELD_ACTIVE_TASK_PATH = "active_task_path"
|
|
18
18
|
FIELD_RUNTIME_CONTEXT_ID = "runtime_context_id"
|
|
19
19
|
FIELD_SCOPE = "scope"
|
|
20
|
+
FIELD_IDENTITY_PROVENANCE = "identity_provenance"
|
|
20
21
|
SCOPE_MAIN = "main"
|
|
21
22
|
SCOPE_SUBAGENT = "subagent"
|
|
23
|
+
PROVENANCE_EXPLICIT = "explicit"
|
|
24
|
+
PROVENANCE_HOST_SESSION = "host_session"
|
|
25
|
+
PROVENANCE_PROCESS_FALLBACK = "process_fallback"
|
|
26
|
+
PROVENANCE_MISSING = "missing"
|
|
27
|
+
FALLBACK_BINDING_BLOCKER = (
|
|
28
|
+
"session identity is process-fallback (shared); pass <task-dir> explicitly "
|
|
29
|
+
"or set COWORK_FLOW_CONTEXT_ID to a trusted session id"
|
|
30
|
+
)
|
|
22
31
|
RUNTIME_CONTEXT_PROMPT_RE = re.compile(
|
|
23
32
|
r"(?im)^\s*cowork_runtime_context_id\s*:\s*([A-Za-z0-9._-]+)\s*$"
|
|
24
33
|
)
|
|
@@ -32,6 +41,7 @@ class ActiveTask:
|
|
|
32
41
|
task_path: str | None
|
|
33
42
|
context_key: str | None
|
|
34
43
|
source: str
|
|
44
|
+
provenance: str = PROVENANCE_MISSING
|
|
35
45
|
|
|
36
46
|
|
|
37
47
|
def _sanitize(raw: str) -> str:
|
|
@@ -54,15 +64,28 @@ def _first_prompt_value(values: Mapping[str, object] | None) -> str | None:
|
|
|
54
64
|
|
|
55
65
|
|
|
56
66
|
def resolve_context_key(values: Mapping[str, object] | None = None) -> str | None:
|
|
57
|
-
|
|
67
|
+
return resolve_context_key_with_provenance(values)[0]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_context_key_with_provenance(
|
|
71
|
+
values: Mapping[str, object] | None = None,
|
|
72
|
+
) -> tuple[str | None, str]:
|
|
73
|
+
context_key, provenance = _resolve_env_context_key_with_provenance()
|
|
74
|
+
if context_key:
|
|
75
|
+
return context_key, provenance
|
|
76
|
+
context_key = _resolve_input_context_key(values)
|
|
58
77
|
if context_key:
|
|
59
|
-
return context_key
|
|
78
|
+
return context_key, PROVENANCE_HOST_SESSION
|
|
60
79
|
# zcode 主会话(Bash CLI)无任何 host session id:用进程标签兜底,
|
|
61
80
|
# 保证 task start / session 解析一次成功,不再回落到其它 host。
|
|
81
|
+
# 该身份是进程级共享的,消费方必须按 PROVENANCE_PROCESS_FALLBACK 降级处理。
|
|
62
82
|
process_label = os.environ.get("ZCODE_PROCESS_LABEL")
|
|
63
83
|
if process_label and process_label.strip():
|
|
64
|
-
return
|
|
65
|
-
|
|
84
|
+
return (
|
|
85
|
+
_prefixed_context_key("zcode", process_label),
|
|
86
|
+
PROVENANCE_PROCESS_FALLBACK,
|
|
87
|
+
)
|
|
88
|
+
return None, PROVENANCE_MISSING
|
|
66
89
|
|
|
67
90
|
|
|
68
91
|
def _prefixed_context_key(prefix: str, raw: str | None) -> str | None:
|
|
@@ -71,10 +94,10 @@ def _prefixed_context_key(prefix: str, raw: str | None) -> str | None:
|
|
|
71
94
|
return None
|
|
72
95
|
|
|
73
96
|
|
|
74
|
-
def
|
|
97
|
+
def _resolve_env_context_key_with_provenance() -> tuple[str | None, str]:
|
|
75
98
|
explicit = os.environ.get("COWORK_FLOW_CONTEXT_ID")
|
|
76
99
|
if explicit and explicit.strip():
|
|
77
|
-
return _sanitize(explicit)
|
|
100
|
+
return _sanitize(explicit), PROVENANCE_EXPLICIT
|
|
78
101
|
|
|
79
102
|
for prefix, env_name in (
|
|
80
103
|
("zcode", "ZCODE_SESSION_ID"),
|
|
@@ -87,8 +110,12 @@ def _resolve_env_context_key() -> str | None:
|
|
|
87
110
|
):
|
|
88
111
|
context_key = _prefixed_context_key(prefix, os.environ.get(env_name))
|
|
89
112
|
if context_key:
|
|
90
|
-
return context_key
|
|
91
|
-
return None
|
|
113
|
+
return context_key, PROVENANCE_HOST_SESSION
|
|
114
|
+
return None, PROVENANCE_MISSING
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resolve_env_context_key() -> str | None:
|
|
118
|
+
return _resolve_env_context_key_with_provenance()[0]
|
|
92
119
|
|
|
93
120
|
|
|
94
121
|
def _resolve_input_context_key(values: Mapping[str, object] | None) -> str | None:
|
|
@@ -262,23 +289,25 @@ def build_active_task_session(
|
|
|
262
289
|
task_path: str,
|
|
263
290
|
) -> tuple[Path, dict, ActiveTask] | None:
|
|
264
291
|
"""Build the session state needed to activate a task."""
|
|
265
|
-
context_key =
|
|
292
|
+
context_key, provenance = resolve_context_key_with_provenance()
|
|
266
293
|
if not context_key:
|
|
267
294
|
return None
|
|
268
295
|
normalized = task_path.replace("\\", "/")
|
|
269
296
|
target = repo_root / normalized
|
|
270
297
|
if not target.is_dir():
|
|
271
298
|
return None
|
|
272
|
-
data = {
|
|
299
|
+
data: dict[str, object] = {
|
|
273
300
|
FIELD_ACTIVE_TASK_PATH: normalized,
|
|
274
301
|
FIELD_SCOPE: SCOPE_MAIN,
|
|
275
302
|
"platform": platform_from_context_key(context_key),
|
|
276
303
|
"last_seen_at": _now(),
|
|
277
304
|
}
|
|
305
|
+
if provenance == PROVENANCE_PROCESS_FALLBACK:
|
|
306
|
+
data[FIELD_IDENTITY_PROVENANCE] = PROVENANCE_PROCESS_FALLBACK
|
|
278
307
|
return (
|
|
279
308
|
_session_path(repo_root, context_key),
|
|
280
309
|
data,
|
|
281
|
-
ActiveTask(normalized, context_key, "session"),
|
|
310
|
+
ActiveTask(normalized, context_key, "session", provenance=provenance),
|
|
282
311
|
)
|
|
283
312
|
|
|
284
313
|
|
|
@@ -292,14 +321,14 @@ def set_active_task(repo_root: Path, task_path: str) -> ActiveTask | None:
|
|
|
292
321
|
|
|
293
322
|
|
|
294
323
|
def get_active_task(repo_root: Path, values: Mapping[str, object] | None = None) -> ActiveTask:
|
|
295
|
-
context_key =
|
|
324
|
+
context_key, provenance = resolve_context_key_with_provenance(values)
|
|
296
325
|
if not context_key:
|
|
297
|
-
return ActiveTask(None, None, "missing-context")
|
|
326
|
+
return ActiveTask(None, None, "missing-context", provenance=provenance)
|
|
298
327
|
data = _read_json(_session_path(repo_root, context_key))
|
|
299
328
|
task_path = data.get(FIELD_ACTIVE_TASK_PATH)
|
|
300
329
|
if isinstance(task_path, str) and task_path.strip():
|
|
301
|
-
return ActiveTask(task_path.strip(), context_key, "session")
|
|
302
|
-
return ActiveTask(None, context_key, "empty-session")
|
|
330
|
+
return ActiveTask(task_path.strip(), context_key, "session", provenance=provenance)
|
|
331
|
+
return ActiveTask(None, context_key, "empty-session", provenance=provenance)
|
|
303
332
|
|
|
304
333
|
|
|
305
334
|
def is_main_session(repo_root: Path, values: Mapping[str, object] | None = None) -> bool:
|
|
@@ -526,7 +526,13 @@ class TaskLifecycleService:
|
|
|
526
526
|
return None
|
|
527
527
|
|
|
528
528
|
def _state_snapshot(self, stage: LifecycleStage, task_dir: Path) -> dict:
|
|
529
|
-
"""Host-hook breadcrumb facts recorded atomically with the transition.
|
|
529
|
+
"""Host-hook breadcrumb facts recorded atomically with the transition.
|
|
530
|
+
|
|
531
|
+
breadcrumbKey intentionally equals target_status today: every current
|
|
532
|
+
stage maps one status to one breadcrumb tag. The field exists so a
|
|
533
|
+
future stage can diverge the two without changing hook consumers,
|
|
534
|
+
which trust this key over their own status-derived convention.
|
|
535
|
+
"""
|
|
530
536
|
return {
|
|
531
537
|
"schemaVersion": 1,
|
|
532
538
|
"generatedAt": (
|
|
@@ -97,9 +97,14 @@ function sanitizeContextKey(raw) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
function resolveSessionKey(input) {
|
|
100
|
+
// Python session_state resolves an explicit COWORK_FLOW_CONTEXT_ID as the
|
|
101
|
+
// raw context key - it must not gain a host prefix here either.
|
|
100
102
|
const explicit = process.env.COWORK_FLOW_CONTEXT_ID;
|
|
103
|
+
if (typeof explicit === "string" && explicit.trim()) {
|
|
104
|
+
const key = sanitizeContextKey(explicit);
|
|
105
|
+
if (key) return key;
|
|
106
|
+
}
|
|
101
107
|
const candidates = [
|
|
102
|
-
explicit,
|
|
103
108
|
process.env.ZCODE_SESSION_ID,
|
|
104
109
|
input?.zcode_session_id,
|
|
105
110
|
input?.ZCODE_SESSION_ID,
|
|
@@ -143,8 +148,8 @@ function toActiveTask(data) {
|
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
// One-level scan of
|
|
147
|
-
//
|
|
151
|
+
// One-level scan of bindable tasks for rebind hints; tasks/archive lives two
|
|
152
|
+
// levels deeper so a plain readdir already excludes it.
|
|
148
153
|
function listActiveTasks(repoRoot) {
|
|
149
154
|
const tasksDir = join(repoRoot, DIR_WORKFLOW, "tasks");
|
|
150
155
|
const out = [];
|
|
@@ -152,7 +157,7 @@ function listActiveTasks(repoRoot) {
|
|
|
152
157
|
for (const name of readdirSync(tasksDir)) {
|
|
153
158
|
const relative = `${DIR_WORKFLOW}/tasks/${name}`;
|
|
154
159
|
const { status, missing } = readTaskStatus(repoRoot, relative);
|
|
155
|
-
if (!missing) out.push(`${relative} (${status})`);
|
|
160
|
+
if (!missing && status !== "completed") out.push(`${relative} (${status})`);
|
|
156
161
|
}
|
|
157
162
|
} catch {
|
|
158
163
|
// tasks dir unreadable
|
|
@@ -445,8 +450,13 @@ function main() {
|
|
|
445
450
|
typeof input?.hook_event_name === "string" &&
|
|
446
451
|
input.hook_event_name.trim() === "PostToolUse"
|
|
447
452
|
) {
|
|
448
|
-
|
|
449
|
-
|
|
453
|
+
// Normalize separators so Windows run.cmd invocations still match; the
|
|
454
|
+
// bare-run fallback covers `cd .cowork-flow && ./run task ...` forms.
|
|
455
|
+
const command = String(input?.tool_input?.command || "").replaceAll("\\", "/");
|
|
456
|
+
if (
|
|
457
|
+
!command.includes(".cowork-flow/run") &&
|
|
458
|
+
!/\brun(?:\.cmd)?\s+task\b/.test(command)
|
|
459
|
+
) {
|
|
450
460
|
process.exit(0);
|
|
451
461
|
}
|
|
452
462
|
}
|
|
@@ -487,10 +497,10 @@ Source: cowork-flow-plugin
|
|
|
487
497
|
// so long sessions don't pay for the full listing on every message.
|
|
488
498
|
const rootForDigest = effectiveRoot || envDir || input.cwd;
|
|
489
499
|
if (rootForDigest) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
) {
|
|
500
|
+
// Use the normalized event so legacy hosts whose only signal is the
|
|
501
|
+
// plugin-root env (detectEventName guesses SessionStart) keep receiving
|
|
502
|
+
// the full block they got before slimming.
|
|
503
|
+
if (event === "SessionStart") {
|
|
494
504
|
context = `${buildContractDigest(rootForDigest)}\n\n${context}`;
|
|
495
505
|
} else {
|
|
496
506
|
const contracts = loadContractRegistry(rootForDigest);
|
|
@@ -480,6 +480,59 @@ try {
|
|
|
480
480
|
assert.match(parsedFor(), /活动任务正在执行/, "missing snapshot falls back cleanly");
|
|
481
481
|
});
|
|
482
482
|
|
|
483
|
+
test("explicit COWORK_FLOW_CONTEXT_ID resolves its raw key like the CLI", () => {
|
|
484
|
+
ensureTask("07-02-test-task");
|
|
485
|
+
resetSessions({
|
|
486
|
+
"main-session.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "zcode", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
487
|
+
});
|
|
488
|
+
const result = runHook(
|
|
489
|
+
{ ZCODE_PROJECT_DIR: tmpRoot, COWORK_FLOW_CONTEXT_ID: "main-session" },
|
|
490
|
+
JSON.stringify({ hook_event_name: "UserPromptSubmit" })
|
|
491
|
+
);
|
|
492
|
+
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
493
|
+
assert.match(ctx, /Task: \.cowork-flow\/tasks\/07-02-test-task/, "raw explicit key must hit the same file as the CLI");
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("PostToolUse refreshes for cd-and-bare-run command forms", () => {
|
|
497
|
+
ensureTask("07-02-test-task");
|
|
498
|
+
resetSessions({
|
|
499
|
+
"claude_abc.json": { active_task_path: ".cowork-flow/tasks/07-02-test-task", scope: "main", platform: "claude", last_seen_at: "2026-07-02T13:00:00Z" },
|
|
500
|
+
});
|
|
501
|
+
for (const command of [
|
|
502
|
+
"cd .cowork-flow && ./run task next --run",
|
|
503
|
+
".\\run.cmd task next .cowork-flow/tasks/x",
|
|
504
|
+
]) {
|
|
505
|
+
const result = runHook(
|
|
506
|
+
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
507
|
+
JSON.stringify({ hook_event_name: "PostToolUse", tool_input: { command } })
|
|
508
|
+
);
|
|
509
|
+
const ctx = JSON.parse(result).hookSpecificOutput.additionalContext;
|
|
510
|
+
assert.match(ctx, /Status: in_progress/, `refresh expected for: ${command}`);
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("rebind hints exclude terminal completed tasks", () => {
|
|
515
|
+
ensureTask("07-02-test-task");
|
|
516
|
+
ensureTask("07-01-done", "completed");
|
|
517
|
+
resetSessions({
|
|
518
|
+
"zcode_stale.json": { active_task_path: ".cowork-flow/tasks/06-21-deleted", scope: "main", platform: "zcode", last_seen_at: "2026-08-01T09:00:00Z" },
|
|
519
|
+
});
|
|
520
|
+
const result = runHook(
|
|
521
|
+
{ ZCODE_PROJECT_DIR: tmpRoot },
|
|
522
|
+
JSON.stringify({ hook_event_name: "UserPromptSubmit", session_id: "stale" })
|
|
523
|
+
);
|
|
524
|
+
const m = JSON.parse(result).hookSpecificOutput.additionalContext.match(/<workflow-state>([\s\S]*?)<\/workflow-state>/);
|
|
525
|
+
assert.ok(m);
|
|
526
|
+
assert.match(m[1], /07-02-test-task/);
|
|
527
|
+
assert.doesNotMatch(m[1], /07-01-done/, "completed tasks are not bindable candidates");
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test("cursor legacy format keeps receiving the full contract digest", () => {
|
|
531
|
+
const result = runHook({ ZCODE_PROJECT_DIR: tmpRoot, CURSOR_PLUGIN_ROOT: "/x" });
|
|
532
|
+
const ctx = JSON.parse(result).additional_context;
|
|
533
|
+
assert.match(ctx, /<contract-digest fingerprint="[a-f0-9]+">/, "legacy cursor hosts get the full block as before slimming");
|
|
534
|
+
});
|
|
535
|
+
|
|
483
536
|
} finally {
|
|
484
537
|
cleanup();
|
|
485
538
|
}
|
|
@@ -380,6 +380,10 @@ def check_session_hygiene(repo_root: Path) -> list[dict[str, str]]:
|
|
|
380
380
|
if seen_at is None and raw_seen_at:
|
|
381
381
|
reasons.append(f"unparsable last_seen_at: {raw_seen_at}")
|
|
382
382
|
aged = True
|
|
383
|
+
elif seen_at is not None and seen_at.tzinfo is None:
|
|
384
|
+
# Naive timestamps cannot be compared against aware now().
|
|
385
|
+
reasons.append(f"last_seen_at without timezone: {raw_seen_at}")
|
|
386
|
+
aged = True
|
|
383
387
|
elif seen_at is not None:
|
|
384
388
|
age_days = (now - seen_at).days
|
|
385
389
|
if age_days > SESSION_STALE_MAX_AGE_DAYS:
|