pi-pier 0.1.5 → 0.2.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 (55) hide show
  1. package/package.json +1 -1
  2. package/schemas/role-manifest.schema.json +1 -2
  3. package/src/bootstrap.ts +18 -22
  4. package/src/core/grid-shape.ts +13 -14
  5. package/src/core/subagent.ts +325 -857
  6. package/src/core/terminal.ts +25 -28
  7. package/src/core/todo.ts +47 -53
  8. package/src/gc-core.ts +15 -15
  9. package/src/git-adapter.ts +104 -0
  10. package/src/herdr-client.ts +76 -68
  11. package/src/history-store.ts +70 -36
  12. package/src/index-locks.ts +105 -0
  13. package/src/index-master.ts +113 -0
  14. package/src/index-notices.ts +43 -0
  15. package/src/index-pipe.ts +73 -0
  16. package/src/index-runtime.ts +23 -0
  17. package/src/index-worker.ts +39 -0
  18. package/src/index.ts +134 -386
  19. package/src/ledger.ts +15 -17
  20. package/src/lock-core.ts +15 -29
  21. package/src/manifest-compose.ts +9 -23
  22. package/src/notice-buffer.ts +9 -14
  23. package/src/pane-title.ts +24 -24
  24. package/src/pi-surface.ts +22 -29
  25. package/src/pipe-channel.ts +67 -34
  26. package/src/platform-paths.ts +72 -0
  27. package/src/progress-core.ts +26 -24
  28. package/src/reconcile-core.ts +25 -24
  29. package/src/role-loader.ts +32 -35
  30. package/src/role-manifest.ts +24 -28
  31. package/src/roles/master.json +2 -2
  32. package/src/roles/worker-default.json +3 -3
  33. package/src/runtime-policy.ts +66 -0
  34. package/src/session-tail.ts +46 -45
  35. package/src/settle-wake-core.ts +19 -18
  36. package/src/slim-frame.ts +45 -44
  37. package/src/stale-core.ts +23 -21
  38. package/src/storage-layout.ts +80 -0
  39. package/src/subagent-core.ts +151 -94
  40. package/src/subagent-gc.ts +237 -0
  41. package/src/subagent-git-io.ts +80 -0
  42. package/src/subagent-launch.ts +110 -0
  43. package/src/subagent-poll-loop.ts +285 -0
  44. package/src/subagent-poller.ts +90 -0
  45. package/src/subagent-port.ts +27 -0
  46. package/src/subagent-scope.ts +4 -4
  47. package/src/subagent-session-io.ts +143 -0
  48. package/src/subagent-spawn.ts +156 -0
  49. package/src/terminal-core.ts +56 -54
  50. package/src/todo-core.ts +27 -64
  51. package/src/todo-read-hook.ts +28 -24
  52. package/src/todo-reminder-core.ts +89 -0
  53. package/src/todos-service.ts +36 -25
  54. package/src/tool-gate.ts +7 -28
  55. package/src/vocab.ts +18 -19
@@ -1,49 +1,58 @@
1
1
  /**
2
- * M11 人机通道分离(D45–D47):pi 扩展自建 Windows 命名管道。
2
+ * M11 separates the human/machine channels (D45–D47) by having the pi extension
3
+ * create its own Windows named pipe.
3
4
  *
4
- * - 主控(客户端):一连接一请求(与 herdr 客户端同风格);
5
- * - 子 pane(服务端):PI_HERDR_SUBAGENT=1 的扩展在 session_start 起 server,
6
- * 收到 prompt/follow_up 后经 pi.sendUserMessage(followUp) 注入会话本身(TUI 可见);
7
- * - 完成信息不回传(权威路径 = herdr 状态 + 会话 JSONL + history,D47)。
5
+ * - Controller (client): one connection per request, matching the herdr client;
6
+ * - Child pane (server): when PI_HERDR_SUBAGENT=1, the extension starts a server at
7
+ * session_start and injects prompt/follow_up through pi.sendUserMessage (visible in TUI);
8
+ * - Completion data is not sent back; authoritative state lives in herdr, session JSONL,
9
+ * and history (D47).
8
10
  *
9
- * 本文件只含传输层纯逻辑(可单测,不依赖 pi API);
10
- * pi 注入由 index.ts 的服务端回调完成。
11
+ * This file contains only pure transport-layer logic for isolated tests; index.ts
12
+ * performs pi injection in the server callback.
11
13
  */
12
14
  import * as net from 'node:net';
13
- import { sessionDirName } from './session-tail.ts';
15
+ import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
14
16
 
