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 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
@@ -314,6 +314,7 @@ npm run release:check # 发布信心门禁;当前等价于 test:all
314
314
  ```bash
315
315
  npm run release # patch
316
316
  npm run release -- minor # minor
317
+ npm run release -- --version 0.1.0 # 精确发布指定版本(跳过自动 bump)
317
318
  ```
318
319
 
319
320
  **发布流程:**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.52",
3
+ "version": "1.0.0",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- 0.0.52
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 clear_active_task, get_active_task
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 get_active_task
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
- if active.task_path:
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 get_active_task
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
- if active.task_path:
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
- context_key = _resolve_env_context_key() or _resolve_input_context_key(values)
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 _prefixed_context_key("zcode", process_label)
65
- return None
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 _resolve_env_context_key() -> str | None:
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 = resolve_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 = resolve_context_key(values)
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": (
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.51",
3
+ "version": "1.0.0",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"
@@ -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 non-archived tasks for rebind hints; tasks/archive lives
147
- // two levels deeper so a plain readdir already excludes it.
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
- const command = String(input?.tool_input?.command || "");
449
- if (!command.includes(".cowork-flow/run task")) {
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
- if (
491
- typeof input?.hook_event_name === "string" &&
492
- input.hook_event_name.trim() === "SessionStart"
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: