@zhushanwen/pi-session-reader 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +100 -0
  2. package/package.json +7 -4
  3. package/src/__tests__/cross-package-subagent-core.test.ts +36 -44
  4. package/src/__tests__/entry-anchor.test.ts +226 -0
  5. package/src/__tests__/env.test.ts +54 -54
  6. package/src/__tests__/execution-tree.test.ts +6 -0
  7. package/src/__tests__/family-zcode.test.ts +255 -0
  8. package/src/__tests__/family.test.ts +1 -1
  9. package/src/__tests__/find.test.ts +24 -1
  10. package/src/__tests__/index.test.ts +1 -1
  11. package/src/__tests__/parser.test.ts +1 -2
  12. package/src/__tests__/real-data.ts +22 -9
  13. package/src/__tests__/render.test.ts +1 -2
  14. package/src/__tests__/result.test.ts +2 -2
  15. package/src/__tests__/roots.test.ts +1 -1
  16. package/src/__tests__/subagents.test.ts +1 -4
  17. package/src/__tests__/tool-handler.test.ts +76 -85
  18. package/src/__tests__/toolcall.test.ts +1 -1
  19. package/src/__tests__/tree.test.ts +1 -1
  20. package/src/__tests__/turns.test.ts +1 -2
  21. package/src/__tests__/zcode-manifest.test.ts +361 -0
  22. package/src/__tests__/zcode-routing.test.ts +751 -0
  23. package/src/__tests__/zcode-whitelist.test.ts +119 -0
  24. package/src/core/execution-tree.ts +3 -1
  25. package/src/core/family.ts +80 -1
  26. package/src/core/render.ts +2 -3
  27. package/src/core/toolcall.ts +3 -4
  28. package/src/core/tree.ts +1 -1
  29. package/src/core/turns.ts +1 -1
  30. package/src/core/units.ts +14 -0
  31. package/src/discovery/entry-anchor.ts +108 -0
  32. package/src/discovery/env.ts +41 -41
  33. package/src/discovery/find.ts +1 -1
  34. package/src/discovery/roots.ts +2 -2
  35. package/src/discovery/session-header.ts +30 -69
  36. package/src/discovery/subagents.ts +9 -2
  37. package/src/discovery/whitelist.ts +87 -0
  38. package/src/discovery/workflows.ts +1 -1
  39. package/src/discovery/zcode-manifest.ts +277 -0
  40. package/src/doctor.ts +5 -70
  41. package/src/index.ts +9 -2
  42. package/src/result-action.ts +15 -6
  43. package/src/search-across.ts +2 -5
  44. package/src/tool-format.ts +455 -0
  45. package/src/tool-handler.ts +321 -325
  46. package/src/zcode-anchor-classify.ts +60 -0
  47. package/vitest.config.ts +2 -4
  48. package/src/core/parser.ts +0 -167
@@ -1,44 +1,28 @@
1
- import { open, type FileHandle } from 'node:fs/promises'
2
- import { closeSync, openSync, readSync } from 'node:fs'
1
+ import {
2
+ readFirstJsonlLine,
3
+ readFirstJsonlLineSync,
4
+ type SessionHeader,
5
+ } from '@zhushanwen/session-core'
3
6
 
4
7
  /**
5
- * [ext-simplify-18 D5] session 文件首行 header 读取的包内单源(原 subagents.ts / find.ts
6
- * 两份 async 副本 + tool-handler.ts 一份 sync 副本合一)。不进 shared——跨包第二消费方
7
- * 不存在(runtime 侧首行读取是另一形态 + 分层禁反向 import,设计负面清单同口径)。
8
+ * [session-reader-shared-core Phase 1] session 文件首行 header 读取的包内单源(原
9
+ * subagents.ts / find.ts 两份 async 副本 + tool-handler.ts 一份 sync 副本合一后的单源)。
10
+ *
11
+ * 字节原语(buffer 管理/续读/CRLF/空文件)已收敛至基座 `readFirstJsonlLine`(sync/async
12
+ * 双形态)——本模块只剩两件 reader 私有事:
13
+ * ① **header 谓词薄包装**(G4 红线,设计 §5 Phase 0):reader 仅要求 `type==='session'`
14
+ * 且 `id` 为 string,cwd/parentSession 可缺。runtime 侧「id+cwd 非空」严谓词是不同的
15
+ * 行为契约(缺 cwd 的 header 在 reader 候选集必须可见),谓词语义绝不统一进基座;
16
+ * ② **错误分流薄包装**:旧副本契约是「任何失败都视为无 header → undefined/null」,基座
17
+ * 原语 IO 错误上抛(错误分类留给有消费方的调用侧),reader 无错误分类消费方,catch-all
18
+ * 收敛回旧契约。
8
19
  */
9
20
 
10
- /** session header(首行 type==='session' entry 的消费子集)。 */
11
- export interface SessionHeader {
12
- id: string
13
- cwd?: string
14
- parentSession?: string
15
- }
21
+ export type { SessionHeader }
16
22
 
