Просмотр исходного кода

Merge live assistant stream into embedded stream migration

# Conflicts:
#	packages/session/session-projection-cache/src/spec.ts
Tianyi Cui 2 недель назад
Родитель
Сommit
042bd8d01a
90 измененных файлов с 2184 добавлено и 541 удалено
  1. 2 2
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml
  2. 3 3
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
  3. 3 3
      .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml
  5. 2 0
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
  6. 2 0
      .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md
  7. 6 0
      .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml
  8. 76 0
      .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
  9. 76 0
      .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md
  10. 2 2
      .agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml
  11. 3 3
      .agents/notes/implemented/feature/2026-08-05-agent-teams.md
  12. 3 3
      .agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md
  13. 6 0
      .agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.i18n.yaml
  14. 41 0
      .agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md
  15. 41 0
      .agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md
  16. 2 2
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml
  17. 0 1
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
  18. 1 1
      .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md
  19. 1 1
      .github/issue-management/config.json
  20. 17 22
      .github/issue-management/policy.mjs
  21. 25 30
      .github/issue-management/policy.test.mjs
  22. 6 0
      apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs
  23. 55 0
      apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts
  24. 2 2
      docs/config-catalog.i18n.yaml
  25. 2 2
      docs/config-catalog.md
  26. 2 2
      docs/config-catalog.zh.md
  27. 2 2
      docs/persistence-catalog.i18n.yaml
  28. 8 8
      docs/persistence-catalog.md
  29. 8 8
      docs/persistence-catalog.zh.md
  30. 2 2
      docs/subsystems/agent-team.i18n.yaml
  31. 4 3
      docs/subsystems/agent-team.md
  32. 4 3
      docs/subsystems/agent-team.zh.md
  33. 2 2
      docs/subsystems/storage.i18n.yaml
  34. 28 5
      docs/subsystems/storage.md
  35. 28 5
      docs/subsystems/storage.zh.md
  36. 2 2
      docs/tool-catalog.i18n.yaml
  37. 3 29
      docs/tool-catalog.md
  38. 2 28
      docs/tool-catalog.zh.md
  39. 1 1
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  40. 2 2
      packages/experimental/README.i18n.yaml
  41. 1 1
      packages/experimental/README.md
  42. 1 1
      packages/experimental/README.zh.md
  43. 2 2
      packages/experimental/agent-team/README.i18n.yaml
  44. 3 1
      packages/experimental/agent-team/README.md
  45. 3 1
      packages/experimental/agent-team/README.zh.md
  46. 1 1
      packages/experimental/agent-team/src/index.ts
  47. 31 41
      packages/experimental/agent-team/src/mailbox.ts
  48. 6 7
      packages/experimental/agent-team/src/projection.ts
  49. 3 3
      packages/experimental/agent-team/src/roster.ts
  50. 2 2
      packages/experimental/agent-team/src/task-board.ts
  51. 4 6
      packages/experimental/agent-team/src/types.ts
  52. 3 3
      packages/experimental/agent-team/tests/invariant.spec.ts
  53. 15 30
      packages/experimental/agent-team/tests/persistence.spec.ts
  54. 43 44
      packages/experimental/agent-team/tests/projection-events.spec.ts
  55. 116 80
      packages/experimental/agent-team/tests/team.spec.ts
  56. 2 2
      packages/experimental/tool-agent-team/README.i18n.yaml
  57. 8 8
      packages/experimental/tool-agent-team/README.md
  58. 9 9
      packages/experimental/tool-agent-team/README.zh.md
  59. 18 25
      packages/experimental/tool-agent-team/src/index.ts
  60. 5 7
      packages/experimental/tool-agent-team/tests/tool-team.spec.ts
  61. 6 6
      packages/extensions/tool-cordis/src/api-catalog.ts
  62. 2 2
      packages/session/session-projection-cache/README.i18n.yaml
  63. 3 0
      packages/session/session-projection-cache/README.md
  64. 3 0
      packages/session/session-projection-cache/README.zh.md
  65. 19 6
      packages/session/session-projection-cache/src/index.ts
  66. 26 4
      packages/session/session-projection-cache/src/spec.ts
  67. 80 3
      packages/session/session-projection-cache/tests/cache.spec.ts
  68. 255 0
      packages/session/session-projection-cache/tests/fixtures.spec.ts
  69. 136 0
      packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json
  70. 212 0
      packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json
  71. 128 0
      packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json
  72. 220 0
      packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json
  73. 21 2
      packages/storage/storage-domain/src/index.ts
  74. 37 3
      packages/storage/storage-domain/src/spec.ts
  75. 41 0
      packages/storage/storage-domain/tests/domain.spec.ts
  76. 2 2
      packages/storage/storage-json/README.i18n.yaml
  77. 2 2
      packages/storage/storage-json/README.md
  78. 2 2
      packages/storage/storage-json/README.zh.md
  79. 10 8
      packages/storage/storage-json/src/format.ts
  80. 57 21
      packages/storage/storage-json/src/per-record-unit.ts
  81. 57 2
      packages/storage/storage-json/tests/json-backend.spec.ts
  82. 26 3
      packages/storage/storage/src/backend.ts
  83. 2 2
      packages/subagent/subagent/README.i18n.yaml
  84. 1 1
      packages/subagent/subagent/README.md
  85. 1 1
      packages/subagent/subagent/README.zh.md
  86. 29 2
      packages/subagent/subagent/src/continuation.ts
  87. 10 5
      packages/subagent/subagent/src/index.ts
  88. 39 6
      packages/subagent/subagent/src/internal.ts
  89. 3 2
      packages/subagent/subagent/tests/control.spec.ts
  90. 1 1
      scripts/gen-tool-catalog.ts

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md
-2026-08-19-projection-cache-per-session-files.md: 0fd171649c1d8c7c3be7a8089287d43782b84714
-2026-08-19-projection-cache-per-session-files.zh.md: 47decb598cc70233c287feab2dddff46bd7bcbc9
+2026-08-19-projection-cache-per-session-files.md: 0792ed6cac23d1ae09767f608c74f82430db68e9
+2026-08-19-projection-cache-per-session-files.zh.md: 201146ce8568b66eebf6793fcdc489929a3e4cdb

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.md

@@ -20,8 +20,8 @@ Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a sy
 - Listing is a synchronous in-memory read; a session without a record document simply lacks the projection column.
 - ACP, headless, SDK, and Web sessions publish cache rows for later consumers. The log-leading durability barrier may flush a covered prefix at the cache cadence and split otherwise coalesced physical JSONL runs; recorded profile snapshots re-pack the logical event stream so cache timing does not define fixture layout.
 - The per-record contract scopes failure: a malformed or stale-version document reads as an absent record at open, so one bad file never bricks the cache, and a checkpoint schema bump discards stale sessions per record instead of rejecting the whole domain.
-- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path and the legacy unit name and version match the requested descriptor. A different version remains untouched and the new domain opens empty; storage never relabels its values as the current version. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log.
-- The `session_projcache` domain uses version 6. Every version-5 record reads as absent, including a healthy one, so a poisoned version-5 record cannot fail domain validation. Session headers and event logs remain in session persistence; an exact read refolds them and writes a version-6 cache record, while zero-I/O listings lack that projection until the cache returns.
+- The json backend bootstraps the per-record tree from the legacy whole-unit cache only when enumeration finds no new-layout document path, the legacy unit name matches, and its version is current or declared compatible. A version outside that accepted set remains untouched and the new domain opens empty; storage never relabels a version the domain owner did not approve. Any new document path, including an unreadable or stale file, suppresses the bootstrap for the whole unit; missing session rows refold from the log. The [cross-version read-compatibility decision](2026-09-02-projcache-cross-version-read-compat.md) owns the version policy.
+- The `session_projcache` domain uses version 7 and declares versions 3 through 6 structurally compatible. Predecessor identities lack the Session `formatVersion`, so they cannot seed current projections and refold cold; a current checkpoint rewrites them with the complete identity. Once the format matches, absent lineage fields normalize to an unseeded identity, and a seeded caller rejects that identity and refolds cold. A record that still fails schema validation is backed up and skipped; every subsequent write stamps version 7.
 - The cache record is bound to the same log lifecycle as before: the stored `{createdAt, cwd, isSeeded, inheritedEventCount}` identity guards against a recreated id or a mismatched inherited prefix.
 
 ## Alternatives considered
@@ -30,4 +30,4 @@ Reads and writes share ONE coherent state: every read (`cachedSnapshot`) is a sy
 - **Cache-owned per-session files** (`<root>/<session-id>/projection_cache.json`, the first revision of this change). Tried and reverted in review: the cache hand-rolled the medium — paths, per-path write chains, in-flight tracking, owner-only file modes, and a sqlite no-path special case — and its listing read hit the disk directly on every call while writes were throttled, so reads and writes were never consistent.
 - **Resolve the path through `sessionPersistence.locate(meta)`** (the file beside the session log). Rejected: the cache would have to guess "beside the log" from a log artifact path (`dirname` + fixed filename), coupling the cache to the persistence service and to a backend's layout.
 - **Make `per-record` a mode of the existing unit instead of a separate unit class.** Rejected: the two layouts have genuinely different state models — `single` is memory-authoritative with whole-file publish, `per-record` is stateless (the directory is the state; `loadAll` re-reads the tree) — so they are separate small classes behind one backend, with record keys validated path-safe instead of encoded.
-- **Copy legacy values across unit versions.** Rejected: the json backend does not know a domain's record schema and cannot derive session-lineage fields. Copying raw values under the requested version relabels data without migrating it. A domain that requires compatibility owns an explicit migration; the projection cache instead discards old records and rebuilds them from session logs.
+- **Copy legacy values across undeclared unit versions.** Rejected: the json backend does not know a domain's record schema and cannot derive session-lineage fields. It copies an older record only when the domain explicitly lists that version in `compatibleVersions` and its current schema accepts the value; otherwise the record stays untouched and reads as absent.

+ 3 - 3
.agents/notes/implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md

@@ -20,8 +20,8 @@ Status: implemented
 - 列表读取是同步内存读;没有记录文档的会话只是缺少投影列。
 - ACP、headless、SDK 与 Web 会话都会发布缓存行,供后续消费方使用。确保日志领先的持久性屏障可能按缓存节奏 flush 已覆盖的前缀,并拆分原本会合并的物理 JSONL 行;各 profile 的录制快照会重新 pack 逻辑事件流,因此缓存时序不会决定 fixture 布局。
 - per-record 契约把故障范围缩小到单记录:畸形或过期版本的文档在打开时读作"无此记录",单个坏文件不会拖垮整个缓存;检查点 schema 升级按会话丢弃过期行,而不是拒绝整个域。
-- json 后端仅在枚举时没有发现任何新布局文档路径,且旧单元名称和版本与请求的 descriptor 相同时,才从旧整单元缓存引导 per-record 目录树。版本不同时,旧文件保持不变,新域为空;存储不会把旧值改标为当前版本。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。
-- `session_projcache` 域使用版本 6。所有版本 5 记录都读作缺失,包括健康记录,因此被污染的版本 5 记录不能再使域校验失败。会话 header 和事件日志仍保存在会话持久化中;精确读取会重折叠这些数据并写入版本 6 缓存,而零 I/O 列表在缓存恢复前缺少对应投影
+- json 后端仅在枚举时没有发现任何新布局文档路径、旧单元名称匹配,且其版本为当前版本或已声明兼容版本时,才从旧整单元缓存引导 per-record 目录树。接受集合之外的版本保持不变,新域为空;存储绝不把域 owner 未批准的版本改标为当前版本。只要存在任意新文档路径,即使文件不可读或版本陈旧,也会对整个单元禁用引导;缺失的会话行从日志重折叠。[跨版本读兼容决策](2026-09-02-projcache-cross-version-read-compat.zh.md)是版本策略的权威说明。
+- `session_projcache` 域使用版本 7,并声明版本 3 至 6 在结构上兼容。前代 identity 缺少 Session `formatVersion`,因此不能播种当前投影并会回落冷折叠;当前检查点会用完整 identity 重写它们。格式匹配后,缺失的 lineage 字段归一化为 unseeded 身份,seeded 调用方会拒绝该身份并回落冷折叠。仍然通不过 schema 校验的记录会被备份并跳过;后续每次写入都使用版本 7
 - 缓存记录仍绑定同一日志生命周期:存储的 `{createdAt, cwd, isSeeded, inheritedEventCount}` 身份防止被重建的 id 或不匹配的继承前缀误导。
 
 ## Alternatives considered
@@ -30,4 +30,4 @@ Status: implemented
 - **缓存自持的每会话文件**(`<root>/<session-id>/projection_cache.json`,本改动的第一版)。试过并在评审中回退:缓存手搓了介质——路径、按路径的写链、在途跟踪、仅属主文件权限,以及 sqlite 无路径特判——而且它的列表读每次调用都直读磁盘、写却在节流,读写永不一致。
 - **经 `sessionPersistence.locate(meta)` 解析路径**(文件放在会话日志旁)。未采用:缓存得从日志 artifact 路径"猜"日志旁边(`dirname` + 固定文件名),把缓存耦合到持久化服务与后端的布局。
 - **把 `per-record` 做成既有单元的一种模式而非独立单元类。** 未采用:两种布局的状态模型本质不同——`single` 内存权威、整文件发布;`per-record` 无状态(目录即状态,`loadAll` 重扫目录树)——所以它们是同一后端下的两个小型独立类,记录键做路径安全校验而非编码。
-- **跨单元版本复制旧值。** 未采用:json 后端不知道域的记录 schema,也无法推导会话谱系字段。按请求版本复制原始值只会修改数据标签,不会迁移数据。需要兼容性的域负责显式迁移;投影缓存改为丢弃旧记录,并从会话日志重建
+- **跨未声明单元版本复制旧值。** 未采用:json 后端不知道域的记录 schema,也无法推导会话 lineage 字段。只有当域在 `compatibleVersions` 中明确列出旧版本,且当前 schema 接受该值时,后端才复制旧记录;否则记录保持不变并读作不存在

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md
-2026-08-31-released-session-format-migrations.md: 65d89b680e827b29a3a0e5d485a53f7a6eb49f63
-2026-08-31-released-session-format-migrations.zh.md: 6089f6dc4f231aaeec31b89600485ef2a06fb9b0
+2026-08-31-released-session-format-migrations.md: 5f6a8145dcf67872461c17408d18f5a22d820ba6
+2026-08-31-released-session-format-migrations.zh.md: 0aa872877d82d919f43f4a16f5570f0c655e34df

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.md

@@ -28,6 +28,8 @@ The current-format fast path classifies the header from one stable source snapsh
 
 The first edge, `@deepseek-ai/dsh-session-format-v0-to-v1`, is intentionally identity-shaped: aside from the version and bounded historical normalizations already accepted by v0, it preserves logical headers, events, sequence numbers, references, timestamps, payloads, and the configured compression choice. The exact `session.jsonl[.zstd]` source remains byte- and inode-identical, while the current writer encodes the new `session.v1.jsonl[.zstd]` successor. This exercises the complete publication lifecycle before a cardinality-changing format needs it.
 
+Projection-cache records bind their fold to the Session header's `formatVersion`. The `session_projcache` v7 reader may load predecessor domain records structurally, but a record without the format generation cannot seed a current Session; the authoritative log refolds it and the next checkpoint writes the complete current identity. This prevents a cache row produced before a bounded normalizer or cardinality-changing edge from bypassing that migration.
+
 ## Consequences
 
 Reading event bodies with a newer build may durably add a higher generation. The exact old generation remains available, but the runtime thereafter selects the highest canonical filename; retention does not promise that an older build can safely downgrade or that the newer build will fall back when the successor is corrupt. A read-only filesystem reports an actionable migration failure instead of returning an in-memory current view that differs from disk.

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-31-released-session-format-migrations.zh.md

@@ -28,6 +28,8 @@ JSONL provider 在 `open` 为已存储 Session 返回句柄前完成 ensure-curr
 
 第一条迁移边 `@deepseek-ai/dsh-session-format-v0-to-v1` 有意保持恒等形态:除版本和 v0 已接纳的有限历史归一化外,它保留逻辑 header、事件、序号、引用、时间戳、payload 与已配置的压缩选择。精确的 `session.jsonl[.zstd]` 源保持字节与 inode 相同,当前 writer 则编码新的 `session.v1.jsonl[.zstd]` 后继。这样可在出现改变基数的格式前先验证完整发布生命周期。
 
+投影缓存记录把自己的折叠结果绑定到 Session header 的 `formatVersion`。`session_projcache` v7 reader 可以在结构上载入前代 domain 记录,但缺少格式代的记录不能播种当前 Session;权威日志会重新折叠它,下一次检查点写入完整的当前 identity。这样,任何在有界规范化或基数变化边之前产生的缓存行都不能绕过该迁移。
+
 ## 后果
 
 较新 build 读取事件正文时可能持久增加一个更高 generation。精确旧 generation 仍然可用,但 runtime 此后选择最高规范文件名;保留不承诺旧 build 能安全 downgrade,也不保证新 build 在后继损坏时 fallback。只读文件系统会报告可操作的迁移失败,而不会返回与磁盘不一致的内存当前视图。

+ 6 - 0
.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md
+2026-09-02-projcache-cross-version-read-compat.md: 7463871852234d5df232f6c426dfbec0543777d5
+2026-09-02-projcache-cross-version-read-compat.zh.md: 663a645cd55a16a26add6edba4e414023f8c622c

+ 76 - 0
.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md

