pi-pier 0.1.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * M11 人机通道分离(D45–D47):pi 扩展自建 Windows 命名管道。
3
+ *
4
+ * - 主控(客户端):一连接一请求(与 herdr 客户端同风格);
5
+ * - 子 pane(服务端):PI_HERDR_SUBAGENT=1 的扩展在 session_start 起 server,
6
+ * 收到 prompt/follow_up 后经 pi.sendUserMessage(followUp) 注入会话本身(TUI 可见);
7
+ * - 完成信息不回传(权威路径 = herdr 状态 + 会话 JSONL + history,D47)。
8
+ *
9
+ * 本文件只含传输层纯逻辑(可单测,不依赖 pi API);
10
+ * pi 注入由 index.ts 的服务端回调完成。
11
+ */
12
+ import * as net from 'node:net';
13
+ import { sessionDirName } from './session-tail.ts';
14
+
15
+ /** 管道名:workspace 作用域(与 history.jsonl 目录命名同约定)+ paneId 编码。 */
16
+ 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}`;
20
+ }
21
+
22
+ /** Windows 命名管道完整地址(内核命名空间,非文件系统路径)。 */
23
+ export function pipePathFor(name: string): string {
24
+ return process.platform === 'win32'
25
+ ? (name.startsWith('\\\\.\\pipe\\') ? name : `\\\\.\\pipe\\${name}`)
26
+ : `/tmp/${name}.sock`;
27
+ }
28
+
29
+ /** 管道消息(JSON 行协议;一连接一请求 = 一个请求一个响应)。 */
30
+ export type PipeRequest =
31
+ | { type: 'ping'; id: string }
32
+ | {
33
+ type: 'prompt' | 'follow_up';
34
+ id: string;
35
+ text: string;
36
+ /** 发送方管道名(D49:回复随 from 走;无主从硬编码)。 */
37
+ from?: string | null;
38
+ /** D50:结算时是否 push reply(后台 true、前台 false——前台由调用方同步取结果)。 */
39
+ push?: boolean;
40
+ }
41
+ | { type: 'interrupt'; id: string }
42
+ | {
43
+ /** D50:结算快路径回信(子 → 请求方;带摘要 + 会话路径,渐进式披露)。 */
44
+ type: 'reply';
45
+ id: string;
46
+ paneId: string;
47
+ text: string | null;
48
+ sessionFile: string | null;
49
+ };
50
+
51
+ export type PipeResponse =
52
+ | { type: 'ok'; id: string; detail?: string }
53
+ | { type: 'error'; id: string; message: string };
54
+
55
+ /** 解析一行 JSON(坏行返回 null)。 */
56
+ export function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
57
+ const t = (line ?? '').trim();
58
+ if (!t) return null;
59
+ try {
60
+ const obj = JSON.parse(t);
61
+ if (obj && typeof obj === 'object' && typeof obj.type === 'string') return obj as PipeRequest | PipeResponse;
62
+ } catch {
63
+ /* 坏行 */
64
+ }
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * 一连接一请求:连接 → 写一行 JSON → 等一行 JSON 响应 → 断开。
70
+ * 超时/连接失败/坏响应 → 抛错(调用方决定重试)。
71
+ */
72
+ export function pipeRequest(
73
+ pipeName: string,
74
+ payload: PipeRequest,
75
+ timeoutMs = 8000,
76
+ ): Promise<PipeResponse> {
77
+ return new Promise((resolve, reject) => {
78
+ const sock = net.createConnection(pipePathFor(pipeName));
79
+ sock.setEncoding('utf8');
80
+ let buf = '';
81
+ let settled = false;
82
+ const timer = setTimeout(() => {
83
+ if (!settled) {
84
+ settled = true;
85
+ sock.destroy();
86
+ reject(new Error(`pipe ${pipeName}: ${payload.type} timeout`));
87
+ }
88
+ }, timeoutMs);
89
+ sock.on('connect', () => {
90
+ sock.write(JSON.stringify(payload) + '\n');
91
+ });
92
+ sock.on('data', (chunk) => {
93
+ buf += chunk;
94
+ const i = buf.indexOf('\n');
95
+ if (i < 0) return;
96
+ clearTimeout(timer);
97
+ if (settled) return;
98
+ settled = true;
99
+ sock.destroy();
100
+ const parsed = parsePipeLine(buf.slice(0, i));
101
+ if (!parsed) {
102
+ reject(new Error(`pipe ${pipeName}: bad response frame`));
103
+ return;
104
+ }
105
+ resolve(parsed);
106
+ });
107
+ sock.on('error', (err) => {
108
+ if (!settled) {
109
+ settled = true;
110
+ clearTimeout(timer);
111
+ reject(err);
112
+ }
113
+ });
114
+ });
115
+ }
116
+
117
+ /** 带重试的 ping(就绪探测;D47 取代 waitSubReady 的 agent 状态门)。 */
118
+ export async function pingUntilReady(
119
+ pipeName: string,
120
+ timeoutMs: number,
121
+ intervalMs = 1000,
122
+ ): Promise<boolean> {
123
+ const deadline = Date.now() + timeoutMs;
124
+ while (Date.now() < deadline) {
125
+ try {
126
+ const res = await pipeRequest(pipeName, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
127
+ if (res.type === 'ok') return true;
128
+ } catch {
129
+ /* 未就绪,继续 */
130
+ }
131
+ await new Promise((r) => setTimeout(r, intervalMs));
132
+ }
133
+ return false;
134
+ }
135
+
136
+ export type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
137
+
138
+ /**
139
+ * 服务端:每连接读一行 → 分发 handler → 回一行 → 断开。
140
+ * 返回 server 实例(调用方管理 close)。
141
+ */
142
+ export function startPipeServer(
143
+ pipeName: string,
144
+ handler: PipeMessageHandler,
145
+ ): net.Server {
146
+ const server = net.createServer((sock) => {
147
+ sock.setEncoding('utf8');
148
+ let buf = '';
149
+ sock.on('data', (chunk) => {
150
+ buf += chunk;
151
+ const i = buf.indexOf('\n');
152
+ if (i < 0) return;
153
+ const line = buf.slice(0, i);
154
+ buf = buf.slice(i + 1);
155
+ const req = parsePipeLine(line);
156
+ if (!req) {
157
+ sock.end(JSON.stringify({ type: 'error', id: '', message: 'bad frame' }) + '\n');
158
+ return;
159
+ }
160
+ void handler(req)
161
+ .then((res) => {
162
+ sock.end(JSON.stringify(res) + '\n');
163
+ })
164
+ .catch((err: unknown) => {
165
+ sock.end(JSON.stringify({
166
+ type: 'error',
167
+ id: req.id,
168
+ message: String((err as Error)?.message ?? err),
169
+ }) + '\n');
170
+ });
171
+ });
172
+ sock.on('error', () => {
173
+ /* 客户端断开等:静默 */
174
+ });
175
+ });
176
+ server.on('error', () => {
177
+ /* 地址占用等:由调用方观察(listen 抛错由 startPipeServer 调用方处理) */
178
+ });
179
+ server.listen(pipePathFor(pipeName));
180
+ return server;
181
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * M16 进度可见性纯核心(开发方案.md §M16;保守形态优先,D3 无新协议)。
3
+ *
4
+ * 承载通道(post-M22 世界):
5
+ * - 进度徽标(N/M + 可选 ETA)→ pane title 内嵌(state_labels 键仅五态,进不了 label);
6
+ * - 工具徽标(🔧 tool)→ report_agent.message;
7
+ * - 速率估算对齐 kimi 估算器语义:速率窗口、未完成 cap、置信度门。
8
+ *
9
+ * 保守原则(风险⑥拍板):估算不可信时回退纯计数「N/M」,绝不显示误导性 ETA。
10
+ */
11
+ import { countTodos, type TodoItem } from './vocab.ts';
12
+
13
+ /** ETA 需要的最少完成点数(<2 无法算速率)。 */
14
+ export const ETA_MIN_SAMPLES = 2;
15
+ /** 速率样本窗口(kimi rate window 语义;取最近 K 个完成点算速率)。 */
16
+ export const RATE_WINDOW_MS = 45_000;
17
+ /** 未完成进度显示上限(kimi unfinished cap 语义;防"快完了"假象)。 */
18
+ export const UNFINISHED_CAP = 0.85;
19
+ /** 完成点新鲜度门:最近完成点距今超过该值 → 数据陈旧,不估 ETA。 */
20
+ export const PROGRESS_STALE_MS = 5 * 60_000;
21
+ /** 速率拟合取最近 K 个完成点。 */
22
+ const RATE_SAMPLE_POINTS = 5;
23
+
24
+ export interface EtaEstimate {
25
+ /** 剩余步数。 */
26
+ remaining: number;
27
+ /** 预计剩余时间(毫秒)。 */
28
+ etaMs: number;
29
+ confidence: 'ok';
30
+ }
31
+
32
+ /**
33
+ * 速率估算:完成时间戳 → 剩余步数的 ETA。
34
+ * - 完成点 <2 → null(无速率可言);
35
+ * - 最近完成点距今 > PROGRESS_STALE_MS → null(陈旧:爆发后停顿/人为搁置);
36
+ * - 速率 = 取最近 K(≤5) 个点,(k-1) / (t_k - t_1);eta = remaining / rate。
37
+ */
38
+ export function estimateEta(opts: {
39
+ completedAt: readonly number[];
40
+ total: number;
41
+ now: number;
42
+ }): EtaEstimate | null {
43
+ const { completedAt, total, now } = opts;
44
+ if (total <= 0) return null;
45
+ const pts = [...completedAt].filter((t) => Number.isFinite(t)).sort((a, b) => a - b);
46
+ const completed = pts.length;
47
+ if (completed < ETA_MIN_SAMPLES) return null;
48
+ if (now - pts[pts.length - 1] > PROGRESS_STALE_MS) return null;
49
+ const remaining = total - completed;
50
+ if (remaining <= 0) return { remaining: 0, etaMs: 0, confidence: 'ok' };
51
+ const sample = pts.slice(-RATE_SAMPLE_POINTS);
52
+ const span = sample[sample.length - 1] - sample[0];
53
+ const steps = sample.length - 1;
54
+ if (steps < 1 || span <= 0) return null;
55
+ const ratePerMs = steps / span;
56
+ return { remaining, etaMs: Math.round(remaining / ratePerMs), confidence: 'ok' };
57
+ }
58
+
59
+ /** title 进度后缀:保守 `3/7`;置信 ETA 时 `3/7 ~4m`;total=0 → 空串;全完成 `5/5 ✓`。 */
60
+ export function formatProgressSuffix(opts: {
61
+ completed: number;
62
+ total: number;
63
+ eta: EtaEstimate | null;
64
+ }): string {
65
+ const { completed, total, eta } = opts;
66
+ if (total <= 0) return '';
67
+ const base = completed >= total ? `${completed}/${total} ✓` : `${completed}/${total}`;
68
+ if (!eta || eta.etaMs <= 0) return base;
69
+ const human = eta.etaMs < 60_000 ? '<1m'
70
+ : (() => {
71
+ const mins = Math.ceil(eta.etaMs / 60_000);
72
+ return mins < 60 ? `~${mins}m` : `~${Math.round((mins / 60) * 10) / 10}h`;
73
+ })();
74
+ return `${base} ${human}`;
75
+ }
76
+
77
+ /** 工具徽标(report_agent.message):单工具名;多工具首 + 计数;空 → null(不覆盖)。 */
78
+ export function planToolBadge(runningToolNames: readonly string[]): string | null {
79
+ if (runningToolNames.length === 0) return null;
80
+ const first = runningToolNames[0];
81
+ return runningToolNames.length === 1 ? `🔧 ${first}` : `🔧 ${first} +${runningToolNames.length - 1}`;
82
+ }
83
+
84
+ /** 从列表算进度输入(completed 计数 / total 开放计数)。 */
85
+ export function progressOf(items: readonly TodoItem[]): { completed: number; total: number } {
86
+ const c = countTodos(items);
87
+ const blockedCount = items.filter((it) => it.status === 'blocked').length;
88
+ return { completed: c.completed, total: c.completed + c.pending + c.inProgress + blockedCount };
89
+ }
90
+
91
+ /** 进度徽标徽记整个生命周期后不再显示(防"完成态常驻"噪音;调用方比对用)。 */
92
+ export const PROGRESS_HIDE_MS = 60_000;
93
+
94
+ /**
95
+ * 两份列表间的 completed 转换数(按 content 匹配;新列表中 completed 且旧状态非
96
+ * completed —— 含新增即 completed 的条目)。M16 速率估算的原料:所有编辑路径
97
+ * (todo_write / 人类编辑 / M17 对账)都过 TodosService,在此统一 diff。
98
+ */
99
+ export function countCompletedTransitions(
100
+ before: readonly TodoItem[],
101
+ after: readonly TodoItem[],
102
+ ): number {
103
+ const oldStatus = new Map(before.map((it) => [it.content, it.status]));
104
+ let n = 0;
105
+ for (const it of after) {
106
+ if (it.status === 'completed' && oldStatus.get(it.content) !== 'completed') n += 1;
107
+ }
108
+ return n;
109
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * M17:todo↔subagent 自动对账纯规划器(开发方案.md §M17;P2 = 低置信度不勾+提示)。
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 条目,分支回放生效)。
13
+ */
14
+ import { applyTodoEdits, type TodoEdit, type TodoItem } from './todo-core.ts';
15
+
16
+ export type ReconcileOutcome = 'settled' | 'failed';
17
+ export type MatchTier = 'exact' | 'prefix' | 'substring' | null;
18
+
19
+ export interface ReconcilePlan {
20
+ /** 应用 edits 后的列表(无编辑 = 原引用)。 */
21
+ items: TodoItem[];
22
+ /** 经权威路径持久化的编辑(done 至多一条 + unblock 若干)。 */
23
+ edits: TodoEdit[];
24
+ /** 被自动勾掉的条目(至多一条)。 */
25
+ completed: TodoItem | null;
26
+ /** 自动勾的匹配档位(candidates 存在时的最优档;无候选 null)。 */
27
+ tier: MatchTier;
28
+ /** 被解锁的条目(blocker 匹配)。 */
29
+ unblocked: TodoItem[];
30
+ /** 随结算通知注入的提示行(对齐 D36 反馈语风;无提示 = 空数组)。 */
31
+ noteLines: string[];
32
+ /** 匹配率指标(P2:采集用,随结算通知落会话 JSONL)。 */
33
+ metric: {
34
+ description: string;
35
+ outcome: ReconcileOutcome;
36
+ bestTier: MatchTier;
37
+ candidates: number;
38
+ autoCompleted: boolean;
39
+ unblocked: number;
40
+ };
41
+ }
42
+
43
+ function norm(s: string): string {
44
+ return s.trim().toLowerCase().replace(/\s+/g, ' ');
45
+ }
46
+
47
+ function matchTier(a: string, b: string): MatchTier {
48
+ const na = norm(a);
49
+ const nb = norm(b);
50
+ if (!na || !nb) return null;
51
+ if (na === nb) return 'exact';
52
+ if (na.startsWith(nb) || nb.startsWith(na)) return 'prefix';
53
+ if (na.includes(nb) || nb.includes(na)) return 'substring';
54
+ return null;
55
+ }
56
+
57
+ const TIER_ORDER: Record<Exclude<MatchTier, null>, number> = { exact: 0, prefix: 1, substring: 2 };
58
+
59
+ export function reconcileTodos(
60
+ prev: readonly TodoItem[],
61
+ opts: { description: string; outcome: ReconcileOutcome },
62
+ ): ReconcilePlan {
63
+ const { description, outcome } = opts;
64
+ const edits: TodoEdit[] = [];
65
+ const noteLines: string[] = [];
66
+
67
+ // 1) 勾选候选:pending / in_progress(completed/abandoned 永不参与)
68
+ const matchable = prev.filter((t) => t.status === 'pending' || t.status === 'in_progress');
69
+ const scored = matchable
70
+ .map((t) => ({ item: t, tier: matchTier(description, t.content) }))
71
+ .filter((c): c is { item: TodoItem; tier: Exclude<MatchTier, null> } => c.tier !== null)
72
+ .sort((a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier]);
73
+ const bestTier = scored.length ? scored[0].tier : null;
74
+ const best = bestTier ? scored.filter((c) => c.tier === bestTier) : [];
75
+
76
+ let completed: TodoItem | null = null;
77
+ if (bestTier === 'exact' || bestTier === 'prefix') {
78
+ if (best.length === 1 && outcome === 'settled') {
79
+ completed = best[0].item;
80
+ edits.push({ op: 'done', content: completed.content });
81
+ noteLines.push(`Reconciled: completed "${completed.content}" (${bestTier} match with subagent description).`);
82
+ } else if (best.length > 1) {
83
+ // 歧义:列候选,留给人
84
+ noteLines.push(
85
+ `Todo match ambiguous between: ${best.map((c) => `"${c.item.content}"`).join(', ')} — update todo_write yourself.`,
86
+ );
87
+ } else if (outcome === 'failed') {
88
+ // 高置信候选但子代理未成功结算 → 保持打开,提示
89
+ for (const c of best) {
90
+ noteLines.push(`Todo kept open: "${c.item.content}" (subagent did not settle successfully).`);
91
+ }
92
+ }
93
+ } else if (bestTier === 'substring') {
94
+ // 低置信度(P2):不勾,提示候选
95
+ for (const c of best) {
96
+ noteLines.push(`Todo not auto-completed (low-confidence match): "${c.item.content}" — update todo_write if this work is done.`);
97
+ }
98
+ }
99
+
100
+ // 2) 解锁:blocker 任意档匹配(含 substring——真实 blocker 是「等 X 完成」短语,
101
+ // 描述嵌在中间,prefix 会漏;低置信度闸只管勾选,解锁回 pending 是软操作)且 settled(可多条)
102
+ const unblocked: TodoItem[] = [];
103
+ if (outcome === 'settled') {
104
+ for (const t of prev) {
105
+ if (t.status !== 'blocked' || typeof t.blocker !== 'string') continue;
106
+ if (matchTier(description, t.blocker) !== null) {
107
+ unblocked.push(t);
108
+ edits.push({ op: 'unblock', content: t.content });
109
+ noteLines.push(`Unblocked "${t.content}" (was waiting on: ${t.blocker}).`);
110
+ }
111
+ }
112
+ }
113
+
114
+ return {
115
+ items: edits.length ? applyTodoEdits(prev, edits) : prev as TodoItem[],
116
+ edits,
117
+ completed,
118
+ tier: bestTier,
119
+ unblocked,
120
+ noteLines,
121
+ metric: {
122
+ description,
123
+ outcome,
124
+ bestTier,
125
+ candidates: scored.length,
126
+ autoCompleted: completed !== null,
127
+ unblocked: unblocked.length,
128
+ },
129
+ };
130
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * 档2 Week1:role 档案加载器(2026-08-18);v1.1(2026-08-22)两层用户目录。
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/`(本包随附)兜底
8
+ *
9
+ * 内置名保留(master / worker-default):用户/工作区层撞名直接拒绝——D83 镜像
10
+ * 单测与 D82 姿态锚在内置档案上,被覆盖会破坏这些保证。
11
+ *
12
+ * 角色名白名单 [a-z0-9-]+ —— 防路径穿越(不合法名不触盘)。
13
+ * 档案 role 字段必须与文件名一致(防错挂)。
14
+ * 校验失败分类:读不到 = ROLE_NOT_FOUND(逐层尝试后才报);读到了但
15
+ * JSON/校验/名字不符 = INVALID_ROLE_CONFIG(**在命中层立即报**,不静默落到
16
+ * 下一层——静默会掩盖用户改了档案却写错的问题)。
17
+ */
18
+ import { existsSync, readFileSync } from 'node:fs';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+ import { validateRoleManifest, type RoleManifest } from './role-manifest.ts';
23
+
24
+ /** 内置档案目录(src/roles/)。 */
25
+ export const ROLES_DIR = join(dirname(fileURLToPath(import.meta.url)), 'roles');
26
+
27
+ /** workspace 级目录(相对基准默认 = 进程 cwd,即 master 的工作目录)。 */
28
+ 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');
35
+ }
36
+
37
+ /** 内置保留名(不可被任何用户层覆盖)。 */
38
+ export const RESERVED_ROLE_NAMES: readonly string[] = ['master', 'worker-default'];
39
+
40
+ const ROLE_NAME_RE = /^[a-z0-9-]+$/;
41
+
42
+ export class RoleLoaderError extends Error {
43
+ readonly code: 'ROLE_NOT_FOUND' | 'INVALID_ROLE_CONFIG' | 'ROLE_RESERVED';
44
+ readonly issues: readonly string[];
45
+ constructor(
46
+ code: 'ROLE_NOT_FOUND' | 'INVALID_ROLE_CONFIG' | 'ROLE_RESERVED',
47
+ message: string,
48
+ issues: readonly string[] = [],
49
+ ) {
50
+ super(message);
51
+ this.name = 'RoleLoaderError';
52
+ this.code = code;
53
+ this.issues = issues;
54
+ }
55
+ }
56
+
57
+ export type RoleReader = (fileName: string) => string;
58
+
59
+ /** 单层读取器(注入式;生产 = existsSync + readFileSync 该目录)。 */
60
+ export type LayerReader = (dir: string, fileName: string) => string | null;
61
+
62
+ const defaultLayerRead: LayerReader = (dir, fileName) => {
63
+ const p = resolve(dir, fileName);
64
+ if (!existsSync(p)) return null;
65
+ return readFileSync(p, 'utf8');
66
+ };
67
+
68
+ export interface LoadRoleOptions {
69
+ /** 单 reader 注入(旧形态:仅命中内置层——测试兼容用)。 */
70
+ read?: RoleReader;
71
+ /** 多层 reader 注入(v1.1)。 */
72
+ layerRead?: LayerReader;
73
+ /** workspace 层基准目录(默认 process.cwd())。 */
74
+ baseDir?: string;
75
+ /**
76
+ * 内置名直读:master 自应用(WS-D7)专用——保留名跳过用户层与撞名检查,
77
+ * 永远加载内置档案。否则 workspace 放 master.json 会让自应用 fail-open,
78
+ * master 静默丢失 manifest(d11 活体抓到的边界)。
79
+ */
80
+ builtinDirect?: boolean;
81
+ }
82
+
83
+ /** 解析查找层序:workspace → 用户全局 → 内置(每层 {label, dir})。 */
84
+ export function roleLayers(opts?: { baseDir?: string }): Array<{ label: string; dir: string }> {
85
+ const base = opts?.baseDir && isAbsolute(opts.baseDir) ? opts.baseDir
86
+ : resolve(opts?.baseDir ?? process.cwd());
87
+ return [
88
+ { label: 'workspace (.pi-herdr/roles/)', dir: workspaceRolesDir(base) },
89
+ { label: `user (${'~/.pi/agent/herdr-pi/roles/'})`, dir: userRolesDir() },
90
+ { label: 'builtin (src/roles/)', dir: ROLES_DIR },
91
+ ];
92
+ }
93
+
94
+ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManifest {
95
+ if (typeof name !== 'string' || !ROLE_NAME_RE.test(name)) {
96
+ throw new RoleLoaderError('ROLE_NOT_FOUND', `role 不存在或名字非法([a-z0-9-]+): ${JSON.stringify(name)}`);
97
+ }
98
+ const fileName = `${name}.json`;
99
+
100
+ // 旧形态单 reader:直接按内置层语义(既有单测/合成器注入路径不破)
101
+ if (opts?.read && !opts.layerRead) {
102
+ let text: string;
103
+ try {
104
+ text = opts.read(fileName);
105
+ } catch (err) {
106
+ const code = (err as { code?: unknown })?.code;
107
+ throw new RoleLoaderError('ROLE_NOT_FOUND', `role "${name}" 不存在(读 ${fileName} 失败: ${typeof code === 'string' ? code : 'error'})`);
108
+ }
109
+ return parseAndValidate(name, fileName, text, 'builtin (src/roles/)');
110
+ }
111
+
112
+ const layerRead = opts?.layerRead ?? defaultLayerRead;
113
+ let layers = roleLayers({ baseDir: opts?.baseDir });
114
+
115
+ // 内置名直读(自应用):只查内置层,不受用户层诱饵/撞名影响
116
+ if (opts?.builtinDirect && RESERVED_ROLE_NAMES.includes(name)) {
117
+ layers = [layers[2]];
118
+ } else if (RESERVED_ROLE_NAMES.includes(name)) {
119
+ // 内置名保留:workspace/用户层撞名 → 拒绝(spawn 侧响亮报错,不静默覆盖内置语义)
120
+ for (const layer of layers.slice(0, 2)) {
121
+ if (layerRead(layer.dir, fileName) != null) {
122
+ throw new RoleLoaderError(
123
+ 'ROLE_RESERVED',
124
+ `role "${name}" 是内置保留名,不能在 ${layer.label} 层定义(内置档案优先;请删除该档案或改名)`,
125
+ );
126
+ }
127
+ }
128
+ }
129
+
130
+ for (const layer of layers) {
131
+ const text = layerRead(layer.dir, fileName);
132
+ if (text == null) continue; // 本层无此档案 → 下一层
133
+ return parseAndValidate(name, fileName, text, layer.label); // 命中层:错误立即报
134
+ }
135
+ throw new RoleLoaderError(
136
+ 'ROLE_NOT_FOUND',
137
+ `role "${name}" 不存在(查找层:${layers.map((l) => l.label).join(' → ')})`,
138
+ );
139
+ }
140
+
141
+ function parseAndValidate(name: string, fileName: string, text: string, layerLabel: string): RoleManifest {
142
+ let parsed: unknown;
143
+ try {
144
+ parsed = JSON.parse(text);
145
+ } catch (err) {
146
+ throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}"(${layerLabel})不是合法 JSON: ${(err as Error).message}`);
147
+ }
148
+ const result = validateRoleManifest(parsed);
149
+ if (!result.ok) {
150
+ throw new RoleLoaderError(
151
+ 'INVALID_ROLE_CONFIG',
152
+ `role "${name}"(${layerLabel})校验失败:\n${result.issues.map((i) => ` - ${i}`).join('\n')}`,
153
+ result.issues,
154
+ );
155
+ }
156
+ if (result.value.role !== name) {
157
+ throw new RoleLoaderError(
158
+ 'INVALID_ROLE_CONFIG',
159
+ `role 档案名不符:${layerLabel} 的 ${fileName} 内 role="${result.value.role}"`,
160
+ [`role 字段 "${result.value.role}" 与请求的 "${name}" 不一致`],
161
+ );
162
+ }
163
+ return result.value;
164
+ }