17
23
  /**
18
- * 首行读取 buffer 上限。header 超 8KB 的极端情况会截断致 parse 失败(parseSessionHeader
19
- * 返回 null)——session header(id/cwd/parentSession)实测 < 300 字节,8KB 约 27 倍余量。
24
+ * 解析 header 首行为 SessionHeader(谓词薄包装)。非 session 行/缺 id/坏 JSON → null。
20
25
  */
21
- export const HEADER_READ_BYTES = 8192
22
-
23
- /** 读文件首行(header)。定长 8KB 一次 read;空文件/读失败返回 undefined。 */
24
- export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
25
- let fh: FileHandle | undefined
26
- try {
27
- fh = await open(path, 'r')
28
- const buf = Buffer.alloc(HEADER_READ_BYTES)
29
- const { bytesRead } = await fh.read(buf, 0, HEADER_READ_BYTES, 0)
30
- if (bytesRead === 0) return undefined
31
- const text = buf.subarray(0, bytesRead).toString('utf8')
32
- const nl = text.indexOf('\n')
33
- return nl === -1 ? text : text.slice(0, nl)
34
- } catch {
35
- return undefined
36
- } finally {
37
- await fh?.close().catch(() => {})
38
- }
39
- }
40
-
41
- /** 解析 header 首行为 SessionHeader。非 session 行/缺 id → null。 */
42
26
  export function parseSessionHeader(line: string | undefined): SessionHeader | null {
43
27
  if (!line) return null
44
28
  let raw: unknown
@@ -56,48 +40,25 @@ export function parseSessionHeader(line: string | undefined): SessionHeader | nu
56
40
  return h
57
41
  }
58
42
 
59
- /** readSessionHeaderIdSync 读首行的 buffer 上限。session header 实测 < 300 字节,4KB 足够。 */
60
- const SYNC_HEADER_READ_BYTES = 4096
43
+ /** 读文件首行(header)薄包装:基座原语 + IO 失败收敛 undefined(空文件基座已返 undefined)。 */
44
+ export async function readSessionHeaderFirstLine(path: string): Promise<string | undefined> {
45
+ try {
46
+ return await readFirstJsonlLine(path)
47
+ } catch {
48
+ return undefined
49
+ }
50
+ }
61
51
 
62
52
  /**
63
53
  * 同步读 session 文件首行 header,返回 type==='session' 的 id。
64
- *
65
- * 任何异常(文件不存在/空文件/解析失败/type 不符)返回 undefined。与 async 版
66
- * readSessionHeaderFirstLine/parseSessionHeader 同构(定长 buffer 读首行 + JSON.parse +
67
- * type 校验),但用同步 fs API(resolveSessionId 内仅调用 1 次,同步开销可接受),
68
- * 窗口取 4KB(仅取 id)——sync 形态与窗口差异是刻意的调用上下文适配,独立常量、
69
- * 不与 async 版 8KB 强行统一。
54
+ * 任何失败(文件不存在/空文件/解析失败/type 不符)→ undefined(旧 sync 副本契约不变)。
70
55
  */
71
56
  export function readSessionHeaderIdSync(filePath: string): string | undefined {
72
- let fd: number | undefined
57
+ let line: string | undefined
73
58
  try {
74
- fd = openSync(filePath, 'r')
75
- const buf = Buffer.alloc(SYNC_HEADER_READ_BYTES)
76
- const bytesRead = readSync(fd, buf, 0, SYNC_HEADER_READ_BYTES, 0)
77
- if (bytesRead === 0) return undefined
78
- const text = buf.subarray(0, bytesRead).toString('utf8')
79
- const nl = text.indexOf('\n')
80
- const line = nl === -1 ? text : text.slice(0, nl)
81
- let raw: unknown
82
- try {
83
- raw = JSON.parse(line)
84
- } catch {
85
- return undefined
86
- }
87
- if (typeof raw !== 'object' || raw === null) return undefined
88
- const o = raw as Record<string, unknown>
89
- if (o.type !== 'session' || typeof o.id !== 'string') return undefined
90
- return o.id
59
+ line = readFirstJsonlLineSync(filePath)
91
60
  } catch {
92
61
  return undefined
93
- } finally {
94
- if (fd !== undefined) {
95
- try {
96
- closeSync(fd)
97
- } catch {
98
- // closeSync 失败:fd 可能已无效,header 数据已读取,关闭失败不影响结果(best-effort)
99
- void fd
100
- }
101
- }
102
62
  }
63
+ return parseSessionHeader(line)?.id
103
64
  }
@@ -1,9 +1,10 @@
1
1
  import { readFile, readdir, open, stat } from 'node:fs/promises'
2
2
  import type { FileHandle } from 'node:fs/promises'
3
3
  import { join, basename } from 'node:path'
4
- import type { Entry } from '../core/parser.js'
4
+ import type { Entry } from '@zhushanwen/session-core'
5
5
  import type { Family, SessionRef } from '../core/family.js'
6
6
  import { buildFamilyIndex, resolveFamily } from '../core/family.js'
7
+ import { listZcodeManifests } from './zcode-manifest.js'
7
8
  import { resolveSessionRoots, type SessionFileMeta } from './roots.js'
8
9
  import { parseSessionHeader, readSessionHeaderFirstLine } from './session-header.js'
9
10
  import { resolveWorkflows } from './workflows.js'