15
- /** 管道名:workspace 作用域(与 history.jsonl 目录命名同约定)+ paneId 编码。 */
17
+ function encodePaneId(paneId: string): string {
18
+ return String(paneId).replace(/[^A-Za-z0-9_-]/g, '-');
19
+ }
20
+
21
+ /** Pipe name: workspace-scoped (same encoding as history dirs) + paneId. */
16
22
  export function pipeNameFor(cwd: string, paneId: string): string {
17
- const dir = sessionDirName(cwd);
18
- const encoded = String(paneId).replace(/[^A-Za-z0-9_-]/g, '-');
19
- return `pi-herdr-${dir}-${encoded}`;
23
+ return `pi-herdr-${sessionDirName(cwd)}-${encodePaneId(paneId)}`;
20
24
  }
21
25
 
22
- /** Windows 命名管道完整地址(内核命名空间,非文件系统路径)。 */
26
+ /** New encoding first, then legacy — client retries so mixed-version peers still connect. */
27
+ export function pipeNameCandidates(cwd: string, paneId: string): string[] {
28
+ return sessionDirCandidates(cwd).map((dir) => `pi-herdr-${dir}-${encodePaneId(paneId)}`);
29
+ }
30
+
31
+ /** Windows named-pipe address in the kernel namespace, not a filesystem path. */
23
32
  export function pipePathFor(name: string): string {
24
33
  return process.platform === 'win32'
25
34
  ? (name.startsWith('\\\\.\\pipe\\') ? name : `\\\\.\\pipe\\${name}`)
26
35
  : `/tmp/${name}.sock`;
27
36
  }
28
37
 
29
- /** 管道消息(JSON 行协议;一连接一请求 = 一个请求一个响应)。 */
38
+ /** Pipe message in the JSON-lines protocol; one connection carries one request and response. */
30
39
  export type PipeRequest =
31
40
  | { type: 'ping'; id: string }
32
41
  | {
33
42
  type: 'prompt' | 'follow_up';
34
43
  id: string;
35
44
  text: string;
36
- /** 发送方管道名(D49:回复随 from 走;无主从硬编码)。 */
45
+ /** D49: reply follows the sender's pipe name, avoiding hard-coded controller/child roles. */
37
46
  from?: string | null;
38
- /** D50:结算时是否 push reply(后台 true、前台 false——前台由调用方同步取结果)。 */
47
+ /** D50: whether settlement pushes a reply (background true, foreground false because the caller reads synchronously). */
39
48
  push?: boolean;
40
- /** B3:follow_up 以 steer 投递(tool-call 间隙即达,秒级)。缺省/ false =
41
- * followUp 队列语义(run 结束才投)——初始 prompt 保持队列语义,续作指令走 steer。 */
49
+ /** B3: deliver follow_up as steer so it arrives between tool calls within seconds. Missing/false keeps
50
+ * queue semantics (delivered when the run ends); the initial prompt stays queued while continuations steer. */
42
51
  steer?: boolean;
43
52
  }
44
53
  | { type: 'interrupt'; id: string }
45
54
  | {
46
- /** D50:结算快路径回信(子 → 请求方;带摘要 + 会话路径,渐进式披露)。 */
55
+ /** D50: fast-path settlement reply from child to requester, with summary and session path for progressive disclosure. */
47
56
  type: 'reply';
48
57
  id: string;
49
58
  paneId: string;
@@ -55,7 +64,7 @@ export type PipeResponse =
55
64
  | { type: 'ok'; id: string; detail?: string }
56
65
  | { type: 'error'; id: string; message: string };
57
66
 
58
- /** 解析一行 JSON(坏行返回 null)。 */
67
+ /** Parse one JSON line, returning null for malformed input. */
59
68
  export function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
60
69
  const t = (line ?? '').trim();
61
70
  if (!t) return null;
@@ -63,14 +72,14 @@ export function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
63
72
  const obj = JSON.parse(t);
64
73
  if (obj && typeof obj === 'object' && typeof obj.type === 'string') return obj as PipeRequest | PipeResponse;
65
74
  } catch {
66
- /* 坏行 */
75
+ /* Malformed line. */
67
76
  }
68
77
  return null;
69
78
  }
70
79
 
71
80
  /**
72
- * 一连接一请求:连接 → 写一行 JSON → 等一行 JSON 响应 → 断开。
73
- * 超时/连接失败/坏响应 → 抛错(调用方决定重试)。
81
+ * One connection, one request: connect → write JSON line → wait for JSON line → close.
82
+ * Timeout / connect failure / bad frame throw (caller retries).
74
83
  */
75
84
  export function pipeRequest(
76
85
  pipeName: string,
@@ -117,19 +126,43 @@ export function pipeRequest(
117
126
  });
118
127
  }
119
128
 