@@ -0,0 +1,76 @@
+# Agent Note: Projection-cache predecessor recovery and Session-format binding (session_projcache v3-v6 → v7)
+
+Status: implemented
+
+English | [中文](2026-09-02-projcache-cross-version-read-compat.zh.md)
+
+## Problem
+
+The `session_projcache` storage domain evolved through several on-disk generations. An upgraded DSH_HOME exposed three risks:
+
+- **A v3 single-file home bricked startup after the upgrade**: the per-record layout's legacy bootstrap migrated the old whole-unit file without checking its `unit.version`, stamping the old records with the current version into the new tree; the domain layer's per-record zod validation at open then hit the missing now-required fields → `invalid-record` → the whole domain refused to open → the plugin tree failed to load. And because the bootstrap writes before validation runs, **the first boot permanently wrote the bad documents into the new tree** ("poisoning") — every later boot saw a non-empty tree, never took the legacy path again, and the home stayed unusable.
+- **A v4 per-record home lost its listing titles after the upgrade**: v4 documents were silently discarded by the version-stamp check (the per-record contract), and SessionList is a zero-I/O cache-only read, so a miss served the row without projections; titles only returned as each session was individually reopened.
+- **A Session-format bump could reuse a fold produced under older event semantics**: versions 3 through 6 did not record the Session format generation. Treating an absent generation as current would let a cache row bypass bounded historical normalization or a cardinality-changing migration.
+
+The cache domain's own contract is "a stale or unreadable cache costs a longer tail replay, never a wrong value, never a refused load" — the hard failure and the wholesale discard each broke the first half of that contract or the product expectation.
+
+## The on-disk generations
+
+| domain version | shipped in | layout | on-disk form | identity fields | row fields |
+|---|---|---|---|---|---|
+| 3 | 0.1.1-rc.2 | single | one file `storages/session_projcache.json` (`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` |
+| 4 | 0.1.2-alpha.3 | per-record | one file per session `storages/session_projcache/sessions/<sessionId>.json` (`{version, record}`) | `createdAt`, `cwd?` | same |
+| 5 | 0.1.2-alpha.4 | per-record | same as v4 | + `isSeeded` (shipped required; now optional), `inheritedEventCount` (same) | same (`seq` numbers mean the same as v4; only type brands were added) |
+| 6 | pre-v1 mainline | per-record | same as v5 | same as v5 | same |
+| 7 | current | per-record | same as v5 | + `formatVersion`; current writes also require both lineage fields | same |
+
+The only substantive v4→v5 difference is the two lineage identity fields; v6 changed only the write stamp. The `ver/seq/val` row representation is identical across these predecessor generations, and `seq` numbering did not change ([the 2026-08-31 seq/offset brands note](2026-08-31-session-sequence-and-log-offset-brands.md) pins the on-disk numbers as unchanged). v3→v4 was a layout migration with identical record content. Version 7 adds the Session format generation to the cache identity because row semantics cannot be inferred from the domain stamp.
+
+One derived shape also exists: a v3 home that ran the v5 build once (the poisoned state) — its new tree holds documents **stamped 5 whose content is a v3 record** (no lineage fields).
+
+## Decision
+
+Declared read compatibility — reads tolerate vouched-for older versions, writes always stamp the current one:
+
+1. **`DomainSpec.compatibleVersions` (new, optional)**: the domain owner declares "records stored under these older versions are also readable under the current record schemas" (typically by declaring the fields old records lack as optional). `defineDomain` validates each entry as a non-negative integer below the current version; `descriptorOf` projects the set onto the backend `KvUnitDescriptor`.
+2. **json backend per-record reads** accept version stamps in "current ∪ compatibleVersions"; anything outside the set is still discarded as foreign. **The write path always stamps the current version** (the first checkpoint after reading an old record naturally advances it). The `single` layout stays exact-version.
+3. **Legacy-bootstrap version gate (the actual bug fix)**: the old whole-unit file's `unit.version` must fall inside the accepted set to be migrated; otherwise the file is left alone and the unit reads empty — stamping records the owner never vouched for turns a discardable stale cache into hard schema failures at the domain layer.
+4. **The projcache domain declares `version: 7, compatibleVersions: [3, 4, 5, 6]`**, and the format and lineage identity fields are optional in the stored schema so vouched-for predecessor records can open. Current writes always include all three fields.
+5. **Identity matching is stricter than structural admission**: an absent `formatVersion` never matches a current Session, so predecessor rows cannot seed a projection and refold from the authoritative log. Once the format matches, `identityMatches` normalizes absent lineage to unseeded (`?? false` / `?? 0`): exact for an unseeded session, while a seeded expectation fails the match. Poisoned v5 homes therefore boot safely, but their unbound rows are not exposed as current values.
+6. **Schema-validation backstop: `invalidRecords: 'backup-and-skip'` (declared by this domain only)**. A stored record that still fails to parse beyond read compatibility no longer refuses the whole domain: the domain layer calls the backend's `KvUnit.backupRecord` (json per-record implementation = rename the document to `<key>.json.bak.<YYYYMMDDHHmm>`, bytes kept, never read again), prints the concrete failure with `logger.error` (domain, table, key, destination, zod cause), and continues the open with the record absent; the next cold read rebuilds and rewrites that session's cache. **The policy is an explicit per-domain declaration and the default stays fail-loud** — other domains still refuse the whole load on invalid stored data, and a backend without `backupRecord` (single layout, row stores) also falls back to fail-loud. Naming history: quarantine → backup-and-skip (user ruling: the word must carry both "back up" and "skip", sharing its root with the `.bak` suffix; skip-backup was rejected because the CLI `--skip-X` convention reads it as "do not back up"). For this domain it supersedes the reset/destroy recovery path of the [2026-07-28 storage recovery proposal](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md), which stays live for authoritative and whole-medium damage.
+
+### v3-v6 → v7 disposition
+
+Versions 3 through 6 remain structurally readable because their record and row representations are valid inputs to the current schema. Their identities lack `formatVersion`, so they are deliberately unusable as current fold shortcuts. A cold read or live checkpoint rebuilds the values from the migrated Session log and writes a v7 record with the complete format and lineage identity. No eager value migration runs at startup; a schema-invalid accepted record follows `backup-and-skip`.
+
+### Upgrade matrix
+
+| home shape | behavior after the fix |
+|---|---|
+| v3 single-file (not poisoned) | bootstrap migrates (3 ∈ accepted set) → boot succeeds; unbound fold waits for cold rebuild |
+| v3 + poisoned new tree | new-tree documents parse under optional fields → boot restored; unbound fold waits for cold rebuild |
+| v4/v5/v6 per-record | documents read structurally → missing format generation rejects the shortcut; current checkpoint rewrites v7 |
+| v7 current with matching identity | cached values serve normally |
+| matching-format record without lineage | unseeded caller may use it; seeded caller rejects it and refolds cold |
+
+## Alternatives considered
+
+- **Reject predecessor stamps in the storage layer**: safe for projections, but prevents the guarded legacy bootstrap and loses the ability to retain a structurally sound record until an authoritative refold replaces it. Structural admission plus semantic identity rejection keeps the boot recoverable without serving an unproven value.
+- **Treat a missing format generation as current**: preserves cached titles, but lets a pre-migration fold bypass the Session-format edge. Rejected because v0→v1 includes bounded historical normalizers and later edges may change event cardinality.
+- **Schema `.default()` fills**: behaviorally equivalent to optional + reader normalization, but bakes the "absent = unseeded" interpretation into the durable schema's output type; ruled for optional — the schema honestly describes every accepted on-disk shape and the interpretation lives at the consumer (user ruling, 2026-09-02).
+- **Roll the domain version back to 4**: a small diff, but breaks version monotonicity, depends on the "bootstrap skips no versions" bug itself, and drops every poisoned and healthy v5 home's cache.
+
+## Consequences
+
+- A deployment routing this domain to the sqlite backend gets none of the tolerance: sqlite implements neither `compatibleVersions` nor `backupRecord`, so behavior degrades to the old strict-version semantics (a whole-unit version mismatch still refuses with `version-mismatch`; nothing loosens, nothing serves wrong values). Shipped compositions route this domain to json, so this stays a deployment-configuration risk only.
+- The optional format field lets predecessor records pass structural validation, but absence always fails current identity matching. Optional lineage is normalized only after the format matches; a seeded caller still refuses a lineage-less record. The per-row `ver` guard continues to screen every served value.
+- `backupRecord` overwrites a same-minute backup of the same key (the newer bytes win); distinct minutes and distinct keys never collide.
+
+## Testing
+
+- `storage-json` unit tests: compat-stamped reads / out-of-set discards / writes stamping current; legacy bootstrap migrating only accepted versions (including the migrated-documents-stamp-current assertion); `backupRecord` move / absent read / rewrite / closed guard.
+- `storage-domain` unit tests: `compatibleVersions` / `invalidRecords` declaration validation; backup-and-skip falling back to fail-loud when the backend has no `backupRecord`.
+- `session-projection-cache` unit tests: a matching format with absent lineage serves only an unseeded Session; a predecessor record without a format generation never serves.
+- **Archived-fixture recovery tests** (`tests/fixtures.spec.ts` + `tests/fixtures/`): four media archives produced by the real released builds — `v3-single-unit.json` (the 0.1.1-rc.2 whole-unit file), `v4-session-doc.json` (0.1.2-alpha.3), `v5-session-doc.json` (0.1.2-alpha.4), `v5-lineageless-doc.json` (the unguarded bootstrap's poisoned form, synthesized from the v3 record) — each opens through the real storage stack without serving its unbound fold, then a live write replaces it with a v7 record carrying the complete identity and fresh value. The same suite proves backup-and-skip for a schema-failing record: boot survives, `.bak` lands, diagnostics name the failure, and a neighboring predecessor record remains rewritable.
+
+Future bump procedure: add an older domain version to `compatibleVersions` only when the current stored schema can parse it, and let the owning reader decide whether its semantic identity is sufficient. A Session-format change never inherits an absent format generation. The package README requires every bump to land with archived fixtures and tests proving structural admission, semantic use or rejection, and current rewrite.

+ 76 - 0
.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md

@@ -0,0 +1,76 @@
+# Agent Note: 投影缓存前代恢复与 Session 格式绑定(session_projcache v3-v6 → v7)
+
+Status: implemented
+
+[English](2026-09-02-projcache-cross-version-read-compat.md) | 中文
+
+## 问题
+
+`session_projcache` 存储域演进过多代磁盘结构。升级后的 DSH_HOME 暴露了三类风险:
+
+- **v3 单文件 home 升级后启动硬失败**:per-record 布局的 legacy bootstrap 迁移旧单文件时不检查其 `unit.version`,把旧记录原样打上当前版本戳写入新树;domain 层开域时逐条 zod 校验,旧记录缺新增必填字段 → `invalid-record` → 整个域拒开 → 插件树加载失败。且 bootstrap 先写盘后校验,**首次启动即把坏文档永久写入新树**("投毒")——此后每次启动新树非空、连 legacy 路径都不再走,home 持续不可用。
+- **v4 per-record home 升级后列表丢标题**:v4 文档被版本戳检查静默丢弃(per-record 契约),SessionList 是零 I/O 纯缓存读,miss 后整行不带投影;标题要等每个会话被逐个重新打开后才恢复。
+- **Session 格式递增可能复用按旧事件语义折叠的结果**:版本 3 至 6 不记录 Session 格式代。若把缺失代视为当前代,缓存行就能绕过有界历史规范化或改变基数的迁移。
+
+缓存域自身的契约是"过期或不可读的缓存只付出更长的尾部重放,绝不给出错值、绝不拒载"——硬失败与整体丢弃都违背该契约的前半句或产品预期。
+
+## 磁盘结构代际
+
+| domain version | 携带发布 | 布局 | 磁盘形态 | identity 字段 | 行字段 |
+|---|---|---|---|---|---|
+| 3 | 0.1.1-rc.2 | single | 单文件 `storages/session_projcache.json`(`{unit:{name,version}, global, tables}`) | `createdAt`, `cwd?` | `ver`, `seq`, `val` |
+| 4 | 0.1.2-alpha.3 | per-record | 每会话一份 `storages/session_projcache/sessions/<sessionId>.json`(`{version, record}`) | `createdAt`, `cwd?` | 同上 |
+| 5 | 0.1.2-alpha.4 | per-record | 同 v4 | + `isSeeded`(v5 首发必填;现为 optional)、`inheritedEventCount`(同前) | 同上(`seq` 数值语义与 v4 相同,仅类型加 brand) |
+| 6 | v1 之前的 mainline | per-record | 同 v5 | 同 v5 | 同上 |
+| 7 | 当前版本 | per-record | 同 v5 | + `formatVersion`;当前写入也要求两个 lineage 字段 | 同上 |
+
+v4→v5 的唯一实质差异是 identity 新增两个 lineage 字段;v6 只改变写入版本戳。这些前代的行内 `ver/seq/val` 表示一致,`seq` 的数值含义未变([2026-08-31 seq/offset brands note](2026-08-31-session-sequence-and-log-offset-brands.zh.md) 明确 on-disk 数值不变)。v3→v4 是布局迁移,记录内容结构一致。v7 把 Session 格式代加入缓存 identity,因为无法从 domain 版本戳推导行语义。
+
+另有一种衍生形态:跑过一次 v5 版本的 v3 home(投毒态)——新树里存在**版本戳为 5 但内容是 v3 记录**(缺 lineage 字段)的文档。
+
+## 决策
+
+声明式读兼容——读容忍 owner 背书过的旧版本,写恒戳当前版本:
+
+1. **`DomainSpec.compatibleVersions`(新增,可选)**:域 owner 声明"这些旧版本的存量记录在当前记录 schema 下也可读"(典型手段:新增字段标 optional)。`defineDomain` 校验各项为小于当前 version 的非负整数;`descriptorOf` 透传到后端 `KvUnitDescriptor`。
+2. **json 后端 per-record 读**:接受"当前版本 ∪ compatibleVersions"内的版本戳,集合外照旧视为 foreign 丢弃;**写路径永远戳当前版本**(读到旧记录后的下一次 checkpoint 自然把它推进到当前版本)。single 布局维持 exact-version 不变。
+3. **legacy bootstrap 版本把关(bug 修复本体)**:旧单文件的 `unit.version` 必须落在接受集合内才迁移,否则视为空 unit 留在原地——为 owner 未背书的记录打当前版本戳,会把"可丢弃的过期缓存"变成 domain 层的 schema 硬失败。
+4. **projcache 域声明 `version: 7, compatibleVersions: [3, 4, 5, 6]`**;存储 schema 中的格式与 lineage identity 字段均为 optional,使 owner 背书的前代记录可以打开。当前写入始终包含这三个字段。
+5. **identity 匹配比结构准入更严格**:缺失 `formatVersion` 的记录绝不匹配当前 Session,因此前代行不能播种投影,而会从权威日志重新折叠。格式匹配后,`identityMatches` 才把缺失 lineage 归一化为 unseeded(`?? false` / `?? 0`):对 unseeded 会话精确,对 seeded 期望则匹配失败。v5 投毒 home 因而可以安全启动,但其未绑定行不会作为当前值暴露。
+6. **schema 校验兜底:`invalidRecords: 'backup-and-skip'`(仅本域声明)**。读兼容之外仍然解析失败的存量记录不再让整个域拒开:domain 层调用后端的 `KvUnit.backupRecord`(json per-record 实现=把文档改名为 `<key>.json.bak.<YYYYMMDDHHmm>`,字节留档、不再被读取),用 `logger.error` 打印具体失败信息(域名、表、键、移动去向、zod 失败原因),随后当该记录不存在继续启动;下一次冷读会重建并重写该会话的缓存。**该策略是域级显式声明,缺省仍为 fail-loud**——其他业务域的存量数据校验失败照旧整域拒载;后端没有 `backupRecord` 能力(single 布局、行存储)时也回退 fail-loud。命名沿革:quarantine → backup-and-skip(用户裁决:词要同时含"备份"与"跳过"两义,且与 `.bak` 后缀同源;skip-backup 因 CLI `--skip-X` 惯例存在"不备份"反读而弃用)。对本域而言,该策略取代了 [2026-07-28 存储恢复提案](../../proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md)中 reset/destroy 的恢复途径;该提案对权威介质与整介质损坏仍然有效。
+
+### v3-v6 → v7 处置
+
+版本 3 至 6 仍可结构化读取,因为它们的记录与行表示是当前 schema 的有效输入。其 identity 缺少 `formatVersion`,因此有意不能作为当前折叠捷径。冷读或实时检查点会从迁移后的 Session 日志重建值,并写入带完整格式与 lineage identity 的 v7 记录。启动时不运行 eager 值迁移;schema 校验失败的已接受记录执行 `backup-and-skip`。
+
+### 升级矩阵
+
+| home 形态 | 修复后行为 |
+|---|---|
+| v3 单文件(未投毒) | bootstrap 迁移(3 ∈ 接受集)→ 启动成功;未绑定折叠等待冷重建 |
+| v3 + 投毒新树 | optional 字段让新树文档可解析 → 启动恢复;未绑定折叠等待冷重建 |
+| v4/v5/v6 per-record | 文档结构化读入 → 缺格式代而拒绝捷径;当前检查点重写 v7 |
+| identity 匹配的 v7 当前记录 | 正常服务缓存值 |
+| 格式匹配但缺 lineage 的记录 | unseeded 调用方可以使用;seeded 调用方拒绝并回落冷折叠 |
+
+## 备选方案
+
+- **在存储层拒绝前代版本戳**:对投影安全,但会阻止受保护的 legacy bootstrap,也无法保留结构完好的记录直到权威重折叠替换它。结构准入加语义 identity 拒绝既让启动可恢复,也不服务未经证明的值。
+- **把缺失格式代视为当前代**:可以保留缓存标题,但让迁移前折叠绕过 Session 格式边。不采用,因为 v0→v1 含有界历史规范化,后续边还可能改变事件基数。
+- **schema `.default()` 填缺省**:行为与 optional+读点归一化等价,但把"缺失=unseeded"的解释固化进 durable schema 的输出类型;拍板为 optional——schema 如实描述介质上所有被接受的形态,解释权在消费点(2026-09-02 用户裁决)。
+- **域版本回退到 4**:改动很小,但破坏版本单调性、依赖"bootstrap 不查版本"这个 bug 本身、且投毒态与正常 v5 home 的缓存全被丢弃。
+
+## 影响
+
+- 部署方若把本域路由到 sqlite 后端,得不到任何容忍能力:sqlite 既未实现 `compatibleVersions` 也没有 `backupRecord`,行为退化为原有的严格版本语义(整 unit 版本不匹配仍 `version-mismatch` 拒开;不放松、不出错值)。shipped 组合固定路由 json,此风险仅存在于部署配置层面。
+- optional 格式字段允许前代记录通过结构校验,但缺失格式始终无法通过当前 identity 匹配。只有格式匹配后才归一化 optional lineage;seeded 调用方仍拒绝缺 lineage 的记录。逐行 `ver` 守卫继续筛查每个实际服务的值。
+- `backupRecord` 对同一键的同一分钟内重复备份会覆盖前一份(新字节胜出);不同分钟、不同键永不冲突。
+
+## 测试
+
+- `storage-json` 单测:compat 版本戳读入/集合外丢弃/写恒当前版本;legacy bootstrap 仅在版本被接受时迁移(含迁移后文档戳当前版本断言);`backupRecord` 移档/读缺席/重写/封闭守卫。
+- `storage-domain` 单测:`compatibleVersions`/`invalidRecords` 声明校验;后端无 `backupRecord` 时 backup-and-skip 回退 fail-loud。
+- `session-projection-cache` 单测:格式匹配且缺 lineage 的记录只服务 unseeded Session;缺格式代的前代记录永不服务。
+- **归档 fixtures 独立恢复测试**(`tests/fixtures.spec.ts` + `tests/fixtures/`):真实发布物产出的四份介质存档——`v3-single-unit.json`(0.1.1-rc.2 整域单文件)、`v4-session-doc.json`(0.1.2-alpha.3)、`v5-session-doc.json`(0.1.2-alpha.4)、`v5-lineageless-doc.json`(无守卫 bootstrap 的投毒形态,由 v3 记录合成)——逐一走真实存储栈开域且不服务其未绑定折叠,随后由实时写入替换成带完整 identity 与新值的 v7 记录。同一套件还证明 schema 失败记录的 backup-and-skip:启动不失败、`.bak` 落盘、诊断点名失败,且邻近前代记录仍可重写。
+
+未来 bump 流程:只有当前存储 schema 能解析旧 domain 版本时,才把它加入 `compatibleVersions`,再由 owner reader 判断其语义 identity 是否充分。Session 格式变化绝不继承缺失的格式代。包 README 要求每次 bump 都随附归档 fixture 与测试,证明结构准入、语义使用或拒绝,以及当前重写。

+ 2 - 2
.agents/notes/implemented/feature/2026-08-05-agent-teams.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-agent-teams.md
-2026-08-05-agent-teams.md: fbcd8485a972323bc0f8ffb6a5cb7cca9a50044e
-2026-08-05-agent-teams.zh.md: 0a1a8a81c47abfca275f5fc3c60b8d514cdddd30
+2026-08-05-agent-teams.md: df84b2710d597330e9fdf6f8f83edb1441f100ec
+2026-08-05-agent-teams.zh.md: f1ef275610f6307578288d483eb561ee8d3c7531

+ 3 - 3
.agents/notes/implemented/feature/2026-08-05-agent-teams.md

@@ -32,7 +32,7 @@ Fresh children have no inherited conversation. Fork children capture the Lead's
 
 Peer communication is a Lead-log mailbox. `team/message/queued` is appended and flushed before delivery. The target message carries the stable message id and sender identity in both durable source metadata and a short model-visible prefix. A target receipt is acknowledged with `team/message/delivered` only after its pending inbox item or recorded user message is flushed. Immediate admission is serialized per target in queued-log order, recovery retries queued-minus-delivered in the same order, and delivery folds live or persisted target inbox/history state before cold resume. Every current-version Team payload is runtime-validated before entering replay state. The Team runtime tracks dispatch and asynchronous acknowledgement work from synchronous admission until settlement; disposal closes admission and awaits both before removing the service. Current waiters wake only after the owning Team event flush succeeds.
 
-Quiet `send_message` injects, flushes, and acknowledges immediately for a live target without waking it; an inactive target remains queued until another event materializes that teammate. Waking `followup_task` becomes the target's next FIFO turn and may cold-resume it. Success means the message is already durable even when immediate delivery is deferred. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim.
+`send_message` always attempts Steer delivery. A running target receives the message at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Success means the message is already durable even when temporary delivery failure leaves it queued. The mechanism provides process-local retry and target-Session de-duplication, not a cross-process exactly-once claim. The [Team Steer messaging decision](../simplification/2026-08-30-team-send-message-steer.md) owns the single-tool scheduling rationale.
 
 Shared tasks are complete snapshots with Team-local ids and monotonic revisions. Every mutation carries `expectedRevision`. Any member creates, reads, or claims a ready unowned task; the owner or Lead edits and transitions it, while only the Lead assigns another member. Numeric task ids remain within the safe-integer allocation range, and exhaustion fails without reusing an id. Dependencies must name non-deleted tasks and form a complete DAG. Deleted tasks are retained tombstones. `writeScopes` are normalized path prefixes that produce overlap diagnostics but never block claim or authorize a write.
 
@@ -48,7 +48,7 @@ Worktree isolation is not a harness runtime behavior. A deployment or prompt may
 
 **Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner.
 
-**Store mail in each target Session before delivery.** Rejected because an inactive target is intentionally not materialized for quiet mail. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary.
+**Store mail in each target Session before Lead-log enqueue.** Rejected because target materialization and acceptance can fail after the Team has committed the send. The always-live Lead Session is the transaction home; target recording is the acknowledgement and de-duplication boundary.
 
 **Treat task ownership or write scopes as locks.** Rejected because external writers bypass them, crashed owners remain durable, and path-prefix overlap cannot prove semantic independence. False mutual exclusion is more dangerous than an explicit warning.
 
@@ -68,6 +68,6 @@ Package tests cover identity, name and authority checks, provider selection, res
 
 The Lead Session grows with whole task/member snapshots and mailbox acknowledgements. This favors independently inspectable recovery over compact deltas; configured task and pending-mail bounds cap active state, while deleted and delivered history remains append-only until broader Session retention applies.
 
-An active roster member can be non-resident, so `inactive` is not failure and a wakeup can incur cold-resume latency. A quiet message for an inactive target can remain pending indefinitely until the target is otherwise materialized. A failed member permanently consumes its name and member slot, making provisioning failures visible instead of silently recycling identity.
+An active roster member can be non-resident, so `inactive` is not failure and a send can incur cold-resume latency. Temporary inspection, resume, or inbox-admission failure can leave a durable message queued for recovery. A failed member permanently consumes its name and member slot, making provisioning failures visible instead of silently recycling identity.
 
 Coordination reduces likely checkout conflicts but cannot eliminate writes outside filesystem compare-and-set tools. The final diff and tests remain the Lead's integration boundary.

+ 3 - 3
.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md

@@ -32,7 +32,7 @@ fresh child 不继承对话。fork child 只捕获一次 Lead 已完成 turn 前
 
 Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/message/queued`。target message 会在持久 source metadata 与短模型可见前缀中同时携带稳定 message id 和 sender identity。只有 pending inbox 条目或已记录用户消息完成 flush,Lead 日志才写入 `team/message/delivered` acknowledgement。即时准入按 target 和 queued 日志顺序串行化,恢复按同一顺序重试 queued-minus-delivered,并在冷恢复前折叠 live 或 persisted target 的 inbox/历史状态。每个当前版本 Team payload 都会经过运行时验证后才进入 replay state。Team runtime 从同步准入到 settlement 全程跟踪 dispatch 与异步 acknowledgement 工作;dispose 会关闭准入,并在移除服务前等待两者。当前 waiter 只在所属 Team event flush 成功后被唤醒。
 
-对于 live target,quiet `send_message` 会立即注入、flush 并确认,但不会唤醒它;inactive target 会保持 queued,直到其他事件 materialize 该 teammate。waking `followup_task` 成为 target 的下一个 FIFO turn,并可冷恢复。即使即时投递被推迟,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。
+`send_message` 始终尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。即使临时投递失败让消息保持 queued,成功也表示消息已经持久化。该机制提供进程内重试与 target Session 去重,不宣称跨进程 exactly-once。[Team Steer 消息决策](../simplification/2026-08-30-team-send-message-steer.zh.md)负责单工具调度的理由。
 
 共享 task 是带 Team-local id 与单调 revision 的完整快照。每次变更都携带 `expectedRevision`。任意 member 可以创建、读取或 claim ready 且无 owner 的任务;Owner 或 Lead 可以编辑和转换;只有 Lead 可以分配给另一个 member。数字 task id 保持在安全整数分配范围内;该范围耗尽时会失败,不会复用 id。依赖必须指向未删除任务,并形成完整 DAG。删除任务保留为 tombstone。`writeScopes` 是规范化路径前缀,只产生重叠诊断,绝不会阻止 claim 或授予写权限。
 
@@ -48,7 +48,7 @@ Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以
 
 **用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。
 
-**投递前把 mail 存入每个 target Session。** 拒绝,因为 quiet mail 不会 materialize inactive target。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。
+**在 Lead 日志入队前把 mail 存入每个 target Session。** 拒绝,因为 Team 提交发送之后,target materialization 与准入仍可能失败。始终 live 的 Lead Session 是事务 owner;target recording 是 acknowledgement 与去重边界。
 
 **把 task ownership 或 write scope 当作锁。** 拒绝,因为外部 writer 会绕过它们,崩溃 owner 会持久保留,而路径前缀重叠不能证明语义独立。虚假的互斥保证比明确 warning 更危险。
 
@@ -68,6 +68,6 @@ Package test 以逐文件 100% coverage 覆盖身份、名字与权限检查、p
 
 Lead Session 会随着完整 task/member 快照与 mailbox acknowledgement 增长。该设计用可独立检查的恢复能力换取更紧凑的 delta;配置的 task 与 pending-mail 限额限制 active state,而 deleted 与 delivered 历史会保持 append-only,直到更广泛的 Session retention 生效。
 
-active roster member 可以不驻留,因此 `inactive` 不表示失败,wakeup 可能产生 cold-resume 延迟。发往 inactive target 的 quiet message 可能无限等待,直到 target 因其他原因 materialize。failed member 会永久占用名字与 member slot,使 provisioning failure 保持可见而不是静默回收身份。
+active roster member 可以不驻留,因此 `inactive` 不表示失败,send 可能产生 cold-resume 延迟。临时 inspection、resume 或 inbox 准入失败可能留下持久 queued 消息等待恢复。failed member 会永久占用名字与 member slot,使 provisioning failure 保持可见而不是静默回收身份。
 
 协调可以降低 checkout 冲突概率,但无法消除文件系统 CAS 工具之外的写入。最终 diff 与测试仍是 Lead 的集成边界。

+ 6 - 0
.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md
+2026-08-30-team-send-message-steer.md: 9eb38ec96f51d074510bd918fc46bac9a61027d0
+2026-08-30-team-send-message-steer.zh.md: 44ed792a1c1a4e149c3309a538dfbe6418612b16

+ 41 - 0
.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md

@@ -0,0 +1,41 @@
+# Agent Note: Team messaging uses one Steer send_message operation
+
+Status: implemented
+
+English | [中文](2026-08-30-team-send-message-steer.zh.md)
+
+## Problem
+
+Agent Teams exposed two model operations for one durable mailbox: quiet `send_message` injected into a live target without waking it, while `followup_task` queued a distinct waking turn and cold-resumed an inactive teammate. Models had to choose a scheduling policy instead of stating whom to message, and quiet messages could accumulate for an inactive teammate until unrelated work resumed it.
+
+The ordinary continuable-Agent controls already use one direction-neutral `send_message` with fixed Steer scheduling. Retaining separate Team names and delivery modes made equivalent model communication depend on whether the target happened to be a direct child or a Team peer.
+
+## Decision
+
+Every Team member receives one `send_message({ target, message })` tool. The Team tool set contains nine operations; `followup_task` and model-selectable quiet delivery are absent. The durable `TeamMessageSnapshot` stores sender, target, content, and message identity without a scheduling field.
+
+Every accepted Team message uses Steer. A running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes through the continuation lifecycle. A successful Team send remains durable before delivery starts. `accepted` means the target inbox accepted the message; `queued` means a temporary inspection, resume, or inbox-admission failure left it in the Team mailbox for recovery. Neither result means the target completed the requested work.
+
+The Lead receives the Team-attributed user message through `Agent.steer()`. A teammate receives it through a symbol-keyed host-only continuation adapter that authorizes the exact Lead-to-direct-child edge, preserves the original `TeamMessageSource`, and performs resident or cold-resume Steer admission. Sibling and teammate-to-Lead messages therefore retain the real sender; the Team runtime never calls public adjacent-Agent `sendMessage()` while impersonating the Lead.
+
+The Lead Session remains the mailbox transaction owner. It flushes `team/message/queued` before dispatch, serializes immediate admissions per target in Lead-log order, and records `team/message/delivered` only after the target Session durably contains the same Team message id. Recovery retries queued-minus-delivered records in order, and target-side source folding prevents duplicate acceptance across the crash window between inbox insertion and acknowledgement.
+
+## Alternatives considered
+
+**Keep quiet `send_message` and waking `followup_task`.** This preserves caller control over turn scheduling but makes the model choose an implementation policy, permits unread durable mail on inactive targets, and diverges from adjacent-Agent messaging.
+
+**Keep `followup_task` as an alias for Steer.** Two names for identical behavior would preserve the tool-selection failure without adding an observable capability.
+
+**Route siblings through public adjacent-Agent `sendMessage()`.** That operation authorizes only exact direct-parent or direct-child model senders and derives its own `AgentMessageSource`. Calling it with the Lead would misattribute sibling mail; widening it to Team membership would weaken its adjacency rule.
+
+**Drop the Team mailbox and deliver directly.** Direct delivery loses durable enqueue-before-admission, recovery after temporary failure, stable message ids, and target-side de-duplication.
+
+## Testing
+
+Package tests pin running, idle, inactive, Lead, sibling, and recovery delivery; target-local ordering; sender attribution; inbox/history de-duplication; temporary failure returning `queued`; and the nine-tool schema. The keyless Agent Teams profile snapshot drives a running implementer, steers a researcher message into its next step, and verifies that both teammates still complete their assigned tasks before the Lead aggregates the result.
+
+## Consequences
+
+Models have one Team communication choice and cannot park quiet information intentionally. A message may extend the target's current turn, so prompts and tests require teammates to integrate new messages without abandoning work already in progress.
+
+The host-only Steer adapter becomes part of the internal continuation integration used by Team delivery. Human browser prompts keep the separate Queue adapter and remain distinct turns. The broader [Agent Teams decision](../feature/2026-08-05-agent-teams.md) retains mailbox, roster, task, and shared-checkout ownership; the [adjacent-Agent messaging decision](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) retains the public direct-edge authorization and model-message source.

+ 41 - 0
.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: Team 消息使用单一 Steer send_message 操作
+
+Status: implemented
+
+[English](2026-08-30-team-send-message-steer.md) | 中文
+
+## 问题
+
+Agent Teams 为一个持久 mailbox 公开了两个模型操作:quiet `send_message` 注入 live target 而不唤醒它,`followup_task` 则排入一个独立 waking turn 并冷恢复 inactive teammate。模型必须选择调度策略,而不是只说明消息目标;quiet 消息可能为 inactive teammate 持续累积,直到无关工作恢复它。
+
+普通 continuable-Agent 控件已经使用一个方向无关、固定 Steer 调度的 `send_message`。保留独立的 Team 名称与投递模式,会让等价的模型通信因为 target 恰好是 direct child 还是 Team peer 而采用不同语义。
+
+## 决策
+
+每个 Team member 都会获得一个 `send_message({ target, message })` 工具。Team 工具集包含九个操作;不存在 `followup_task` 与模型可选的 quiet 投递。持久 `TeamMessageSnapshot` 存储 sender、target、content 与 message identity,不存储调度字段。
+
+每条已接受的 Team 消息都使用 Steer。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则通过 continuation lifecycle 冷恢复。每次成功的 Team send 都会在开始投递前完成持久化。`accepted` 表示 target inbox 已接受消息;`queued` 表示临时 inspection、resume 或 inbox 准入失败让消息留在 Team mailbox 等待恢复。两种结果都不表示 target 已完成所请求的工作。
+
+Lead 通过 `Agent.steer()` 接收携带 Team 归因的用户消息。teammate 通过 symbol-keyed host-only continuation adapter 接收消息;该 adapter 会授权精确的 Lead-to-direct-child edge、保留原始 `TeamMessageSource`,并执行 resident 或 cold-resume Steer 准入。因此 sibling 与 teammate-to-Lead 消息保留真实 sender;Team 运行时绝不会伪装成 Lead 调用公开的相邻 Agent `sendMessage()`。
+
+Lead Session 继续作为 mailbox transaction owner。它在 dispatch 前 flush `team/message/queued`,按 Lead 日志顺序为每个 target 串行化即时准入,并且只有 target Session 持久包含相同 Team message id 后才记录 `team/message/delivered`。恢复按顺序重试 queued-minus-delivered 记录;target 侧 source 折叠会防止 inbox insertion 与 acknowledgement 之间的 crash window 导致重复准入。
+
+## 考虑过的替代方案
+
+**保留 quiet `send_message` 与 waking `followup_task`。** 这会保留调用方对 turn 调度的控制,但要求模型选择实现策略、允许 inactive target 存在未读持久 mail,并与相邻 Agent 消息语义分叉。
+
+**保留 `followup_task` 作为 Steer 别名。** 两个名字表达同一行为只会保留工具选择错误,不会增加可观察能力。
+
+**通过公开的相邻 Agent `sendMessage()` 路由 sibling。** 该操作只授权精确的 direct-parent 或 direct-child 模型 sender,并派生自己的 `AgentMessageSource`。以 Lead 身份调用会错误归因 sibling mail;把它扩展到 Team membership 则会削弱相邻关系规则。
+
+**删除 Team mailbox 并直接投递。** 直接投递会失去准入前持久入队、临时失败后的恢复、稳定 message id 与 target 侧去重。
+
+## 测试
+
+包测试固定 running、idle、inactive、Lead、sibling 与 recovery 投递,target-local ordering、sender attribution、inbox/history 去重、临时失败返回 `queued`,以及九工具 schema。无密钥 Agent Teams profile snapshot 驱动 running implementer,把 researcher 消息 Steer 到其下一步骤,并验证两个 teammate 都继续完成各自任务,之后 Lead 才汇总结果。
+
+## 后果
+
+模型只有一种 Team 通信选择,不能有意停放 quiet information。一条消息可能扩展 target 的当前 turn,因此提示词与测试要求 teammate 整合新消息,同时不放弃已经进行的工作。
+
+host-only Steer adapter 成为 Team 投递使用的内部 continuation 集成。人类浏览器 prompt 保留独立 Queue adapter,并继续形成不同 turn。更广泛的 [Agent Teams 决策](../feature/2026-08-05-agent-teams.zh.md)继续负责 mailbox、roster、task 与共享 checkout;[相邻 Agent 消息决策](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)继续负责公开 direct-edge authorization 与 model-message source。

+ 2 - 2
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md
-2026-07-28-storage-root-and-derived-medium-recovery.md: 1505be1c58d5cf829327b2919113bb2e42798ce7
-2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 1bab5ab663df1419cc826c3d6acb59bd8bff7de0
+2026-07-28-storage-root-and-derived-medium-recovery.md: 68a6792c38a8fe097558de3d5a857ab4ea0d3533
+2026-07-28-storage-root-and-derived-medium-recovery.zh.md: 0c712b3b928f4b3df44e706b154c3a90a784403c

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.md


+ 1 - 1
.agents/notes/proposed/architecture/2026-07-28-storage-root-and-derived-medium-recovery.zh.md

@@ -10,7 +10,7 @@ Status: proposed
 
 **文件到底存在哪(根错位已收口,resolve-once 残余仍开放)。** 共享 base 将会话存储默认为全局 harness home(`$DSH_HOME/sessions`,默认 `~/.dsh/sessions`),而出厂 Web overlay 曾给 json 后端相对根 `./.storages`:`workspace.json` 和 `session_projcache.json` 落在 `<启动目录>/.storages/` 下——从两个不同目录启动,会话相同,工作区注册表和投影缓存却各是一份,而缓存存在的意义恰恰是跨会话冷列表,凡上次在别的启动目录下缓存过的会话全部 miss。这一错位已消除:overlay 现以与会话根同一段 `!!js` 表达式把 `storage-json.root` 锚定到 `$DSH_HOME/storages`(`apps/cli/config/web.cordis.yml`)。残余隐患:`JsonStorageBackend` 仍从不 resolve 根——每次打开 unit 都把路径 join 到当时的 `process.cwd()` 上(packages/storage/storage-json/src/index.ts);出厂 overlay 的根已是绝对路径不受影响,但任何相对根(裸 Loader 启动、测试)仍会被后续 cwd 变化劈开,JSONL 会话后端用「构造时 resolve 一次」防住的正是它("later process.cwd() changes cannot split one backend across roots",packages/session/session-persistence-jsonl/src/index.ts)。
 
-**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
+**恢复行为。** 在健康介质内部,缓存按设计完全自愈:`stateVersion` 不匹配的行被丢弃重折,日志缩短到行水位以下由带锚的 restore floor 检出并以一次全量重读回答,每次后台写都是 fail-soft。但在*介质*层面完全没有恢复:被截断、被手改或版本被 bump 的 `session_projcache.json` 会让 `openJsonUnit` 以 `malformed-medium`/`version-mismatch` 失败(packages/storage/storage-json/src/format.ts),schema 漂移的记录让域 open 以 `invalid-record` 失败(packages/storage/storage-domain/src/index.ts),拒绝一路穿过 `SessionProjectionCache[Service.init]`,在 CLI 的 fail-loud 启动下整个组装拒绝启动。一个内容完全可从会话日志重建的文件能把启动搞死。这与缓存包自己声明的立场("a stale or unreadable cache costs a longer tail replay, never a wrong value")和缓存域 spec 的 JSDoc("version bumps discard the whole medium")相矛盾——后者描述的是愿望而非实现。投影缓存半边已被[每会话缓存文件 note](../../implemented/architecture/2026-08-19-projection-cache-per-session-files.zh.md) 部分取代:全局 `session_projcache` domain 已移除,本提案的缓存恢复半边不再适用;`workspace.json` 半边仍然有效。per-record 投影缓存的 `invalid-record` 一类如今也已被取代:已发布的域声明了 `invalidRecords: 'backup-and-skip'`([跨版本读兼容 note](../../implemented/architecture/2026-09-02-projcache-cross-version-read-compat.zh.md)),open 时把失败记录备份后跳过,因此下文 reset/destroy 提案仅对权威介质或单文档介质的整介质损坏仍然相关。同一条 fail-loud 路径对 `workspace.json` 却是*正确*的——工作区记录是权威数据,不可派生——所以缺的概念是按域声明权威性,而不是全局改行为。
 
 ## 提案
 

+ 1 - 1
.github/issue-management/config.json

@@ -5,7 +5,7 @@
   "projectTitle": "DSH Issue Management",
   "lifecycleActor": "dsh-issue-management",
   "priorityField": "Priority",
-  "startDateField": "Start date",
+  "startDateField": "Start Date",
   "projectTimeZone": "Asia/Shanghai",
   "allowUnassignedOwner": true,
   "statuses": [

+ 17 - 22
.github/issue-management/policy.mjs

@@ -484,13 +484,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
           title
           fields(first: 50) {
             nodes {
-              ... on ProjectV2Field {
-                id
-                name
-                dataType
-                isIssueField
-                issueField { ... on IssueFieldDate { id } }
-              }
+              ... on ProjectV2Field { id name dataType isIssueField }
               ... on ProjectV2SingleSelectField { id name dataType options { id name } }
             }
           }
@@ -518,9 +512,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
               }
               startDateValue: fieldValueByName(name: $startDateField)
                 @include(if: $includeStartDate) {
-                ... on ProjectV2ItemIssueFieldValue {
-                  issueFieldValue { ... on IssueFieldDateValue { value } }
-                }
+                ... on ProjectV2ItemFieldDateValue { date }
               }
             }
           }
@@ -552,8 +544,8 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
   if (startDateField && startDateField.dataType !== 'DATE') {
     throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
   }
-  if (startDateField && (!startDateField.isIssueField || !startDateField.issueField?.id)) {
-    throw new Error(`Project ${config.startDateField} 字段必须为 Issue Date 字段`)
+  if (startDateField?.isIssueField) {
+    throw new Error(`Project ${config.startDateField} 字段必须为 Project Date 字段`)
   }
   const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
   const latestStatusEvent = issue.timelineItems?.nodes
@@ -593,24 +585,27 @@ async function ensureProjectItem(number, includeStartDate = false) {
 }
 
 /**
- * Initialize one Issue's organization Start date when it is empty.
+ * Initialize one Issue's Project Start Date when it is empty.
  * @param {number} number Same-repository Issue number.
  * @param {string} date Date in YYYY-MM-DD form.
- * @returns {Promise<void>} Resolves after the conditional Issue-field update.
+ * @returns {Promise<void>} Resolves after the conditional Project update.
  */
 export async function initializeIssueStartDate(number, date) {
   const context = await ensureProjectItem(number, true)
-  if (context.item.startDateValue?.issueFieldValue?.value) return
+  if (context.item.startDateValue?.date) return
   await graphql(
-    `mutation($issueId: ID!, $fieldId: ID!, $date: String!) {
-      updateIssueFieldValue(input: {
-        issueId: $issueId,
-        issueField: {fieldId: $fieldId, dateValue: $date}
-      }) { issue { id } }
+    `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
+      updateProjectV2ItemFieldValue(input: {
+        projectId: $projectId,
+        itemId: $itemId,
+        fieldId: $fieldId,
+        value: {date: $date}
+      }) { projectV2Item { id } }
     }`,
     {
-      issueId: context.issue.id,
-      fieldId: context.startDateField.issueField.id,
+      projectId: context.project.id,
+      itemId: context.item.id,
+      fieldId: context.startDateField.id,
       date,
     },
   )

+ 25 - 30
.github/issue-management/policy.test.mjs

@@ -21,8 +21,7 @@ const projectGraphqlData = ({
   startDate = null,
   startDateField = true,
   startDateType = 'DATE',
-  startDateIsIssueField = true,
-  startDateIssueField = true,
+  startDateIsIssueField = false,
 } = {}) => ({
   organization: {
     projectV2: {
@@ -34,11 +33,10 @@ const projectGraphqlData = ({
           ...(startDateField
             ? [
                 {
-                  id: 'start-date-project-field-id',
-                  name: 'Start date',
+                  id: 'start-date-field-id',
+                  name: 'Start Date',
                   dataType: startDateType,
                   isIssueField: startDateIsIssueField,
-                  issueField: startDateIssueField ? { id: 'start-date-issue-field-id' } : null,
                 },
               ]
             : []),
@@ -56,8 +54,7 @@ const projectGraphqlData = ({
                 id: 'item-id',
                 project: { id: 'project-id' },
                 fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
-                startDateValue:
-                  startDate === null ? null : { issueFieldValue: { value: startDate } },
+                startDateValue: startDate === null ? null : { date: startDate },
               },
             ]
           : [],
@@ -269,27 +266,29 @@ test('initializes every referenced Issue only for a PR opened event', async () =
   assert.equal(writes.length, 3)
 })
 
-test('writes an empty Issue Start date with the configured field', async (t) => {
+test('writes an empty Project Start Date with the configured field', async (t) => {
   const requests = mockGraphql(t, (request) => {
     if (request.query.includes('query(')) return projectGraphqlData()
-    return { updateIssueFieldValue: { issue: { id: 'issue-id' } } }
+    return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'item-id' } } }
   })
 
   await initializeIssueStartDate(42, '2026-08-28')
 
   assert.equal(requests.length, 2)
   assert.match(requests[0].query, /isIssueField/)
-  assert.match(requests[0].query, /ProjectV2ItemIssueFieldValue/)
-  assert.match(requests[1].query, /updateIssueFieldValue/)
-  assert.match(requests[1].query, /issueField: \{fieldId: \$fieldId, dateValue: \$date\}/)
+  assert.doesNotMatch(requests[0].query, /issueField\s*\{/)
+  assert.match(requests[0].query, /ProjectV2ItemFieldDateValue/)
+  assert.match(requests[1].query, /updateProjectV2ItemFieldValue/)
+  assert.match(requests[1].query, /value: \{date: \$date\}/)
   assert.deepEqual(requests[1].variables, {
-    issueId: 'issue-id',
-    fieldId: 'start-date-issue-field-id',
+    projectId: 'project-id',
+    itemId: 'item-id',
+    fieldId: 'start-date-field-id',
     date: '2026-08-28',
   })
 })
 
-test('preserves an existing Issue Start date', async (t) => {
+test('preserves an existing Project Start Date', async (t) => {
   const requests = mockGraphql(t, () => projectGraphqlData({ startDate: '2026-08-01' }))
 
   await initializeIssueStartDate(42, '2026-08-28')
@@ -297,13 +296,13 @@ test('preserves an existing Issue Start date', async (t) => {
   assert.equal(requests.length, 1)
 })
 
-test('adds a referenced Issue to the Project before setting Start date', async (t) => {
+test('adds a referenced Issue to the Project before setting Start Date', async (t) => {
   const requests = mockGraphql(t, (request) => {
     if (request.query.includes('query(')) return projectGraphqlData({ projectItem: false })
     if (request.query.includes('addProjectV2ItemById')) {
       return { addProjectV2ItemById: { item: { id: 'new-item-id' } } }
     }
-    return { updateIssueFieldValue: { issue: { id: 'issue-id' } } }
+    return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'new-item-id' } } }
   })
 
   await initializeIssueStartDate(42, '2026-08-28')
@@ -311,30 +310,26 @@ test('adds a referenced Issue to the Project before setting Start date', async (
   assert.equal(requests.length, 3)
   assert.deepEqual(requests[1].variables, { projectId: 'project-id', contentId: 'issue-id' })
   assert.deepEqual(requests[2].variables, {
-    issueId: 'issue-id',
-    fieldId: 'start-date-issue-field-id',
+    projectId: 'project-id',
+    itemId: 'new-item-id',
+    fieldId: 'start-date-field-id',
     date: '2026-08-28',
   })
 })
 
-test('rejects a missing, non-Date, or Project-local Start date field', async (t) => {
+test('rejects a missing, non-Date, or Issue-level Start Date field', async (t) => {
   let response = projectGraphqlData({ startDateField: false })
   const requests = mockGraphql(t, () => response)
 
-  await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start date 字段/)
+  await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start Date 字段/)
   response = projectGraphqlData({ startDateType: 'TEXT' })
-  await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start date 字段必须为 Date/)
-  response = projectGraphqlData({ startDateIsIssueField: false })
+  await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start Date 字段必须为 Date/)
+  response = projectGraphqlData({ startDateIsIssueField: true })
   await assert.rejects(
     initializeIssueStartDate(42, '2026-08-28'),
-    /Start date 字段必须为 Issue Date 字段/,
+    /Start Date 字段必须为 Project Date 字段/,
   )
-  response = projectGraphqlData({ startDateIssueField: false })
-  await assert.rejects(
-    initializeIssueStartDate(42, '2026-08-28'),
-    /Start date 字段必须为 Issue Date 字段/,
-  )
-  assert.equal(requests.length, 4)
+  assert.equal(requests.length, 3)
 })
 
 test('does not treat pull request references as Issue associations', () => {

+ 6 - 0
apps/cli/tests/profiles/headless/tests/fixtures/team-llm.mjs

@@ -88,6 +88,9 @@ function implementer(messages) {
   const names = calls(messages)
   const last = latestAssistantCalls(messages)
   const text = latestToolText(messages)
+  const userText = messages.flatMap(message => message.role === 'user'
+    ? message.content.filter(block => block.type === 'text').map(block => block.text)
+    : []).join('\n')
   if (!names.includes('team_task_create')) {
     if (last.includes('team_task_get') && text.includes('"subject":"Research"')) {
       return toolChunks([{ name: 'team_task_create', args: {
@@ -116,6 +119,9 @@ function implementer(messages) {
     }
     return toolChunks([{ name: 'team_task_get', args: { task_id: 'task-1' } }])
   }
+  if (!userText.includes('Research complete: use the deterministic finding.')) {
+    return toolChunks([{ name: 'wait_agent', args: { timeout_ms: 10000 } }])
+  }
   if (!names.includes('send_message')) {
     return toolChunks([
       { name: 'team_task_update', args: { task_id: 'task-2', expected_revision: 2, action: 'complete' } },

+ 55 - 0
apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts

@@ -578,6 +578,49 @@ describe('headless stream-json snapshots', () => {
         const tasks = rows.filter(row => row.type === 'team/task')
           .map(row => ((row.data as JsonObject).task as JsonObject))
         const latestTasks = Object.values(Object.fromEntries(tasks.map(task => [String(task.subject), task])))
+        const implementer = logs.find(log => typeof log.header.parentSession === 'string'
+          && parseJsonl(log.content).some((row) => {
+            if (row.type !== 'user/message') return false
+            const content: unknown = (row.data as JsonObject).content
+            return Array.isArray(content) && content.some((block: unknown) => (
+              typeof block === 'object' && block !== null && !Array.isArray(block)
+              && (block as JsonObject).type === 'text'
+              && typeof (block as JsonObject).text === 'string'
+              && ((block as JsonObject).text as string).includes('IMPLEMENTER_MARK')
+            ))
+          }))
+        if (implementer === undefined) throw new Error('Agent Teams snapshot did not persist the implementer')
+        const implementerRows = parseJsonl(implementer.content)
+        const steeredInboxIndex = implementerRows.findIndex((row) => {
+          if (row.type !== 'agent/inbox/spliced') return false
+          const data = row.data as JsonObject
+          const inserted: unknown = data.inserted
+          return data.target === 'next-step' && Array.isArray(inserted)
+            && inserted.some((message: unknown) => {
+              if (typeof message !== 'object' || message === null || Array.isArray(message)) return false
+              const source = (message as JsonObject).source
+              return typeof source === 'object' && source !== null && !Array.isArray(source)
+                && (source as JsonObject).kind === 'team-message'
+            })
+        })
+        const steeredMessageIndex = implementerRows.findIndex((row) => {
+          if (row.type !== 'user/message') return false
+          const source = (row.data as JsonObject).source
+          return typeof source === 'object' && source !== null && !Array.isArray(source)
+            && (source as JsonObject).kind === 'team-message'
+        })
+        const openTurnStart = implementerRows.findLastIndex((row, index) => (
+          index < steeredMessageIndex && row.type === 'turn/start'
+        ))
+        const openTurnEnd = implementerRows.findLastIndex((row, index) => (
+          index < steeredMessageIndex && row.type === 'turn/end'
+        ))
+        const completionAfterSteer = implementerRows.some((row, index) => {
+          if (index <= steeredMessageIndex || row.type !== 'tool/call') return false
+          const data = row.data as JsonObject
+          if (data.name !== 'team_task_update' || typeof data.arguments !== 'string') return false
+          return (JSON.parse(data.arguments) as JsonObject).action === 'complete'
+        })
         projection = {
           sessions: logs.length,
           memberEdges: members.length,
@@ -593,6 +636,12 @@ describe('headless stream-json snapshots', () => {
             && (row.data as JsonObject).name === 'wait_agent'),
           checkedRoster: rows.some(row => row.type === 'tool/call'
             && (row.data as JsonObject).name === 'list_agents'),
+          steerEvidence: {
+            nextStepInbox: steeredInboxIndex >= 0,
+            messageEntered: steeredMessageIndex > steeredInboxIndex,
+            enteredOpenTurn: openTurnStart > openTurnEnd,
+            completedAfterMessage: completionAfterSteer,
+          },
         }
       },
     })
@@ -612,6 +661,12 @@ describe('headless stream-json snapshots', () => {
         "memberEdges": 4,
         "queuedMessages": 2,
         "sessions": 3,
+        "steerEvidence": {
+          "completedAfterMessage": true,
+          "enteredOpenTurn": true,
+          "messageEntered": true,
+          "nextStepInbox": true,
+        },
         "tasks": [
           {
             "revision": 3,

+ 2 - 2
docs/config-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/config-catalog.md
-config-catalog.md: 84ed7bc7160b9d26c8da0df023f8b2a15c7e544e
-config-catalog.zh.md: 519d4a5cdca1a43a115c5a3ad44970f9f6f15d48
+config-catalog.md: bc490f1896ef4e8ab0c8b19c41bcf883a357b2d7
+config-catalog.zh.md: 7cb16a292955a202d74bf852f86604056a457e98

+ 2 - 2
docs/config-catalog.md

@@ -537,7 +537,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/experimental/agent-team/src/types.ts:131`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="deepseek-aidsh-experimental-code-runtime-python"></a>
 
@@ -1870,7 +1870,7 @@ export interface Config {
 }
 ```
 
-Source: [`packages/session/session-projection-cache/src/index.ts:54`](../packages/session/session-projection-cache/src/index.ts)
+Source: [`packages/session/session-projection-cache/src/index.ts:61`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 

+ 2 - 2
docs/config-catalog.zh.md

@@ -539,7 +539,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/experimental/agent-team/src/types.ts:125`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:124`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="deepseek-aidsh-experimental-code-runtime-python"></a>
 
@@ -1872,7 +1872,7 @@ export interface Config {
 }
 ```
 
-来源:[`packages/session/session-projection-cache/src/index.ts:54`](../packages/session/session-projection-cache/src/index.ts)
+来源:[`packages/session/session-projection-cache/src/index.ts:61`](../packages/session/session-projection-cache/src/index.ts)
 
 <a id="deepseek-aidsh-session-query-sqlite"></a>
 

+ 2 - 2
docs/persistence-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/persistence-catalog.md
-persistence-catalog.md: 4626ab8c84e1764de1124f0f59140f1ac7f570f2
-persistence-catalog.zh.md: 560732d2418e3859a2d8c374f605c82a6ec24882
+persistence-catalog.md: 76d91ea8e8edb625f9733b6ad28d225d2ee059a8
+persistence-catalog.zh.md: 52625069c16094e1b056b9ee9013409fd1690705

+ 8 - 8
docs/persistence-catalog.md

@@ -786,12 +786,12 @@ Source: [`packages/subagent/tool-subagent/src/model-selection-state.ts:17`](../p
 
 ```ts persistence-catalog
 /** Whole teammate lifecycle value, stored only in the Team Lead Session. */
-'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot }
+'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot }
 ```
 
 Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md)
 
-Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:221`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="teammessagedelivered--log-only"></a>
 
@@ -800,7 +800,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experi
 ```ts persistence-catalog
 /** Durable acknowledgement that the target Session recorded the message. */
 'team/message/delivered': {
-  version: 1
+  version: 2
   teamId: TeamId
   messageId: TeamMessageId
   targetId: SessionId
@@ -809,7 +809,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experi
 
 Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md)
 
-Source: [`packages/experimental/agent-team/src/types.ts:229`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="teammessagequeued--log-only"></a>
 
@@ -817,12 +817,12 @@ Source: [`packages/experimental/agent-team/src/types.ts:229`](../packages/experi
 
 ```ts persistence-catalog
 /** Durable mailbox enqueue, stored before delivery is attempted. */
-'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot }
+'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot }
 ```
 
 Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md)
 
-Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="teamtask--log-only"></a>
 
@@ -830,12 +830,12 @@ Source: [`packages/experimental/agent-team/src/types.ts:227`](../packages/experi
 
 ```ts persistence-catalog
 /** Whole shared-task value, stored only in the Team Lead Session. */
-'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot }
+'team/task': { version: 2; teamId: TeamId; task: TeamTaskSnapshot }
 ```
 
 Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md)
 
-Source: [`packages/experimental/agent-team/src/types.ts:225`](../packages/experimental/agent-team/src/types.ts)
+Source: [`packages/experimental/agent-team/src/types.ts:223`](../packages/experimental/agent-team/src/types.ts)
 
 ### `todo/*`
 

+ 8 - 8
docs/persistence-catalog.zh.md

@@ -788,12 +788,12 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 ```ts persistence-catalog
 /** Whole teammate lifecycle value, stored only in the Team Lead Session. */
-'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot }
+'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot }
 ```
 
 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md)
 
-来源:[`packages/experimental/agent-team/src/types.ts:206`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:204`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="teammessagedelivered--log-only"></a>
 
@@ -802,7 +802,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 ```ts persistence-catalog
 /** Durable acknowledgement that the target Session recorded the message. */
 'team/message/delivered': {
-  version: 1
+  version: 2
   teamId: TeamId
   messageId: TeamMessageId
   targetId: SessionId
@@ -811,7 +811,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md)
 
-来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="teammessagequeued--log-only"></a>
 
@@ -819,12 +819,12 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 ```ts persistence-catalog
 /** Durable mailbox enqueue, stored before delivery is attempted. */
-'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot }
+'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot }
 ```
 
 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md)
 
-来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:208`](../packages/experimental/agent-team/src/types.ts)
 
 <a id="teamtask--log-only"></a>
 
@@ -832,12 +832,12 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
 
 ```ts persistence-catalog
 /** Whole shared-task value, stored only in the Team Lead Session. */
-'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot }
+'team/task': { version: 2; teamId: TeamId; task: TeamTaskSnapshot }
 ```
 
 类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md)
 
-来源:[`packages/experimental/agent-team/src/types.ts:208`](../packages/experimental/agent-team/src/types.ts)
+来源:[`packages/experimental/agent-team/src/types.ts:206`](../packages/experimental/agent-team/src/types.ts)
 
 ### `todo/*`
 

+ 2 - 2
docs/subsystems/agent-team.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/agent-team.md
-agent-team.md: e3a746734b067bb7ef8f083c5c9af9cb3707dcf7
-agent-team.zh.md: 1f885a73861c091b50d27d15080ebc09f6d382ec
+agent-team.md: 201123ea94499ff20cae25d68b1f7a826439d143
+agent-team.zh.md: d6973ae6899c1d884dc51bd71afbd36a41895527

+ 4 - 3
docs/subsystems/agent-team.md

@@ -2,7 +2,7 @@
 
 English | [中文](agent-team.zh.md)
 
-Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts).
+Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; the [Team Steer messaging Agent Note](../../.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.md) owns message scheduling; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts).
 
 ## Identity and roster
 
@@ -34,11 +34,12 @@ interface TeamMessageSnapshot {
   readonly senderId: SessionId
   readonly senderName: string
   readonly targetId: SessionId
-  readonly delivery: 'quiet' | 'wakeup'
   readonly content: ContentBlock[]
 }
 ```
 
+Every message attempts Steer delivery. A running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. Scheduling is not stored in the durable record because callers cannot select another mode.
+
 The target Session keeps message identity and sender attribution on both the pending inbox item and the eventual user message. Folding that source across inbox and history is the target-side de-duplication key; the model-visible framing repeats the id and sender.
 
 ```ts type-equiv
@@ -116,7 +117,7 @@ async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise<Spawn
 /**
  * Queue one durable peer message, then attempt immediate delivery.
  * @param caller - exact live sending Team member.
- * @param request - target name, content, scheduling mode, and pre-queue cancellation.
+ * @param request - target name, content, and pre-queue cancellation.
  * @returns durable message identity and immediate-delivery observation.
  */
 async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise<SendTeamMessageResult>

+ 4 - 3
docs/subsystems/agent-team.zh.md

@@ -2,7 +2,7 @@
 
 [English](agent-team.md) | 中文
 
-实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。
+实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;[Team Steer 消息 Agent Note](../../.agents/notes/implemented/simplification/2026-08-30-team-send-message-steer.zh.md)负责消息调度;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。
 
 ## 身份与 roster
 
@@ -34,11 +34,12 @@ interface TeamMessageSnapshot {
   readonly senderId: SessionId
   readonly senderName: string
   readonly targetId: SessionId
-  readonly delivery: 'quiet' | 'wakeup'
   readonly content: ContentBlock[]
 }
 ```
 
+每条消息都会尝试 Steer 投递。running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。调用方不能选择其他模式,因此持久记录不存储调度方式。
+
 target Session 会在 pending inbox 条目和最终用户消息上保留消息身份与发送者归因。跨 inbox 与历史折叠该 source 构成 target 侧去重键;模型可见的 framing 会重复 id 和发送者。
 
 ```ts type-equiv
@@ -116,7 +117,7 @@ async spawnTeammate(caller: Agent, request: SpawnTeammateRequest): Promise<Spawn
 /**
  * Queue one durable peer message, then attempt immediate delivery.
  * @param caller - exact live sending Team member.
- * @param request - target name, content, scheduling mode, and pre-queue cancellation.
+ * @param request - target name, content, and pre-queue cancellation.
  * @returns durable message identity and immediate-delivery observation.
  */
 async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise<SendTeamMessageResult>

+ 2 - 2
docs/subsystems/storage.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/storage.md
-storage.md: 1e4141e6ef1c6f8e1c2593e21e788b626d6b1ed7
-storage.zh.md: f0433c600674741c3de0ce3e99430297839ce124
+storage.md: 03b0fa1c674064994039d37c4fec906dc45794b3
+storage.zh.md: ded8783fbfe7f0d7d8a846c7a66d780b48de1445

+ 28 - 5
docs/subsystems/storage.md

@@ -44,7 +44,7 @@ interface StorageBackend {
 }
 ```
 
-A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, format version, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A medium stamped with a different version rejects `version-mismatch`; one that cannot be parsed as the unit rejects `malformed-medium` (no migration, pre-release stance). [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data.
+A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data.
 
 ## Declaring a domain
 
@@ -55,16 +55,36 @@ A domain is declared once by its owning package as a spec object — the single
 interface DomainSpec {
   /** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */
   readonly name: string
-  /** Domain format version; a medium stamped with a different version rejects at open. */
+  /** Current domain format version; reads enforce it according to the selected layout. */
   readonly version: number
   /**
    * Medium layout for the backend unit: `single` (the default) stores the
    * whole unit as one document; `per-record` stores each record as its own
    * document, for units whose records are large, sparse, or individually
-   * disposable — the projection cache — and scopes version bumps per record
-   * (a stale record document is discarded, never migrated).
+   * disposable — the projection cache — and scopes version checks per record
+   * (an unaccepted record document is discarded, never migrated).
    */
   readonly layout?: 'single' | 'per-record'
+  /**
+   * Older domain versions whose stored records the current record schemas
+   * also accept (the declaring owner vouches for that, typically by
+   * declaring the fields older records lack as optional). `per-record` backends
+   * read documents stamped with a listed version instead of discarding them,
+   * and accept a legacy whole-unit file so stamped for the one-time
+   * bootstrap; writes always stamp {@link version}.
+   */
+  readonly compatibleVersions?: readonly number[]
+  /**
+   * What `open` does with a stored table record that fails its zod schema.
+   * Absent (the default), the whole open rejects with `invalid-record` —
+   * right for authoritative data. `'backup-and-skip'` is for domains whose
+   * records are disposable derived data: the backend moves the record's
+   * document aside (`KvUnit.backupRecord`), the failure is logged with
+   * its cause, and the open continues with the record absent. A backend
+   * without `backupRecord` (no per-record document to move) falls back
+   * to the rejecting default. The global slot always rejects.
+   */
+  readonly invalidRecords?: 'backup-and-skip'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
@@ -180,7 +200,10 @@ The mounted domain facility. Opens declared domains over routed backends; one fa
  * (`facet-unsupported`); open the unit projected from the spec (backend
  * `version-mismatch`/`malformed-medium` pass through); load and validate
  * every stored record against the spec's zod schemas (`invalid-record`
- * with the offending table and key); construct the domain.
+ * with the offending table and key — unless the spec declares
+ * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in
+ * which case the failing record is backed up, logged, and skipped);
+ * construct the domain.
  *
  * Lifecycle: the CALLER owns the returned handle and closes it via
  * `Domain.close()` (typically as its own `ctx.effect` disposer) — the

+ 28 - 5
docs/subsystems/storage.zh.md

@@ -44,7 +44,7 @@ interface StorageBackend {
 }
 ```
 
-一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、格式版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。介质上记录的版本与之不同时拒绝 `version-mismatch`;无法按该 unit 解析的介质拒绝 `malformed-medium`(不做迁移:预发布立场)。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。
+一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。
 
 ## 声明领域
 
@@ -55,16 +55,36 @@ interface StorageBackend {
 interface DomainSpec {
   /** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */
   readonly name: string
-  /** Domain format version; a medium stamped with a different version rejects at open. */
+  /** Current domain format version; reads enforce it according to the selected layout. */
   readonly version: number
   /**
    * Medium layout for the backend unit: `single` (the default) stores the
    * whole unit as one document; `per-record` stores each record as its own
    * document, for units whose records are large, sparse, or individually
-   * disposable — the projection cache — and scopes version bumps per record
-   * (a stale record document is discarded, never migrated).
+   * disposable — the projection cache — and scopes version checks per record
+   * (an unaccepted record document is discarded, never migrated).
    */
   readonly layout?: 'single' | 'per-record'
+  /**
+   * Older domain versions whose stored records the current record schemas
+   * also accept (the declaring owner vouches for that, typically by
+   * declaring the fields older records lack as optional). `per-record` backends
+   * read documents stamped with a listed version instead of discarding them,
+   * and accept a legacy whole-unit file so stamped for the one-time
+   * bootstrap; writes always stamp {@link version}.
+   */
+  readonly compatibleVersions?: readonly number[]
+  /**
+   * What `open` does with a stored table record that fails its zod schema.
+   * Absent (the default), the whole open rejects with `invalid-record` —
+   * right for authoritative data. `'backup-and-skip'` is for domains whose
+   * records are disposable derived data: the backend moves the record's
+   * document aside (`KvUnit.backupRecord`), the failure is logged with
+   * its cause, and the open continues with the record absent. A backend
+   * without `backupRecord` (no per-record document to move) falls back
+   * to the rejecting default. The global slot always rejects.
+   */
+  readonly invalidRecords?: 'backup-and-skip'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
@@ -180,7 +200,10 @@ The mounted domain facility. Opens declared domains over routed backends; one fa
  * (`facet-unsupported`); open the unit projected from the spec (backend
  * `version-mismatch`/`malformed-medium` pass through); load and validate
  * every stored record against the spec's zod schemas (`invalid-record`
- * with the offending table and key); construct the domain.
+ * with the offending table and key — unless the spec declares
+ * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in
+ * which case the failing record is backed up, logged, and skipped);
+ * construct the domain.
  *
  * Lifecycle: the CALLER owns the returned handle and closes it via
  * `Domain.close()` (typically as its own `ctx.effect` disposer) — the

+ 2 - 2
docs/tool-catalog.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/tool-catalog.md
-tool-catalog.md: f71c5dea97ca92fd816c45c6fbd816ee05309d63
-tool-catalog.zh.md: 3286da7015cfa793820f6107a96af1fc73472ddb
+tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b
+tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d

+ 3 - 29
docs/tool-catalog.md

@@ -36,7 +36,7 @@ This table connects model-visible tool names to the plugin package and service s
 | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`, `subagent` | `ctx.tools`, `ctx.subagents`, `ctx.systemPrompt`, `ctx.llm for model discovery and selected-route validation` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered delegation name is the load-time `toolName` config (default `subagent`); the default schema above has model selection off, while the discovery schema is shown as the fixed companion available in an enabled Session. Web presets sample the Plugins preference for each new top-level Session and preserve that decision for its child Sessions; `subagent_fork` remains fixed-route. Each instance independently controls whether it reads model-selection settings and its background behavior through `modelSelectionSettings`, `backgroundMode`, and `enableRunInBackground`. |
 | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`, `list_agents`, `send_message` | `ctx.tools`, `ctx.subagents`, `ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`, `tool/result`, `child session events through ctx.subagents` | - | The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` and `interrupt_agent` once, plus `list_agents` from its separately loaded `/list-agents` plugin (whose catalog rows use the sessionProjections and live Agent registries). |
 | `@deepseek-ai/dsh-tool-jobs` | `job_kill`, `job_list`, `job_output` | `ctx.tools`, `ctx.jobs`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-job controller: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the controller that arms producers' `ctx.jobs.start()`. |
-| `@deepseek-ai/dsh-experimental-tool-agent-team` | `followup_task`, `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. |
+| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`, `list_agents`, `send_message`, `spawn_teammate`, `team_task_create`, `team_task_get`, `team_task_list`, `team_task_update`, `wait_agent` | `ctx.tools`, `ctx.systemPrompt`, `ctx.agentTeams`, `an exact live Team member Agent` | `tool/call`, `team/member`, `team/message/queued`, `team/message/delivered`, `team/task`, `tool/result` | - | All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names. |
 | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. `allowParallelInProgress` is required with no default, so the catalog states its choice: `true`, whose description invites several `in_progress` items. A deployment choosing `false` receives the same tool with a description asking for exactly one active task. |
 | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflowEngine`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-web` | `web_fetch`, `web_search` | `ctx.tools`, `ctx.web`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | web_search and web_fetch keep provider selection behind ctx.web so model-visible schemas stay stable across backend swaps. |
@@ -1742,32 +1742,6 @@ The kind-agnostic background-job controller: background bash commands, PTY sends
 
 ## `@deepseek-ai/dsh-experimental-tool-agent-team`
 
-### `followup_task`
-
-Send a durable follow-up task to another Team member and start a turn when needed.
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "target": {
-      "type": "string",
-      "description": "Team member name, or lead."
-    },
-    "message": {
-      "type": "string",
-      "description": "Self-contained message for the target."
-    }
-  },
-  "required": [
-    "target",
-    "message"
-  ]
-}
-```
-
-Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts)
-
 ### `interrupt_agent`
 
 Interrupt one teammate's current turn while preserving its pending inbox. Team Lead only.
@@ -1804,7 +1778,7 @@ Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/exper
 
 ### `send_message`
 
-Send durable information to another Team member without starting an idle member.
+Send one durable message to another Team member. A running target receives it at the nearest step boundary; an idle target starts a turn; an inactive teammate cold-resumes.
 
 ```json
 {
@@ -2052,7 +2026,7 @@ Wait for the next teammate status, mailbox, or shared-task change after this cal
 
 Source: [`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts)
 
-All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names.
+All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names.
 
 <a id="deepseek-aidsh-tool-todo"></a>
 

+ 2 - 28
docs/tool-catalog.zh.md

@@ -40,7 +40,7 @@
 | `@deepseek-ai/dsh-tool-subagent` | `list_subagent_models`、`subagent` | `ctx.tools`、`ctx.subagents`、`ctx.systemPrompt`、`用于模型发现和所选路由校验的 ctx.llm` | `tool/call`、`tool/result`、`child session events through the chosen provider` | `subagent`、`subagent_fork` | 注册的委派工具名称取决于加载时 `toolName` 配置(默认为 `subagent`);上述默认 schema 关闭模型选择,而发现 schema 则展示为已启用 Session 中可用的固定配套工具。Web preset 会在每个新顶层 Session 创建时读取插件页偏好,并为其子 Session 保留该决定;`subagent_fork` 始终使用固定路由。每个实例通过 `modelSelectionSettings`、`backgroundMode` 与 `enableRunInBackground` 独立控制是否读取模型选择设置及其后台行为。 |
 | `@deepseek-ai/dsh-tool-subagent-control` | `interrupt_agent`、`list_agents`、`send_message` | `ctx.tools`、`ctx.subagents`、`ctx.agents and ctx.sessionProjections (list_agents only)` | `tool/call`、`tool/result`、`child session events through ctx.subagents` | - | 这些是控制可继续后台 subagent 的全局命名工具:绑定提供方的 `tool-subagent` 实例注册不同的委派工具;本包注册一次 `send_message` 和 `interrupt_agent`,另由 `list_agents` 通过单独加载的 `/list-agents` 插件提供,其目录行使用 sessionProjections 和实时 Agent 注册表。 |
 | `@deepseek-ai/dsh-tool-jobs` | `job_kill`、`job_list`、`job_output` | `ctx.tools`、`ctx.jobs`、`ctx.systemPrompt` | `tool/call`、`tool/result`、`user/message via agent.inject() for background completion notices` | - | 与任务种类无关的后台任务控制器:后台 bash 命令、PTY 发送和 subagent 都通过相同的 3 个工具读取、列出和终止。加载该插件会挂接控制器,从而启用生产方的 `ctx.jobs.start()`。 |
-| `@deepseek-ai/dsh-experimental-tool-agent-team` | `followup_task`、`interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 10 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 |
+| `@deepseek-ai/dsh-experimental-tool-agent-team` | `interrupt_agent`、`list_agents`、`send_message`、`spawn_teammate`、`team_task_create`、`team_task_get`、`team_task_list`、`team_task_update`、`wait_agent` | `ctx.tools`、`ctx.systemPrompt`、`ctx.agentTeams`、`an exact live Team member Agent` | `tool/call`、`team/member`、`team/message/queued`、`team/message/delivered`、`team/task`、`tool/result` | - | 这 9 个工具限定于隐式 Team Lead 与持久 teammate 作用域。随产品发布的 dsh-base bundle 默认禁用该包;文档中的 Agent Teams profile patch 会启用它,并禁用旧 continuable child 的同名控制工具。 |
 | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`、`owning Agent session` | `tool/call`、`todo/write`、`tool/result` | - | todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为检查清单。`allowParallelInProgress` 是没有默认值的必填项,因此本目录明确选择 `true`,对应描述允许同时存在多个 `in_progress` 项。选择 `false` 的部署会获得同一工具,但描述会要求只能有 1 个活动任务。 |
 | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`、`ctx.workflowEngine`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents the script children)` | `tool/call`、`tool/result` | - | - |
 | `@deepseek-ai/dsh-tool-web` | `web_fetch`、`web_search` | `ctx.tools`、`ctx.web`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | web_search 和 web_fetch 将提供方选择置于 ctx.web 之后,使模型可见 schema 在更换后端时保持稳定。 |
@@ -1748,32 +1748,6 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
 
 ## `@deepseek-ai/dsh-experimental-tool-agent-team`
 
-### `followup_task`
-
-向另一名 Team member 发送持久 follow-up task,并在需要时启动一个 turn。
-
-```json
-{
-  "type": "object",
-  "properties": {
-    "target": {
-      "type": "string",
-      "description": "Team member name, or lead."
-    },
-    "message": {
-      "type": "string",
-      "description": "Self-contained message for the target."
-    }
-  },
-  "required": [
-    "target",
-    "message"
-  ]
-}
-```
-
-来源:[`packages/experimental/tool-agent-team/src/index.ts`](../packages/experimental/tool-agent-team/src/index.ts)
-
 ### `interrupt_agent`
 
 中断一名 teammate 的当前 turn,同时保留其待处理 inbox。仅 Team Lead 可用。
@@ -1810,7 +1784,7 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,
 
 ### `send_message`
 
-向另一名 Team member 发送持久信息,但不启动 idle member
+向另一名 Team member 发送一条持久消息。running target 会在最近的步骤边界收到消息;idle target 会启动一个 turn;inactive teammate 会冷恢复
 
 ```json
 {

+ 1 - 1
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -28,7 +28,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
     expect(names).toEqual([
       'ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_inspect_list',
       'cordis_inspect_query', 'cordis_inspect_self', 'cordis_run', 'cordis_stop',
-      'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'followup_task', 'get_goal', 'glob', 'grep',
+      'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep',
       'interrupt_agent', 'interrupt_agent', 'job_kill', 'job_list', 'job_output',
       'list_agents', 'list_agents', 'list_subagent_models', 'lsp', 'pwsh', 'pwsh', 'ralph',
       'read', 'read_image', 'run_code', 'schedule_create', 'schedule_delete',

+ 2 - 2
packages/experimental/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/README.md
-README.md: 689739bc532ddde4c41c3fb550d098add0501f70
-README.zh.md: 18499a28849dd3f4671ef266f24818cdc30f15e8
+README.md: 1a5f60c84327f47d9edea7d6bccb87d710a766f7
+README.zh.md: b97fd66854bcf8ad435348ddcba605f44eeffe52

+ 1 - 1
packages/experimental/README.md

@@ -30,7 +30,7 @@ The experimental group contains prototype capabilities that are not part of any
 | [`client-ui-agent-team`](client-ui-agent-team/README.md) | Team roster, task board, and teammate navigation for Web | — |
 | [`code-runtime-python`](code-runtime-python/README.md) | CPython subprocess backend for the code-execution seam | `ctx.codeRuntime` |
 | [`inspector`](inspector/README.md) | Cross-realm CDP hub for Host debugging, Client Runtime inspection, network capture, and Cordis trees | `ctx.inspector` |
-| [`tool-agent-team`](tool-agent-team/README.md) | Ten tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` |
+| [`tool-agent-team`](tool-agent-team/README.md) | Nine tools that let the model create, message, and coordinate teammates | registers scoped tools on `ctx.tools` |
 | [`webworker-packer`](webworker-packer/README.md) | Builds the gzip-compressed VFS image consumed by the browser worker preview | library and CLI — no ctx key |
 | [`webworker-runtime`](webworker-runtime/README.md) | Runs the harness plugin tree inside a dedicated browser worker | library and worker entry — no ctx key |
 

+ 1 - 1
packages/experimental/README.zh.md

@@ -30,7 +30,7 @@ kind: "package-group"
 | [`client-ui-agent-team`](client-ui-agent-team/README.zh.md) | Web Team roster、任务板与 teammate 导航 | — |
 | [`code-runtime-python`](code-runtime-python/README.zh.md) | 代码执行 seam 的 CPython 子进程后端 | `ctx.codeRuntime` |
 | [`inspector`](inspector/README.zh.md) | 用于 Host 调试、Client Runtime 检查、网络采集与 Cordis 树的跨 realm CDP hub | `ctx.inspector` |
-| [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的个工具 | 按作用域注册工具到 `ctx.tools` |
+| [`tool-agent-team`](tool-agent-team/README.zh.md) | 让模型创建、发消息与协调 teammate 的个工具 | 按作用域注册工具到 `ctx.tools` |
 | [`webworker-packer`](webworker-packer/README.zh.md) | 构建浏览器 worker 预览所消费的 gzip 压缩 VFS 镜像 | 库与 CLI,不使用 ctx key |
 | [`webworker-runtime`](webworker-runtime/README.zh.md) | 在专用浏览器 worker 中运行 harness 插件树 | 库与 worker 入口,不使用 ctx key |
 

+ 2 - 2
packages/experimental/agent-team/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/agent-team/README.md
-README.md: 0316834f566112b708f513b46cd0e5e3ab503061
-README.zh.md: 0aae14bccc06c57a9e1698f302cadd6b293baca8
+README.md: 1f9f7e98fdd2d8c32c6da2d514ff763367d6eb65
+README.zh.md: d6b57d35b6305c94fad966e3c9b3e20f6a3e81f5

+ 3 - 1
packages/experimental/agent-team/README.md

@@ -68,7 +68,7 @@ Only the Lead can create teammates or interrupt them.
 
 Any member can send a message to any other member or to the Lead. A live member receives it immediately; an offline member's messages queue and arrive when it resumes. Messages are never lost and never delivered twice.
 
-Two delivery modes cover the two common intents: a quiet message delivers information without starting an idle teammate (use it for updates that can wait), and a follow-up makes the message the recipient's next turn (use it to hand over work). The sender always sees the outcome — delivered now, or queued. A queued message is already safely stored, so it must not be resent.
+Every message uses Steer: a running target receives it at the nearest step boundary, an idle target starts a turn, and an inactive teammate cold-resumes. The sender always sees the outcome — accepted by the target inbox, or retained as queued when delivery is temporarily unavailable. A queued message is already safely stored, so it must not be resent.
 
 ### Shared task board
 
@@ -131,6 +131,8 @@ Every ordinary runtime root is the implicit Lead of a Team whose `TeamId` equals
 
 `sendMessage()` validates peer membership, appends `team/message/queued`, and flushes before attempting delivery. The target message begins with `Team message <id> from <name>:` and keeps the same id and sender in `TeamMessageSource`. A target receipt is acknowledged with `team/message/delivered` only after the target Session durably holds the message identity in its pending inbox or recorded history. Immediate admissions are serialized per target in durable queue order; recovery dispatches queued-minus-delivered records in the same order. Delivery folds both live and persisted target inbox/history state before retrying, so a crash between inbox acceptance and model claim does not duplicate the message. The guarantee is process-local retry plus target-Session de-duplication, not cross-process exactly-once delivery.
 
+Lead delivery calls `Agent.steer()` directly. Teammate delivery uses the continuation owner's host-only Steer path, which preserves the Team sender source while authorizing the Lead-to-child edge and cold-resuming inactive targets. Sibling messages never impersonate the Lead through the public adjacent-Agent messaging operation.
+
 ### Shared task board
 
 Tasks are complete versioned snapshots; every mutation carries `expectedRevision`, and a stale caller receives `TEAM_TASK_STALE_REVISION` instead of overwriting a newer value. Numeric `task-<n>` ids require a safe-integer suffix, and id-space exhaustion reports `TEAM_TASK_LIMIT` instead of reusing the final id. Deleted tasks remain tombstones for replay and id stability but do not consume `maxTasks` or appear in `listTasks()`. `writeScopes` are normalized workspace-relative prefixes; views warn on overlap with in-progress tasks but never block claim or authorize writes.

+ 3 - 1
packages/experimental/agent-team/README.zh.md

@@ -68,7 +68,7 @@ roster 显示每个成员的职责(`lead` 或 `teammate`)与当前状态:`
 
 任何成员都可以向任何其他成员或 Lead 发送消息。live 成员会立即收到;离线成员的消息会排队,并在其恢复后到达。消息不会丢失,也绝不会重复投递。
 
-两种投递模式覆盖两种常见意图:quiet 消息在不让 idle teammate 启动的情况下传达信息(用于可以等待的更新),follow-up 让消息成为接收方的下一个轮次(用于移交工作)。发送方始终能看到结果——已送达,或正在排队。排队的消息已经安全存储,因此绝不能重发。
+每条消息都使用 Steer:running target 在最近的步骤边界收到消息,idle target 启动一个轮次,inactive teammate 则冷恢复。发送方始终能看到结果——target inbox 已接受,或在投递暂时不可用时保留为 queued。排队的消息已经安全存储,因此绝不能重发。
 
 ### 共享任务板
 
@@ -131,6 +131,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息
 
 `sendMessage()` 校验 peer 成员关系,追加 `team/message/queued` 并在尝试投递前 flush。目标消息以 `Team message <id> from <name>:` 开头,并在 `TeamMessageSource` 中保留同一 id 与发送者。只有目标 Session 在 pending inbox 或已记录历史中持久持有消息身份后,才会以 `team/message/delivered` 确认投递。即时准入按目标与持久队列顺序串行化;恢复按同一顺序重新投递 queued-minus-delivered 记录。重试前会同时折叠 live 与持久目标 inbox/历史状态,因此 inbox 已接受但模型尚未 claim 时发生崩溃不会复制消息。该保证是进程内重试加 target Session 去重,而不是跨进程 exactly-once 投递。
 
+投递给 Lead 时直接调用 `Agent.steer()`。投递给 teammate 时使用 continuation owner 的 host-only Steer 路径;该路径会保留 Team 发送者 source,同时授权 Lead-to-child edge 并冷恢复 inactive target。sibling 消息绝不会通过公开的相邻 Agent 消息操作伪装成 Lead。
+
 ### 共享任务板
 
 任务是完整版本化快照;每次变更都携带 `expectedRevision`,陈旧调用方会收到 `TEAM_TASK_STALE_REVISION`,而不会覆盖更新的值。数字 `task-<n>` id 的后缀必须是安全整数,id 空间耗尽时报告 `TEAM_TASK_LIMIT`,而不是复用最后一个 id。已删除任务作为 tombstone 保留以供回放与维持 id 稳定,但不占用 `maxTasks`,也不出现在 `listTasks()` 中。`writeScopes` 是规范化后的 workspace 相对前缀;视图会对与 in-progress 任务的重叠发出警告,但绝不阻止 claim 或授予写权限。

+ 1 - 1
packages/experimental/agent-team/src/index.ts

@@ -157,7 +157,7 @@ export class TeamService extends TypertRemoteService {
   /**
    * Queue one durable peer message, then attempt immediate delivery.
    * @param caller - exact live sending Team member.
-   * @param request - target name, content, scheduling mode, and pre-queue cancellation.
+   * @param request - target name, content, and pre-queue cancellation.
    * @returns durable message identity and immediate-delivery observation.
    */
   async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise<SendTeamMessageResult> {

+ 31 - 41
packages/experimental/agent-team/src/mailbox.ts

@@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
-import { queueHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal'
+import { steerHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal'
 import { errorMessage, TeamError } from './error.ts'
 import type { TeamJournal } from './journal.ts'
 import type { TeamRuntimeLifecycle } from './lifecycle.ts'
@@ -26,7 +26,6 @@ import type {
 /** Owns every process-local state transition for the durable Team mailbox. */
 export class TeamMailbox {
   private readonly dispatchTails = new Map<SessionId, Promise<void>>()
-  private readonly activeDispatches = new Map<SessionId, TeamMessageSnapshot>()
   private readonly inFlightMessages = new Set<TeamMessageId>()
   private readonly inFlightDispatches = new Set<Promise<unknown>>()
 
@@ -50,7 +49,7 @@ export class TeamMailbox {
   /**
    * Queue one durable peer message, then attempt immediate delivery.
    * @param caller - exact live sending Team member.
-   * @param request - target name, content, scheduling mode, and pre-queue cancellation.
+   * @param request - target name, content, and pre-queue cancellation.
    * @returns durable message identity and immediate-delivery observation.
    */
   async send(caller: Agent, request: SendTeamMessageRequest): Promise<SendTeamMessageResult> {
@@ -94,8 +93,6 @@ export class TeamMailbox {
       && (membership.role === 'lead' || message.targetId === agent.id))
     for (const message of messages) {
       signal.throwIfAborted()
-      if (membership.role === 'lead' && message.delivery === 'quiet'
-        && message.targetId !== membership.root.id && this.ctx.agents.get(message.targetId) === undefined) continue
       await this.tryDispatch(membership.root, message, signal)
     }
   }
@@ -135,14 +132,13 @@ export class TeamMailbox {
         senderId: caller.id,
         senderName: membership.name,
         targetId: target.id,
-        delivery: request.delivery,
         content,
       }
       if (Buffer.byteLength(JSON.stringify(this.deliveryContent(queued)), 'utf8') > this.maxMessageBytes) {
         throw new TeamError(`team message exceeds ${this.maxMessageBytes} bytes`, 'TEAM_MESSAGE_TOO_LARGE')
       }
       await this.journal.appendAndFlush(root, 'team/message/queued', {
-        version: 1,
+        version: 2,
         teamId: TeamId(root.id),
         message: queued,
       })
@@ -190,13 +186,7 @@ export class TeamMailbox {
     message: TeamMessageSnapshot,
     signal: AbortSignal,
   ): Promise<boolean> {
-    const active = this.activeDispatches.get(message.targetId)
-    const live = message.targetId === root.id ? root : this.ctx.agents.get(message.targetId)
-    if (active !== undefined && live !== undefined && message.delivery === 'quiet'
-      && this.messagePrecedes(root, message.id, active.id)) {
-      return await this.dispatchOnce(root, message, signal)
-    }
-    return await this.serializeDispatch(message, () => this.dispatchOnce(root, message, signal))
+    return await this.serializeDispatch(message, () => this.dispatchThrough(root, message, signal))
   }
 
   /** Serialize delivery admission for one durable target in queued order. */
@@ -206,16 +196,8 @@ export class TeamMailbox {
   ): Promise<boolean> {
     const targetId = message.targetId
     const prior = this.dispatchTails.get(targetId) ?? Promise.resolve()
-    const dispatch = async (): Promise<boolean> => {
-      this.activeDispatches.set(targetId, message)
-      try {
-        return await operation()
-      } finally {
-        this.activeDispatches.delete(targetId)
-      }
-    }
     /* v8 ignore next -- dispatch tails absorb rejection, so the recovery callback is a fail-safe backstop. */
-    const run = prior.then(dispatch, dispatch)
+    const run = prior.then(operation, operation)
     /* v8 ignore next -- dispatchOnce contains delivery failures and serializeDispatch itself does not throw. */
     const tail = run.then(() => undefined, () => undefined)
     this.dispatchTails.set(targetId, tail)
@@ -226,6 +208,29 @@ export class TeamMailbox {
     }
   }
 
+  /** Deliver every pending target message through `message` in durable queue order. */
+  private async dispatchThrough(
+    root: Agent,
+    message: TeamMessageSnapshot,
+    signal: AbortSignal,
+  ): Promise<boolean> {
+    const state = this.journal.state(root)
+    const pending = state.messages.filter(candidate =>
+      candidate.targetId === message.targetId && !state.delivered.includes(candidate.id))
+    const requested = pending.findIndex(candidate => candidate.id === message.id)
+    if (requested < 0) return state.delivered.includes(message.id)
+    for (const candidate of pending.slice(0, requested + 1)) {
+      const ownsInFlight = !this.inFlightMessages.has(candidate.id)
+      if (ownsInFlight) this.inFlightMessages.add(candidate.id)
+      try {
+        if (!await this.dispatchOnce(root, candidate, signal)) return false
+      } finally {
+        if (ownsInFlight) this.inFlightMessages.delete(candidate.id)
+      }
+    }
+    return true
+  }
+
   /** Attempt one queued delivery after target-local ordering admits it. */
   private async dispatchOnce(root: Agent, message: TeamMessageSnapshot, signal: AbortSignal): Promise<boolean> {
     try {
@@ -243,18 +248,9 @@ export class TeamMailbox {
       const content = this.deliveryContent(message)
       if (message.targetId === root.id) {
         const input = createUserMessage({ content, source })
-        if (message.delivery === 'wakeup') {
-          root.followup(input)
-          return await this.checkpointDelivered(root, root.session, message.id)
-        }
-        root.inject(input)
+        root.steer(input)
         return await this.checkpointDelivered(root, root.session, message.id)
       }
-      if (message.delivery === 'quiet') {
-        if (target === undefined) return false
-        target.inject(createUserMessage({ content, source }))
-        return await this.checkpointDelivered(root, target.session, message.id)
-      }
       if (target === undefined) {
         const recorded = await this.persistedTargetRecorded(message.targetId, message.id, signal)
         if (recorded === undefined) return false
@@ -263,7 +259,7 @@ export class TeamMailbox {
           return true
         }
       }
-      await queueHostSubagentPrompt(this.ctx.subagents, root, message.targetId, content, source, signal)
+      await steerHostSubagentPrompt(this.ctx.subagents, root, message.targetId, content, source, signal)
       return target === undefined
         ? true
         : await this.checkpointDelivered(root, target.session, message.id)
@@ -273,12 +269,6 @@ export class TeamMailbox {
     }
   }
 
-  /** Whether `left` was durably queued before `right` in one Lead log. */
-  private messagePrecedes(root: Agent, left: TeamMessageId, right: TeamMessageId): boolean {
-    const ids = this.journal.state(root).messages.map(message => message.id)
-    return ids.indexOf(left) < ids.indexOf(right)
-  }
-
   /** Flush one live target receipt before the Lead records its delivered edge. */
   private async checkpointDelivered(
     root: Agent,
@@ -299,7 +289,7 @@ export class TeamMailbox {
       const queued = state.messages.find(message => message.id === messageId)
       if (queued === undefined || queued.targetId !== targetId) return
       await this.journal.appendAndFlush(root, 'team/message/delivered', {
-        version: 1,
+        version: 2,
         teamId: TeamId(root.id),
         messageId,
         targetId,

+ 6 - 7
packages/experimental/agent-team/src/projection.ts

@@ -90,7 +90,6 @@ const teamMessageSnapshotSchema = z.object({
   senderId: sessionIdSchema,
   senderName: z.string(),
   targetId: sessionIdSchema,
-  delivery: z.enum(['quiet', 'wakeup']),
   content: z.array(contentBlockSchema),
 }).strict() as z.ZodType<TeamMessageSnapshot>
 
@@ -100,25 +99,25 @@ const teamEventSelectorSchema = z.object({
 }).loose()
 
 const teamMemberEventSchema = z.object({
-  version: z.literal(1),
+  version: z.literal(2),
   teamId: teamIdSchema,
   member: teamMemberSnapshotSchema,
 }).strict() as z.ZodType<SessionEventMap['team/member']>
 
 const teamTaskEventSchema = z.object({
-  version: z.literal(1),
+  version: z.literal(2),
   teamId: teamIdSchema,
   task: teamTaskSnapshotSchema,
 }).strict() as z.ZodType<SessionEventMap['team/task']>
 
 const teamMessageQueuedEventSchema = z.object({
-  version: z.literal(1),
+  version: z.literal(2),
   teamId: teamIdSchema,
   message: teamMessageSnapshotSchema,
 }).strict() as z.ZodType<SessionEventMap['team/message/queued']>
 
 const teamMessageDeliveredEventSchema = z.object({
-  version: z.literal(1),
+  version: z.literal(2),
   teamId: teamIdSchema,
   messageId: teamMessageIdSchema,
   targetId: sessionIdSchema,
@@ -225,7 +224,7 @@ function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent):
   try {
     const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data)
     if (selector.teamId !== state.id) return
-    if (selector.version !== 1) {
+    if (selector.version !== 2) {
       throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`)
     }
     applyCurrentTeamEvent(state, parseCurrentTeamEvent(event))
@@ -307,7 +306,7 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void
 /** Host-only Team projection selected by the projected Session identity. */
 export const teamProjectionDefinition = {
   key: 'agentTeam',
-  stateVersion: 2,
+  stateVersion: 3,
   stateSchema: teamProjectionEntrySchema,
   init: header => emptyTeamState(header.id),
   apply: (state, event) => {

+ 3 - 3
packages/experimental/agent-team/src/roster.ts

@@ -274,7 +274,7 @@ export class TeamRoster {
       if (state.members.length >= this.maxMembers) {
         throw new TeamError(`Team member limit ${this.maxMembers} reached`, 'TEAM_MEMBER_LIMIT')
       }
-      await this.journal.appendAndFlush(root, 'team/member', { version: 1, teamId: TeamId(root.id), member })
+      await this.journal.appendAndFlush(root, 'team/member', { version: 2, teamId: TeamId(root.id), member })
     })
 
     let started: ContinuableStart
@@ -424,7 +424,7 @@ export class TeamRoster {
           ...phase === 'failed' ? { error: failure } : {},
         }
         await this.journal.appendAndFlush(root, 'team/member', {
-          version: 1,
+          version: 2,
           teamId: TeamId(root.id),
           member: settled,
         })
@@ -472,7 +472,7 @@ export class TeamRoster {
       }
       if (current.phase !== 'provisioning') return current.phase
       await this.journal.appendAndFlush(root, 'team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(root.id),
         member: terminal,
       })

+ 2 - 2
packages/experimental/agent-team/src/task-board.ts

@@ -67,7 +67,7 @@ export class TeamTaskBoard {
         writeScopes: this.writeScopes(request.writeScopes ?? []),
       }
       this.assertTaskGraph(state, task)
-      await this.journal.appendAndFlush(root, 'team/task', { version: 1, teamId: TeamId(root.id), task })
+      await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task })
       return this.taskView(root, state, task)
     })
   }
@@ -209,7 +209,7 @@ export class TeamTaskBoard {
         revision: current.revision + 1,
       }
       this.assertTaskGraph(state, task)
-      await this.journal.appendAndFlush(root, 'team/task', { version: 1, teamId: TeamId(root.id), task })
+      await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task })
       return this.taskView(root, state, task)
     })
   }

+ 4 - 6
packages/experimental/agent-team/src/types.ts

@@ -108,7 +108,6 @@ export interface TeamMessageSnapshot {
   readonly senderId: SessionId
   readonly senderName: string
   readonly targetId: SessionId
-  readonly delivery: 'quiet' | 'wakeup'
   readonly content: ContentBlock[]
 }
 
@@ -160,7 +159,6 @@ export interface SpawnTeammateResult {
 export interface SendTeamMessageRequest {
   readonly target: string
   readonly content: ContentBlock[]
-  readonly delivery: 'quiet' | 'wakeup'
   readonly signal: AbortSignal
 }
 
@@ -220,14 +218,14 @@ export interface TeamWaitResult {
 declare module '@deepseek-ai/dsh-session/types' {
   interface SessionEventMap {
     /** Whole teammate lifecycle value, stored only in the Team Lead Session. */
-    'team/member': { version: 1; teamId: TeamId; member: TeamMemberSnapshot }
+    'team/member': { version: 2; teamId: TeamId; member: TeamMemberSnapshot }
     /** Whole shared-task value, stored only in the Team Lead Session. */
-    'team/task': { version: 1; teamId: TeamId; task: TeamTaskSnapshot }
+    'team/task': { version: 2; teamId: TeamId; task: TeamTaskSnapshot }
     /** Durable mailbox enqueue, stored before delivery is attempted. */
-    'team/message/queued': { version: 1; teamId: TeamId; message: TeamMessageSnapshot }
+    'team/message/queued': { version: 2; teamId: TeamId; message: TeamMessageSnapshot }
     /** Durable acknowledgement that the target Session recorded the message. */
     'team/message/delivered': {
-      version: 1
+      version: 2
       teamId: TeamId
       messageId: TeamMessageId
       targetId: SessionId

+ 3 - 3
packages/experimental/agent-team/tests/invariant.spec.ts

@@ -30,13 +30,13 @@ describe('Agent Teams stream invariant', () => {
       phase: 'provisioning' as const,
     }
     expect(() => {
-      session.append('team/member', { version: 1, teamId: TeamId(session.id), member })
+      session.append('team/member', { version: 2, teamId: TeamId(session.id), member })
     }).not.toThrow()
 
     const invalid = ctx.sessions.create(SessionId('team-invariant-invalid'))
     expect(() => {
       invalid.append('team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(invalid.id),
         member: { ...member, phase: 'active' },
       })
@@ -53,7 +53,7 @@ describe('Agent Teams stream invariant', () => {
 
     expect(() => {
       session.append('team/task', {
-        version: 1,
+        version: 2,
         teamId: TeamId(session.id),
         task: {
           id: TeamTaskId('task-1'),

+ 15 - 30
packages/experimental/agent-team/tests/persistence.spec.ts

@@ -178,12 +178,12 @@ for (const backend of backends) {
       await Promise.resolve()
 
       activeRoot.session.append('team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(activeRoot.id),
         member: provisioning(childId, 'recoverable'),
       })
       failedRoot.session.append('team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(failedRoot.id),
         member: provisioning(SessionId(`${backend.name}-missing`), 'missing'),
       })
@@ -225,7 +225,6 @@ for (const backend of backends) {
       const receipt = await second.ctx.agentTeams.sendMessage(activeHandle.agent, {
         target: 'recoverable',
         content: [{ type: 'text', text: 'resume after reconciliation' }],
-        delivery: 'wakeup',
         signal: SIGNAL,
       })
       expect(receipt.status).toBe('accepted')
@@ -249,7 +248,7 @@ for (const backend of backends) {
       await Promise.resolve()
       await Promise.resolve()
       root.session.append('team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(root.id),
         member: provisioning(childId, 'pending-worker'),
       })
@@ -278,7 +277,7 @@ for (const backend of backends) {
       await second.dispose()
     })
 
-    it('replays queued-minus-delivered mail in FIFO order without waking for quiet mail', {
+    it('retries queued mail through cold-resume Steer after restart', {
       timeout: PERSISTENCE_TEST_TIMEOUT_MS,
     }, async () => {
       const storageRoot = mkdtempSync(join(tmpdir(), `dsh-team-mail-${backend.name.toLowerCase()}-`))
@@ -296,14 +295,15 @@ for (const backend of backends) {
         signal: SIGNAL,
       })
       await vi.waitFor(() => { expect(first.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 })
-      const quiet = await first.ctx.agentTeams.sendMessage(firstLead, {
+      vi.spyOn(first.ctx.sessionPersistence, 'open')
+        .mockRejectedValueOnce(new Error('temporary target read failure'))
+      const queued = await first.ctx.agentTeams.sendMessage(firstLead, {
         target: 'mail-worker',
-        content: [{ type: 'text', text: 'durable quiet context' }],
-        delivery: 'quiet',
+        content: [{ type: 'text', text: 'durable retry context' }],
         signal: SIGNAL,
       })
-      expect(quiet.status).toBe('queued')
-      expect(durable(firstLead).pendingMessages.map(message => message.id)).toEqual([quiet.messageId])
+      expect(queued.status).toBe('queued')
+      expect(durable(firstLead).pendingMessages.map(message => message.id)).toEqual([queued.messageId])
       await first.dispose()
 
       const second = await stack(backend, storageRoot, [textResponse('resumed teammate answer')])
@@ -311,19 +311,6 @@ for (const backend of backends) {
         resumeSessionId: rootId,
         agentOptions: { provider: 'mock', model: 'mock' },
       })
-      await vi.waitFor(() => {
-        expect(durable(rootHandle.agent).pendingMessages.map(message => message.id))
-          .toEqual([quiet.messageId])
-      })
-      expect(second.ctx.agents.get(started.member.id)).toBeUndefined()
-
-      const waking = await second.ctx.agentTeams.sendMessage(rootHandle.agent, {
-        target: 'mail-worker',
-        content: [{ type: 'text', text: 'resume after restart' }],
-        delivery: 'wakeup',
-        signal: SIGNAL,
-      })
-      expect(waking.status).toBe('accepted')
       await vi.waitFor(() => { expect(second.ctx.agents.get(started.member.id)).toBeUndefined() }, { timeout: 5_000 })
       await vi.waitFor(() => { expect(durable(rootHandle.agent).pendingMessages).toEqual([]) })
 
@@ -332,7 +319,7 @@ for (const backend of backends) {
         && event.data.source.kind === 'team-message'
         ? [event.data.source.messageId]
         : [])
-      expect(peerIds).toEqual([quiet.messageId, waking.messageId])
+      expect(peerIds).toEqual([queued.messageId])
 
       await rootHandle.dispose()
       await second.dispose()
@@ -386,11 +373,10 @@ for (const backend of backends) {
         senderId: rootId,
         senderName: 'lead',
         targetId: started.member.id,
-        delivery: 'wakeup',
         content: [{ type: 'text', text: 'already recorded before acknowledgement' }],
       }
       firstLead.session.append('team/message/queued', {
-        version: 1,
+        version: 2,
         teamId: TeamId(rootId),
         message: queued,
       })
@@ -439,21 +425,20 @@ for (const backend of backends) {
         senderId: rootId,
         senderName: 'lead',
         targetId: childId,
-        delivery: 'wakeup',
         content: [{ type: 'text', text: 'already durable in target inbox' }],
       }
       root.session.append('team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(root.id),
         member: provisioned,
       })
       root.session.append('team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(root.id),
         member: active,
       })
       root.session.append('team/message/queued', {
-        version: 1,
+        version: 2,
         teamId: TeamId(root.id),
         message: queued,
       })

+ 43 - 44
packages/experimental/agent-team/tests/projection-events.spec.ts

@@ -76,7 +76,6 @@ function message(overrides: Partial<TeamMessageSnapshot> = {}): TeamMessageSnaps
     senderId: ROOT,
     senderName: 'lead',
     targetId: CHILD,
-    delivery: 'quiet',
     content: [{ type: 'text', text: 'hello' }],
     ...overrides,
   }
@@ -85,15 +84,15 @@ function message(overrides: Partial<TeamMessageSnapshot> = {}): TeamMessageSnaps
 describe('Agent Teams projection events', () => {
   it('projects current-team records independently from inherited records', () => {
     const records: SessionEvent[] = [
-      event('team/member', { version: 1, teamId: TeamId('ancestor'), member: member() }, SessionSeq(0)),
-      event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(1)),
+      event('team/member', { version: 2, teamId: TeamId('ancestor'), member: member() }, SessionSeq(0)),
+      event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(1)),
       event('team/member', {
-        version: 1,
+        version: 2,
         teamId: TEAM,
         member: member({ phase: 'active' }),
       }, SessionSeq(2)),
-      event('team/task', { version: 1, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, SessionSeq(3)),
-      event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(4)),
+      event('team/task', { version: 2, teamId: TEAM, task: task({ id: TeamTaskId('task-7') }) }, SessionSeq(3)),
+      event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(4)),
     ]
     const projected = project(ROOT, records)
     const state = teamState(projected)
@@ -109,53 +108,53 @@ describe('Agent Teams projection events', () => {
   })
 
   it('enforces teammate identity and lifecycle', () => {
-    const base = event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0))
+    const base = event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0))
     expect(() => projectTeam(ROOT, [event('team/member', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       member: member({ phase: 'active' }),
     }, SessionSeq(0))])).toThrow(/must begin provisioning/)
     expect(() => projectTeam(ROOT, [base, event('team/member', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       member: member({ name: 'renamed', phase: 'active' }),
     }, SessionSeq(1))])).toThrow(/immutable identity/)
     expect(() => projectTeam(ROOT, [base, event('team/member', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       member: member({ phase: 'active' }),
     }, SessionSeq(1)), event('team/member', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       member: member({ phase: 'failed' }),
     }, SessionSeq(2))])).toThrow(/invalid active -> failed/)
 
     const duplicateName = member({ id: SessionId('child-b') })
     expect(() => projectTeam(ROOT, [base, event('team/member', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       member: duplicateName,
     }, SessionSeq(1))])).toThrow(/name .* reused/)
   })
 
   it('enforces task revision continuity', () => {
-    const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0))
+    const first = event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0))
     expect(() => projectTeam(ROOT, [event('team/task', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       task: task({ revision: 2 }),
     }, SessionSeq(0))])).toThrow(/begin at revision 1/)
     expect(() => projectTeam(ROOT, [first, event('team/task', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       task: task({ revision: 3 }),
     }, SessionSeq(1))])).toThrow(/revision is not contiguous/)
   })
 
   it('rejects every invalid persisted task dependency relation', () => {
-    const first = event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0))
+    const first = event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0))
     const second = event('team/task', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       task: task({
         id: TeamTaskId('task-2'),
@@ -165,7 +164,7 @@ describe('Agent Teams projection events', () => {
     const invalid: Array<{ records: SessionEvent[]; message: RegExp }> = [
       {
         records: [event('team/task', {
-          version: 1,
+          version: 2,
           teamId: TEAM,
           task: task({ blockedBy: [TeamTaskId('missing')] }),
         }, SessionSeq(0))],
@@ -173,7 +172,7 @@ describe('Agent Teams projection events', () => {
       },
       {
         records: [event('team/task', {
-          version: 1,
+          version: 2,
           teamId: TEAM,
           task: task({ blockedBy: [TeamTaskId('task-1')] }),
         }, SessionSeq(0))],
@@ -188,7 +187,7 @@ describe('Agent Teams projection events', () => {
       },
       {
         records: [first, second, event('team/task', {
-          version: 1,
+          version: 2,
           teamId: TEAM,
           task: task({ revision: 2, blockedBy: [TeamTaskId('task-2')] }),
         }, SessionSeq(2))],
@@ -196,7 +195,7 @@ describe('Agent Teams projection events', () => {
       },
       {
         records: [first, second, event('team/task', {
-          version: 1,
+          version: 2,
           teamId: TEAM,
           task: task({ revision: 2, status: 'deleted' }),
         }, SessionSeq(2))],
@@ -211,7 +210,7 @@ describe('Agent Teams projection events', () => {
 
   it('leaves numeric allocation unchanged for a branded nonstandard task id', () => {
     const state = projectTeam(ROOT, [event('team/task', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       task: task({ id: TeamTaskId('external-task') }),
     }, SessionSeq(0))])
@@ -220,16 +219,16 @@ describe('Agent Teams projection events', () => {
 
   it('rejects a persisted numeric task id outside the safe integer range', () => {
     expect(() => projectTeam(ROOT, [event('team/task', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       task: task({ id: TeamTaskId('task-9007199254740992') }),
     }, SessionSeq(0))])).toThrow(/persisted Agent Teams team\/task payload is invalid/)
   })
 
   it('enforces mailbox queue and acknowledgement relations', () => {
-    const queued = event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(0))
+    const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(0))
     const delivered = event('team/message/delivered', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       messageId: TeamMessageId('message-1'),
       targetId: CHILD,
@@ -247,42 +246,42 @@ describe('Agent Teams projection events', () => {
   it('validates every current-version persisted payload before projecting it', () => {
     const malformed = [
       {
-        ...event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)),
-        data: { version: 1, teamId: TEAM, member: { ...member(), name: 42 } },
+        ...event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)),
+        data: { version: 2, teamId: TEAM, member: { ...member(), name: 42 } },
       },
       {
-        ...event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)),
-        data: { version: 1, teamId: TEAM, task: { ...task(), blockedBy: [42] } },
+        ...event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0)),
+        data: { version: 2, teamId: TEAM, task: { ...task(), blockedBy: [42] } },
       },
       {
-        ...event('team/message/queued', { version: 1, teamId: TEAM, message: message() }, SessionSeq(0)),
+        ...event('team/message/queued', { version: 2, teamId: TEAM, message: message() }, SessionSeq(0)),
         data: {
-          version: 1,
+          version: 2,
           teamId: TEAM,
           message: { ...message(), content: [{ type: 'text', text: 42 }] },
         },
       },
       {
         ...event('team/message/delivered', {
-          version: 1,
+          version: 2,
           teamId: TEAM,
           messageId: TeamMessageId('message-1'),
           targetId: CHILD,
         }, SessionSeq(0)),
         data: {
-          version: 1,
+          version: 2,
           teamId: TEAM,
           messageId: TeamMessageId('message-1'),
           targetId: 42,
         },
       },
       {
-        ...event('team/member', { version: 1, teamId: TEAM, member: member() }, SessionSeq(0)),
-        data: { version: 1, teamId: TEAM, member: member(), unexpected: true },
+        ...event('team/member', { version: 2, teamId: TEAM, member: member() }, SessionSeq(0)),
+        data: { version: 2, teamId: TEAM, member: member(), unexpected: true },
       },
       {
-        ...event('team/task', { version: 1, teamId: TEAM, task: task() }, SessionSeq(0)),
-        data: { version: 1, teamId: 42, task: task() },
+        ...event('team/task', { version: 2, teamId: TEAM, task: task() }, SessionSeq(0)),
+        data: { version: 2, teamId: 42, task: task() },
       },
     ] as unknown as SessionEvent[]
 
@@ -295,7 +294,7 @@ describe('Agent Teams projection events', () => {
   it('retains merge-extensible content blocks while rejecting malformed core variants', () => {
     const extension = { type: 'plugin/custom', payload: { value: 1 } } as never
     const state = projectTeam(ROOT, [event('team/message/queued', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       message: message({ content: [extension] }),
     }, SessionSeq(0))])
@@ -304,23 +303,23 @@ describe('Agent Teams projection events', () => {
 
   it('records unsupported event versions without applying them', () => {
     const invalid = event('team/task', {
-      version: 2 as 1,
+      version: 1 as 2,
       teamId: TEAM,
       task: task(),
     }, SessionSeq(0))
     const later = event('team/task', {
-      version: 1,
+      version: 2,
       teamId: TEAM,
       task: task(),
     }, SessionSeq(1))
     const state = project(ROOT, [invalid, later])
-    expect(state.failure).toMatch(/unsupported Agent Teams event version 2/)
+    expect(state.failure).toMatch(/unsupported Agent Teams event version 1/)
     expect(isEmptyState(state)).toBe(true)
   })
 
   it('isolates unsupported inherited Team records from the current Team', () => {
     const inherited = event('team/task', {
-      version: 2 as 1,
+      version: 1 as 2,
       teamId: TeamId('ancestor'),
       task: task(),
     }, SessionSeq(0))
@@ -332,12 +331,12 @@ describe('Agent Teams projection events', () => {
   it('ignores malformed current-version records inherited from another Team', () => {
     const inherited = {
       ...event('team/task', {
-        version: 1,
+        version: 2,
         teamId: TeamId('ancestor'),
         task: task(),
       }, SessionSeq(0)),
       data: {
-        version: 1,
+        version: 2,
         teamId: TeamId('ancestor'),
         task: { ...task(), subject: 42 },
       },

+ 116 - 80
packages/experimental/agent-team/tests/team.spec.ts

@@ -11,7 +11,7 @@ import { SessionLogOffset, SessionId, type Session, type SessionEvent } from '@d
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import SubagentService from '@deepseek-ai/dsh-subagent'
-import { queueSubagentPrompt, type HostPromptQueue } from '@deepseek-ai/dsh-subagent/internal'
+import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal'
 import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -193,7 +193,7 @@ describe('Team identity and provisioning', () => {
       phase: 'provisioning' as const,
     }
     lead.session.append('team/member', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       member: provisioning,
     })
@@ -346,7 +346,7 @@ describe('Team identity and provisioning', () => {
       diagnostics: ['string provider failure'],
     })
     await expect(first.ctx.agentTeams.sendMessage(first.lead, {
-      target: 'string-failure', content: content('cannot deliver'), delivery: 'quiet', signal: SIGNAL,
+      target: 'string-failure', content: content('cannot deliver'), signal: SIGNAL,
     })).rejects.toMatchObject({ code: 'TEAM_MEMBER_NOT_FOUND' })
 
     const second = await setup([])
@@ -354,7 +354,7 @@ describe('Team identity and provisioning', () => {
       const provisioning = durable(second.lead).members[0]
       if (provisioning === undefined) throw new Error('missing provisioning edge')
       second.lead.session.append('team/member', {
-        version: 1,
+        version: 2,
         teamId: TeamId(second.lead.id),
         member: { ...provisioning, phase: 'active' },
       })
@@ -555,7 +555,7 @@ describe('Team shared task DAG', () => {
     const { ctx, lead } = await setup([])
     const id = TeamTaskId(`task-${Number.MAX_SAFE_INTEGER}`)
     lead.session.append('team/task', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       task: {
         id,
@@ -594,7 +594,7 @@ describe('Team shared task DAG', () => {
   })
 
   it('enforces CAS, ownership, dependencies, transitions, and write-scope warnings', async () => {
-    const { ctx, lead } = await setup(['hang', 'hang'])
+    const { ctx, lead } = await setup(['hang', 'hang', textResponse('beta integrated update')])
     const firstMember = await spawn(ctx, lead, 'alpha')
     const alpha = await waitRunning(ctx, firstMember.member.id)
     const secondMember = await spawn(ctx, lead, 'beta')
@@ -938,29 +938,31 @@ describe('Team Remote API', () => {
 })
 
 describe('Team mailbox and waiting', () => {
-  it('injects a quiet message addressed to the Lead and checkpoints its receipt', async () => {
-    const { ctx, lead } = await setup([])
+  it('steers a message addressed to the Lead and checkpoints its receipt', async () => {
+    const { ctx, lead } = await setup(['hang'])
     const message: TeamMessageSnapshot = {
-      id: TeamMessageId('quiet-lead-message'),
+      id: TeamMessageId('steer-lead-message'),
       senderId: SessionId('team-worker'),
       senderName: 'worker',
       targetId: lead.id,
-      delivery: 'quiet',
-      content: content('quiet report'),
+      content: content('progress report'),
     }
     lead.session.append('team/message/queued', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       message,
     })
 
     await expect(teamInternals(ctx).mailbox.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true)
-    expect(lead.inbox.nextStep.some(input => input.source.kind === 'team-message'
-      && input.source.messageId === message.id)).toBe(true)
+    expect(lead.session.snapshotEvents().some(event => event.type === 'agent/inbox/spliced'
+      && event.data.inserted.some(input => input.source.kind === 'team-message'
+        && input.source.messageId === message.id))).toBe(true)
     expect(durable(lead).pendingMessages).toEqual([])
+    lead.cancel({ kind: 'parent' })
+    await lead.whenIdle()
   })
 
-  it('acknowledges waking messages persisted by a busy Lead before model claim', async () => {
+  it('acknowledges steered messages persisted by a busy Lead before model claim', async () => {
     const { ctx, lead, teamFiber } = await setup(['hang', 'hang'], { maxPendingMessagesPerMember: 1 })
     const started = await spawn(ctx, lead, 'lead-reporter')
     const reporter = await waitRunning(ctx, started.member.id)
@@ -968,10 +970,10 @@ describe('Team mailbox and waiting', () => {
     await waitRunning(ctx, lead.id)
 
     const first = await ctx.agentTeams.sendMessage(reporter, {
-      target: 'lead', content: content('first wakeup report'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'lead', content: content('first progress report'), signal: SIGNAL,
     })
     const second = await ctx.agentTeams.sendMessage(reporter, {
-      target: 'lead', content: content('second wakeup report'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'lead', content: content('second progress report'), signal: SIGNAL,
     })
     expect([first.status, second.status]).toEqual(['accepted', 'accepted'])
     expect(lead.status).toBe('running')
@@ -1016,8 +1018,7 @@ describe('Team mailbox and waiting', () => {
     const target = await waitRunning(ctx, started.member.id)
     const immediate = await ctx.agentTeams.sendMessage(lead, {
       target: 'pending-target',
-      content: content('live quiet receipt'),
-      delivery: 'quiet',
+      content: content('live steer receipt'),
       signal: SIGNAL,
     })
     expect(immediate.status).toBe('accepted')
@@ -1030,11 +1031,10 @@ describe('Team mailbox and waiting', () => {
       senderId: lead.id,
       senderName: 'lead',
       targetId: target.id,
-      delivery: 'quiet',
       content: content('durable pending receipt'),
     }
     lead.session.append('team/message/queued', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       message,
     })
@@ -1070,7 +1070,7 @@ describe('Team mailbox and waiting', () => {
       content: content('canceled before checkpoint'),
     }
     lead.session.append('team/message/queued', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       message: disappearing,
     })
@@ -1098,7 +1098,7 @@ describe('Team mailbox and waiting', () => {
     await waitNoAgent(ctx, target.id)
   })
 
-  it('acknowledges waking messages accepted by a busy target inbox', async () => {
+  it('acknowledges steered messages accepted by a busy target inbox', async () => {
     const { ctx, lead } = await setup(['hang'], { maxPendingMessagesPerMember: 1 })
     const started = await spawn(ctx, lead, 'busy-target')
     const target = await waitRunning(ctx, started.member.id)
@@ -1110,24 +1110,24 @@ describe('Team mailbox and waiting', () => {
     })
 
     const first = await ctx.agentTeams.sendMessage(lead, {
-      target: 'busy-target', content: content('first waking message'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'busy-target', content: content('first steered message'), signal: SIGNAL,
     })
 
     expect(first.status).toBe('accepted')
     expect(flushed).toEqual([lead.id, target.id, lead.id])
     expect(durable(lead).pendingMessages).toEqual([])
-    expect(target.inbox.nextTurn.some(message => message.source.kind === 'team-message'
+    expect(target.inbox.nextStep.some(message => message.source.kind === 'team-message'
       && message.source.messageId === first.messageId)).toBe(true)
 
     flushed.length = 0
     const second = await ctx.agentTeams.sendMessage(lead, {
-      target: 'busy-target', content: content('second waking message'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'busy-target', content: content('second steered message'), signal: SIGNAL,
     })
 
     expect(second.status).toBe('accepted')
     expect(flushed).toEqual([lead.id, target.id, lead.id])
     expect(durable(lead).pendingMessages).toEqual([])
-    expect(target.inbox.nextTurn.filter(message => message.source.kind === 'team-message'
+    expect(target.inbox.nextStep.filter(message => message.source.kind === 'team-message'
       && (message.source.messageId === first.messageId || message.source.messageId === second.messageId)))
       .toHaveLength(2)
 
@@ -1136,35 +1136,37 @@ describe('Team mailbox and waiting', () => {
     await waitNoAgent(ctx, target.id)
   })
 
-  it('serializes concurrent waking delivery admission for one target', async () => {
-    const { ctx, lead } = await setup([textResponse('target initial')])
-    const target = await spawn(ctx, lead, 'ordered-target')
-    await waitNoAgent(ctx, target.member.id)
+  it('serializes concurrent Steer delivery admission for one target', async () => {
+    const { ctx, lead } = await setup(['hang'])
+    const started = await spawn(ctx, lead, 'ordered-target')
+    const target = await waitRunning(ctx, started.member.id)
     const entered = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
     const admitted: string[] = []
-    vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt)
-      .mockImplementation(async (_parent, _childId, blocks) => {
+    vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt)
+      .mockImplementation(async (_parent, _childId, blocks, source) => {
         const last = blocks.at(-1)
         const text = last?.type === 'text' ? last.text : ''
         admitted.push(text)
-        if (text === 'first waking') {
+        if (text === 'first steer') {
           entered.resolve(undefined)
           await release.promise
         }
-        return createUserMessage({ content: blocks, source: { kind: 'user' } }).id
+        const input = createUserMessage({ content: blocks, source })
+        target.inject(input)
+        return input.id
       })
 
     const first = ctx.agentTeams.sendMessage(lead, {
-      target: 'ordered-target', content: content('first waking'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'ordered-target', content: content('first steer'), signal: SIGNAL,
     })
     await entered.promise
     let secondSettled = false
     const second = ctx.agentTeams.sendMessage(lead, {
-      target: 'ordered-target', content: content('second waking'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'ordered-target', content: content('second steer'), signal: SIGNAL,
     }).finally(() => { secondSettled = true })
-    await new Promise<void>((resolve) => { setTimeout(resolve, 0) })
-    expect(admitted).toEqual(['first waking'])
+    await vi.waitFor(() => { expect(durable(lead).pendingMessages).toHaveLength(2) })
+    expect(admitted).toEqual(['first steer'])
     expect(secondSettled).toBe(false)
 
     release.resolve(undefined)
@@ -1172,7 +1174,48 @@ describe('Team mailbox and waiting', () => {
       { status: 'accepted' },
       { status: 'accepted' },
     ])
-    expect(admitted).toEqual(['first waking', 'second waking'])
+    expect(admitted).toEqual(['first steer', 'second steer'])
+
+    ctx.agentTeams.interrupt(lead, 'ordered-target')
+    target.cancel({ kind: 'parent' })
+    await waitNoAgent(ctx, target.id)
+  })
+
+  it('delivers persisted mail before the later message that cold-resumes its target', async () => {
+    const { ctx, lead } = await setup([textResponse('target initial'), 'hang', 'hang'])
+    const started = await spawn(ctx, lead, 'reordered-target')
+    await waitNoAgent(ctx, started.member.id)
+    const earlier: TeamMessageSnapshot = {
+      id: TeamMessageId('earlier-message'),
+      senderId: lead.id,
+      senderName: 'lead',
+      targetId: started.member.id,
+      content: content('earlier steer'),
+    }
+    lead.session.append('team/message/queued', {
+      version: 2,
+      teamId: TeamId(lead.id),
+      message: earlier,
+    })
+    await ctx.sessions.flush(lead.session)
+
+    const later = await ctx.agentTeams.sendMessage(lead, {
+      target: 'reordered-target', content: content('later steer'), signal: SIGNAL,
+    })
+    expect(later.status).toBe('accepted')
+    const target = await waitRunning(ctx, started.member.id)
+    await vi.waitFor(() => {
+      const accepted = target.session.snapshotEvents().flatMap(event => event.type === 'agent/inbox/spliced'
+        ? event.data.inserted.flatMap(message => message.source.kind === 'team-message'
+          ? [message.source.messageId]
+          : [])
+        : [])
+      expect(accepted).toEqual([earlier.id, later.messageId])
+    })
+
+    ctx.agentTeams.interrupt(lead, 'reordered-target')
+    target.cancel({ kind: 'parent' })
+    await waitNoAgent(ctx, target.id)
   })
 
   it('deduplicates live target history and contains inspection and delivery failures', async () => {
@@ -1185,11 +1228,10 @@ describe('Team mailbox and waiting', () => {
       senderId: lead.id,
       senderName: 'lead',
       targetId: live.id,
-      delivery: 'wakeup',
       content: content('already in live history'),
     }
     lead.session.append('team/message/queued', {
-      version: 1, teamId: TeamId(lead.id), message,
+      version: 2, teamId: TeamId(lead.id), message,
     })
     await ctx.sessions.flush(lead.session)
     live.session.append('user/message', createUserMessage({
@@ -1214,13 +1256,14 @@ describe('Team mailbox and waiting', () => {
     }), { surfaceOp: 'append' })
     await expect(internal.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true)
     await internal.markDelivered(lead, message.id, live.id)
+    await expect(internal.tryDispatch(lead, message, SIGNAL)).resolves.toBe(true)
 
     const wrongTarget: TeamMessageSnapshot = {
       ...message,
       id: TeamMessageId('wrong-target-message'),
     }
     lead.session.append('team/message/queued', {
-      version: 1, teamId: TeamId(lead.id), message: wrongTarget,
+      version: 2, teamId: TeamId(lead.id), message: wrongTarget,
     })
     await ctx.sessions.flush(lead.session)
     await internal.markDelivered(lead, wrongTarget.id, SessionId('wrong-target'))
@@ -1261,15 +1304,15 @@ describe('Team mailbox and waiting', () => {
     await waitNoAgent(ctx, inactiveStarted.member.id)
     const openRead = vi.spyOn(ctx.sessionPersistence, 'open').mockRejectedValueOnce(new Error('read unavailable'))
     const uncertain = await ctx.agentTeams.sendMessage(lead, {
-      target: 'inactive-target', content: content('inspection failure'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'inactive-target', content: content('inspection failure'), signal: SIGNAL,
     })
     expect(uncertain.status).toBe('queued')
     openRead.mockRestore()
 
-    vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt)
+    vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt)
       .mockRejectedValueOnce(new Error('delivery unavailable'))
     const failed = await ctx.agentTeams.sendMessage(lead, {
-      target: 'inactive-target', content: content('delivery failure'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'inactive-target', content: content('delivery failure'), signal: SIGNAL,
     })
     expect(failed.status).toBe('queued')
     expect(warnings.some(warning => warning.includes('read unavailable'))).toBe(true)
@@ -1279,22 +1322,19 @@ describe('Team mailbox and waiting', () => {
     await waitNoAgent(ctx, live.id)
   })
 
-  it('keeps quiet mail dormant, wakes on follow-up, preserves FIFO, and de-duplicates delivery', async () => {
-    const { ctx, lead } = await setup(['hang', textResponse('beta first'), textResponse('beta resumed')])
+  it('cold-resumes an inactive sibling with sender attribution', async () => {
+    const { ctx, lead } = await setup(['hang', 'hang'])
     const alphaStarted = await spawn(ctx, lead, 'alpha')
     const alpha = await waitRunning(ctx, alphaStarted.member.id)
     const betaStarted = await spawn(ctx, lead, 'beta')
-    await waitNoAgent(ctx, betaStarted.member.id)
+    const beta = await waitRunning(ctx, betaStarted.member.id)
+    ctx.agentTeams.interrupt(lead, 'beta')
+    await waitNoAgent(ctx, beta.id)
 
-    const quiet = await ctx.agentTeams.sendMessage(alpha, {
-      target: 'beta', content: content('quiet info'), delivery: 'quiet', signal: SIGNAL,
-    })
-    expect(quiet.status).toBe('queued')
-    expect(ctx.agents.get(betaStarted.member.id)).toBeUndefined()
-    const waking = await ctx.agentTeams.sendMessage(alpha, {
-      target: 'beta', content: content('do another turn'), delivery: 'wakeup', signal: SIGNAL,
+    const first = await ctx.agentTeams.sendMessage(alpha, {
+      target: 'beta', content: content('first update'), signal: SIGNAL,
     })
-    expect(waking.status).toBe('accepted')
+    expect(first.status).toBe('accepted')
     await waitNoAgent(ctx, betaStarted.member.id)
     await vi.waitFor(() => { expect(durable(lead).pendingMessages).toEqual([]) })
 
@@ -1305,18 +1345,16 @@ describe('Team mailbox and waiting', () => {
       if (event.type !== 'user/message') return undefined
       const block = event.data.content.at(-1)
       return block?.type === 'text' ? block.text : undefined
-    })).toEqual(['quiet info', 'do another turn'])
+    })).toEqual(['first update'])
     expect(peerMessages.map(event => event.type === 'user/message'
       ? event.data.content[0]?.type === 'text' && event.data.content[0].text
       : undefined)).toEqual([
       expect.stringMatching(/^Team message .* from alpha:$/u),
-      expect.stringMatching(/^Team message .* from alpha:$/u),
     ])
     expect(peerMessages.map(event => event.type === 'user/message' && event.data.source.kind === 'team-message'
       ? [event.data.source.messageId, event.data.source.senderName]
       : undefined)).toEqual([
-      [quiet.messageId, 'alpha'],
-      [waking.messageId, 'alpha'],
+      [first.messageId, 'alpha'],
     ])
 
     ctx.agentTeams.interrupt(lead, 'alpha')
@@ -1331,25 +1369,26 @@ describe('Team mailbox and waiting', () => {
     const target = await spawn(ctx, lead, 'target')
     await waitNoAgent(ctx, target.member.id)
     await expect(ctx.agentTeams.sendMessage(lead, {
-      target: 'target', content: content('x'.repeat(300)), delivery: 'quiet', signal: SIGNAL,
+      target: 'target', content: content('x'.repeat(300)), signal: SIGNAL,
     })).rejects.toMatchObject({ code: 'TEAM_MESSAGE_TOO_LARGE' })
+    vi.spyOn(ctx.sessionPersistence, 'open').mockRejectedValueOnce(new Error('temporary read failure'))
     const queued = await ctx.agentTeams.sendMessage(lead, {
-      target: 'target', content: content('one'), delivery: 'quiet', signal: SIGNAL,
+      target: 'target', content: content('one'), signal: SIGNAL,
     })
     expect(queued.status).toBe('queued')
     await expect(ctx.agentTeams.sendMessage(lead, {
-      target: 'target', content: content('two'), delivery: 'quiet', signal: SIGNAL,
+      target: 'target', content: content('two'), signal: SIGNAL,
     })).rejects.toMatchObject({ code: 'TEAM_MAILBOX_FULL' })
     await expect(ctx.agentTeams.sendMessage(lead, {
-      target: 'lead', content: content('self'), delivery: 'quiet', signal: SIGNAL,
+      target: 'lead', content: content('self'), signal: SIGNAL,
     })).rejects.toMatchObject({ code: 'TEAM_SELF_MESSAGE' })
     await expect(ctx.agentTeams.sendMessage(lead, {
-      target: 'missing', content: content('unknown target'), delivery: 'quiet', signal: SIGNAL,
+      target: 'missing', content: content('unknown target'), signal: SIGNAL,
     })).rejects.toMatchObject({ code: 'TEAM_MEMBER_NOT_FOUND' })
     const controller = new AbortController()
     controller.abort(new TeamError('cancelled before queue', 'TEST_CANCELLED'))
     await expect(ctx.agentTeams.sendMessage(lead, {
-      target: 'target', content: content('cancelled'), delivery: 'quiet', signal: controller.signal,
+      target: 'target', content: content('cancelled'), signal: controller.signal,
     })).rejects.toMatchObject({ code: 'TEST_CANCELLED' })
   })
 
@@ -1358,12 +1397,12 @@ describe('Team mailbox and waiting', () => {
     const started = await spawn(ctx, lead, 'worker')
     const worker = await waitRunning(ctx, started.member.id)
     const followup = await ctx.agentTeams.sendMessage(lead, {
-      target: 'worker', content: content('retained follow-up'), delivery: 'wakeup', signal: SIGNAL,
+      target: 'worker', content: content('retained follow-up'), signal: SIGNAL,
     })
     expect(followup.status).toBe('accepted')
     expect(ctx.agentTeams.interrupt(lead, 'worker')).toEqual({ previousStatus: 'running' })
     await vi.waitFor(() => { expect(worker.status).toBe('idle') })
-    expect(worker.inbox.nextTurn.some(message => message.source.kind === 'team-message'
+    expect(worker.inbox.nextStep.some(message => message.source.kind === 'team-message'
       && message.source.messageId === followup.messageId)).toBe(true)
     worker.cancel({ kind: 'parent' })
     await waitNoAgent(ctx, worker.id)
@@ -1538,7 +1577,7 @@ describe('Team mailbox and waiting', () => {
       phase: 'provisioning' as const,
     }
     lead.session.append('team/member', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       member,
     })
@@ -1551,7 +1590,7 @@ describe('Team mailbox and waiting', () => {
     })
     await waitRunning(ctx, childId)
     lead.session.append('team/member', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       member: {
         ...member,
@@ -1574,7 +1613,7 @@ describe('Team mailbox and waiting', () => {
     const entered = Promise.withResolvers<undefined>()
     const aborted = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
-    vi.spyOn(ctx.subagents as unknown as HostPromptQueue, queueSubagentPrompt)
+    vi.spyOn(ctx.subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt)
       .mockImplementation(async (_parent, _childId, _content, _source, signal) => {
         entered.resolve(undefined)
         return await new Promise<never>((_resolve, reject) => {
@@ -1591,7 +1630,6 @@ describe('Team mailbox and waiting', () => {
     const sending = ctx.agentTeams.sendMessage(lead, {
       target: 'mailbox-worker',
       content: content('resume during disposal'),
-      delivery: 'wakeup',
       signal: SIGNAL,
     })
     await entered.promise
@@ -1616,11 +1654,10 @@ describe('Team mailbox and waiting', () => {
       senderId: SessionId('sender'),
       senderName: 'sender',
       targetId: lead.id,
-      delivery: 'wakeup',
       content: content('acknowledge before disposal'),
     }
     lead.session.append('team/message/queued', {
-      version: 1,
+      version: 2,
       teamId: TeamId(lead.id),
       message,
     })
@@ -1695,14 +1732,13 @@ describe('Team mailbox and waiting', () => {
       signal: SIGNAL,
     })).rejects.toMatchObject({ code: 'TEAM_DISPOSED' })
     await expect(ctx.agentTeams.sendMessage(lead, {
-      target: 'nobody', content: content('must reject'), delivery: 'quiet', signal: SIGNAL,
+      target: 'nobody', content: content('must reject'), signal: SIGNAL,
     })).rejects.toMatchObject({ code: 'TEAM_DISPOSED' })
     await expect(internal.mailbox.tryDispatch(lead, {
       id: TeamMessageId('post-disposal-message'),
       senderId: lead.id,
       senderName: 'lead',
       targetId: lead.id,
-      delivery: 'quiet',
       content: content('must not dispatch'),
     }, SIGNAL)).resolves.toBe(false)
   })
@@ -1774,7 +1810,7 @@ describe('Team mailbox and waiting', () => {
       phase: 'provisioning' as const,
     }
     first.lead.session.append('team/member', {
-      version: 1, teamId: TeamId(first.lead.id), member: provisioning,
+      version: 2, teamId: TeamId(first.lead.id), member: provisioning,
     })
     const reconcileFirst = teamInternals(first.ctx).roster
     await reconcileFirst.reconcileProvisioning(first.lead, SIGNAL)
@@ -1794,7 +1830,7 @@ describe('Team mailbox and waiting', () => {
     const childId = SessionId('concurrently-settled-child')
     const member = { ...provisioning, id: childId, name: 'concurrent-child' }
     second.lead.session.append('team/member', {
-      version: 1, teamId: TeamId(second.lead.id), member,
+      version: 2, teamId: TeamId(second.lead.id), member,
     })
     const entered = Promise.withResolvers<undefined>()
     const release = Promise.withResolvers<undefined>()
@@ -1807,7 +1843,7 @@ describe('Team mailbox and waiting', () => {
     const reconciling = reconcileSecond.reconcileProvisioning(second.lead, SIGNAL)
     await entered.promise
     second.lead.session.append('team/member', {
-      version: 1,
+      version: 2,
       teamId: TeamId(second.lead.id),
       member: { ...member, phase: 'failed', error: 'settled elsewhere' },
     })

+ 2 - 2
packages/experimental/tool-agent-team/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/experimental/tool-agent-team/README.md
-README.md: b8474117d429d6f03562479834bb13daad3307b9
-README.zh.md: ca4dd17e0ccafef4112655f80543d539c9a11a4b
+README.md: 4f8d589189540c40560c9abd4e4db999c3d18542
+README.zh.md: 15a87f5640e7115d09792f138f1cc36f20d9eab0

+ 8 - 8
packages/experimental/tool-agent-team/README.md

@@ -1,5 +1,5 @@
 ---
-description: "Ten tools that let the model create, message, and coordinate teammates, for compositions mounting the experimental Team plugins."
+description: "Nine tools that let the model create, message, and coordinate teammates, for compositions mounting the experimental Team plugins."
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-`dsh-experimental-tool-agent-team` gives the model a team toolset on top of the team domain package: create named teammates, send them messages or follow-up work, see who is available, wait for progress, interrupt a stuck teammate, and manage a shared task board — ten tools in total. A short policy section in every member's prompt teaches the model when to form a team (only when you ask for one) and how to coordinate on a shared workspace. Mounting it replaces legacy subagent controls with the same tool names, so a composition that wants both must disable the legacy definitions. It is experimental: excluded from official releases, carries no stability promise, and creates teammates only when you explicitly ask for a team.
+`dsh-experimental-tool-agent-team` gives the model a team toolset on top of the team domain package: create named teammates, steer messages to them, see who is available, wait for progress, interrupt a stuck teammate, and manage a shared task board — nine tools in total. A short policy section in every member's prompt teaches the model when to form a team (only when you ask for one) and how to coordinate on a shared workspace. Mounting it replaces legacy subagent controls with the same tool names, so a composition that wants both must disable the legacy definitions. It is experimental: excluded from official releases, carries no stability promise, and creates teammates only when you explicitly ask for a team.
 
 ## Table of Contents
 
@@ -25,7 +25,7 @@ English | [中文](README.zh.md)
 <a id="use-this-package"></a>
 ## Use this package
 
-Add this package on top of `@deepseek-ai/dsh-experimental-agent-team` when the model should run a team through tools. Once mounted, every team member — the Lead and each teammate — gets the same ten tools plus a policy paragraph that states its own role and name.
+Add this package on top of `@deepseek-ai/dsh-experimental-agent-team` when the model should run a team through tools. Once mounted, every team member — the Lead and each teammate — gets the same nine tools plus a policy paragraph that states its own role and name.
 
 ### When to choose it
 
@@ -54,10 +54,10 @@ Try it by asking the Lead model: "create a teammate named reviewer to check the
 
 ### What the model can do
 
-The ten tools group into four capabilities:
+The nine tools group into four capabilities:
 
 - **Create a teammate** — `spawn_teammate` takes a name, a description, and the initial task; only the Lead can call it.
-- **Send messages** — `send_message` delivers information without waking an idle teammate; `followup_task` makes the message the recipient's next turn and wakes it when needed.
+- **Send messages** — `send_message` steers a running member at its nearest step boundary, starts an idle member, and cold-resumes an inactive teammate.
 - **See and wait** — `list_agents` shows the roster with live status; `wait_agent` waits for the next team change; `interrupt_agent` stops a teammate's current turn (Lead only).
 - **Manage the task board** — `team_task_create`, `team_task_list`, `team_task_get`, and `team_task_update` add, browse, read, and update shared tasks.
 
@@ -91,12 +91,12 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08-
 
 | File | Role |
 |---|---|
-| [`src/index.ts`](src/index.ts) | Plugin entry: config, the fixed policy text, and the ten scoped tool registrations |
+| [`src/index.ts`](src/index.ts) | Plugin entry: config, the fixed policy text, and the nine scoped tool registrations |
 | — | No runtime invariant companion is published; the Team service owns durable and authorization relations. |
 
 ### Policy and tools
 
-One `team:policy` section on the member scope teaches each member its role and the coordination rules; the fixed text and the ten tool registrations are declared in [`src/index.ts`](src/index.ts). The ten tool schemas appear only in Team member scopes, so non-Team subagents keep the default catalog. Scoped registrations with the same names as the legacy global continuable-subagent controls shadow those globals for team members only.
+One `team:policy` section on the member scope teaches each member its role and the coordination rules; the fixed text and the nine tool registrations are declared in [`src/index.ts`](src/index.ts). The nine tool schemas appear only in Team member scopes, so non-Team subagents keep the default catalog. Scoped registrations with the same names as the legacy global continuable-subagent controls shadow those globals for team members only.
 
 ### Scoped registration and teardown
 
@@ -125,7 +125,7 @@ Read these pages when the package-level contract is not enough. They move from t
 
 #### What the model sees
 
-One stable policy section states the exact Team role/name/id, the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, quiet versus waking delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. The ten Team schemas from `spawn_teammate` through `team_task_update` appear only in Team member scopes.
+One stable policy section states the exact Team role/name/id, the explicit-delegation requirement, shared-cwd behavior, filesystem stale-version recovery, Bash/formatter/codegen risk, task and write-scope coordination, Steer delivery, the no-retry mailbox rule, and the Lead's duty to wait before answering. The nine Team schemas from `spawn_teammate` through `team_task_update` appear only in Team member scopes.
 
 #### Token effect
 

+ 9 - 9
packages/experimental/tool-agent-team/README.zh.md

@@ -1,5 +1,5 @@
 ---
-description: "个让模型创建、发消息与协调 teammate 的工具,供组合实验性 Team 插件的部署方阅读。"
+description: "个让模型创建、发消息与协调 teammate 的工具,供组合实验性 Team 插件的部署方阅读。"
 kind: "package-reference"
 ---
 
@@ -9,7 +9,7 @@ kind: "package-reference"
 
 ## 概述
 
-`dsh-experimental-tool-agent-team` 在团队领域包之上给模型一套团队工具:创建具名 teammate、给它们发消息或后续任务、查看谁在线、等待进展、中断卡住的 teammate,以及管理共享任务板——共十个工具。每个成员的提示词中都有一段简短策略,教模型何时组建团队(只有你要求时)以及如何在共享工作区协作。挂载它会用同名的团队工具取代旧版 subagent 控件,因此想同时使用两者的组合必须禁用旧定义。它是实验性的:不进入正式发布、不承诺稳定性,并且只有你明确要求组建团队时才会创建 teammate。
+`dsh-experimental-tool-agent-team` 在团队领域包之上给模型一套团队工具:创建具名 teammate、向它们 Steer 消息、查看谁在线、等待进展、中断卡住的 teammate,以及管理共享任务板——共九个工具。每个成员的提示词中都有一段简短策略,教模型何时组建团队(只有你要求时)以及如何在共享工作区协作。挂载它会用同名的团队工具取代旧版 subagent 控件,因此想同时使用两者的组合必须禁用旧定义。它是实验性的:不进入正式发布、不承诺稳定性,并且只有你明确要求组建团队时才会创建 teammate。
 
 ## 目录
 
@@ -25,7 +25,7 @@ kind: "package-reference"
 <a id="use-this-package"></a>
 ## 使用本包
 
-当模型应该通过工具运行一支团队时,在 `@deepseek-ai/dsh-experimental-agent-team` 之上挂载本包。挂载后,每个团队成员——Lead 与每个 teammate——都会获得相同的个工具,外加一段说明自身角色与名字的策略段落。
+当模型应该通过工具运行一支团队时,在 `@deepseek-ai/dsh-experimental-agent-team` 之上挂载本包。挂载后,每个团队成员——Lead 与每个 teammate——都会获得相同的个工具,外加一段说明自身角色与名字的策略段落。
 
 ### 何时选择
 
@@ -54,10 +54,10 @@ kind: "package-reference"
 
 ### 模型能做什么
 
-个工具分为四类能力:
+个工具分为四类能力:
 
 - **创建 teammate**——`spawn_teammate` 接收名字、描述与初始任务;只有 Lead 可以调用它。
-- **发送消息**——`send_message` 在不唤醒 idle teammate 的情况下传达信息;`followup_task` 让消息成为接收方的下一个轮次,并在需要时唤醒它
+- **发送消息**——`send_message` 在最近的步骤边界 Steer running member、启动 idle member,并冷恢复 inactive teammate
 - **查看与等待**——`list_agents` 显示带实时状态的 roster;`wait_agent` 等待下一次团队变化;`interrupt_agent` 停止 teammate 的当前轮次(仅限 Lead)。
 - **管理任务板**——`team_task_create`、`team_task_list`、`team_task_get` 与 `team_task_update` 添加、浏览、读取与更新共享任务。
 
@@ -91,12 +91,12 @@ kind: "package-reference"
 
 | 文件 | 职责 |
 |---|---|
-| [`src/index.ts`](src/index.ts) | 插件入口:配置、固定策略文本与个 scoped 工具注册 |
-| — | 不发布运行时不变式伴生入口;委托只能通过 `ctx.agentTeams` 观察。 |
+| [`src/index.ts`](src/index.ts) | 插件入口:配置、固定策略文本与个 scoped 工具注册 |
+| — | 不发布运行时不变式伴生入口;Team 服务拥有持久化与授权关系。 |
 
 ### 策略与工具
 
-member scope 上的一个 `team:policy` 段落教每个成员自己的角色与协作规则;固定文本与十个工具注册都声明在 [`src/index.ts`](src/index.ts)。十个工具 schema 只出现在 Team member scope 中,因此非 Team subagent 保持默认目录。与旧全局 continuable-subagent 控件同名的 scoped 注册只会为团队成员覆盖这些全局控件。
+member scope 上的一个 `team:policy` 段落教每个成员自己的角色与协作规则;固定文本与九个工具注册都声明在 [`src/index.ts`](src/index.ts)。九个工具 schema 只出现在 Team member scope 中,因此非 Team subagent 保持默认目录。与旧全局 continuable-subagent 控件同名的 scoped 注册只会为团队成员覆盖这些全局控件。
 
 ### 按作用域注册与拆除
 
@@ -125,7 +125,7 @@ member scope 上的一个 `team:policy` 段落教每个成员自己的角色与
 
 #### 模型看到什么
 
-一段稳定策略会说明确切 Team role/name/id、显式 delegation 要求、共享 cwd 行为、文件 stale-version 恢复、Bash/formatter/codegen 风险、task/write-scope 协调、quiet 与 waking 投递区别、mailbox 不重试规则,以及 Lead 必须在回答前等待。`spawn_teammate` 到 `team_task_update` 的个 Team schema 只出现在 Team member scope。
+一段稳定策略会说明确切 Team role/name/id、显式 delegation 要求、共享 cwd 行为、文件 stale-version 恢复、Bash/formatter/codegen 风险、task/write-scope 协调、Steer 投递、mailbox 不重试规则,以及 Lead 必须在回答前等待。`spawn_teammate` 到 `team_task_update` 的个 Team schema 只出现在 Team member scope。
 
 #### Token 影响
 

+ 18 - 25
packages/experimental/tool-agent-team/src/index.ts

@@ -34,10 +34,10 @@ The Team Lead and all teammates share the same working directory and filesystem.
 
 Prefer read/edit/write for file changes. If a file operation returns FS_STALE_VERSION, read the current file, rebase your intended change onto the new content, and retry. Bash, formatters, code generators, and scripts are not fully protected by the filesystem version guard; coordinate them explicitly and have the Lead review the final diff and run tests.
 
-Use send_message for quiet information that must not start an idle teammate. Use followup_task when the target should run another turn. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use followup_task first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.`
+send_message steers a running target at its nearest step boundary, starts an idle target, and cold-resumes an inactive teammate. A delivered peer item starts with its stable message id and sender name. A successful send is already durable even when its result says queued; do not resend it. Shared-task workflow is list, get, claim with the current revision, perform the work, then complete. Task readiness never starts an owner. Before wait_agent, use list_agents and make sure another required member is running or provisioning; use send_message first when the required member is inactive. wait_agent observes only changes after that call starts, never wakes a member, and returns noProgress immediately when no other member can produce a change. Re-list after wakeup or timeout. The Lead must wait for required teammates before giving the final answer.`
 
 const ACTIVE_WAIT_STATUSES: ReadonlySet<TeamMemberView['status']> = new Set(['running', 'provisioning'])
-const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use followup_task to wake each required inactive teammate before waiting again.'
+const NO_ACTIVE_PEER_MESSAGE = 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use send_message to wake each required inactive teammate before waiting again.'
 
 /**
  * One roster row, matching `TeamMemberView`. The Lead pseudo-row omits the
@@ -198,29 +198,22 @@ function install(agent: Agent, ctx: Context, config: Required<Config>): () => vo
       },
     })))
 
-    const messageTool = (toolName: 'send_message' | 'followup_task', delivery: 'quiet' | 'wakeup'): void => {
-      register(scoped.tools.register(defineTool({
-        name: toolName,
-        description: delivery === 'quiet'
-          ? 'Send durable information to another Team member without starting an idle member.'
-          : 'Send a durable follow-up task to another Team member and start a turn when needed.',
-        parameters: {
-          target: { type: 'string', required: true, description: 'Team member name, or lead.' },
-          message: { type: 'string', required: true, description: 'Self-contained message for the target.' },
-        },
-        output: jsonOutput(SEND_VALUE_SCHEMA),
-        execute(args, exec) {
-          return ctx.agentTeams.sendMessage(callingAgent(exec.agent, toolName), {
-            target: args.target,
-            content: [{ type: 'text', text: args.message }],
-            delivery,
-            signal: exec.signal,
-          })
-        },
-      })))
-    }
-    messageTool('send_message', 'quiet')
-    messageTool('followup_task', 'wakeup')
+    register(scoped.tools.register(defineTool({
+      name: 'send_message',
+      description: 'Send one durable message to another Team member. A running target receives it at the nearest step boundary; an idle target starts a turn; an inactive teammate cold-resumes.',
+      parameters: {
+        target: { type: 'string', required: true, description: 'Team member name, or lead.' },
+        message: { type: 'string', required: true, description: 'Self-contained message for the target.' },
+      },
+      output: jsonOutput(SEND_VALUE_SCHEMA),
+      execute(args, exec) {
+        return ctx.agentTeams.sendMessage(callingAgent(exec.agent, 'send_message'), {
+          target: args.target,
+          content: [{ type: 'text', text: args.message }],
+          signal: exec.signal,
+        })
+      },
+    })))
 
     register(scoped.tools.register(defineTool({
       name: 'list_agents',

+ 5 - 7
packages/experimental/tool-agent-team/tests/tool-team.spec.ts

@@ -26,7 +26,6 @@ const SIGNAL = new AbortController().signal
 const TOOL_NAMES = [
   'spawn_teammate',
   'send_message',
-  'followup_task',
   'list_agents',
   'wait_agent',
   'interrupt_agent',
@@ -179,7 +178,7 @@ describe('dsh-tool-team', () => {
       timedOut: false,
       noProgress: {
         reason: 'no-active-peer',
-        message: 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use followup_task to wake each required inactive teammate before waiting again.',
+        message: 'No other Team member is running or provisioning. wait_agent cannot make progress or wake inactive teammates. Re-list with list_agents and team_task_list, then use send_message to wake each required inactive teammate before waiting again.',
       },
     })
     for (const timeout_ms of [9_999, 3_600_001, Number.MAX_SAFE_INTEGER + 1]) {
@@ -221,12 +220,12 @@ describe('dsh-tool-team', () => {
     // Every Team result reaches the model as compact JSON: indentation would
     // spend tokens on every roster, task, and receipt without adding meaning.
     expect(text(roster)).toBe(JSON.stringify(JSON.parse(text(roster))))
-    const peer = await execute(ctx, child, 'send_message', { target: 'lead', message: 'quiet report' })
+    const peer = await execute(ctx, child, 'send_message', { target: 'lead', message: 'progress report' })
     expect(peer.isError).toBe(false)
     expect(JSON.parse(text(peer))).toMatchObject({ status: 'accepted' })
-    const waking = await execute(ctx, child, 'followup_task', { target: 'lead', message: 'review the report' })
-    expect(waking.isError).toBe(false)
-    expect(JSON.parse(text(waking))).toMatchObject({ status: 'accepted' })
+    const followup = await execute(ctx, child, 'send_message', { target: 'lead', message: 'review the report' })
+    expect(followup.isError).toBe(false)
+    expect(JSON.parse(text(followup))).toMatchObject({ status: 'accepted' })
     await lead.whenIdle()
 
     const created = await execute(ctx, lead, 'team_task_create', {
@@ -425,7 +424,6 @@ describe('dsh-tool-team', () => {
     await ctx.agentTeams.sendMessage(lead, {
       target: 'cold-worker',
       content: [{ type: 'text', text: 'resume with Team scope' }],
-      delivery: 'wakeup',
       signal: SIGNAL,
     })
     const resumed = await waitRunning(ctx, childId)

+ 6 - 6
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -370,7 +370,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
       {
         signature: 'async sendMessage(caller: Agent, request: SendTeamMessageRequest): Promise<SendTeamMessageResult>',
         description: 'Queue one durable peer message, then attempt immediate delivery.',
-        parameters: [{ name: 'caller', description: 'exact live sending Team member.' }, { name: 'request', description: 'target name, content, scheduling mode, and pre-queue cancellation.' }],
+        parameters: [{ name: 'caller', description: 'exact live sending Team member.' }, { name: 'request', description: 'target name, content, and pre-queue cancellation.' }],
         returns: 'durable message identity and immediate-delivery observation.',
       },
       {
@@ -2109,7 +2109,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
     methods: [
       {
         signature: 'async open<S extends DomainSpec>(spec: S): Promise<Domain<S>>',
-        description: 'Open one declared domain. Steps, each failing the whole call: reject a name that is already open (`already-open`); resolve the backend route (`backend-not-found` passes through from the hub); require its `kv` facet (`facet-unsupported`); open the unit projected from the spec (backend `version-mismatch`/`malformed-medium` pass through); load and validate every stored record against the spec\'s zod schemas (`invalid-record` with the offending table and key); construct the domain.\n\nLifecycle: the CALLER owns the returned handle and closes it via `Domain.close()` (typically as its own `ctx.effect` disposer) — the facility does not tie the domain to any consumer fiber. Domains still open when the facility unmounts are closed by the plugin disposer.',
+        description: 'Open one declared domain. Steps, each failing the whole call: reject a name that is already open (`already-open`); resolve the backend route (`backend-not-found` passes through from the hub); require its `kv` facet (`facet-unsupported`); open the unit projected from the spec (backend `version-mismatch`/`malformed-medium` pass through); load and validate every stored record against the spec\'s zod schemas (`invalid-record` with the offending table and key — unless the spec declares `invalidRecords: \'backup-and-skip\'` and the unit can move documents aside, in which case the failing record is backed up, logged, and skipped); construct the domain.\n\nLifecycle: the CALLER owns the returned handle and closes it via `Domain.close()` (typically as its own `ctx.effect` disposer) — the facility does not tie the domain to any consumer fiber. Domains still open when the facility unmounts are closed by the plugin disposer.',
         parameters: [{ name: 'spec', description: 'The domain declaration, typically from `defineDomain`.' }],
         returns: 'the opened domain handle, typed by the spec.',
       },
@@ -3918,7 +3918,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'DomainSpec',
-    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly layout?: \'single\' | \'per-record\';\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
+    declaration: 'export interface DomainSpec {\n    readonly name: string;\n    readonly version: number;\n    readonly layout?: \'single\' | \'per-record\';\n    readonly compatibleVersions?: readonly number[];\n    readonly invalidRecords?: \'backup-and-skip\';\n    readonly global?: DomainGlobalSpec<unknown>;\n    readonly tables: Record<string, DomainTableSpec>;\n}',
   },
   {
     name: 'DomainTableSpec',
@@ -4226,11 +4226,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'KvUnit',
-    declaration: 'export interface KvUnit {\n    loadAll(): Promise<{\n        tables: Record<string, Record<string, unknown>>;\n        global: unknown;\n    }>;\n    putRecord(table: string, key: string, value: unknown): Promise<void>;\n    deleteRecord(table: string, key: string): Promise<void>;\n    setGlobal(value: unknown): Promise<void>;\n    close(): Promise<void>;\n}',
+    declaration: 'export interface KvUnit {\n    loadAll(): Promise<{\n        tables: Record<string, Record<string, unknown>>;\n        global: unknown;\n    }>;\n    putRecord(table: string, key: string, value: unknown): Promise<void>;\n    deleteRecord(table: string, key: string): Promise<void>;\n    backupRecord?(table: string, key: string): Promise<string>;\n    setGlobal(value: unknown): Promise<void>;\n    close(): Promise<void>;\n}',
   },
   {
     name: 'KvUnitDescriptor',
-    declaration: 'export interface KvUnitDescriptor {\n    readonly name: string;\n    readonly version: number;\n    readonly tables: readonly string[];\n    readonly hasGlobal: boolean;\n    readonly layout?: \'single\' | \'per-record\';\n}',
+    declaration: 'export interface KvUnitDescriptor {\n    readonly name: string;\n    readonly version: number;\n    readonly tables: readonly string[];\n    readonly hasGlobal: boolean;\n    readonly layout?: \'single\' | \'per-record\';\n    readonly compatibleVersions?: readonly number[];\n}',
   },
   {
     name: 'LlmAdapter',
@@ -4754,7 +4754,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SendTeamMessageRequest',
-    declaration: 'export interface SendTeamMessageRequest {\n    readonly target: string;\n    readonly content: ContentBlock[];\n    readonly delivery: \'quiet\' | \'wakeup\';\n    readonly signal: AbortSignal;\n}',
+    declaration: 'export interface SendTeamMessageRequest {\n    readonly target: string;\n    readonly content: ContentBlock[];\n    readonly signal: AbortSignal;\n}',
   },
   {
     name: 'SendTeamMessageResult',

+ 2 - 2
packages/session/session-projection-cache/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/session/session-projection-cache/README.md
-README.md: 8b519a811834aa245fdafacd4d93a1b951efdcd7
-README.zh.md: ed53309b320b43cf050ae54cd687b7cc7435bd9a
+README.md: 4b454e277d2a9341baaae333f6aa5fa02a86f42c
+README.zh.md: c166592d071669e2075040111fd61c1b747c0264

+ 3 - 0
packages/session/session-projection-cache/README.md

@@ -64,6 +64,8 @@ Three mandatory points always write: session creation persists the seed-derived
 
 The log leads and the cache follows: a live checkpoint flushes the session's buffered events durably before the cache row lands, so a crash can leave the cache behind the log but never ahead of it. Reads and writes share the storage domain's coherent in-memory state; the per-unit write chain mutates memory only after durability. Each version-stamped record must match the live unit schema and complete lifecycle identity (`formatVersion`, `createdAt`, `cwd`, `isSeeded`, and `inheritedEventCount`), so a row folded from another Session format generation or fork cut cannot seed the caller. The JSON backend stores each record at `<root>/session_projcache/sessions/<id>.json` in an owner-only directory tree.
 
+Upgrades never cost the boot or expose an unproven fold. Records stamped with a version in the spec's `compatibleVersions` remain structurally readable for a current checkpoint rewrite, but a missing `formatVersion` never matches a current Session and therefore refolds cold. Once the format matches, absent lineage fields decode as the unseeded lineage — exact for unseeded sessions, while a seeded caller fails the identity match and refolds cold. A stored record that still fails schema validation is moved aside as `<id>.json.bak.<stamp>` under the domain's `invalidRecords: 'backup-and-skip'` policy, logged with its cause, and rebuilt by the next checkpoint.
+
 -----
 
 <a id="understand-the-implementation"></a>
@@ -126,6 +128,7 @@ These limits define where the cache needs operational care. They are current pac
 - **No eviction or retention surface** — records accumulate per session; pruning stored checkpoints is out-of-band maintenance, same stance as session persistence itself.
 - **Interval throttle is per-session coarse** — the timer arms at the first dirty event after a clean write; a steady sub-threshold trickle writes once per interval, not a sliding window.
 - **No cache-side cold refold** — the cache serves and refreshes its rows but never reads the session log (it does not depend on the persistence layer); a consumer that needs a guaranteed cold snapshot refolds from the log itself.
+- **Every schema or domain-version change must prove its upgrade story** — a change to the stored record schema or the domain version lands in the same PR with an archived fixture of the previously shipped on-disk format under `tests/fixtures/` and test cases in `tests/fixtures.spec.ts` proving the chosen disposition: read-compat recovery (`compatibleVersions`), current-version rewrite, or backup-and-skip salvage. A bump whose old records are simply discarded still proves that the discard neither fails the boot nor poisons the tree.
 
 <a id="dev-note"></a>
 ### Dev Note

+ 3 - 0
packages/session/session-projection-cache/README.zh.md

@@ -64,6 +64,8 @@ kind: "package-reference"
 
 日志领先,缓存跟随:实时检查点先把会话的缓冲事件持久化,然后才保存缓存记录。因此崩溃可能让缓存落后于日志,但绝不会让缓存领先。读取和写入共享存储域内一致的内存状态;逐单元写入链只在持久化成功后修改内存。每个带版本戳的记录必须匹配实时单元 schema 与完整生命周期身份(`formatVersion`、`createdAt`、`cwd`、`isSeeded` 和 `inheritedEventCount`),因此从另一会话格式代或 fork 切点折叠出的行不能播种调用方。JSON 后端把每条记录存于仅所有者可访问的 `<root>/session_projcache/sessions/<id>.json` 目录树中。
 
+升级绝不拖垮启动,也不会暴露未经证明的折叠结果。版本戳落在 spec `compatibleVersions` 集合内的记录仍可被结构化读取并等待当前检查点重写,但缺失 `formatVersion` 的记录绝不匹配当前 Session,因此会回落冷折叠。格式匹配后,缺失的 lineage 字段解码为 unseeded lineage——对非 fork 会话精确无误,seeded 调用方则通不过身份比对、回落冷折叠。仍然通不过 schema 校验的存量记录会按域的 `invalidRecords: 'backup-and-skip'` 策略移出为 `<id>.json.bak.<时间戳>`、连同原因写入日志,并由下一次检查点重建。
+
 -----
 
 <a id="understand-the-implementation"></a>
@@ -126,6 +128,7 @@ kind: "package-reference"
 - **无淘汰或保留接口**——记录按会话持续累积;清理已存储检查点属于带外维护,与会话持久化采用相同策略。
 - **间隔节流采用按会话的粗粒度控制**——一次无脏数据的写入完成后,计时器在首个脏事件到达时启动;持续但低于条数阈值的事件流每间隔写入一次,而非滑动窗口。
 - **缓存侧不做冷重折叠**——缓存只服务并刷新自己的记录,从不读取会话日志,因为它不依赖持久化层;需要保证冷快照的消费方自行从日志重新折叠。
+- **每次 schema 或域版本变更都必须论证升级路径**——改动存储记录 schema 或域版本时,同一 PR 必须在 `tests/fixtures/` 下归档此前已发布的磁盘格式样本,并在 `tests/fixtures.spec.ts` 中用测试论证所选的处置方式:读兼容恢复(`compatibleVersions`)、当前版本重写,或 backup-and-skip 抢救。即便选择直接丢弃旧记录的 bump,也要证明丢弃既不炸启动、也不污染缓存树。
 
 <a id="dev-note"></a>
 ### 开发备注

+ 19 - 6
packages/session/session-projection-cache/src/index.ts

@@ -35,6 +35,13 @@ import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
 import { projectionCacheDomainSpec } from './spec.ts'
 import type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
 
+/** Complete identity written by the current cache generation. */
+type CurrentCheckpointIdentity = CheckpointIdentity & {
+  formatVersion: number
+  isSeeded: boolean
+  inheritedEventCount: SessionLogOffset
+}
+
 export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts'
 export type { CheckpointIdentity, CheckpointRecord } from './spec.ts'
 
@@ -112,7 +119,7 @@ export class SessionProjectionCache extends Service {
    * @param expected - the log identity the caller holds (live or stored header).
    * @returns the identity-matching record, or `undefined` (absent or unrelated).
    */
-  private recordFor(id: SessionId, expected: CheckpointIdentity): CheckpointRecord | undefined {
+  private recordFor(id: SessionId, expected: CurrentCheckpointIdentity): CheckpointRecord | undefined {
     const record = this.requireTable().get(id)
     if (record === undefined) return undefined
     return identityMatches(record.identity, expected) ? record : undefined
@@ -350,7 +357,7 @@ export class SessionProjectionCache extends Service {
 function identityOf(
   header: SessionHeader,
   inheritedEventCount: SessionLogOffset,
-): CheckpointIdentity {
+): CurrentCheckpointIdentity {
   const cut = SessionLogOffset(inheritedEventCount)
   if (!header.isSeeded && cut !== 0) {
     throw new Error('unseeded projection-cache identity inherited event count must be 0')
@@ -364,13 +371,19 @@ function identityOf(
   }
 }
 
-/** Whether a stored record's bound identity names the caller's lifecycle. */
-function identityMatches(stored: CheckpointIdentity, expected: CheckpointIdentity): boolean {
+/**
+ * Whether a stored record's bound identity names the caller's lifecycle.
+ * An absent format generation cannot prove the fold semantics and never
+ * matches. Once the format matches, absent lineage fields (records admitted
+ * via `compatibleVersions` predate them) read as the unseeded lineage: exact
+ * for an unseeded caller, while a seeded caller fails the match.
+ */
+function identityMatches(stored: CheckpointIdentity, expected: CurrentCheckpointIdentity): boolean {
   return stored.formatVersion === expected.formatVersion
     && stored.createdAt === expected.createdAt
     && stored.cwd === expected.cwd
-    && stored.isSeeded === expected.isSeeded
-    && stored.inheritedEventCount === expected.inheritedEventCount
+    && (stored.isSeeded ?? false) === expected.isSeeded
+    && (stored.inheritedEventCount ?? 0) === expected.inheritedEventCount
 }
 
 export default SessionProjectionCache

+ 26 - 4
packages/session/session-projection-cache/src/spec.ts

@@ -38,13 +38,20 @@ export const checkpointRow = z.object({
  * old record pass every watermark check and seed state folded from an
  * unrelated log. Reads validate this against the live header (listing) or
  * the stored header (cold read) before accepting any record.
+ *
+ * The format and lineage fields are optional because records admitted through
+ * `compatibleVersions` predate them. The reader (`identityMatches`) refuses an
+ * absent format generation because no current Session log can prove that
+ * record's fold semantics. It interprets absent lineage as unseeded only after
+ * the format generation matches. Current-version writes always store all three
+ * fields.
  */
 export const checkpointIdentity = z.object({
-  formatVersion: z.number().int().nonnegative(),
+  formatVersion: z.number().int().nonnegative().optional(),
   createdAt: z.number().int().nonnegative(),
   cwd: z.string().optional(),
-  isSeeded: z.boolean(),
-  inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset),
+  isSeeded: z.boolean().optional(),
+  inheritedEventCount: z.number().int().nonnegative().transform(SessionLogOffset).optional(),
 })
 
 /** The identity fields a record is bound to, inferred from {@link checkpointIdentity}. */
@@ -69,11 +76,26 @@ export type CheckpointRecord = z.infer<typeof checkpointRecord>
  * bumps per session: after a bump, a stale session document is discarded on
  * open (cache semantics — a stale or unreadable cache costs a longer tail
  * replay, never a wrong value) while the rest of the domain stays usable,
- * instead of rejecting the whole medium.
+ * instead of rejecting the whole medium. The `compatibleVersions` entries
+ * keep structurally valid predecessor records available for a later current
+ * checkpoint rewrite. Records without `formatVersion` remain unusable as fold
+ * shortcuts because they cannot prove which Session event semantics produced
+ * their rows; the per-record version map and disposition live in the read-compat Agent Note
+ * (.agents/notes/implemented/architecture/2026-09-02-projcache-cross-version-read-compat.md).
+ * The per-row `ver` guard and the identity match still discard anything the
+ * current fold semantics cannot vouch for.
+ *
+ * `invalidRecords: 'backup-and-skip'`: a stored record that fails the schema
+ * anyway is disposable derived data, so it must never cost the boot — the
+ * domain layer moves the document aside as `<key>.json.bak.<stamp>`, logs
+ * the concrete validation failure, and serves the session as uncached (a
+ * cold read rebuilds and rewrites it).
  */
 export const projectionCacheDomainSpec = defineDomain({
   name: 'session_projcache',
   version: 7,
+  compatibleVersions: [3, 4, 5, 6],
+  invalidRecords: 'backup-and-skip',
   layout: 'per-record',
   tables: { sessions: domainTable<SessionId, CheckpointRecord>(checkpointRecord) },
 })

+ 80 - 3
packages/session/session-projection-cache/tests/cache.spec.ts

@@ -44,10 +44,12 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
     'cache-test/marks2': Map<string, string>
     'cache-test/count': number
     'cache-test/secret': string
+    'cache-test/marks3': MarksState
   }
   interface SessionProjectionMap {
     'cache-test/marks': { marks: string[] }
     'cache-test/secondary-marks': { marks: string[] }
+    'cache-test/marks3': { marks: string[] }
   }
 }
 
@@ -74,6 +76,18 @@ const marksUnit = (stateVersion = 1) => ({
   stateVersion,
 }) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
 
+const marks3Unit = {
+  key: 'cache-test/marks3',
+  stateSchema: z.object({ marks: z.array(z.string()) }).nullable(),
+  init: () => null,
+  apply: state => state,
+  wire: {
+    viewSchema: z.object({ marks: z.array(z.string()) }),
+    view: state => state ?? { marks: [] },
+  },
+  stateVersion: 1,
+} satisfies ProjectionDefinition<'cache-test/marks3', MarksState>
+
 const secretUnit = {
   key: 'cache-test/secret',
   stateSchema: z.string(),
@@ -443,14 +457,35 @@ describe('SessionProjectionCache listing read', () => {
       .toBeUndefined()
   })
 
-  it('returns undefined when the stored record is version-mismatched', async () => {
+  it('carries ONE cut across multiple served rows: the lowest watermark wins', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // Equal watermarks: whichever row is visited second cannot lower the cut,
+    // so the one-cut fold sees both a lowering and a non-lowering row in
+    // every iteration order.
+    await seedRecord(root, 'multi-row', {
+      'cache-test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['a'] } },
+      'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } },
+    })
+    const { ctx, cache } = await harness({ root })
+    ctx.sessionProjections.register(marks3Unit)
+    const block = cache.cachedSnapshot(headerOf(SessionId('multi-row')), SessionLogOffset(0))
+    expect(block?.values).toEqual({
+      'cache-test/marks': { marks: ['a'] },
+      'cache-test/marks3': { marks: ['b'] },
+    })
+    expect(block?.asOfSeq).toBe(4)
+  })
+
+  it('returns undefined when the stored record version is not accepted', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
     roots.push(root)
-    // A stale version-stamped document is discarded at open: absent record.
+    // Version 2 is neither current nor declared compatible, so the document
+    // is discarded at open and the record reads as absent.
     const path = recordPath(root, SessionId('all-stale'))
     await mkdir(dirname(path), { recursive: true })
     await writeFile(path, JSON.stringify({
-      version: projectionCacheDomainSpec.version - 1,
+      version: 2,
       record: {
         identity: {
           formatVersion: SESSION_FORMAT_VERSION,
@@ -466,6 +501,48 @@ describe('SessionProjectionCache listing read', () => {
       .toBeUndefined()
   })
 
+  it('serves a pre-lineage record (accepted old version) to an unseeded caller only', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    // A document stamped with an accepted older version whose identity
+    // predates the lineage fields: absent lineage reads as unseeded.
+    const path = recordPath(root, SessionId('pre-lineage'))
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, JSON.stringify({
+      version: 4,
+      record: {
+        identity: { formatVersion: SESSION_FORMAT_VERSION, createdAt: 0 },
+        rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['kept'] } } },
+      },
+    }))
+    const { cache } = await harness({ root })
+    const id = SessionId('pre-lineage')
+    // Unseeded caller: the absent lineage is exactly its identity — served.
+    expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0)))
+      .toEqual({ asOfSeq: 4, values: { 'cache-test/marks': { marks: ['kept'] } } })
+    // Seeded caller: the lineage-less record cannot vouch for the cut — refused.
+    expect(cache.cachedSnapshot({ ...headerOf(id), isSeeded: true }, SessionLogOffset(2)))
+      .toBeUndefined()
+  })
+
+  it('refuses an accepted predecessor record without a Session format generation', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
+    roots.push(root)
+    const id = SessionId('pre-format-identity')
+    const path = recordPath(root, id)
+    await mkdir(dirname(path), { recursive: true })
+    await writeFile(path, JSON.stringify({
+      version: 6,
+      record: {
+        identity: { createdAt: 0, isSeeded: false, inheritedEventCount: 0 },
+        rows: { 'cache-test/marks': { ver: 1, seq: 4, val: { marks: ['unbound'] } } },
+      },
+    }))
+    const { cache } = await harness({ root })
+
+    expect(cache.cachedSnapshot(headerOf(id), SessionLogOffset(0))).toBeUndefined()
+  })
+
   it('returns undefined when every stored row is version-mismatched', async () => {
     const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-'))
     roots.push(root)

+ 255 - 0
packages/session/session-projection-cache/tests/fixtures.spec.ts

@@ -0,0 +1,255 @@
+/**
+ * Cross-version recovery over archived on-disk artifacts. `fixtures/` holds
+ * real `session_projcache` media, each produced by driving the named release
+ * through its own web app (session created over RPC, real model turns, a
+ * rename): the v3 whole-unit file (published 0.1.1-rc.2), a v4 per-record
+ * document (published 0.1.2-alpha.3), a published v5 document, and the
+ * v5-stamped lineage-less document reproducing byte-for-byte what the
+ * formerly unguarded legacy bootstrap wrote over v3 records. Each must open
+ * through the real storage stack without becoming a fold shortcut for the
+ * current Session format, then accept a current checkpoint rewrite. A record
+ * that fails schema validation is backed up and skipped instead of failing the
+ * boot.
+ */
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { Context } from '@deepseek-ai/cordis'
+import { z } from 'zod'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
+import type { SessionHeader } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import Storage from '@deepseek-ai/dsh-storage'
+import {
+  apply as storageJsonApply, Config as storageJsonConfig, inject as storageJsonInject, name as storageJsonName,
+} from '@deepseek-ai/dsh-storage-json'
+import {
+  apply as storageDomainApply, Config as storageDomainConfig, inject as storageDomainInject, name as storageDomainName,
+} from '@deepseek-ai/dsh-storage-domain'
+import SessionProjectionCache from '../src/index.ts'
+import { projectionCacheDomainSpec } from '../src/spec.ts'
+
+// Declarations must match the shipped title unit's exactly (the repo-wide
+// compile face sees both).
+declare module '@deepseek-ai/dsh-session-projection/types' {
+  interface SessionProjectionStateMap {
+    title: string | null
+  }
+  interface SessionProjectionMap {
+    title: string | null
+  }
+}
+
+declare module '@deepseek-ai/dsh-session/types' {
+  interface SessionEventMap {
+    'fixtures-test/set-title': { title: string }
+  }
+
+  interface OutOfBandSessionEventMap {
+    'fixtures-test/set-title': true
+  }
+}
+
+// Mirrors the shipped title unit's storage face: stateVersion 1, bare-string
+// state (the fixture rows carry exactly this shape in every archived
+// version), folding a test event so the rewrite path has fresh data.
+const titleUnit = {
+  key: 'title',
+  stateSchema: z.string().nullable(),
+  init: () => null,
+  apply: (state, event) => (event.type === 'fixtures-test/set-title' ? event.data.title : state),
+  wire: { viewSchema: z.string().nullable(), view: state => state },
+  stateVersion: 1,
+} satisfies ProjectionDefinition<'title', string | null>
+
+const FIXTURES = fileURLToPath(new URL('./fixtures/', import.meta.url))
+
+/** One archived per-record document (`{version, record}`). */
+interface FixtureDoc {
+  version: number
+  record: {
+    identity: { createdAt: number; cwd?: string }
+    rows: Record<string, { ver: number; seq: number; val: unknown }>
+  }
+}
+
+async function fixtureJson<T>(name: string): Promise<T> {
+  return JSON.parse(await readFile(join(FIXTURES, name), 'utf8')) as T
+}
+
+/** Header for the session a fixture record is bound to (identity witness). */
+function headerFor(id: SessionId, identity: FixtureDoc['record']['identity']): SessionHeader {
+  return {
+    version: SESSION_FORMAT_VERSION,
+    id,
+    createdAt: identity.createdAt,
+    isSeeded: false,
+    ...identity.cwd === undefined ? {} : { cwd: identity.cwd },
+  }
+}
+
+const contexts: Context[] = []
+const roots: string[] = []
+
+async function harness(root: string) {
+  roots.push(root)
+  const ctx = new Context()
+  contexts.push(ctx)
+  await ctx.plugin(Storage)
+  await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+  await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
+  await ctx.plugin(SessionStore)
+  await ctx.plugin(SessionProjectionRegistry)
+  ctx.sessionProjections.register(titleUnit)
+  await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
+  return { ctx, cache: ctx.sessionProjectionCache }
+}
+
+/** Lay one per-record fixture document into a fresh backend root. */
+async function placeDoc(root: string, id: string, name: string): Promise<FixtureDoc> {
+  const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`)
+  await mkdir(dirname(path), { recursive: true })
+  await cp(join(FIXTURES, name), path)
+  return fixtureJson<FixtureDoc>(name)
+}
+
+/**
+ * Drive a live write over a recovered session id and assert the archived
+ * document is replaced by a current-version one: current domain and Session
+ * format stamps, lineage, and the freshly folded title.
+ */
+async function assertRewrite(ctx: Context, root: string, id: SessionId): Promise<void> {
+  const session = ctx.sessions.create(id)
+  session.append('fixtures-test/set-title', { title: '重写标题' })
+  session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
+  const path = join(root, projectionCacheDomainSpec.name, 'sessions', `${id}.json`)
+  await vi.waitFor(async () => {
+    const doc = JSON.parse(await readFile(path, 'utf8')) as FixtureDoc
+    expect(doc.version).toBe(projectionCacheDomainSpec.version)
+    expect(doc.record.identity).toMatchObject({
+      formatVersion: SESSION_FORMAT_VERSION,
+      isSeeded: false,
+      inheritedEventCount: 0,
+    })
+    expect(doc.record.rows['title']?.val).toBe('重写标题')
+  }, { timeout: 5_000 })
+}
+
+afterEach(async () => {
+  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
+  await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })))
+})
+
+describe('archived version recovery', () => {
+  it('recovers the v3 whole-unit archive through the legacy bootstrap', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
+    await cp(join(FIXTURES, 'v3-single-unit.json'), join(root, `${projectionCacheDomainSpec.name}.json`))
+    type SingleUnit = {
+      unit: { version: number }
+      tables: { sessions: Record<string, FixtureDoc['record']> }
+    }
+    const archive = await fixtureJson<SingleUnit>('v3-single-unit.json')
+    expect(archive.unit.version).toBe(3) // the fixture IS the old format
+    const [sid, record] = Object.entries(archive.tables.sessions)[0]!
+
+    const { ctx, cache } = await harness(root)
+    expect(cache.cachedSnapshot(
+      headerFor(SessionId(sid), record.identity),
+      SessionLogOffset(0),
+      ['title'],
+    )).toBeUndefined()
+
+    // The one-time bootstrap materialized a current-version document.
+    const migrated = JSON.parse(
+      await readFile(join(root, projectionCacheDomainSpec.name, 'sessions', `${sid}.json`), 'utf8'),
+    ) as { version: number }
+    expect(migrated.version).toBe(projectionCacheDomainSpec.version)
+
+    await assertRewrite(ctx, root, SessionId(sid))
+  })
+
+  for (const [fixture, storedVersion] of [
+    ['v4-session-doc.json', 4],
+    ['v5-session-doc.json', 5],
+    ['v5-lineageless-doc.json', 5],
+  ] as const) {
+    it(`opens ${fixture} without serving its unbound fold, then rewrites it current`, async () => {
+      const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
+      const id = SessionId('fixture-session')
+      const doc = await placeDoc(root, id, fixture)
+      expect(doc.version).toBe(storedVersion)
+
+      const { ctx, cache } = await harness(root)
+      expect(cache.cachedSnapshot(
+        headerFor(id, doc.record.identity),
+        SessionLogOffset(0),
+        ['title'],
+      )).toBeUndefined()
+
+      await assertRewrite(ctx, root, id)
+    })
+  }
+
+  it('refuses a lineage-less archive for a seeded caller (identity mismatch, cold rebuild)', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
+    const id = SessionId('fixture-seeded')
+    const doc = await placeDoc(root, id, 'v5-lineageless-doc.json')
+
+    const { cache } = await harness(root)
+    const seeded = { ...headerFor(id, doc.record.identity), isSeeded: true }
+    expect(cache.cachedSnapshot(seeded, SessionLogOffset(2), ['title'])).toBeUndefined()
+  })
+
+  it('backs up and skips a record that fails schema validation instead of failing the boot', async () => {
+    const root = await mkdtemp(join(tmpdir(), 'dsh-projcache-fx-'))
+    roots.push(root)
+    const sessionsDir = join(root, projectionCacheDomainSpec.name, 'sessions')
+    await mkdir(sessionsDir, { recursive: true })
+    // Current-version stamp, hopeless record content: no compat rung can save it.
+    await writeFile(join(sessionsDir, 'broken.json'), JSON.stringify({
+      version: projectionCacheDomainSpec.version,
+      record: { identity: { createdAt: 'not-a-number' }, rows: 'not-an-object' },
+    }))
+    const good = await placeDoc(root, SessionId('survivor'), 'v5-session-doc.json')
+
+    const ctx = new Context()
+    contexts.push(ctx)
+    await ctx.plugin(Storage)
+    await ctx.plugin({ name: storageJsonName, inject: storageJsonInject, apply: storageJsonApply, Config: storageJsonConfig }, { root })
+    await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' })
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    ctx.sessionProjections.register(titleUnit)
+    const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {})
+    // The boot survives the broken record — this line rejecting IS the fixed bug.
+    await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
+
+    // Concrete console diagnostics: which record, where it went, and why.
+    expect(error).toHaveBeenCalledWith(expect.stringContaining("record 'broken'"))
+    expect(error).toHaveBeenCalledWith(expect.stringContaining('.json.bak.'))
+
+    // The document was moved aside as <key>.json.bak.<YYYYMMDDHHmm>, bytes intact.
+    const entries = await readdir(sessionsDir)
+    expect(entries).not.toContain('broken.json')
+    const backup = entries.find(name => /^broken\.json\.bak\.\d{12}$/.test(name))
+    expect(backup).toBeDefined()
+    expect(JSON.parse(await readFile(join(sessionsDir, backup!), 'utf8')))
+      .toMatchObject({ record: { rows: 'not-an-object' } })
+
+    // The broken record reads as absent; its predecessor-stamped neighbor
+    // remains available for a safe current rewrite.
+    const cache = ctx.sessionProjectionCache
+    expect(cache.cachedSnapshot(headerFor(SessionId('broken'), { createdAt: 0 }), SessionLogOffset(0)))
+      .toBeUndefined()
+    expect(cache.cachedSnapshot(
+      headerFor(SessionId('survivor'), good.record.identity),
+      SessionLogOffset(0),
+      ['title'],
+    )).toBeUndefined()
+    await assertRewrite(ctx, root, SessionId('survivor'))
+  })
+})

+ 136 - 0
packages/session/session-projection-cache/tests/fixtures/v3-single-unit.json

@@ -0,0 +1,136 @@
+{
+  "unit": {
+    "name": "session_projcache",
+    "version": 3
+  },
+  "global": null,
+  "tables": {
+    "sessions": {
+      "session-1374fa81-15da-44ca-be12-b4c6fe8076a3": {
+        "identity": {
+          "createdAt": 1788286864454,
+          "cwd": "/tmp"
+        },
+        "rows": {
+          "sessionStats": {
+            "ver": 1,
+            "seq": 54,
+            "val": {
+              "turns": 2,
+              "steps": 2,
+              "llmMs": 2169,
+              "toolMs": 0,
+              "ttftMs": 1872,
+              "ttftSteps": 2,
+              "decodeMs": 297,
+              "decodeTokens": 20,
+              "lastTurn": 2,
+              "openStep": null,
+              "pendingCalls": {}
+            }
+          },
+          "title": {
+            "ver": 1,
+            "seq": 54,
+            "val": "验收标题-rc2"
+          },
+          "goal": {
+            "ver": 4,
+            "seq": 54,
+            "val": null
+          },
+          "tokenUsage": {
+            "ver": 1,
+            "seq": 54,
+            "val": {
+              "totals": {
+                "uncachedInputTokens": 6083,
+                "outputTokens": 20,
+                "cacheReadTokens": 9728,
+                "cacheWriteTokens": 0
+              },
+              "last": {
+                "turn": 2,
+                "step": 1,
+                "buckets": {
+                  "uncachedInputTokens": 112,
+                  "outputTokens": 4,
+                  "cacheReadTokens": 7808,
+                  "cacheWriteTokens": 0
+                }
+              }
+            }
+          },
+          "contextPressure": {
+            "ver": 4,
+            "seq": 54,
+            "val": {
+              "surfaceTokens": 178,
+              "contextWindow": 1000000,
+              "pressureTokens": 7920,
+              "sampledSurfaceTokens": 168
+            }
+          },
+          "contextBreakdown": {
+            "ver": 2,
+            "seq": 54,
+            "val": {
+              "systemTokens": 1620,
+              "toolsTokens": 6475,
+              "messageTokens": 178
+            }
+          },
+          "subagentTiming": {
+            "ver": 2,
+            "seq": 54,
+            "val": {
+              "descriptorSeen": false,
+              "settledMs": 0
+            }
+          },
+          "subagent": {
+            "ver": 2,
+            "seq": 54,
+            "val": {}
+          },
+          "permissions": {
+            "ver": 1,
+            "seq": 54,
+            "val": {
+              "preset": "workspace-write",
+              "sandbox": "workspace-write",
+              "approval": "ask"
+            }
+          },
+          "sessionListMetadata": {
+            "ver": 1,
+            "seq": 54,
+            "val": {
+              "blank": false,
+              "lastPromptAt": 1788286867410
+            }
+          },
+          "imageLimits": {
+            "ver": 1,
+            "seq": 54,
+            "val": null
+          },
+          "todos": {
+            "ver": 2,
+            "seq": 54,
+            "val": null
+          },
+          "plan": {
+            "ver": 2,
+            "seq": 54,
+            "val": {
+              "active": false,
+              "wanted": null,
+              "running": null
+            }
+          }
+        }
+      }
+    }
+  }
+}

+ 212 - 0
packages/session/session-projection-cache/tests/fixtures/v4-session-doc.json

@@ -0,0 +1,212 @@
+{
+  "version": 4,
+  "record": {
+    "identity": {
+      "createdAt": 1788286912530,
+      "cwd": "/tmp"
+    },
+    "rows": {
+      "title": {
+        "ver": 1,
+        "seq": 68,
+        "val": "验收标题-alpha3"
+      },
+      "titleInput": {
+        "ver": 3,
+        "seq": 68,
+        "val": {
+          "first": {
+            "seq": 7,
+            "text": "请只回复一个词:pong"
+          },
+          "count": 2,
+          "lastSeq": 58
+        }
+      },
+      "llmRetry": {
+        "ver": 1,
+        "seq": 68,
+        "val": {}
+      },
+      "sandboxMode": {
+        "ver": 1,
+        "seq": 68,
+        "val": "workspace-write"
+      },
+      "goal": {
+        "ver": 6,
+        "seq": 68,
+        "val": {
+          "current": null,
+          "seenGoalIds": [],
+          "failure": null
+        }
+      },
+      "tokenUsage": {
+        "ver": 2,
+        "seq": 68,
+        "val": {
+          "totals": {
+            "uncachedInputTokens": 8192,
+            "outputTokens": 35,
+            "cacheReadTokens": 8064,
+            "cacheWriteTokens": 0
+          },
+          "last": {
+            "turn": 2,
+            "step": 1,
+            "buckets": {
+              "uncachedInputTokens": 86,
+              "outputTokens": 4,
+              "cacheReadTokens": 8064,
+              "cacheWriteTokens": 0
+            }
+          }
+        }
+      },
+      "contextPressure": {
+        "ver": 4,
+        "seq": 68,
+        "val": {
+          "surfaceTokens": 193,
+          "contextWindow": 1000000,
+          "pressureTokens": 8150,
+          "sampledSurfaceTokens": 183
+        }
+      },
+      "contextBreakdown": {
+        "ver": 2,
+        "seq": 68,
+        "val": {
+          "systemTokens": 1760,
+          "toolsTokens": 6579,
+          "messageTokens": 193
+        }
+      },
+      "turnBoundary": {
+        "ver": 2,
+        "seq": 68,
+        "val": {
+          "openTurnStartSeq": null,
+          "lastStepStartSeq": 57,
+          "lastStepBoundary": {
+            "kind": "end",
+            "seq": 67
+          },
+          "lastTurn": 2
+        }
+      },
+      "sessionStats": {
+        "ver": 1,
+        "seq": 68,
+        "val": {
+          "turns": 2,
+          "steps": 2,
+          "llmMs": 3486,
+          "toolMs": 0,
+          "ttftMs": 3199,
+          "ttftSteps": 2,
+          "decodeMs": 287,
+          "decodeTokens": 35,
+          "lastTurn": 2,
+          "openStep": null,
+          "pendingCalls": {}
+        }
+      },
+      "turnOutline": {
+        "ver": 2,
+        "seq": 68,
+        "val": {
+          "turns": [
+            {
+              "turn": 1,
+              "seq": 4,
+              "prompt": "请只回复一个词:pong",
+              "response": "pong"
+            },
+            {
+              "turn": 2,
+              "seq": 55,
+              "prompt": "请只回复一个词:pong2",
+              "response": "pong2"
+            }
+          ],
+          "draft": ""
+        }
+      },
+      "agentPreset": {
+        "ver": 1,
+        "seq": 68,
+        "val": "standard"
+      },
+      "subagentTiming": {
+        "ver": 2,
+        "seq": 68,
+        "val": {
+          "descriptorSeen": false,
+          "settledMs": 0
+        }
+      },
+      "subagent": {
+        "ver": 2,
+        "seq": 68,
+        "val": {}
+      },
+      "permissions": {
+        "ver": 2,
+        "seq": 68,
+        "val": {
+          "preset": "workspace-write",
+          "sandbox": "workspace-write",
+          "approval": "ask",
+          "seeded": false
+        }
+      },
+      "modelSelection": {
+        "ver": 2,
+        "seq": 68,
+        "val": {
+          "lastUsed": {
+            "provider": "deepseek-official",
+            "model": "deepseek-v4-flash",
+            "reasoningEffort": "high"
+          },
+          "pending": null
+        }
+      },
+      "sessionListMetadata": {
+        "ver": 1,
+        "seq": 68,
+        "val": {
+          "blank": false,
+          "lastPromptAt": 1788286917834
+        }
+      },
+      "imageLimits": {
+        "ver": 1,
+        "seq": 68,
+        "val": null
+      },
+      "todos": {
+        "ver": 2,
+        "seq": 68,
+        "val": null
+      },
+      "plan": {
+        "ver": 3,
+        "seq": 68,
+        "val": {
+          "active": false,
+          "wanted": null,
+          "running": null,
+          "activeAtLastHeader": false
+        }
+      },
+      "subagentModelSelectionPolicy": {
+        "ver": 1,
+        "seq": 68,
+        "val": null
+      }
+    }
+  }
+}

+ 128 - 0
packages/session/session-projection-cache/tests/fixtures/v5-lineageless-doc.json

@@ -0,0 +1,128 @@
+{
+  "version": 5,
+  "record": {
+    "identity": {
+      "createdAt": 1788286864454,
+      "cwd": "/tmp"
+    },
+    "rows": {
+      "sessionStats": {
+        "ver": 1,
+        "seq": 54,
+        "val": {
+          "turns": 2,
+          "steps": 2,
+          "llmMs": 2169,
+          "toolMs": 0,
+          "ttftMs": 1872,
+          "ttftSteps": 2,
+          "decodeMs": 297,
+          "decodeTokens": 20,
+          "lastTurn": 2,
+          "openStep": null,
+          "pendingCalls": {}
+        }
+      },
+      "title": {
+        "ver": 1,
+        "seq": 54,
+        "val": "\u9a8c\u6536\u6807\u9898-rc2"
+      },
+      "goal": {
+        "ver": 4,
+        "seq": 54,
+        "val": null
+      },
+      "tokenUsage": {
+        "ver": 1,
+        "seq": 54,
+        "val": {
+          "totals": {
+            "uncachedInputTokens": 6083,
+            "outputTokens": 20,
+            "cacheReadTokens": 9728,
+            "cacheWriteTokens": 0
+          },
+          "last": {
+            "turn": 2,
+            "step": 1,
+            "buckets": {
+              "uncachedInputTokens": 112,
+              "outputTokens": 4,
+              "cacheReadTokens": 7808,
+              "cacheWriteTokens": 0
+            }
+          }
+        }
+      },
+      "contextPressure": {
+        "ver": 4,
+        "seq": 54,
+        "val": {
+          "surfaceTokens": 178,
+          "contextWindow": 1000000,
+          "pressureTokens": 7920,
+          "sampledSurfaceTokens": 168
+        }
+      },
+      "contextBreakdown": {
+        "ver": 2,
+        "seq": 54,
+        "val": {
+          "systemTokens": 1620,
+          "toolsTokens": 6475,
+          "messageTokens": 178
+        }
+      },
+      "subagentTiming": {
+        "ver": 2,
+        "seq": 54,
+        "val": {
+          "descriptorSeen": false,
+          "settledMs": 0
+        }
+      },
+      "subagent": {
+        "ver": 2,
+        "seq": 54,
+        "val": {}
+      },
+      "permissions": {
+        "ver": 1,
+        "seq": 54,
+        "val": {
+          "preset": "workspace-write",
+          "sandbox": "workspace-write",
+          "approval": "ask"
+        }
+      },
+      "sessionListMetadata": {
+        "ver": 1,
+        "seq": 54,
+        "val": {
+          "blank": false,
+          "lastPromptAt": 1788286867410
+        }
+      },
+      "imageLimits": {
+        "ver": 1,
+        "seq": 54,
+        "val": null
+      },
+      "todos": {
+        "ver": 2,
+        "seq": 54,
+        "val": null
+      },
+      "plan": {
+        "ver": 2,
+        "seq": 54,
+        "val": {
+          "active": false,
+          "wanted": null,
+          "running": null
+        }
+      }
+    }
+  }
+}

+ 220 - 0
packages/session/session-projection-cache/tests/fixtures/v5-session-doc.json

@@ -0,0 +1,220 @@
+{
+  "version": 5,
+  "record": {
+    "identity": {
+      "createdAt": 1788286864454,
+      "cwd": "/tmp",
+      "isSeeded": false,
+      "inheritedEventCount": 0
+    },
+    "rows": {
+      "title": {
+        "ver": 1,
+        "seq": 71,
+        "val": "验收标题-rc2"
+      },
+      "titleInput": {
+        "ver": 3,
+        "seq": 71,
+        "val": {
+          "first": {
+            "seq": 7,
+            "text": "请只回复一个词:pong"
+          },
+          "count": 3,
+          "lastSeq": 60
+        }
+      },
+      "llmRetry": {
+        "ver": 1,
+        "seq": 71,
+        "val": {}
+      },
+      "sandboxMode": {
+        "ver": 1,
+        "seq": 71,
+        "val": "workspace-write"
+      },
+      "goal": {
+        "ver": 6,
+        "seq": 71,
+        "val": {
+          "current": null,
+          "seenGoalIds": [],
+          "failure": null
+        }
+      },
+      "tokenUsage": {
+        "ver": 2,
+        "seq": 71,
+        "val": {
+          "totals": {
+            "uncachedInputTokens": 14219,
+            "outputTokens": 24,
+            "cacheReadTokens": 9728,
+            "cacheWriteTokens": 0
+          },
+          "last": {
+            "turn": 3,
+            "step": 1,
+            "buckets": {
+              "uncachedInputTokens": 8136,
+              "outputTokens": 4,
+              "cacheReadTokens": 0,
+              "cacheWriteTokens": 0
+            }
+          }
+        }
+      },
+      "contextPressure": {
+        "ver": 4,
+        "seq": 71,
+        "val": {
+          "surfaceTokens": 200,
+          "contextWindow": 1000000,
+          "pressureTokens": 8136,
+          "sampledSurfaceTokens": 190
+        }
+      },
+      "contextBreakdown": {
+        "ver": 2,
+        "seq": 71,
+        "val": {
+          "systemTokens": 1729,
+          "toolsTokens": 6611,
+          "messageTokens": 200
+        }
+      },
+      "sessionStats": {
+        "ver": 1,
+        "seq": 71,
+        "val": {
+          "turns": 3,
+          "steps": 3,
+          "llmMs": 4368,
+          "toolMs": 0,
+          "ttftMs": 4030,
+          "ttftSteps": 3,
+          "decodeMs": 338,
+          "decodeTokens": 24,
+          "lastTurn": 3,
+          "openStep": null,
+          "pendingCalls": {}
+        }
+      },
+      "agentPreset": {
+        "ver": 1,
+        "seq": 71,
+        "val": "standard"
+      },
+      "subagentTiming": {
+        "ver": 2,
+        "seq": 71,
+        "val": {
+          "descriptorSeen": false,
+          "settledMs": 0
+        }
+      },
+      "subagent": {
+        "ver": 2,
+        "seq": 71,
+        "val": {}
+      },
+      "turnBoundary": {
+        "ver": 2,
+        "seq": 71,
+        "val": {
+          "openTurnStartSeq": null,
+          "lastStepStartSeq": 59,
+          "lastStepBoundary": {
+            "kind": "end",
+            "seq": 70
+          },
+          "lastTurn": 3
+        }
+      },
+      "turnOutline": {
+        "ver": 2,
+        "seq": 71,
+        "val": {
+          "turns": [
+            {
+              "turn": 1,
+              "seq": 4,
+              "prompt": "请只回复一个词:pong",
+              "response": "pong"
+            },
+            {
+              "turn": 2,
+              "seq": 40,
+              "prompt": "请只回复一个词:pong2",
+              "response": "pong2"
+            },
+            {
+              "turn": 3,
+              "seq": 57,
+              "prompt": "请只回复一个词:pong3",
+              "response": "pong3"
+            }
+          ],
+          "draft": ""
+        }
+      },
+      "permissions": {
+        "ver": 2,
+        "seq": 71,
+        "val": {
+          "preset": "workspace-write",
+          "sandbox": "workspace-write",
+          "approval": "ask",
+          "seeded": true
+        }
+      },
+      "modelSelection": {
+        "ver": 2,
+        "seq": 71,
+        "val": {
+          "lastUsed": {
+            "provider": "deepseek-official",
+            "model": "deepseek-v4-flash",
+            "reasoningEffort": "high"
+          },
+          "pending": null
+        }
+      },
+      "sessionListMetadata": {
+        "ver": 1,
+        "seq": 71,
+        "val": {
+          "blank": false,
+          "lastPromptAt": 1788287660106
+        }
+      },
+      "imageLimits": {
+        "ver": 1,
+        "seq": 71,
+        "val": null
+      },
+      "todos": {
+        "ver": 2,
+        "seq": 71,
+        "val": null
+      },
+      "plan": {
+        "ver": 3,
+        "seq": 71,
+        "val": {
+          "active": false,
+          "wanted": null,
+          "running": null,
+          "activeAtLastHeader": false
+        }
+      },
+      "subagentModelSelectionPolicy": {
+        "ver": 1,
+        "seq": 71,
+        "val": null
+      }
+    }
+  }
+}

+ 21 - 2
packages/storage/storage-domain/src/index.ts

@@ -88,7 +88,10 @@ export class DomainFacility {
    * (`facet-unsupported`); open the unit projected from the spec (backend
    * `version-mismatch`/`malformed-medium` pass through); load and validate
    * every stored record against the spec's zod schemas (`invalid-record`
-   * with the offending table and key); construct the domain.
+   * with the offending table and key — unless the spec declares
+   * `invalidRecords: 'backup-and-skip'` and the unit can move documents aside, in
+   * which case the failing record is backed up, logged, and skipped);
+   * construct the domain.
    *
    * Lifecycle: the CALLER owns the returned handle and closes it via
    * `Domain.close()` (typically as its own `ctx.effect` disposer) — the
@@ -118,7 +121,23 @@ export class DomainFacility {
         for (const [table, tableSpec] of Object.entries(spec.tables)) {
           const records = new Map<string, unknown>()
           for (const [key, raw] of Object.entries(snapshot.tables[table] ?? {})) {
-            records.set(key, parseRecord(spec.name, table, key, () => tableSpec.valueSchema.parse(raw)))
+            let parsed: unknown
+            try {
+              parsed = parseRecord(spec.name, table, key, () => tableSpec.valueSchema.parse(raw))
+            } catch (error) {
+              // Backup-and-skip policy (disposable derived data): move the record's
+              // document aside, log the concrete failure, and open without the
+              // record. Backends that cannot move a document keep the loud path.
+              if (spec.invalidRecords !== 'backup-and-skip' || unit.backupRecord === undefined) throw error
+              const moved = await unit.backupRecord(table, key)
+              // parseRecord always wraps the zod failure as the cause.
+              this.ctx.logger.error(
+                `domain '${spec.name}': stored record '${key}' in table '${table}' failed schema validation; `
+                + `moved to '${moved}' and treated as absent. Cause: ${String((error as DomainError).cause)}`,
+              )
+              continue
+            }
+            records.set(key, parsed)
           }
           tables.set(table, records)
         }

+ 37 - 3
packages/storage/storage-domain/src/spec.ts

@@ -35,16 +35,36 @@ export interface DomainTableSpec<K extends string = string, V = unknown> {
 export interface DomainSpec {
   /** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */
   readonly name: string
-  /** Domain format version; a medium stamped with a different version rejects at open. */
+  /** Current domain format version; reads enforce it according to the selected layout. */
   readonly version: number
   /**
    * Medium layout for the backend unit: `single` (the default) stores the
    * whole unit as one document; `per-record` stores each record as its own
    * document, for units whose records are large, sparse, or individually
-   * disposable — the projection cache — and scopes version bumps per record
-   * (a stale record document is discarded, never migrated).
+   * disposable — the projection cache — and scopes version checks per record
+   * (an unaccepted record document is discarded, never migrated).
    */
   readonly layout?: 'single' | 'per-record'
+  /**
+   * Older domain versions whose stored records the current record schemas
+   * also accept (the declaring owner vouches for that, typically by
+   * declaring the fields older records lack as optional). `per-record` backends
+   * read documents stamped with a listed version instead of discarding them,
+   * and accept a legacy whole-unit file so stamped for the one-time
+   * bootstrap; writes always stamp {@link version}.
+   */
+  readonly compatibleVersions?: readonly number[]
+  /**
+   * What `open` does with a stored table record that fails its zod schema.
+   * Absent (the default), the whole open rejects with `invalid-record` —
+   * right for authoritative data. `'backup-and-skip'` is for domains whose
+   * records are disposable derived data: the backend moves the record's
+   * document aside (`KvUnit.backupRecord`), the failure is logged with
+   * its cause, and the open continues with the record absent. A backend
+   * without `backupRecord` (no per-record document to move) falls back
+   * to the rejecting default. The global slot always rejects.
+   */
+  readonly invalidRecords?: 'backup-and-skip'
   /** Optional global singleton slot. */
   readonly global?: DomainGlobalSpec<unknown>
   /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
@@ -91,6 +111,13 @@ export function defineDomain<S extends DomainSpec>(spec: S): S {
   if (!Number.isInteger(spec.version) || spec.version < 0) {
     throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`)
   }
+  for (const compat of spec.compatibleVersions ?? []) {
+    if (!Number.isInteger(compat) || compat < 0 || compat >= spec.version) {
+      throw new Error(
+        `domain '${spec.name}' compatibleVersions entries must be non-negative integers below version ${spec.version}, got ${compat}`,
+      )
+    }
+  }
   if (spec.layout !== undefined) {
     // Runtime boundary: the union type is compile-time only — a spec built
     // from config could carry any value, and a bad one must fail loud here.
@@ -99,6 +126,12 @@ export function defineDomain<S extends DomainSpec>(spec: S): S {
       throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${layout}`)
     }
   }
+  if (spec.invalidRecords !== undefined) {
+    const policy: string = spec.invalidRecords
+    if (policy !== 'backup-and-skip') {
+      throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present, got ${policy}`)
+    }
+  }
   for (const table of Object.keys(spec.tables)) {
     if (!UNIT_NAME_RE.test(table)) {
       throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)
@@ -125,5 +158,6 @@ export function descriptorOf(spec: DomainSpec): KvUnitDescriptor {
     tables: Object.keys(spec.tables),
     hasGlobal: spec.global !== undefined,
     ...spec.layout === undefined ? {} : { layout: spec.layout },
+    ...spec.compatibleVersions === undefined ? {} : { compatibleVersions: spec.compatibleVersions },
   }
 }

+ 41 - 0
packages/storage/storage-domain/tests/domain.spec.ts

@@ -58,6 +58,25 @@ describe('defineDomain', () => {
     })).toThrow(/must not accept null/)
   })
 
+  it('validates compatibleVersions entries and projects them onto the descriptor', () => {
+    expect(() => defineDomain({ name: 'ok', version: 2, compatibleVersions: [1.5], tables: {} }))
+      .toThrow(/compatibleVersions/)
+    expect(() => defineDomain({ name: 'ok', version: 2, compatibleVersions: [2], tables: {} }))
+      .toThrow(/below version/)
+    expect(() => defineDomain({ name: 'ok', version: 2, compatibleVersions: [-1], tables: {} }))
+      .toThrow(/compatibleVersions/)
+    expect(descriptorOf(defineDomain({ name: 'ok', version: 2, compatibleVersions: [0, 1], tables: {} })))
+      .toMatchObject({ compatibleVersions: [0, 1] })
+    // An undeclared set is absent from the descriptor.
+    expect(descriptorOf(spec)).not.toHaveProperty('compatibleVersions')
+  })
+
+  it('rejects an unknown invalidRecords policy', () => {
+    expect(() => defineDomain({
+      name: 'ok', version: 1, invalidRecords: 'zap' as 'backup-and-skip', tables: {},
+    })).toThrow(/invalidRecords/)
+  })
+
   it('rejects an invalid layout and projects the declared one onto the descriptor', () => {
     // A spec built from config can carry any value; the union type is
     // compile-time only, so the runtime boundary check must reject it.
@@ -139,6 +158,28 @@ describe('DomainFacility.open', () => {
     })
   })
 
+  it('keeps the rejecting default under backup-and-skip when the backend cannot move documents', async () => {
+    // The memory backend has no backupRecord, so the declared policy cannot
+    // apply and the open falls back to failing loud.
+    const salvageSpec = defineDomain({
+      name: 'salvage',
+      version: 1,
+      invalidRecords: 'backup-and-skip',
+      tables: { items: domainTable<string, Item>(itemSchema) },
+    })
+    const pool = new MemoryMediaPool()
+    {
+      const { facility } = await harness({ pool })
+      await (await facility.open(salvageSpec)).table('items').put('bad', { label: 'x', count: 2 })
+    }
+    pool.media.get('salvage')!.tables.get('items')!.set('bad', { label: 'x', count: 'NaN' })
+    const { facility } = await harness({ pool })
+    await expect(facility.open(salvageSpec)).rejects.toMatchObject({
+      code: 'invalid-record',
+      detail: { table: 'items', key: 'bad' },
+    })
+  })
+
   it('rejects a stored global that fails its schema with the global marker', async () => {
     const pool = new MemoryMediaPool()
     pool.versions.set('demo', 1)

+ 2 - 2
packages/storage/storage-json/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/storage/storage-json/README.md
-README.md: 2972a617248c73e6105e8f19635e2325337405b6
-README.zh.md: 5c9f52d2a12981f2bff36e4df198270d09f2a8e5
+README.md: d36c6333392c3efe4462aebd82cf3ff9e76f66ae
+README.zh.md: 9c266537945941d4bc779a2cb1a349e2409f21dc

+ 2 - 2
packages/storage/storage-json/README.md

@@ -53,9 +53,9 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 ### Observable behavior
 
-A missing `single` file or `per-record` directory opens as an empty unit and materializes on the first write. In `single`, malformed content rejects with `malformed-medium`, and a different stored version rejects with `version-mismatch`. In `per-record`, each malformed, unreadable, or differently versioned document reads as an absent record, so one bad document does not reject the unit. Record keys must match `[a-zA-Z0-9_-]+`; an unsafe key rejects before any file operation. Every resolved write is durable, and operations after close reject with `closed`.
+A missing `single` file or `per-record` directory opens as an empty unit and materializes on the first write. In `single`, malformed content rejects with `malformed-medium`, and a different stored version rejects with `version-mismatch`. In `per-record`, each malformed or unreadable document, and each document whose version is outside the descriptor's current and compatible versions, reads as an absent record, so one bad document does not reject the unit. Record keys must match `[a-zA-Z0-9_-]+`; an unsafe key rejects before any file operation. Every resolved write is durable, and operations after close reject with `closed`.
 
-An empty `per-record` tree can initialize its declared tables from a valid `<root>/<unit>.json` whole-unit document only when the source unit name and version match the current descriptor. The backend leaves that source file unchanged. A different source version leaves the new tree empty. Any document path in a declared table, or a declared `global.json`, suppresses this initialization for the complete unit, even if that document is unreadable or stale.
+An empty `per-record` tree can initialize its declared tables from a valid `<root>/<unit>.json` whole-unit document only when the source unit name matches and its version is current or declared compatible. The backend leaves that source file unchanged and stamps migrated records with the current version. A source version outside the accepted set leaves the new tree empty. Any document path in a declared table, or a declared `global.json`, suppresses this initialization for the complete unit, even if that document is unreadable or stale.
 
 -----
 

+ 2 - 2
packages/storage/storage-json/README.zh.md

@@ -53,9 +53,9 @@ kind: "package-reference"
 
 ### 可观察行为
 
-缺失的 `single` 文件或 `per-record` 目录会作为空单元打开,并在第一次写入时物化。在 `single` 中,畸形内容以 `malformed-medium` 拒绝,不同的已存版本以 `version-mismatch` 拒绝。在 `per-record` 中,每份畸形、不可读或版本不同的文档都读作记录不存在,因此单个坏文档不会使单元被拒绝。记录键必须匹配 `[a-zA-Z0-9_-]+`;不安全的键在任何文件操作前被拒绝。每次已完成的写入都已持久化,关闭后的操作以 `closed` 拒绝。
+缺失的 `single` 文件或 `per-record` 目录会作为空单元打开,并在第一次写入时物化。在 `single` 中,畸形内容以 `malformed-medium` 拒绝,不同的已存版本以 `version-mismatch` 拒绝。在 `per-record` 中,每份畸形或不可读的文档,以及版本不在 descriptor 当前版本和兼容版本内的文档,都读作记录不存在,因此单个坏文档不会使单元被拒绝。记录键必须匹配 `[a-zA-Z0-9_-]+`;不安全的键在任何文件操作前被拒绝。每次已完成的写入都已持久化,关闭后的操作以 `closed` 拒绝。
 
-只有当源单元名称和版本与当前描述符相同时,空的 `per-record` 目录树才可以从有效的 `<root>/<unit>.json` 整单元文档初始化其已声明表。后端保持该源文件不变。源版本不同会使新目录树保持为空。已声明表中只要存在任意文档路径,或存在已声明的 `global.json`,就会对整个单元禁止该初始化,即使该文档不可读或版本陈旧。
+只有当源单元名称匹配,且源版本为当前版本或已声明的兼容版本时,空的 `per-record` 目录树才可以从有效的 `<root>/<unit>.json` 整单元文档初始化其已声明表。后端保持该源文件不变,并为迁移的记录写入当前版本戳接受集合之外的源版本会使新目录树保持为空。已声明表中只要存在任意文档路径,或存在已声明的 `global.json`,就会对整个单元禁止该初始化,即使该文档不可读或版本陈旧。
 
 -----
 

+ 10 - 8
packages/storage/storage-json/src/format.ts

@@ -100,16 +100,18 @@ export function serializeRecord(version: number, value: unknown): string {
 
 /**
  * Parse one per-record document, validating its version stamp. A document
- * that is malformed or stamped with a different version is FOREIGN and reads
- * as absent — the per-record contract: one bad or stale record file must not
- * brick the whole unit, and a version bump discards stale records instead of
- * migrating them (the whole-unit format rejects instead, because there is
- * exactly one document).
+ * that is malformed or stamped with an unaccepted version is FOREIGN and
+ * reads as absent — the per-record contract: one bad or stale record file
+ * must not brick the whole unit, and an unaccepted version stamp discards the
+ * record instead of migrating it (the whole-unit format rejects instead,
+ * because there is exactly one document).
  * @param text - Raw per-record document content.
- * @param version - Expected unit version; a mismatch discards the document.
+ * @param versions - Accepted unit versions (the current one plus the
+ * descriptor's compatibleVersions); any other stamp discards the
+ * document.
  * @returns the record value, or `undefined` for a foreign document.
  */
-export function parseRecord(text: string, version: number): unknown {
+export function parseRecord(text: string, versions: readonly number[]): unknown {
   let document: unknown
   try {
     document = JSON.parse(text)
@@ -118,6 +120,6 @@ export function parseRecord(text: string, version: number): unknown {
   }
   if (typeof document !== 'object' || document === null) return undefined
   const { version: stamped, record } = document as Record<string, unknown>
-  if (stamped !== version) return undefined
+  if (typeof stamped !== 'number' || !versions.includes(stamped)) return undefined
   return record
 }

+ 57 - 21
packages/storage/storage-json/src/per-record-unit.ts

@@ -10,21 +10,23 @@
  * memory unchanged.
  *
  * Per-record contract: a record document that is malformed or stamped with a
- * different version reads as an absent record — one bad or stale file never
- * bricks the whole unit, and a version bump discards stale records instead
- * of migrating them. Record keys become path segments, so they must be
- * path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
+ * version outside the accepted set (the descriptor's current version plus
+ * its `compatibleVersions`) reads as an absent record — one bad or stale
+ * file never bricks the whole unit, and an unaccepted version stamp discards
+ * the record instead of migrating it. Record keys become path segments, so
+ * they must be path-safe (`[a-zA-Z0-9_-]+`); an unsafe key rejects at write.
  *
  * Legacy bootstrap: when the new tree has no document path, a legacy
  * whole-unit file `<root>/<name>.json` (the pre-per-record layout) seeds
- * per-record documents only when its unit name and version match the current
- * descriptor. Any new document path, including one whose contents are
+ * per-record documents, provided its stored unit version is in the accepted
+ * set — a legacy file stamped with any other version is left alone and reads
+ * as the empty unit. Any new document path, including one whose contents are
  * unreadable or stale, suppresses the bootstrap for the whole unit. The
  * legacy file is never changed or deleted.
  * @module @deepseek-ai/dsh-storage-json/src/per-record-unit
  */
 
-import { mkdir, readFile, readdir, rm } from 'node:fs/promises'
+import { mkdir, readFile, readdir, rename, rm } from 'node:fs/promises'
 import { dirname, join } from 'node:path'
 import type { Dirent } from 'node:fs'
 import { StorageError } from '@deepseek-ai/dsh-storage'
@@ -58,13 +60,14 @@ export async function openPerRecordUnit(
  * Read every record document under the unit directory: each declared table's
  * `<key>.json` files plus `global.json`. A missing directory is the empty
  * unit (materialization defers to the first write); a foreign document
- * (missing, malformed, or stamped with another version) reads as an absent
+ * (missing, malformed, or stamped with an unaccepted version) reads as an absent
  * record, per the per-record contract.
  * @param descriptor - Static identity and shape of the unit.
  * @param dir - Absolute unit directory path.
  * @returns the authoritative state reconstructed from the tree.
  */
 async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Promise<UnitState> {
+  const versions = acceptedStamps(descriptor)
   const state: UnitState = {
     version: descriptor.version,
     global: null,
@@ -84,11 +87,11 @@ async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Pr
       if (entry.isDirectory()) {
         const records = state.tables.get(entry.name)
         if (records !== undefined) {
-          return loadTableRecords(records, descriptor.version, join(dir, entry.name))
+          return loadTableRecords(records, versions, join(dir, entry.name))
         }
       }
       if (entry.name === 'global.json' && descriptor.hasGlobal) {
-        const global = await readRecord(join(dir, entry.name), descriptor.version)
+        const global = await readRecord(join(dir, entry.name), versions)
         if (global !== undefined) state.global = global
         return true
       }
@@ -98,13 +101,21 @@ async function loadPerRecordState(descriptor: KvUnitDescriptor, dir: string): Pr
   return state
 }
 
+/** The version stamps this unit reads as its own: current plus declared compatible versions. */
+function acceptedStamps(descriptor: KvUnitDescriptor): readonly number[] {
+  return [descriptor.version, ...descriptor.compatibleVersions ?? []]
+}
+
 /**
  * Bootstrap an empty per-record tree from a legacy whole-unit file
  * (`<root>/<name>.json`, the pre-per-record layout). Every declared-table
- * record is copied into a same-version document, while the legacy file is
- * retained unchanged. A missing, foreign (another unit name or version),
- * malformed, or non-unit legacy file is left alone; other read failures
- * propagate.
+ * record is copied into a current-version document, while the legacy file is
+ * retained unchanged. A missing, foreign (another unit's name), malformed,
+ * or non-unit legacy file is left alone, and so is one whose stored unit
+ * version is outside the accepted set — migrating records the owner never
+ * vouched for would stamp them with the current version and turn a
+ * discardable stale cache into schema failures at the domain layer. Other
+ * read failures propagate.
  * @param descriptor - Static identity and shape of the unit.
  * @param dir - The per-record unit directory (`<root>/<name>`).
  * @param state - The empty tree state; bootstrapped records are added.
@@ -118,15 +129,18 @@ async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, st
     if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
     return
   }
-  // The legacy document is runtime data: its unit identity and the tables map
-  // shape are checked here; the domain layer's schemas judge record values.
+  // The legacy document is runtime data: only `unit.name`, `unit.version`,
+  // and the tables map shape are checked here — the record values are
+  // migrated as-is and the domain layer's schemas judge them.
   let document: { unit?: { name?: unknown; version?: unknown }; tables?: unknown }
   try {
     document = JSON.parse(text) as { unit?: { name?: unknown; version?: unknown }; tables?: unknown }
   } catch {
     return // Malformed legacy file: not ours to interpret or delete.
   }
-  if (document.unit?.name !== descriptor.name || document.unit.version !== descriptor.version) return
+  if (document.unit?.name !== descriptor.name) return
+  const stamped = document.unit.version
+  if (typeof stamped !== 'number' || !acceptedStamps(descriptor).includes(stamped)) return
   const tables = document.tables
   if (typeof tables !== 'object' || tables === null) return
   const recordsByTable = tables as Record<string, Record<string, unknown>>
@@ -147,14 +161,14 @@ async function bootstrapLegacyUnit(descriptor: KvUnitDescriptor, dir: string, st
  * @returns whether the directory contains any `.json` document path,
  * independently of key safety, readability, or stored version.
  */
-async function loadTableRecords(records: Map<string, unknown>, version: number, dir: string): Promise<boolean> {
+async function loadTableRecords(records: Map<string, unknown>, versions: readonly number[], dir: string): Promise<boolean> {
   const files = await readdir(dir, { withFileTypes: true })
   const hasDocuments = files.some(file => file.name.endsWith('.json'))
   const loaded = await Promise.all(files.map(async (file) => {
     if (!file.name.endsWith('.json')) return
     const key = file.name.slice(0, -'.json'.length)
     if (!SAFE_KEY_RE.test(key)) return
-    const record = await readRecord(join(dir, file.name), version)
+    const record = await readRecord(join(dir, file.name), versions)
     if (record !== undefined) return [key, record] as const
   }))
   for (const record of loaded) {
@@ -164,9 +178,9 @@ async function loadTableRecords(records: Map<string, unknown>, version: number,
 }
 
 /** Read one record document; a foreign (unreadable or stale) one reads as absent. */
-async function readRecord(path: string, version: number): Promise<unknown> {
+async function readRecord(path: string, versions: readonly number[]): Promise<unknown> {
   try {
-    return parseRecord(await readFile(path, 'utf8'), version)
+    return parseRecord(await readFile(path, 'utf8'), versions)
   } catch {
     return undefined
   }
@@ -214,6 +228,22 @@ export class PerRecordJsonUnit implements KvUnit {
     await this.tracked(rm(join(this.tableDir(table), `${key}.json`), { force: true }))
   }
 
+  /**
+   * Move one record's document aside as `<key>.json.bak.<YYYYMMDDHHmm>`. The
+   * moved file no longer ends in `.json`, so every later read ignores it; the
+   * bytes stay on disk for inspection. A same-minute backup of the same
+   * key overwrites the previous backup (the newer bytes are the ones worth
+   * keeping).
+   */
+  async backupRecord(table: string, key: string): Promise<string> {
+    this.assertOpen()
+    assertSafeKey(this.descriptor.name, key)
+    const path = join(this.tableDir(table), `${key}.json`)
+    const moved = `${path}.bak.${backupStamp(new Date())}`
+    await this.tracked(rename(path, moved))
+    return moved
+  }
+
   /** Durably replace the global singleton. Only valid when declared. */
   async setGlobal(value: unknown): Promise<void> {
     this.assertOpen()
@@ -268,6 +298,12 @@ export class PerRecordJsonUnit implements KvUnit {
   }
 }
 
+/** Local-time `YYYYMMDDHHmm` suffix for backed-up documents. */
+function backupStamp(now: Date): string {
+  const pad = (value: number): string => String(value).padStart(2, '0')
+  return `${String(now.getFullYear())}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(now.getMinutes())}`
+}
+
 /** Reject a record key that would be unsafe as a path segment. */
 function assertSafeKey(unit: string, key: string): void {
   if (!SAFE_KEY_RE.test(key)) {

+ 57 - 2
packages/storage/storage-json/tests/json-backend.spec.ts

@@ -347,10 +347,14 @@ describe('per-record layout', () => {
     await backend.close()
   })
 
-  it('leaves an older-version legacy whole-unit file unconverted', async () => {
+  it('bootstraps from a legacy file only when its stored version is accepted', async () => {
+    // Version 3 is neither current (2) nor declared compat: the legacy file
+    // is left alone and the unit reads empty — migrating unvouched records
+    // would stamp them current and surface as schema failures at the domain
+    // layer instead of a discardable stale cache.
     const root = await freshRoot()
     const legacy = JSON.stringify({
-      unit: { name: 'recs', version: descriptor.version - 1 },
+      unit: { name: 'recs', version: 3 },
       global: null,
       tables: { t: { old: { v: 1 } } },
     })
@@ -360,6 +364,56 @@ describe('per-record layout', () => {
     expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
     await expect(readFile(recordPath(root, 'old'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
     await expect(readFile(join(root, 'recs.json'), 'utf8')).resolves.toBe(legacy)
+    await unit.close()
+    await backend.close()
+
+    // The same file bootstraps once version 3 is declared read-compatible…
+    const root2 = await freshRoot()
+    await writeFile(join(root2, 'recs.json'), legacy, 'utf8')
+    const backend2 = new JsonStorageBackend(root2)
+    const compat = { ...descriptor, version: 4, compatibleVersions: [3] }
+    const unit2 = await backend2.kv.open(compat)
+    expect(await unit2.loadAll()).toEqual({ tables: { t: { old: { v: 1 } } }, global: null })
+    // …and the migrated documents are stamped with the CURRENT version.
+    expect(JSON.parse(await readFile(join(root2, 'recs', 't', 'old.json'), 'utf8')))
+      .toEqual({ version: 4, record: { v: 1 } })
+    await unit2.close()
+    await backend2.close()
+  })
+
+  it('backupRecord moves the document aside; reads see it absent and a write recreates it', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const unit = await backend.kv.open(descriptor)
+    await unit.putRecord('t', 'k', { v: 1 })
+    const moved = await unit.backupRecord!('t', 'k')
+    expect(moved).toMatch(/k\.json\.bak\.\d{12}$/)
+    expect(JSON.parse(await readFile(moved, 'utf8'))).toEqual({ version: 2, record: { v: 1 } })
+    await expect(readFile(recordPath(root, 'k'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
+    // The moved file no longer ends in .json, so it reads as absent…
+    expect(await unit.loadAll()).toEqual({ tables: { t: {} }, global: null })
+    // …and the key is free for a fresh write.
+    await unit.putRecord('t', 'k', { v: 2 })
+    expect(await unit.loadAll()).toEqual({ tables: { t: { k: { v: 2 } } }, global: null })
+    await expect(unit.backupRecord!('t', 'a/b')).rejects.toThrow(/not path-safe/)
+    await unit.close()
+    await expect(unit.backupRecord!('t', 'k')).rejects.toMatchObject({ code: 'closed' })
+    await backend.close()
+  })
+
+  it('reads per-record documents stamped with a declared compat version and stamps writes current', async () => {
+    const root = await freshRoot()
+    const backend = new JsonStorageBackend(root)
+    const compat = { ...descriptor, compatibleVersions: [1] }
+    await mkdir(join(root, 'recs', 't'), { recursive: true })
+    await writeFile(recordPath(root, 'oldrec'), JSON.stringify({ version: 1, record: { v: 'old' } }), 'utf8')
+    await writeFile(recordPath(root, 'ancient'), JSON.stringify({ version: 0, record: { v: 'no' } }), 'utf8')
+    const unit = await backend.kv.open(compat)
+    // Version 1 is declared compat and served; version 0 is not and discards.
+    expect(await unit.loadAll()).toEqual({ tables: { t: { oldrec: { v: 'old' } } }, global: null })
+    await unit.putRecord('t', 'oldrec', { v: 'new' })
+    expect(JSON.parse(await readFile(recordPath(root, 'oldrec'), 'utf8')))
+      .toEqual({ version: 2, record: { v: 'new' } })
     await backend.close()
   })
 
@@ -416,6 +470,7 @@ describe('per-record layout', () => {
     await backend4.close()
 
     const root5 = await freshRoot()
+    // A current-version stamp so the shapeless `tables` is what stops the bootstrap.
     await writeFile(
       join(root5, 'recs.json'),
       JSON.stringify({ unit: { name: 'recs', version: descriptor.version }, tables: 'not an object' }),

+ 26 - 3
packages/storage/storage/src/backend.ts

@@ -56,11 +56,21 @@ export interface KvUnitDescriptor {
    * Medium layout. `single` (the default) keeps the whole unit in one
    * document; `per-record` keeps each record in its own document, so a unit
    * whose records are large or sparse never rewrites the rest on one write,
-   * and a version bump discards stale records instead of rejecting the whole
-   * unit. Backends that only serve one layout accept the other's units as
-   * foreign documents.
+   * and an unaccepted version stamp discards only that record instead of
+   * rejecting the whole unit. Backends that only serve one layout accept the
+   * other's units as foreign documents.
    */
   readonly layout?: 'single' | 'per-record'
+  /**
+   * Older unit versions whose stored records are also readable under the
+   * declaring owner's current record schemas (the owner vouches for that —
+   * typically by declaring the fields old records lack as optional). Reads of
+   * a `per-record` unit accept documents stamped with any listed version, and
+   * the legacy whole-unit bootstrap accepts a legacy file stamped with one;
+   * writes always stamp {@link version}. `single`-layout reads stay
+   * exact-version.
+   */
+  readonly compatibleVersions?: readonly number[]
 }
 
 /**
@@ -99,6 +109,19 @@ export interface KvUnit {
    */
   deleteRecord(table: string, key: string): Promise<void>
 
+  /**
+   * Move one record's stored document out of the unit's readable set,
+   * preserving its bytes for inspection instead of deleting them. Backends
+   * whose medium has no per-record document to move (the `single` layout, a
+   * row store) omit this member, and the caller falls back to its
+   * reject-loud path. Absent after the move: a later {@link loadAll} reads
+   * the key as missing and a later {@link putRecord} recreates it fresh.
+   * @param table - Declared table name.
+   * @param key - Record key.
+   * @returns the medium location the document was moved to (diagnostics).
+   */
+  backupRecord?(table: string, key: string): Promise<string>
+
   /**
    * Write the global singleton durably. Only valid when the descriptor
    * declared `hasGlobal`.

+ 2 - 2
packages/subagent/subagent/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
-README.md: 257a76a4ef84583d3337f697bc8024d72219c4d6
-README.zh.md: 135086b982df38089cdef0acf85535d35354a9d7
+README.md: 6712af94000d82878d8f7cea6194eeb0d2f92421
+README.zh.md: ee0cc8861c174939cc767ba6791c1c19c1d2bc8c

+ 1 - 1
packages/subagent/subagent/README.md

@@ -77,7 +77,7 @@ This section explains how the service is built and where the observable behavior
 |---|---|
 | [`src/index.ts`](src/index.ts) | Service entry: provider registry, start and continuation API, lifecycle events |
 | [`src/continuation.ts`](src/continuation.ts) | Continuable children: identity reservation, Activation residency, adjacent messaging, interrupt, settlement |
-| [`src/internal.ts`](src/internal.ts) | Host-only Queue adapter for browser and Team message protocols |
+| [`src/internal.ts`](src/internal.ts) | Host-only Queue and Steer adapters for browser and Team message protocols |
 | [`src/types.ts`](src/types.ts) | Public request, result, and provider contracts |
 | [`src/descriptor.ts`](src/descriptor.ts) | Versioned `subagent/descriptor` session-event vocabulary |
 | [`src/child-agent.ts`](src/child-agent.ts) | Child composition, delegated policy, depth helpers |

+ 1 - 1
packages/subagent/subagent/README.zh.md

@@ -77,7 +77,7 @@ kind: "package-reference"
 |---|---|
 | [`src/index.ts`](src/index.ts) | 服务入口:提供方注册表、启动与继续 API、生命周期事件 |
 | [`src/continuation.ts`](src/continuation.ts) | 可继续子级:身份预留、Activation 驻留、相邻消息、中断、结算 |
-| [`src/internal.ts`](src/internal.ts) | 供浏览器与 Team 消息协议使用的 host-only Queue 适配器 |
+| [`src/internal.ts`](src/internal.ts) | 供浏览器与 Team 消息协议使用的 host-only Queue 与 Steer 适配器 |
 | [`src/types.ts`](src/types.ts) | 公开的请求、结果与提供方约定 |
 | [`src/descriptor.ts`](src/descriptor.ts) | 版本化的 `subagent/descriptor` 会话事件词汇 |
 | [`src/child-agent.ts`](src/child-agent.ts) | 子级组装、委派策略、深度辅助函数 |

+ 29 - 2
packages/subagent/subagent/src/continuation.ts

@@ -133,7 +133,15 @@ export interface SubagentSendMessageOptions {
 
 /** Inputs shared by model steering and the human Queue adapter. */
 type ChildDeliveryOptions =
-  | { readonly delivery: 'steer'; readonly signal: AbortSignal }
+  | {
+    readonly delivery: 'steer'
+    /**
+     * A provided host source is preserved on the user message; omission attributes
+     * an adjacent-Agent message to the parent.
+     */
+    readonly source?: MessageSource
+    readonly signal: AbortSignal
+  }
   | { readonly delivery: 'queue'; readonly source: MessageSource; readonly signal: AbortSignal }
 
 /**
@@ -623,6 +631,25 @@ export class SubagentContinuationManager {
     return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'queue' })
   }
 
+  /**
+   * Steer one host-authored prompt to a direct continuable child.
+   * @param parent - exact live direct parent authorizing delivery.
+   * @param childId - durable direct-child session id.
+   * @param content - host-authored content to deliver.
+   * @param source - durable host-protocol provenance.
+   * @param signal - caller cancellation before inbox acceptance.
+   * @returns the accepted message's inbox id.
+   */
+  async steerPrompt(
+    parent: Agent,
+    childId: SessionId,
+    content: ContentBlock[],
+    source: MessageSource,
+    signal: AbortSignal,
+  ): Promise<MessageId> {
+    return this.deliverToChild(parent, childId, content, { source, signal, delivery: 'steer' })
+  }
+
   /** Route one parent-originated delivery through residency and cold resume. */
   private async deliverToChild(
     parent: Agent,
@@ -1338,7 +1365,7 @@ export class SubagentContinuationManager {
     // Parent-originated delivery keeps the parent live through ownership, so
     // establish it before the message can enter the child's inbox.
     this.acquireOwnership(parent, activation.childId)
-    const message = options.delivery === 'steer'
+    const message = options.source === undefined
       ? agentMessage(parent, content)
       : createUserMessage({ content, source: options.source })
     const accepted = this.admitWaking(activation, message.id, () => {

+ 10 - 5
packages/subagent/subagent/src/index.ts

@@ -75,7 +75,7 @@ import { listChildren as listSubagentChildren, listDescendants as listSubagentDe
 import type { SubagentDescendantListEntry, SubagentListEntry } from './list-children.ts'
 import { snapshotSubagentDescriptor } from './descriptor.ts'
 import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts'
-import { queueSubagentPrompt } from './internal.ts'
+import { deliverSubagentPrompt, type HostPromptDeliveryMode } from './internal.ts'
 
 export * from './out-of-process.ts'
 export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts'
@@ -254,7 +254,7 @@ export class SubagentRuntime extends TypertRemoteService {
   }
 
   /**
-   * Queue one host-protocol message as a distinct direct-child turn.
+   * Deliver one host-protocol message to a direct continuable child.
    * Symbol-keyed so host adapters can preserve their own provenance without
    * widening the public Service Definition or impersonating an Agent sender.
    * @param parent - exact live direct parent authorizing delivery.
@@ -262,16 +262,20 @@ export class SubagentRuntime extends TypertRemoteService {
    * @param content - host-authored content to deliver.
    * @param source - durable host-protocol provenance.
    * @param signal - caller cancellation before inbox acceptance.
+   * @param delivery - Queue as a distinct turn or Steer at the nearest step.
    * @returns the accepted message's inbox id.
    */
-  private [queueSubagentPrompt](
+  private [deliverSubagentPrompt](
     parent: Agent,
     childId: SessionId,
     content: ContentBlock[],
     source: MessageSource,
     signal: AbortSignal,
+    delivery: HostPromptDeliveryMode,
   ): Promise<MessageId> {
-    return this.requireContinuations().queuePrompt(parent, childId, content, source, signal)
+    return delivery === 'steer'
+      ? this.requireContinuations().steerPrompt(parent, childId, content, source, signal)
+      : this.requireContinuations().queuePrompt(parent, childId, content, source, signal)
   }
 
   /**
@@ -444,12 +448,13 @@ export class SubagentRuntime extends TypertRemoteService {
         content = await admitPromptContent(attachments, request.content)
       }
       return {
-        messageId: await this[queueSubagentPrompt](
+        messageId: await this[deliverSubagentPrompt](
           parent,
           childSessionId,
           content,
           source,
           signal,
+          'queue',
         ),
       }
     } catch (error: unknown) {

+ 39 - 6
packages/subagent/subagent/src/internal.ts

@@ -34,20 +34,24 @@ export function isAdjacentAgentSendMessageTool(definition: ToolDefinition | unde
 }
 
 /**
- * Process-stable symbol-keyed Queue delivery shared by the bundled runtime
+ * Process-stable symbol-keyed host delivery shared by the bundled runtime
  * entry and this unbundled internal subpath.
  * @internal
  */
-export const queueSubagentPrompt = Symbol.for('dsh.subagent.queuePrompt')
+export const deliverSubagentPrompt = Symbol.for('dsh.subagent.deliverPrompt')
 
-/** Runtime face required by the host-only Queue adapter. */
-export interface HostPromptQueue {
-  [queueSubagentPrompt](
+/** Scheduling mode for one host-only direct-child prompt. */
+export type HostPromptDeliveryMode = 'queue' | 'steer'
+
+/** Runtime face required by the host-only prompt adapters. */
+export interface HostPromptDeliverer {
+  [deliverSubagentPrompt](
     parent: Agent,
     childId: SessionId,
     content: ContentBlock[],
     source: MessageSource,
     signal: AbortSignal,
+    delivery: HostPromptDeliveryMode,
   ): Promise<MessageId>
 }
 
@@ -69,11 +73,40 @@ export function queueHostSubagentPrompt(
   source: MessageSource,
   signal: AbortSignal,
 ): Promise<MessageId> {
-  return (runtime as unknown as HostPromptQueue)[queueSubagentPrompt](
+  return (runtime as unknown as HostPromptDeliverer)[deliverSubagentPrompt](
+    parent,
+    childId,
+    content,
+    source,
+    signal,
+    'queue',
+  )
+}
+
+/**
+ * Steer one host-protocol message without exposing another Service operation.
+ * @param runtime - subagent runtime owning continuation residency.
+ * @param parent - exact live direct parent authorizing delivery.
+ * @param childId - durable direct-child session id.
+ * @param content - host-authored content to deliver.
+ * @param source - durable host-protocol provenance.
+ * @param signal - caller cancellation before inbox acceptance.
+ * @returns the accepted message's inbox id.
+ */
+export function steerHostSubagentPrompt(
+  runtime: SubagentRuntime,
+  parent: Agent,
+  childId: SessionId,
+  content: ContentBlock[],
+  source: MessageSource,
+  signal: AbortSignal,
+): Promise<MessageId> {
+  return (runtime as unknown as HostPromptDeliverer)[deliverSubagentPrompt](
     parent,
     childId,
     content,
     source,
     signal,
+    'steer',
   )
 }

+ 3 - 2
packages/subagent/subagent/tests/control.spec.ts

@@ -13,7 +13,7 @@ import SubagentRuntime, {
   type SubagentListEntry,
   type SubagentPromptRequestId,
 } from '@deepseek-ai/dsh-subagent'
-import { queueSubagentPrompt, type HostPromptQueue } from '@deepseek-ai/dsh-subagent/internal'
+import { deliverSubagentPrompt, type HostPromptDeliverer } from '@deepseek-ai/dsh-subagent/internal'
 
 const PARENT = SessionId('parent')
 const CHILD = SessionId('child')
@@ -36,7 +36,7 @@ async function bench(live?: Record<string, { status: 'running' | 'idle' }>) {
 
 /** Spy on the private human-Queue adapter without widening the public service. */
 function promptDelivery(subagents: SubagentRuntime) {
-  return vi.spyOn(subagents as unknown as HostPromptQueue, queueSubagentPrompt)
+  return vi.spyOn(subagents as unknown as HostPromptDeliverer, deliverSubagentPrompt)
 }
 
 function childRow(id: SessionId, activity: 'running' | 'inactive'): SubagentListEntry {
@@ -254,6 +254,7 @@ describe('subagent prompt Remote', () => {
       [{ type: 'text', text: 'continue' }],
       { kind: 'user', rpcId: REQUEST_ID, clientTimeZone: 'Asia/Shanghai' },
       signal,
+      'queue',
     )
   })
 

+ 1 - 1
scripts/gen-tool-catalog.ts

@@ -543,7 +543,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
     },
     scope: ctx => catalogChildScopes.get(ctx) as Agent,
     note:
-      'All ten tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names.',
+      'All nine tools are scoped to implicit Team Leads and durable teammates. The shipped dsh-base bundle keeps the package disabled; the documented Agent Teams profile patch enables it while disabling the legacy continuable-child control names.',
   },
   {
     pkg: '@deepseek-ai/dsh-tool-todo',

Некоторые файлы не были показаны из-за большого количества измененных файлов