@@ -54,7 +55,13 @@ export async function buildFamilyFromFs(sessionId: string, agentDir: string): Pr
54
55
  if (!scan.sessionIdToPath.has(sessionId)) {
55
56
  throw new Error(await formatSessionNotFound(sessionId, agentDir))
56
57
  }
57
- const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats)
58
+ // U9 family 接线(design session-reader-shared-core §5 Phase 3 单元三):zcode 节点
59
+ // 并入 subagentsByRoot(D5-1 判据在 core 层 buildZcodeSubagentsByRoot)——**纯追加**,
60
+ // pi 现路径(listRecordManifests/isRecordManifest/appendOrphanIdentities)零改动;
61
+ // zcode manifest 无 sessionFile 键本就被 isRecordManifest 整条丢弃(F21),与 pi 扫描
62
+ // 结果集天然不相交,两条来源互不污染。枚举失败容错内置(根不存在 → 空数组不报错)。
63
+ const zcodeNodes = await listZcodeManifests(agentDir)
64
+ const index = buildFamilyIndex(scan.headers, scan.identities, scan.fileStats, zcodeNodes)
58
65
  const family = resolveFamily(sessionId, index)
59
66
 
60
67
  // ---- 6. workflows(enrichRefs 回填已删除,ext-simplify-04 U4/E2:fileName/cwd 占位
@@ -0,0 +1,87 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import { dirname } from 'node:path'
3
+
4
+ import { zcodeImportDbAllowlist } from '@zhushanwen/zcode-session-source'
5
+
6
+ /**
7
+ * zcode 会话库白名单闸(design session-reader-shared-core §3.1 第③步 / §3.3 D5 /
8
+ * §3.4 检查顺序 / 探针 P-whitelist)。
9
+ *
10
+ * dbPath 来自 session 数据(manifest engineHandle.sessionRef / 主 session custom
11
+ * entry),是**不可信输入**(会话文件可被构造)——开库前必须过本闸,白名单是必需
12
+ * 闸而非可选优化。集合与 runtime 导入侧 `zcodeImportDbAllowlist(dataDir)` **同源
13
+ * 同形**:隔离库(现役)+ 宿主库(存量兼容)两项,路径段单源 = 该函数内部引用的
14
+ * `@zhushanwen/subagent-engine-sdk` zcode-db-paths 常量(跨侧契约根,禁手拼字面量
15
+ * 段——SDK 头注「两侧必须同源推导,禁止各自拼字符串漂移」)。
16
+ *
17
+ * **检查顺序铁律(§3.4 三段递进,实现与测试双锚定)**:存在性先于路径——
18
+ * `existsSync` 为 false 报 `zcode_db_unreadable`(库文件不存在)而非
19
+ * `zcode_db_path_forbidden`,避免把「只是还没落库」误报成安全事件(forbidden 是
20
+ * 安全面错误,语义更重、更惊扰 agent)。存在才 realpath 后与派生允许集合精确比对。
21
+ * 开库(第 3 段)由调用方在本闸通过后进行。
22
+ *
23
+ * **TOCTOU 残留(设计 §3.4 显式登记,不在本梯队硬化)**:realpath 之后仍按原
24
+ * dbPath 开库,两步间存在理论竞态窗口;利用它需攻击者具备同用户写权限,而同用户
25
+ * 权限下本就可直接读该库——实测方向安全,fd 复用/O_NOFOLLOW 属纵深防御非实际威胁面。
26
+ *
27
+ * **realpath 对齐**:dbPath 与集合项都经 realpath 归一后再比对(realpath 失败——
28
+ * 竞态删除/权限——按原串参与)。taiji 生产布局(`~/.taiji` 下无 symlink)两者等价;
29
+ * macOS 临时目录(`/var` → `/private/var`)等 symlink 环境下不对齐会把合法库误判
30
+ * 集合外,P-whitelist 单测按 tmp dataDir 布局锚定此语义。
31
+ */
32
+
33
+ /** symlink 归一(失败按原串——不存在的路径进不了比对成功分支,由调用方顺序保证)。 */
34
+ function realOf(p: string): string {
35
+ try {
36
+ return realpathSync(p)
37
+ } catch {
38
+ return p
39
+ }
40
+ }
41
+
42
+ /**
43
+ * dbPath 的派生允许集合(realpath 对齐后):`zcodeIsolatedDbPath(dirname(agentDir))`
44
+ * + `hostZcodeDbPath()`。F11:taiji 下 agentDir = `<dataDir>/agent` →
45
+ * dirname(agentDir) = dataDir,与引擎侧/导入侧的隔离库推导同一 dataDir。纯 pi 宿主
46
+ * (agentDir 退化为 `~/.pi/...`)下隔离库项恒不可达且不存在 taiji 派生的 zcode 锚
47
+ * (D5「纯 pi 宿主边界」),无害,不需特例分支。
48
+ */
49
+ export function zcodeDbAllowlistFor(agentDir: string): readonly string[] {
50
+ return zcodeImportDbAllowlist(dirname(agentDir)).map(realOf)
51
+ }
52
+
53
+ /** `zcode_db_unreadable` 的 👉 恢复指引(§3.4 表逐行;隔离库路径运行时动态推导)。 */
54
+ export function formatZcodeDbUnreadable(dataDir: string, detail: string): string {
55
+ const isolated = zcodeImportDbAllowlist(dataDir)[0]
56
+ return (
57
+ `[zcode_db_unreadable] zcode 会话库不可读(恢复阶梯 L1-L3 均未成功)。${detail}\n` +
58
+ `👉 使原目录可写,或把库连 -wal/-shm 一起拷回白名单内原路径(隔离库 = ${isolated})后重试。\n` +
59
+ `⚠️ 告诫:缺 -wal 的库未 checkpoint 的已提交数据可能已丢——这是写者未干净退出造成的既成事实,非恢复引入;完整性以连 -wal 一起拷贝为准。`
60
+ )
61
+ }
62
+
63
+ /** `zcode_db_path_forbidden` 的 👉 恢复指引(§3.4 表逐行)。 */
64
+ export function formatZcodeDbPathForbidden(dbPath: string): string {
65
+ return (
66
+ `[zcode_db_path_forbidden] 该会话引用的 zcode 库路径不在允许范围:${dbPath}\n` +
67
+ `👉 在 taiji 内打开原会话读取,或用会话导入对话框导入后读取。`
68
+ )
69
+ }
70
+
71
+ /**
72
+ * 白名单闸(第 1 段存在性 → 第 2 段路径;通过才允许开库)。
73
+ *
74
+ * @throws Error(消费侧映射为 zcode_db_unreadable / zcode_db_path_forbidden,
75
+ * 消息即 §3.4 的 👉 指引文案——tool-handler 直接 throw 给 pi 错误面)
76
+ */
77
+ export function assertZcodeDbPathAllowed(dbPath: string, agentDir: string): void {
78
+ // 第 1 段 存在性(先于路径——「还没落库」不是安全事件)
79
+ if (!existsSync(dbPath)) {
80
+ throw new Error(formatZcodeDbUnreadable(dirname(agentDir), `(库文件不存在:${dbPath})`))
81
+ }
82
+ // 第 2 段 路径(存在才 realpath 比对;TOCTOU 残留见文件头注)
83
+ const allowed = zcodeDbAllowlistFor(agentDir)
84
+ if (!allowed.includes(realOf(dbPath))) {
85
+ throw new Error(formatZcodeDbPathForbidden(dbPath))
86
+ }
87
+ }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { basename } from 'node:path'
3
- import { parseSessionContent } from '../core/parser.js'
3
+ import { parseSessionContent } from '@zhushanwen/session-core'
4
4
  import type { SessionRef, WorkflowRef } from '../core/family.js'