120
- /** 带重试的 ping(就绪探测;D47 取代 waitSubReady 的 agent 状态门)。 */
129
+ /**
130
+ * Try new then legacy pipe names so a new client can still reach an old server.
131
+ */
132
+ export async function pipeRequestTo(
133
+ cwd: string,
134
+ paneId: string,
135
+ payload: PipeRequest,
136
+ timeoutMs = 8000,
137
+ ): Promise<PipeResponse> {
138
+ const names = pipeNameCandidates(cwd, paneId);
139
+ let lastErr: unknown;
140
+ for (const name of names) {
141
+ try {
142
+ return await pipeRequest(name, payload, timeoutMs);
143
+ } catch (err) {
144
+ lastErr = err;
145
+ }
146
+ }
147
+ throw lastErr instanceof Error ? lastErr : new Error(`pipe ${names[0] ?? paneId}: unreachable`);
148
+ }
149
+
150
+ /** Ping with retry (readiness probe). Accepts one name or new+legacy candidates. */
121
151
  export async function pingUntilReady(
122
- pipeName: string,
152
+ pipeName: string | readonly string[],
123
153
  timeoutMs: number,
124
154
  intervalMs = 1000,
125
155
  ): Promise<boolean> {
156
+ const names = typeof pipeName === 'string' ? [pipeName] : pipeName;
126
157
  const deadline = Date.now() + timeoutMs;
127
158
  while (Date.now() < deadline) {
128
- try {
129
- const res = await pipeRequest(pipeName, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
130
- if (res.type === 'ok') return true;
131
- } catch {
132
- /* 未就绪,继续 */
159
+ for (const name of names) {
160
+ try {
161
+ const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
162
+ if (res.type === 'ok') return true;
163
+ } catch {
164
+ /* not ready on this name */
165
+ }
133
166
  }
134
167
  await new Promise((r) => setTimeout(r, intervalMs));
135
168
  }
@@ -139,8 +172,8 @@ export async function pingUntilReady(
139
172
  export type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
140
173
 
141
174
  /**
142
- * 服务端:每连接读一行 → 分发 handler → 回一行 → 断开。
143
- * 返回 server 实例(调用方管理 close)。
175
+ * Server: one line in → handler → one line out → close.
176
+ * Returns the server (caller closes it).
144
177
  */
145
178
  export function startPipeServer(
146
179
  pipeName: string,
@@ -173,11 +206,11 @@ export function startPipeServer(
173
206
  });
174
207
  });
175
208
  sock.on('error', () => {
176
- /* 客户端断开等:静默 */
209
+ /* client disconnect: silent */
177
210
  });
178
211
  });
179
212
  server.on('error', () => {
180
- /* 地址占用等:由调用方观察(listen 抛错由 startPipeServer 调用方处理) */
213
+ /* address in use: caller observes listen errors */
181
214
  });
182
215
  server.listen(pipePathFor(pipeName));
183
216
  return server;
@@ -0,0 +1,72 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Platform-aware storage and workspace path resolution.
6
+ *
7
+ * Why: Centralizes platform-specific path logic to support Windows (LOCALAPPDATA),
8
+ * Unix XDG conventions, and testability through injection.
9
+ */
10
+
11
+ export interface PlatformPaths {
12
+ /** Base directory for agent sessions and state */
13
+ readonly agentDataDir: string
14
+ /** Base directory for worktrees */
15
+ readonly worktreeBaseDir: string
16
+ /** Sessions subdirectory under agent data */
17
+ readonly sessionsDir: string
18
+ }
19
+
20
+ function getDefaultDataDir(): string {
21
+ const platform = process.platform
22
+
23
+ if (platform === 'win32') {
24
+ // Windows: use LOCALAPPDATA or fallback to homedir
25
+ return process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
26
+ }
27
+
28
+ // Unix: XDG_DATA_HOME or ~/.local/share on Linux, ~/.local/share on macOS
29
+ if (platform === 'linux') {
30
+ return process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
31
+ }
32
+
33
+ // macOS: use ~/.local/share for consistency, but .pi is acceptable legacy
34
+ return process.env.XDG_DATA_HOME || join(homedir(), '.pi')
35
+ }
36
+
37
+ function getDefaultWorktreeDir(): string {
38
+ const platform = process.platform
39
+
40
+ if (platform === 'win32') {
41
+ const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
42
+ return join(localAppData, 'herdr', 'worktrees')
43
+ }
44
+
45
+ // Unix: use XDG_DATA_HOME or ~/.local/share
46
+ if (platform === 'linux') {
47
+ const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
48
+ return join(dataHome, 'herdr', 'worktrees')
49
+ }
50
+
51
+ // macOS legacy: ~/.herdr/worktrees
52
+ return join(homedir(), '.herdr', 'worktrees')
53
+ }
54
+
55
+ export function createPlatformPaths(overrides?: Partial<PlatformPaths>): PlatformPaths {
56
+ const defaultAgentDataDir = join(getDefaultDataDir(), 'agent')
57
+ const agentDataDir = overrides?.agentDataDir || defaultAgentDataDir
58
+ const sessionsDir = overrides?.sessionsDir || join(agentDataDir, 'sessions')
59
+ const worktreeBaseDir = overrides?.worktreeBaseDir || getDefaultWorktreeDir()
60
+
61
+ return {
62
+ agentDataDir,
63
+ sessionsDir,
64
+ worktreeBaseDir,
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Singleton platform paths for production use.
70
+ * Tests should inject via createPlatformPaths().
71
+ */
72
+ export const platformPaths = createPlatformPaths()
@@ -1,39 +1,41 @@
1
1
  /**
2
- * M16 进度可见性纯核心(开发方案.md §M16;保守形态优先,D3 无新协议)。
2
+ * M16 pure core for progress visibility (development plan §M16; prioritize conservative output, with no new D3 protocol).
3
3
  *
4
- * 承载通道(post-M22 世界):
5
- * - 进度徽标(N/M + 可选 ETA)→ pane title 内嵌(state_labels 键仅五态,进不了 label);
6
- * - 工具徽标(🔧 tool)→ report_agent.message;
7
- * - 速率估算对齐 kimi 估算器语义:速率窗口、未完成 cap、置信度门。
4
+ * Channels in the post-M22 world:
5
+ * - Progress badge (N/M plus optional ETA) is embedded in the pane title (state_labels has only five keys, so it cannot fit there);
6
+ * - Tool badge (🔧 tool) is sent through report_agent.message;
7
+ * - Rate estimates follow kimi estimator semantics: rate window, unfinished cap, and confidence gate.
8
8
  *
9
- * 保守原则(风险⑥拍板):估算不可信时回退纯计数「N/M」,绝不显示误导性 ETA。
9
+ * Conservative principle (risk ⑥ decision): when an estimate is not trustworthy, fall back to plain `N/M` and
10
+ * never display a misleading ETA.
10
11
  */
12
+
11
13
  import { countTodos, type TodoItem } from './vocab.ts';
12
14
 
13
- /** ETA 需要的最少完成点数(<2 无法算速率)。 */
15
+ /** Minimum completed points needed for an ETA (<2 cannot establish a rate). */
14
16
  export const ETA_MIN_SAMPLES = 2;
15
- /** 速率样本窗口(kimi rate window 语义;取最近 K 个完成点算速率)。 */
17
+ /** Rate sample window (kimi rate-window semantics; use the most recent K completion points). */
16
18
  export const RATE_WINDOW_MS = 45_000;
17
- /** 未完成进度显示上限(kimi unfinished cap 语义;防"快完了"假象)。 */
19
+ /** Cap for displayed unfinished progress (kimi unfinished-cap semantics; prevent a false "almost done" impression). */
18
20
  export const UNFINISHED_CAP = 0.85;
19
- /** 完成点新鲜度门:最近完成点距今超过该值 → 数据陈旧,不估 ETA。 */
21
+ /** Completion freshness gate: if the most recent completion is older than this, data is stale and no ETA is estimated. */
20
22
  export const PROGRESS_STALE_MS = 5 * 60_000;
21
- /** 速率拟合取最近 K 个完成点。 */
23
+ /** Number of recent completion points used for rate fitting. */
22
24
  const RATE_SAMPLE_POINTS = 5;
23
25
 
24
26
  export interface EtaEstimate {
25
- /** 剩余步数。 */
27
+ /** Remaining steps. */
26
28
  remaining: number;
27
- /** 预计剩余时间(毫秒)。 */
29
+ /** Estimated remaining time (milliseconds). */
28
30
  etaMs: number;
29
31
  confidence: 'ok';
30
32
  }
31
33
 
32
34
  /**
33
- * 速率估算:完成时间戳 → 剩余步数的 ETA。
34
- * - 完成点 <2 → null(无速率可言);
35
- * - 最近完成点距今 > PROGRESS_STALE_MS → null(陈旧:爆发后停顿/人为搁置);
36
- * - 速率 = 取最近 K(≤5) 个点,(k-1) / (t_k - t_1);eta = remaining / rate。
35
+ * Estimate ETA from completion timestamps and remaining steps.
36
+ * - Fewer than 2 completion points → null (no rate can be inferred);
37
+ * - Most recent completion older than PROGRESS_STALE_MS → null (stale after a burst followed by a pause or manual hold);
38
+ * - Rate = use the latest K(≤5) points, (k-1) / (t_k - t_1); eta = remaining / rate.
37
39
  */
38
40
  export function estimateEta(opts: {
39
41
  completedAt: readonly number[];
@@ -56,7 +58,7 @@ export function estimateEta(opts: {
56
58
  return { remaining, etaMs: Math.round(remaining / ratePerMs), confidence: 'ok' };
57
59
  }
58
60
 
59
- /** title 进度后缀:保守 `3/7`;置信 ETA 时 `3/7 ~4m`;total=0 → 空串;全完成 `5/5 ✓`。 */
61
+ /** Title progress suffix: conservative `3/7`; trusted ETA gives `3/7 ~4m`; total=0 → empty; all complete → `5/5 ✓`. */
60
62
  export function formatProgressSuffix(opts: {
61
63
  completed: number;
62
64
  total: number;
@@ -74,27 +76,27 @@ export function formatProgressSuffix(opts: {
74
76
  return `${base} ${human}`;
75
77
  }
76
78
 
77
- /** 工具徽标(report_agent.message):单工具名;多工具首 + 计数;空 → null(不覆盖)。 */
79
+ /** Tool badge (report_agent.message): one tool name; multiple use the first plus a count; empty → null (do not overwrite). */
78
80
  export function planToolBadge(runningToolNames: readonly string[]): string | null {
79
81
  if (runningToolNames.length === 0) return null;
80
82
  const first = runningToolNames[0];
81
83
  return runningToolNames.length === 1 ? `🔧 ${first}` : `🔧 ${first} +${runningToolNames.length - 1}`;
82
84
  }
83
85
 
84
- /** 从列表算进度输入(completed 计数 / total 开放计数)。 */
86
+ /** Derive progress input from a list (completed count / total open-work count). */
85
87
  export function progressOf(items: readonly TodoItem[]): { completed: number; total: number } {
86
88
  const c = countTodos(items);
87
89
  const blockedCount = items.filter((it) => it.status === 'blocked').length;
88
90
  return { completed: c.completed, total: c.completed + c.pending + c.inProgress + blockedCount };
89
91
  }
90
92
 
91
- /** 进度徽标徽记整个生命周期后不再显示(防"完成态常驻"噪音;调用方比对用)。 */
93
+ /** Hide the progress badge after it has been marked for the full lifecycle (avoid persistent completed-state noise; callers compare this). */
92
94
  export const PROGRESS_HIDE_MS = 60_000;
93
95
 
94
96
  /**
95
- * 两份列表间的 completed 转换数(按 content 匹配;新列表中 completed 且旧状态非
96
- * completed —— 含新增即 completed 的条目)。M16 速率估算的原料:所有编辑路径
97
- * (todo_write / 人类编辑 / M17 对账)都过 TodosService,在此统一 diff。
97
+ * Count completed transitions between two lists (match by content; an item completed in the new list whose old status
98
+ * was not completed, including items newly added as completed). M16 ETA estimation uses this as input: every edit path
99
+ * (todo_write / human edit / M17 reconciliation) passes through TodosService, so the diff is centralized here.
98
100
  */
99
101
  export function countCompletedTransitions(
100
102
  before: readonly TodoItem[],
@@ -1,35 +1,35 @@
1
1
  /**
2
- * M17:todo↔subagent 自动对账纯规划器(开发方案.md §M17;P2 = 低置信度不勾+提示)。
2
+ * M17: pure planner for automatic todo↔subagent reconciliation (development plan §M17; P2 = do not check off low-confidence matches, only prompt).
3
3
  *
4
- * 时机(adapter):主控收到结算 push(pollLoop / pipe reply)后、注入 followUp 前。
5
- * 语义(对齐 omp reconcileTodosWithSubagents):
6
- * - 匹配:description ↔ todo.content,norm(小写+空白折叠)后 exact → prefix(双向)
7
- * → substring,与 D38 fuzzyFind 同族;
8
- * - 自动勾:仅 settled 且最优档唯一且 ∈ {exact, prefix},一次结算至多一条;
9
- * - 解锁:blocker ↔ description 同档匹配(exact/prefix)的 blocked 条目回 pending
10
- * (可多条;failed/低置信度不解锁);
11
- * - 低置信度/歧义/失败:不动列表,noteLines 随结算通知提示(P2 拍板);
12
- * - 落盘:edits 走 D38 权威路径(pi-herdr.todo-edit custom 条目,分支回放生效)。
4
+ * Timing (adapter): after the controller receives a settlement push (pollLoop / pipe reply), before injecting followUp.
5
+ * Semantics (aligned with omp reconcileTodosWithSubagents):
6
+ * - Match description ↔ todo.content: normalize (lowercase + collapse whitespace), then exact → bidirectional prefix
7
+ * → substring, in the same family as D38 fuzzyFind;
8
+ * - Auto-complete only when settled and the unique best tier is in {exact, prefix}; at most one item per settlement;
9
+ * - Unblock blocked items whose blocker ↔ description matches at any tier (exact/prefix)
10
+ * (multiple allowed; failed/low-confidence matches do not unblock);
11
+ * - Low confidence/ambiguity/failure: leave the list unchanged and add a noteLines prompt to the settlement notice (P2 decision);
12
+ * - Persistence: edits use the D38 authoritative path (pi-herdr.todo-edit custom entry, effective during branch replay).
13
13
  */
14
+
14
15
  import { applyTodoEdits, type TodoEdit, type TodoItem } from './todo-core.ts';
15
16
 
16
17
  export type ReconcileOutcome = 'settled' | 'failed';
17
18
  export type MatchTier = 'exact' | 'prefix' | 'substring' | null;
18
-
19
19
  export interface ReconcilePlan {
20
- /** 应用 edits 后的列表(无编辑 = 原引用)。 */
20
+ /** List after applying edits (original reference when there are no edits). */
21
21
  items: TodoItem[];
22
- /** 经权威路径持久化的编辑(done 至多一条 + unblock 若干)。 */
22
+ /** Edits persisted through the authoritative path (at most one done plus any unblocks). */
23
23
  edits: TodoEdit[];
24
- /** 被自动勾掉的条目(至多一条)。 */
24
+ /** Item automatically checked off (at most one). */
25
25
  completed: TodoItem | null;
26
- /** 自动勾的匹配档位(candidates 存在时的最优档;无候选 null)。 */
26
+ /** Best matching tier for auto-completion (null when there are no candidates). */
27
27
  tier: MatchTier;
28
- /** 被解锁的条目(blocker 匹配)。 */
28
+ /** Items unblocked by a blocker match. */
29
29
  unblocked: TodoItem[];
30
- /** 随结算通知注入的提示行(对齐 D36 反馈语风;无提示 = 空数组)。 */
30
+ /** Prompt lines injected into the settlement notice (aligned with D36 feedback tone; empty when none). */
31
31
  noteLines: string[];
32
- /** 匹配率指标(P2:采集用,随结算通知落会话 JSONL)。 */
32
+ /** Match-rate metric (P2: collected with the settlement notice in session JSONL). */
33
33
  metric: {
34
34
  description: string;
35
35
  outcome: ReconcileOutcome;
@@ -64,7 +64,7 @@ export function reconcileTodos(
64
64
  const edits: TodoEdit[] = [];
65
65
  const noteLines: string[] = [];
66
66
 
67
- // 1) 勾选候选:pending / in_progress(completed/abandoned 永不参与)
67
+ // 1) Check-off candidates: pending / in_progress (completed/abandoned never participate).
68
68
  const matchable = prev.filter((t) => t.status === 'pending' || t.status === 'in_progress');
69
69
  const scored = matchable
70
70
  .map((t) => ({ item: t, tier: matchTier(description, t.content) }))
@@ -80,25 +80,26 @@ export function reconcileTodos(
80
80
  edits.push({ op: 'done', content: completed.content });
81
81
  noteLines.push(`Reconciled: completed "${completed.content}" (${bestTier} match with subagent description).`);
82
82
  } else if (best.length > 1) {
83
- // 歧义:列候选,留给人
83
+ // Ambiguous match: list candidates and leave the decision to a person.
84
84
  noteLines.push(
85
85
  `Todo match ambiguous between: ${best.map((c) => `"${c.item.content}"`).join(', ')} — update todo_write yourself.`,
86
86
  );
87
87
  } else if (outcome === 'failed') {
88
- // 高置信候选但子代理未成功结算 → 保持打开,提示
88
+ // High-confidence candidate, but the subagent did not settle successfully: keep it open and prompt.
89
89
  for (const c of best) {
90
90
  noteLines.push(`Todo kept open: "${c.item.content}" (subagent did not settle successfully).`);
91
91
  }
92
92
  }
93
93
  } else if (bestTier === 'substring') {
94
- // 低置信度(P2):不勾,提示候选
94
+ // Low-confidence match (P2): do not check it off; prompt with the candidate.
95
95
  for (const c of best) {
96
96
  noteLines.push(`Todo not auto-completed (low-confidence match): "${c.item.content}" — update todo_write if this work is done.`);
97
97
  }
98
98
  }
99
99
 
100
- // 2) 解锁:blocker 任意档匹配(含 substring——真实 blocker 是「等 X 完成」短语,
101
- // 描述嵌在中间,prefix 会漏;低置信度闸只管勾选,解锁回 pending 是软操作)且 settled(可多条)
100
+ // 2) Unblock on any blocker match, including substring—real blockers are phrases such as "waiting for X to finish",
101
+ // so the description may be embedded in the middle and prefix would miss it. The low-confidence gate applies only
102
+ // to check-offs; returning an unblocked item to pending is a soft operation. Only settled subagents can unblock (possibly multiple).
102
103
  const unblocked: TodoItem[] = [];
103
104
  if (outcome === 'settled') {
104
105
  for (const t of prev) {
@@ -1,40 +1,37 @@
1
1
  /**
2
- * 档2 Week1:role 档案加载器(2026-08-18);v1.1(2026-08-22)两层用户目录。
2
+ * Layer 2 Week 1: load role manifests (2026-08-18); v1.1 (2026-08-22) adds two user-directory layers.
3
3
  *
4
- * 查找序(v1.1,用户拍板):
5
- * 1. workspace 级 `<cwd>/.pi-herdr/roles/<name>.json`(随 clone 走,团队共享)
6
- * 2. 用户全局 `~/.pi/agent/herdr-pi/roles/<name>.json`(跨仓库个人偏好)
7
- * 3. 内置 `src/roles/`(本包随附)兜底
4
+ * Lookup order (v1.1, user-approved):
5
+ * 1. Workspace-level `<cwd>/.pi-herdr/roles/<name>.json` (travels with the clone and is team-shared)
6
+ * 2. User-global `~/.pi/agent/herdr-pi/roles/<name>.json` (personal preference across repositories)
7
+ * 3. Bundled `src/roles/` as the fallback shipped with this package
8
8
  *
9
- * 内置名保留(master / worker-default):用户/工作区层撞名直接拒绝——D83 镜像
10
- * 单测与 D82 姿态锚在内置档案上,被覆盖会破坏这些保证。
9
+ * Built-in names remain reserved (master / worker-default): reject collisions in user/workspace layers—D83 mirror.
10
+ * Tests and the D82 stance are anchored to bundled manifests; allowing overrides would break those guarantees.
11
11
  *
12
- * 角色名白名单 [a-z0-9-]+ —— 防路径穿越(不合法名不触盘)。
13
- * 档案 role 字段必须与文件名一致(防错挂)。
14
- * 校验失败分类:读不到 = ROLE_NOT_FOUND(逐层尝试后才报);读到了但
15
- * JSON/校验/名字不符 = INVALID_ROLE_CONFIG(**在命中层立即报**,不静默落到
16
- * 下一层——静默会掩盖用户改了档案却写错的问题)。
12
+ * Role names are restricted to [a-z0-9-]—this prevents path traversal and avoids touching disk for invalid names.
13
+ * The manifest role field must match the filename to prevent attaching the wrong manifest.
14
+ * Validation failures are classified as: unreadable = ROLE_NOT_FOUND (reported only after trying every layer);
15
+ * parsed but invalid JSON/validation/name = INVALID_ROLE_CONFIG (report immediately at the hit layer rather than
16
+ * silently falling through, because silence would hide a user editing a manifest incorrectly).
17
17
  */
18
18
  import { existsSync, readFileSync } from 'node:fs';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { dirname, isAbsolute, join, resolve } from 'node:path';
21
- import { homedir } from 'node:os';
22
21
  import { validateRoleManifest, type RoleManifest } from './role-manifest.ts';
22
+ import { userRolesDir, workspaceRolesDir as layoutWorkspaceRolesDir } from './storage-layout.ts';
23
23
 
24
- /** 内置档案目录(src/roles/)。 */
24
+ export { userRolesDir };
25
+
26
+ /** Bundled manifest directory (`src/roles/`). */
25
27
  export const ROLES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'roles');
26
28
 
27
- /** workspace 级目录(相对基准默认 = 进程 cwd,即 master 的工作目录)。 */
29
+ /** Workspace-level directory (the default base is the process cwd, i.e. the master's working directory). */
28
30
  export function workspaceRolesDir(baseDir?: string): string {
29
- return join(baseDir ?? process.cwd(), '.pi-herdr', 'roles');
30
- }
31
-
32
- /** 用户全局目录(~/.pi/agent/herdr-pi/roles/)。 */
33
- export function userRolesDir(): string {
34
- return join(homedir(), '.pi', 'agent', 'herdr-pi', 'roles');
31
+ return layoutWorkspaceRolesDir(baseDir ?? process.cwd());
35
32
  }
36
33
 
37
- /** 内置保留名(不可被任何用户层覆盖)。 */
34
+ /** Reserved built-in names (no user layer may override them). */
38
35
  export const RESERVED_ROLE_NAMES: readonly string[] = ['master', 'worker-default'];
39
36
 
40
37
  const ROLE_NAME_RE = /^[a-z0-9-]+$/;
@@ -56,7 +53,7 @@ export class RoleLoaderError extends Error {
56
53
 
57
54
  export type RoleReader = (fileName: string) => string;
58
55
 
59
- /** 单层读取器(注入式;生产 = existsSync + readFileSync 该目录)。 */
56
+ /** Single-layer reader (injectable; production uses existsSync + readFileSync for that directory). */
60
57
  export type LayerReader = (dir: string, fileName: string) => string | null;
61
58
 
62
59
  const defaultLayerRead: LayerReader = (dir, fileName) => {
@@ -66,21 +63,21 @@ const defaultLayerRead: LayerReader = (dir, fileName) => {
66
63
  };
67
64
 
68
65
  export interface LoadRoleOptions {
69
- /** 单 reader 注入(旧形态:仅命中内置层——测试兼容用)。 */
66
+ /** Inject one reader (legacy shape: only the bundled layer is hit, for test compatibility). */
70
67
  read?: RoleReader;
71
- /** 多层 reader 注入(v1.1)。 */
68
+ /** Inject layered readers (v1.1). */
72
69
  layerRead?: LayerReader;
73
- /** workspace 层基准目录(默认 process.cwd())。 */
70
+ /** Base directory for the workspace layer (defaults to process.cwd()). */
74
71
  baseDir?: string;
75
72
  /**
76
- * 内置名直读:master 自应用(WS-D7)专用——保留名跳过用户层与撞名检查,
77
- * 永远加载内置档案。否则 workspace 放 master.json 会让自应用 fail-open,
78
- * master 静默丢失 manifest(d11 活体抓到的边界)。
73
+ * Direct built-in lookup: used by master's self-application (WS-D7). Reserved names skip user layers and
74
+ * collision checks and always load the bundled manifest. Otherwise, a workspace master.json could cause
75
+ * self-application to fail open and silently lose master's manifest (the boundary caught by d11 live testing).
79
76
  */
80
77
  builtinDirect?: boolean;
81
78
  }
82
79
 
83
- /** 解析查找层序:workspace → 用户全局 → 内置(每层 {label, dir})。 */
80
+ /** Resolve lookup layers in order: workspace → user-global → bundled (each layer has {label, dir}). */
84
81
  export function roleLayers(opts?: { baseDir?: string }): Array<{ label: string; dir: string }> {
85
82
  const base = opts?.baseDir && isAbsolute(opts.baseDir) ? opts.baseDir
86
83
  : resolve(opts?.baseDir ?? process.cwd());
@@ -97,7 +94,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
97
94
  }
98
95
  const fileName = `${name}.json`;
99
96
 
100
- // 旧形态单 reader:直接按内置层语义(既有单测/合成器注入路径不破)
97
+ // Legacy single reader: use bundled-layer semantics so existing tests/composer injection paths remain intact.
101
98
  if (opts?.read && !opts.layerRead) {
102
99
  let text: string;
103
100
  try {
@@ -112,11 +109,11 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
112
109
  const layerRead = opts?.layerRead ?? defaultLayerRead;
113
110
  let layers = roleLayers({ baseDir: opts?.baseDir });
114
111
 
115
- // 内置名直读(自应用):只查内置层,不受用户层诱饵/撞名影响
112
+ // Direct built-in lookup (self-application): inspect only the bundled layer, ignoring user-layer bait/collisions.
116
113
  if (opts?.builtinDirect && RESERVED_ROLE_NAMES.includes(name)) {
117
114
  layers = [layers[2]];
118
115
  } else if (RESERVED_ROLE_NAMES.includes(name)) {
119
- // 内置名保留:workspace/用户层撞名 → 拒绝(spawn 侧响亮报错,不静默覆盖内置语义)
116
+ // Reserved built-in name: reject workspace/user collisions instead of silently overriding built-in semantics.
120
117
  for (const layer of layers.slice(0, 2)) {
121
118
  if (layerRead(layer.dir, fileName) != null) {
122
119
  throw new RoleLoaderError(
@@ -129,8 +126,8 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
129
126
 
130
127
  for (const layer of layers) {
131
128
  const text = layerRead(layer.dir, fileName);
132
- if (text == null) continue; // 本层无此档案 → 下一层
133
- return parseAndValidate(name, fileName, text, layer.label); // 命中层:错误立即报
129
+ if (text == null) continue; // No manifest in this layer; try the next layer.
130
+ return parseAndValidate(name, fileName, text, layer.label); // At the hit layer, report errors immediately.
134
131
  }
135
132
  throw new RoleLoaderError(
136
133
  'ROLE_NOT_FOUND',