5
5
  import { extractSessionIdFromFilename } from './subagents.js'
6
6
 
@@ -0,0 +1,277 @@
1
+ import { readFile, readdir } from 'node:fs/promises'
2
+ import { statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ import type { ZcodeAnchor } from './entry-anchor.js'
6
+ import type { ZcodeFamilyNode } from '../core/family.js'
7
+
8
+ /**
9
+ * zcode manifest 直读与枚举(design session-reader-shared-core §3.1 第①步 / D5 / D5-1)。
10
+ *
11
+ * 背景(F21):pi 路径的 `isRecordManifest` 三必填含 `sessionFile`,而 zcode manifest
12
+ * 落盘时该键被 JSON.stringify 丢弃(subagent-core record-store-rebuild.ts:665/725 投影
13
+ * 照写 `sessionFile: undefined`)→ `listRecordManifests` 对 zcode manifest 恒返回 []。
14
+ * 本单元另开一条**不经过该守卫**的独立路径:
15
+ * - **定点直读**(`readZcodeManifest`):manifest 文件名由 sa-id 完全确定(写侧
16
+ * `path.join(recordsDir, \`${record.id}.json\`)`,manifest-store.ts:141/153),按确定性
17
+ * 文件名 `<sa-id>.json` 在 `<agentDir>/subagents/<cwd>/records/` 下**窄 walk(命中即止,
18
+ * 不读不解析任何其他 manifest)**→ JSON.parse → `engine === 'zcode'` ∧
19
+ * `engineHandle.sessionRef` 双键(sessionId/dbPath 均非空 string)→ 取锚。
20
+ * - **全量枚举**(`listZcodeManifests`,family 用):扫同布局下全部 .json,逐条
21
+ * parse + 谓词过滤,只返回 zcode 形态(pi manifest / 坏 JSON 跳过)。
22
+ *
23
+ * **对 pi 行为零影响(本方案的定义性质)**:`isRecordManifest` / `RecordManifest` /
24
+ * `listRecordManifests`(subagents.ts)一行不改——pi manifest 仍走原扫描路径,本单元
25
+ * 的谓词**不走** isRecordManifest。判别规则与 runtime `transcriptAnchorOf` 同构
26
+ * (F8:`engine === 'zcode'` 第一判据 + sessionRef 双键齐)。用 `engine` 判别而非
27
+ * 「engineHandle 存在性」的理由见 D5 被否项 ②(pi record 提升后 engineHandle 仍持久化,
28
+ * 存在性判别会误路由)。
29
+ */
30
+
31
+ /**
32
+ * zcode manifest 的读取记录(枚举产出,family 消费)。
33
+ *
34
+ * 富字段(slug/task/model/status/agentName)从 manifest 顶层透传(旧 manifest 缺省
35
+ * → undefined);slug 的 `m.slug ?? m.agentName ?? ''` 兜底在枚举组装时完成(与 pi
36
+ * 孤儿链 appendOrphanIdentities 同款,core 层直接透传)。
37
+ */
38
+ export interface ZcodeManifestRecord extends ZcodeFamilyNode {
39
+ /** 写侧盖章的引擎 id(枚举谓词保证 === 'zcode') */
40
+ engine: string
41
+ }
42
+
43
+ /**
44
+ * anchor-missing 的缺失键归因(§3.4:缺失键名进结构化日志——engineHandle 整体缺席 /
45
+ * 缺 sessionRef / 缺 sessionId / 缺 dbPath 归因不同:整体缺席多为旧版本产物,缺
46
+ * sessionId 多为引擎未回调 onHandleReady,缺 dbPath 多为写入面截断)。entry 兜底侧
47
+ * 的同构分类(tool-handler classifyIncompleteEntryAnchor)复用本类型单源。
48
+ */
49
+ export type ZcodeAnchorMissingReason =
50
+ | 'missing-engineHandle'
51
+ | 'missing-sessionRef'
52
+ | 'missing-sessionId'
53
+ | 'missing-dbPath'
54
+
55
+ /**
56
+ * 定点直读的结果三态(discriminated result——错误码映射归路由单元 U9,本单元只给
57
+ * 归因信号,§3.4 检查顺序第 0 段「定位链」):
58
+ * - `zcode`:engine==='zcode' ∧ sessionRef 双键齐 → 携带锚,继续 zcode 链(白名单闸 → 开库);
59
+ * - `not-zcode`:文件不存在 / 坏 JSON / `engine` 缺省或 `'pi'`(或非 string id)——
60
+ * 该 sa-id 不是「zcode 单子」,交回调用方走今天的 pi 现路径(sessionFile + JSONL);
61
+ * - `anchor-missing`:engine==='zcode' 但 `engineHandle` 整体缺席或 `sessionRef` 缺键
62
+ * ——路由映射 `zcode_anchor_missing`(§3.4;缺失键名以 `reason` 逐键归因,由结构化
63
+ * 日志承载)。
64
+ */
65
+ export type ZcodeManifestLookup =
66
+ | { kind: 'zcode'; anchor: ZcodeAnchor }
67
+ | { kind: 'not-zcode' }
68
+ | { kind: 'anchor-missing'; reason: ZcodeAnchorMissingReason }
69
+
70
+ /** manifest 顶层 JSON 的读取视图(守卫输入) */
71
+ type ManifestJson = Record<string, unknown>
72
+
73
+ function asObject(v: unknown): ManifestJson | undefined {
74
+ if (typeof v !== 'object' || v === null) return undefined
75
+ return v as ManifestJson
76
+ }
77
+
78
+ /** 第一判据:`engine === 'zcode'`(缺省 / 'pi' / 其他引擎均非 zcode 形态) */
79
+ function isZcodeEngine(m: ManifestJson): boolean {
80
+ return m.engine === 'zcode'
81
+ }
82
+
83
+ /**
84
+ * 第二判据:`engineHandle.sessionRef` 双键(sessionId/dbPath)均为非空 string → 锚。
85
+ * (F8 判据的锚字段部分;pi record 的 sessionRef 无 dbPath → 天然不齐。)
86
+ */
87
+ function zcodeAnchorOf(m: ManifestJson): ZcodeAnchor | undefined {
88
+ const handle = asObject(m.engineHandle)
89
+ if (!handle) return undefined
90
+ const ref = asObject(handle.sessionRef)
91
+ if (!ref) return undefined
92
+ const { sessionId, dbPath } = ref
93
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined
94
+ if (typeof dbPath !== 'string' || dbPath === '') return undefined
95
+ return { sessionId, dbPath }
96
+ }
97
+
98
+ /**
99
+ * anchor-missing 的逐键归因(判定顺序与 zcodeAnchorOf 的键序一致——同一键序下
100
+ * 「首个不满足的键」即归因,两函数对同一 manifest 的 zcode/anchor-missing 判定互洽)。
101
+ */
102
+ function zcodeAnchorMissingReason(m: ManifestJson): ZcodeAnchorMissingReason {
103
+ const handle = asObject(m.engineHandle)
104
+ if (!handle) return 'missing-engineHandle'
105
+ const ref = asObject(handle.sessionRef)
106
+ if (!ref) return 'missing-sessionRef'
107
+ if (typeof ref.sessionId !== 'string' || ref.sessionId === '') return 'missing-sessionId'
108
+ return 'missing-dbPath'
109
+ }
110
+
111
+ /** manifest 顶层富字段(旧 manifest 缺省 → undefined,不伪造) */
112
+ interface ManifestRichFields {
113
+ agentName?: string
114
+ task?: string
115
+ model?: string
116
+ status?: string
117
+ }
118
+
119
+ function richFieldsOf(m: ManifestJson): ManifestRichFields {
120
+ const out: ManifestRichFields = {}
121
+ if (typeof m.agentName === 'string') out.agentName = m.agentName
122
+ if (typeof m.task === 'string') out.task = m.task
123
+ if (typeof m.model === 'string') out.model = m.model
124
+ if (typeof m.status === 'string') out.status = m.status
125
+ return out
126
+ }
127
+
128
+ /** stat 存在性(任意路径无安全面——D5-1 只查「文件在不在」,不开库) */
129
+ function fileExists(p: string): boolean {
130
+ try {
131
+ statSync(p)
132
+ return true
133
+ } catch {
134
+ return false
135
+ }
136
+ }
137
+
138
+ /**
139
+ * manifest JSON → zcode 记录(枚举产出形状)。非 zcode 形态 → undefined。
140
+ * slug 兜底 m.slug ?? m.agentName ?? ''(与 pi 孤儿链同款)。
141
+ */
142
+ function toZcodeManifestRecord(m: ManifestJson): ZcodeManifestRecord | undefined {
143
+ if (!isZcodeEngine(m)) return undefined
144
+ const anchor = zcodeAnchorOf(m)
145
+ if (!anchor) return undefined
146
+ if (typeof m.id !== 'string' || m.id === '') return undefined
147
+ if (typeof m.rootSessionId !== 'string' || m.rootSessionId === '') return undefined
148
+ const rich = richFieldsOf(m)
149
+ const slug = typeof m.slug === 'string' ? m.slug : (rich.agentName ?? '')
150
+ return {
151
+ id: m.id,
152
+ rootSessionId: m.rootSessionId,
153
+ slug,
154
+ agentName: rich.agentName,
155
+ task: rich.task,
156
+ model: rich.model,
157
+ status: rich.status,
158
+ dbPath: anchor.dbPath,
159
+ dbFileExists: fileExists(anchor.dbPath),
160
+ engine: 'zcode',
161
+ }
162
+ }
163
+
164
+ /** `<agentDir>/subagents` 布局常量(与写侧 path-encoding/manifest-store 布局对齐) */
165
+ const SUBAGENTS_DIR = 'subagents'
166
+ const RECORDS_DIR = 'records'
167
+
168
+ /**
169
+ * 逐条解析一个 manifest 文件为三态结果(直读用)。
170
+ * 坏 JSON / 非 string id → not-zcode(残缺 manifest 交回 pi 现路径——与今天的
171
+ * tryReadManifest 丢弃行为等价,只是换了显式信号)。
172
+ */
173
+ function parseManifestLookup(raw: string): ZcodeManifestLookup {
174
+ let v: unknown
175
+ try {
176
+ v = JSON.parse(raw)
177
+ } catch {
178
+ return { kind: 'not-zcode' } // 坏 JSON
179
+ }
180
+ const m = asObject(v)
181
+ if (!m || !isZcodeEngine(m)) return { kind: 'not-zcode' } // engine 缺省 / 'pi' / 其他
182
+ const anchor = zcodeAnchorOf(m)
183
+ if (!anchor) return { kind: 'anchor-missing', reason: zcodeAnchorMissingReason(m) } // 逐键归因
184
+ return { kind: 'zcode', anchor }
185
+ }
186
+
187
+ /**
188
+ * 定点直读(§3.1 第①步主路径):按确定性文件名 `<sa-id>.json` 在
189
+ * `<agentDir>/subagents/<cwd>/records/` 下窄 walk,**命中即止——不读不解析任何其他
190
+ * manifest**(对比 listRecordManifests 的全量 walk + 逐条 parse)。
191
+ *
192
+ * 无会话边界(§3.1):manifest 文件名由 sa-id 确定性推出,任何会话中都可直读。
193
+ * 根目录不可读 / 无候选目录 → not-zcode(调用方交回 pi 现路径,与今天行为一致)。
194
+ */
195
+ export async function readZcodeManifest(
196
+ agentDir: string,
197
+ saId: string,
198
+ ): Promise<ZcodeManifestLookup> {
199
+ const root = join(agentDir, SUBAGENTS_DIR)
200
+ let slugs: DirentLike[]
201
+ try {
202
+ slugs = await readdir(root, { withFileTypes: true })
203
+ } catch {
204
+ return { kind: 'not-zcode' } // subagents 根不存在/不可读 → 无 manifest
205
+ }
206
+ for (const e of slugs) {
207
+ if (!e.isDirectory()) continue
208
+ const file = join(root, e.name, RECORDS_DIR, `${saId}.json`)
209
+ let raw: string
210
+ try {
211
+ // 命中即止:只读这一个文件(文件名由 sa-id 确定),坏内容同样终止(见 parseManifestLookup)
212
+ raw = await readFile(file, 'utf8')
213
+ } catch {
214
+ continue // 该 cwd 目录下无此 manifest → 下一候选目录
215
+ }
216
+ return parseManifestLookup(raw)
217
+ }
218
+ return { kind: 'not-zcode' }
219
+ }
220
+
221
+ /** readdir(withFileTypes) 条目的最小结构视图(便于类型标注) */
222
+ interface DirentLike {
223
+ isDirectory(): boolean
224
+ isFile(): boolean
225
+ name: string
226
+ }
227
+
228
+ /**
229
+ * zcode manifest 全量枚举(family 用,D5-1 的节点列表来源):扫
230
+ * `<agentDir>/subagents/<cwd>/records/` 全部 .json,逐条 JSON.parse + 谓词过滤
231
+ * (engine==='zcode' ∧ sessionRef 双键齐 ∧ id/rootSessionId 非空 string),
232
+ * 只返回 zcode 形态——**pi manifest 与坏 JSON 直接跳过,不走 isRecordManifest
233
+ * (那条链零改动)**。每个返回记录已携带 dbFileExists(statSync 预计算)。
234
+ *
235
+ * 单条 manifest 不可达(坏 JSON / 残缺 / stat 失败)不中断枚举、不抛异常——
236
+ * family 是列表视图,单节点不可达不炸整屏(D5-1 GC 语义)。
237
+ */
238
+ export async function listZcodeManifests(agentDir: string): Promise<ZcodeManifestRecord[]> {
239
+ const root = join(agentDir, SUBAGENTS_DIR)
240
+ const out: ZcodeManifestRecord[] = []
241
+ let slugs: DirentLike[]
242
+ try {
243
+ slugs = await readdir(root, { withFileTypes: true })
244
+ } catch {
245
+ return out // 根不存在/不可读 → 空(无 zcode 节点,不报错)
246
+ }
247
+ for (const slug of slugs) {
248
+ if (!slug.isDirectory()) continue
249
+ const recordsDir = join(root, slug.name, RECORDS_DIR)
250
+ let files: DirentLike[]
251
+ try {
252
+ files = await readdir(recordsDir, { withFileTypes: true })
253
+ } catch {
254
+ continue // 该 cwd 目录无 records/ → 跳过
255
+ }
256
+ for (const f of files) {
257
+ if (!f.isFile() || !f.name.endsWith('.json')) continue
258
+ let raw: string
259
+ try {
260
+ raw = await readFile(join(recordsDir, f.name), 'utf8')
261
+ } catch {
262
+ continue // 单文件读失败 → 跳过
263
+ }
264
+ let v: unknown
265
+ try {
266
+ v = JSON.parse(raw)
267
+ } catch {
268
+ continue // 坏 JSON → 跳过
269
+ }
270
+ const m = asObject(v)
271
+ if (!m) continue
272
+ const rec = toZcodeManifestRecord(m)
273
+ if (rec) out.push(rec)
274
+ }
275
+ }
276
+ return out
277
+ }
package/src/doctor.ts CHANGED
@@ -2,13 +2,10 @@
2
2
  * doctor action(u8:design 2026-09-10 §6.2/§6.3/§6.4/§7B 要点 2/4/5/8 + §6.11 U14b 段)。
3
3
  *
4
4
  * 从 tool-handler.ts 机械提取(max-lines 拆分轮,零行为变更):环境判定 + 根表渲染 +
5
- * 旧布局残留 glob 探测。SessionReadSignals(u3 信号包超集)在域内定义,tool-handler
5
+ * SessionReadSignals(u3 信号包超集)在域内定义,tool-handler
6
6
  * re-export 供 index.ts 生产消费。原 u8 的扫描缓存机已删除(ext-simplify-04 U3):
7
7
  * 每次调用实扫,无缓存命中语义;TTL/mtime 判定随 metadata 缓存留在 tool-handler 侧。
8
8
  */
9
- import { existsSync } from 'node:fs'
10
- import { readdir } from 'node:fs/promises'
11
- import { join, dirname } from 'node:path'
12
9
  import { detectEnvironment, type DetectedEnvironment } from './discovery/env.js'
13
10
  import { resolveSessionRoots, type SessionRoot, type SessionRootSignals } from './discovery/roots.js'
14
11
  import type { SessionReadParams, ToolResult } from './tool-handler.js'
@@ -29,52 +26,8 @@ export interface SessionReadSignals extends SessionRootSignals {
29
26
  /** doctor 根表来源标签列宽(最长 `[subagent]` 10 字符 + 1 对齐间距,§5.1 表格形态)。 */
30
27
  const DOCTOR_ROOT_LABEL_PAD = 11
31
28
 
32
- /** 旧布局残留条目(§6.11 U14b doctor 侧独立 glob 探测)。 */
33
- /** 旧布局残留探测单条命中(doctor 与 u14b 启动探测同源判据,§6.11)。 */
34
- export interface PiLayoutLeftover {
35
- path: string
36
- kind: 'backup' | 'unmigrated'
37
- }
38
-
39
29
  /**
40
- * 旧布局残留独立 glob 探测(§6.11 v9.1)。基点 = dirname(agentDir)(xyz-agent 下 =
41
- * `<dataDir>`,纯 pi 下 = `~/.pi`);`pi/`(未迁移)与 `pi.backup-v2-` 前缀目录(迁移备份)
42
- * 都不在任何候选根推导式内([legacy] 够不到带时间戳的备份名与 pi/ 层),故须独立探测。
43
- *
44
- * 形态判据(与 u14b 启动探测 / u14a 脚本 0a 同源):目录下含 `agent/` 或 `sessions/`
45
- * 子目录才算残留——防纯 pi 宿主下任意来源的 `~/.pi/pi/` 目录误报。读探测,每次现查
46
- * 不入缓存(两次 readdir 成本可忽略,缓存只服务扫盘贵的根)。
47
- *
48
- * 迁移期支持(A4 登记):本探测服务于 v2 布局迁移窗口,全量迁移确认后可整段删除。
49
- */
50
- async function detectPiLayoutLeftovers(
51
- agentDir: string,
52
- ): Promise<{ base: string; hits: PiLayoutLeftover[] }> {
53
- const base = dirname(agentDir)
54
- if (agentDir.length === 0) return { base, hits: [] }
55
- let names: string[]
56
- try {
57
- names = (await readdir(base, { withFileTypes: true }))
58
- .filter((e) => e.isDirectory())
59
- .map((e) => e.name)
60
- } catch {
61
- return { base, hits: [] }
62
- }
63
- const hits: PiLayoutLeftover[] = []
64
- for (const name of names) {
65
- const isUnmigrated = name === 'pi'
66
- const isBackup = name.startsWith('pi.backup-v2-')
67
- if (!isUnmigrated && !isBackup) continue
68
- const dir = join(base, name)
69
- // 形态判据:含 agent/ 或 sessions/ 子目录(迁移备份必含其一——备份即原 pi/ 改名)
70
- if (!existsSync(join(dir, 'agent')) && !existsSync(join(dir, 'sessions'))) continue
71
- hits.push({ path: dir, kind: isBackup ? 'backup' : 'unmigrated' })
72
- }
73
- return { base, hits }
74
- }
75
-
76
- /**
77
- * doctor:环境判定 + 根表 + 事实型诊断 + legacy 非空告警 + 旧布局残留探测。
30
+ * doctor:环境判定 + 根表 + 事实型诊断 + legacy 非空告警。
78
31
  *
79
32
  * 诊断结论只陈述事实(「最高优先级 main 根 N 文件」),禁止「真的没有 session」类
80
33
  * 归因断言(§7B 要点 5 / §3.3 教训);env/bundleUrl 信号缺失时环境判定按降级输出,
@@ -94,8 +47,7 @@ export async function doDoctor(
94
47
  // 才扫。无缓存——每次实扫(原缓存机已删,ext-simplify-04 U3,§7B 要点 8 新态)。
95
48
  subagents: params.includeSubagents === true ? 'scan' : 'stat',
96
49
  })
97
- const leftovers = await detectPiLayoutLeftovers(signals.agentDir)
98
- return renderDoctor(roots, environment, leftovers)
50
+ return renderDoctor(roots, environment)
99
51
  }
100
52
 
101
53
  /**
@@ -105,24 +57,21 @@ export async function doDoctor(
105
57
  export interface DoctorDetails {
106
58
  environment: DetectedEnvironment
107
59
  roots: SessionRoot[]
108
- leftovers: PiLayoutLeftover[]
109
- globBase: string
110
60
  }
111
61
 
112
62
  /**
113
63
  * 渲染 doctor 输出(§5.1 形态):环境判定 + evidence + 会话根表(来源标签/路径/存在/
114
- * 文件数/扫描耗时/去重注记)+ 事实型诊断 + legacy 非空告警 + 旧布局残留(命中才出现)。
64
+ * 文件数/扫描耗时/去重注记)+ 事实型诊断 + legacy 非空告警。
115
65
  */
116
66
  function renderDoctor(
117
67
  roots: SessionRoot[],
118
68
  environment: DetectedEnvironment,
119
- leftovers: { base: string; hits: PiLayoutLeftover[] },
120
69
  ): ToolResult {
121
70
  const lines: string[] = []
122
71
 
123
72
  // 环境判定行 + evidence(§6.2:判定可被证据推翻,非黑盒断言;evidence 恒非空)
124
73
  lines.push(
125
- `环境判定:${environment.kind === 'xyz-agent' ? 'xyz-agent(托管)' : 'standalone-pi'}` +
74
+ `环境判定:${environment.kind === 'taiji' ? 'taiji(托管)' : 'standalone-pi'}` +
126
75
  ` · 发行形态:${environment.distribution ?? '未知(不猜)'}`,
127
76
  )
128
77
  if (environment.dataDir !== undefined) lines.push(`数据目录:${environment.dataDir}`)
@@ -172,25 +121,11 @@ function renderDoctor(
172
121
  )
173
122
  }
174
123
 
175
- // 旧布局残留(§6.11:命中才列,附同一迁移指引)
176
- if (leftovers.hits.length > 0) {
177
- lines.push('')
178
- lines.push(`旧布局残留探测(基点 ${leftovers.base}):`)
179
- for (const h of leftovers.hits) {
180
- lines.push(
181
- ` - ${h.path}(${h.kind === 'backup' ? '迁移备份 pi.backup-v2-*' : '未迁移旧布局 pi/'})`,
182
- )
183
- }
184
- lines.push(' 👉 关闭应用后运行 scripts/migrate-pi-layout-v2.mjs 完成迁移。')
185
- }
186
-
187
124
  return {
188
125
  content: [{ type: 'text', text: lines.join('\n') }],
189
126
  details: {
190
127
  environment,
191
128
  roots,
192
- leftovers: leftovers.hits,
193
- globBase: leftovers.base,
194
129
  } satisfies DoctorDetails,
195
130
  }
196
131
  }