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.
- package/README.md +62 -0
- package/package.json +55 -0
- package/schemas/role-manifest.schema.json +53 -0
- package/skills/pi-herdr-workflow/SKILL.md +86 -0
- package/src/bootstrap.ts +106 -0
- package/src/core/grid-shape.ts +86 -0
- package/src/core/subagent.ts +1256 -0
- package/src/core/terminal.ts +359 -0
- package/src/core/todo.ts +313 -0
- package/src/gc-core.ts +56 -0
- package/src/herdr-client.ts +579 -0
- package/src/history-store.ts +115 -0
- package/src/index.ts +751 -0
- package/src/ledger.ts +71 -0
- package/src/lock-core.ts +155 -0
- package/src/manifest-compose.ts +102 -0
- package/src/notice-buffer.ts +28 -0
- package/src/pane-title.ts +84 -0
- package/src/pi-surface.ts +135 -0
- package/src/pipe-channel.ts +181 -0
- package/src/progress-core.ts +109 -0
- package/src/reconcile-core.ts +130 -0
- package/src/role-loader.ts +164 -0
- package/src/role-manifest.ts +166 -0
- package/src/roles/master.json +21 -0
- package/src/roles/worker-default.json +30 -0
- package/src/session-tail.ts +169 -0
- package/src/subagent-core.ts +310 -0
- package/src/subagent-scope.ts +39 -0
- package/src/terminal-core.ts +328 -0
- package/src/todo-core.ts +331 -0
- package/src/todo-read-hook.ts +54 -0
- package/src/todos-service.ts +66 -0
- package/src/tool-gate.ts +106 -0
- package/src/vocab.ts +82 -0
package/src/todo-core.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* todo 纯逻辑:校验 / 折叠 / M10 增量(D34–D43)。
|
|
3
|
+
*
|
|
4
|
+
* 全部无副作用、无 pi API 依赖 → 可单测(node --test 直接跑 TS)。
|
|
5
|
+
* 语义 = DESIGN.md §5.1.1 + §15:全量替换(last-wins),五态 + blocker + phase。
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
TODO_DETAILS_KEY,
|
|
9
|
+
TODO_STATUSES,
|
|
10
|
+
TODO_TOOL_NAME,
|
|
11
|
+
type TodoCounts,
|
|
12
|
+
type TodoItem,
|
|
13
|
+
type TodoSnapshot,
|
|
14
|
+
type TodoStatus,
|
|
15
|
+
} from './vocab.ts';
|
|
16
|
+
import { countTodos } from './vocab.ts';
|
|
17
|
+
|
|
18
|
+
export type { TodoItem, TodoSnapshot, TodoStatus, TodoCounts };
|
|
19
|
+
|
|
20
|
+
/** D43:phase 名长度上限。 */
|
|
21
|
+
export const PHASE_MAX_LEN = 30;
|
|
22
|
+
|
|
23
|
+
/** D38:人类编辑 custom 条目类型(/todos done/drop/rm 写回权威的通道)。 */
|
|
24
|
+
export const TODO_EDIT_CUSTOM_TYPE = 'pi-herdr.todo-edit';
|
|
25
|
+
|
|
26
|
+
/** 人类编辑操作(D38):done=completed、drop=abandoned、rm=移除、unblock=blocked→pending(M17)。 */
|
|
27
|
+
export type TodoEditOp = 'done' | 'drop' | 'rm' | 'unblock';
|
|
28
|
+
|
|
29
|
+
export interface TodoEdit {
|
|
30
|
+
op: TodoEditOp;
|
|
31
|
+
content: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TodoEditPayload {
|
|
35
|
+
version: 1;
|
|
36
|
+
edits: TodoEdit[];
|
|
37
|
+
ts: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface TodoValidationResult {
|
|
41
|
+
ok: boolean;
|
|
42
|
+
items?: TodoItem[];
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 校验一次 todo_write 的入参并规范化为条目数组(D34/D43 扩展)。
|
|
48
|
+
* 规则:
|
|
49
|
+
* - todos 必须是数组(空数组合法 = 清空列表);
|
|
50
|
+
* - 每条允许键 = content/status/blocker/phase,不允许其他键;
|
|
51
|
+
* - content 非空且全局唯一;status ∈ 五态;
|
|
52
|
+
* - blocker 仅 status==='blocked' 时允许(非空字符串),其他状态携带即拒绝;
|
|
53
|
+
* - phase 可选:非空、≤30 字符;
|
|
54
|
+
* - allowParallelInProgress=false 时,至多一条 in_progress(B2 严格模式的第一道闸)。
|
|
55
|
+
*/
|
|
56
|
+
export function validateTodos(
|
|
57
|
+
todos: unknown,
|
|
58
|
+
allowParallelInProgress = true,
|
|
59
|
+
): TodoValidationResult {
|
|
60
|
+
if (!Array.isArray(todos)) {
|
|
61
|
+
return { ok: false, error: 'invalid todos: `todos` must be an array' };
|
|
62
|
+
}
|
|
63
|
+
const seen = new Set<string>();
|
|
64
|
+
let inProgress = 0;
|
|
65
|
+
const items: TodoItem[] = [];
|
|
66
|
+
for (const raw of todos) {
|
|
67
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
68
|
+
return { ok: false, error: 'invalid todos: each todo must be an object' };
|
|
69
|
+
}
|
|
70
|
+
const entry = raw as Record<string, unknown>;
|
|
71
|
+
const allowed = ['content', 'status', 'blocker', 'phase'];
|
|
72
|
+
const keys = Object.keys(entry);
|
|
73
|
+
for (const k of keys) {
|
|
74
|
+
if (!allowed.includes(k)) {
|
|
75
|
+
return { ok: false, error: `invalid todos: unknown field "${k}" (allowed: content, status, blocker, phase)` };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (!('content' in entry) || !('status' in entry)) {
|
|
79
|
+
return { ok: false, error: 'invalid todos: each todo must have `content` and `status`' };
|
|
80
|
+
}
|
|
81
|
+
const content = entry.content;
|
|
82
|
+
if (typeof content !== 'string' || content.trim() === '') {
|
|
83
|
+
return { ok: false, error: 'invalid todo: `content` must be a non-empty string' };
|
|
84
|
+
}
|
|
85
|
+
if (seen.has(content)) {
|
|
86
|
+
return { ok: false, error: `invalid todos: duplicate content "${content}"` };
|
|
87
|
+
}
|
|
88
|
+
seen.add(content);
|
|
89
|
+
const status = entry.status;
|
|
90
|
+
if (typeof status !== 'string' || !(TODO_STATUSES as readonly string[]).includes(status)) {
|
|
91
|
+
return { ok: false, error: `invalid todo: \`status\` must be one of ${TODO_STATUSES.join('|')}` };
|
|
92
|
+
}
|
|
93
|
+
const item: TodoItem = { content, status: status as TodoStatus };
|
|
94
|
+
if ('blocker' in entry) {
|
|
95
|
+
const blocker = entry.blocker;
|
|
96
|
+
if (status !== 'blocked') {
|
|
97
|
+
return { ok: false, error: `invalid todo: \`blocker\` is only allowed when status is "blocked" (got "${status}")` };
|
|
98
|
+
}
|
|
99
|
+
if (typeof blocker !== 'string' || blocker.trim() === '') {
|
|
100
|
+
return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
|
|
101
|
+
}
|
|
102
|
+
item.blocker = blocker;
|
|
103
|
+
}
|
|
104
|
+
if ('phase' in entry) {
|
|
105
|
+
const phase = entry.phase;
|
|
106
|
+
if (typeof phase !== 'string' || phase.trim() === '' || phase.length > PHASE_MAX_LEN) {
|
|
107
|
+
return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
|
|
108
|
+
}
|
|
109
|
+
item.phase = phase;
|
|
110
|
+
}
|
|
111
|
+
if (status === 'in_progress') {
|
|
112
|
+
inProgress++;
|
|
113
|
+
if (!allowParallelInProgress && inProgress > 1) {
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
error: `invalid todos: at most one task may be in_progress (got ${inProgress})`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
items.push(item);
|
|
121
|
+
}
|
|
122
|
+
return { ok: true, items };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* D42 严格模式归一化(OMP normalizeInProgressTask 语义,按 pane 角色自动推导):
|
|
127
|
+
* - 多余 in_progress(列表顺序第二条起)退回 pending;
|
|
128
|
+
* - 无 in_progress 且有 pending → 晋升列表顺序第一条 pending;
|
|
129
|
+
* - blocked/abandoned 不参与晋升。
|
|
130
|
+
* 返回新数组(不改入参)。
|
|
131
|
+
*/
|
|
132
|
+
export function normalizeStrict(items: readonly TodoItem[]): TodoItem[] {
|
|
133
|
+
if (items.length === 0) return [];
|
|
134
|
+
const out = items.map((it) => ({ ...it }));
|
|
135
|
+
let kept = false;
|
|
136
|
+
for (const it of out) {
|
|
137
|
+
if (it.status === 'in_progress') {
|
|
138
|
+
if (kept) it.status = 'pending';
|
|
139
|
+
else kept = true;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!kept) {
|
|
143
|
+
const firstPending = out.find((it) => it.status === 'pending');
|
|
144
|
+
if (firstPending) firstPending.status = 'in_progress';
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** D35:逐项全等比较(content/status/blocker/phase),用于 no-op 检测。 */
|
|
150
|
+
export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
|
|
151
|
+
if (a.length !== b.length) return false;
|
|
152
|
+
for (let i = 0; i < a.length; i++) {
|
|
153
|
+
const x = a[i];
|
|
154
|
+
const y = b[i];
|
|
155
|
+
if (x.content !== y.content || x.status !== y.status || x.blocker !== y.blocker || x.phase !== y.phase) return false;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** D36:本轮新变为 completed 的 content 列表(prev → next)。 */
|
|
161
|
+
export function completionTransitions(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
|
|
162
|
+
const prevMap = new Map(prev.map((it) => [it.content, it.status]));
|
|
163
|
+
const out: string[] = [];
|
|
164
|
+
for (const it of next) {
|
|
165
|
+
if (it.status === 'completed' && prevMap.get(it.content) !== 'completed') out.push(it.content);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** D37:上轮 completed、本轮退回 pending/in_progress 的 content 列表(软警告用)。 */
|
|
171
|
+
export function revertedCompleted(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
|
|
172
|
+
const nextMap = new Map(next.map((it) => [it.content, it.status]));
|
|
173
|
+
const out: string[] = [];
|
|
174
|
+
for (const it of prev) {
|
|
175
|
+
if (it.status !== 'completed') continue;
|
|
176
|
+
const now = nextMap.get(it.content);
|
|
177
|
+
if (now === 'pending' || now === 'in_progress') out.push(it.content);
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** 构造持久化快照(version 字段让消费方在未来形状变化时丢弃陈旧数据)。 */
|
|
183
|
+
export function makeSnapshot(items: TodoItem[]): TodoSnapshot {
|
|
184
|
+
return { version: 1, items };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface BranchEntryLike {
|
|
188
|
+
/** pi 会话条目:type 为 'message' 的消息条目或 'custom' 的自定义条目。 */
|
|
189
|
+
type?: string;
|
|
190
|
+
message?: {
|
|
191
|
+
role?: string;
|
|
192
|
+
toolName?: string;
|
|
193
|
+
details?: Record<string, unknown> | unknown;
|
|
194
|
+
} | unknown;
|
|
195
|
+
customType?: string;
|
|
196
|
+
data?: unknown;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* D38:在列表上应用人类编辑(幂等:目标不存在则 no-op)。
|
|
201
|
+
* - done:pending/in_progress/blocked → completed;completed 保持(completed 不可逆,D37);
|
|
202
|
+
* - drop:非 completed/abandoned → abandoned;
|
|
203
|
+
* - rm:移除(任意状态);
|
|
204
|
+
* - unblock(M17):blocked → pending(清 blocker);非 blocked 为 no-op。
|
|
205
|
+
*/
|
|
206
|
+
export function applyTodoEdits(items: readonly TodoItem[], edits: readonly TodoEdit[]): TodoItem[] {
|
|
207
|
+
let out = items.map((it) => ({ ...it }));
|
|
208
|
+
for (const edit of edits) {
|
|
209
|
+
const target = out.find((it) => it.content === edit.content);
|
|
210
|
+
if (!target) continue;
|
|
211
|
+
switch (edit.op) {
|
|
212
|
+
case 'done':
|
|
213
|
+
if (target.status !== 'completed') {
|
|
214
|
+
target.status = 'completed';
|
|
215
|
+
delete target.blocker;
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
case 'drop':
|
|
219
|
+
if (target.status !== 'completed' && target.status !== 'abandoned') {
|
|
220
|
+
target.status = 'abandoned';
|
|
221
|
+
delete target.blocker;
|
|
222
|
+
}
|
|
223
|
+
break;
|
|
224
|
+
case 'rm':
|
|
225
|
+
out = out.filter((it) => it.content !== edit.content);
|
|
226
|
+
break;
|
|
227
|
+
case 'unblock':
|
|
228
|
+
if (target.status === 'blocked') {
|
|
229
|
+
target.status = 'pending';
|
|
230
|
+
delete target.blocker;
|
|
231
|
+
}
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function extractEditPayload(data: unknown): TodoEditPayload | null {
|
|
239
|
+
if (typeof data !== 'object' || data === null) return null;
|
|
240
|
+
const raw = data as Partial<TodoEditPayload>;
|
|
241
|
+
if (raw.version !== 1 || !Array.isArray(raw.edits)) return null;
|
|
242
|
+
const edits: TodoEdit[] = [];
|
|
243
|
+
for (const e of raw.edits) {
|
|
244
|
+
if (
|
|
245
|
+
typeof e === 'object' && e !== null &&
|
|
246
|
+
(e.op === 'done' || e.op === 'drop' || e.op === 'rm' || e.op === 'unblock') &&
|
|
247
|
+
typeof (e as TodoEdit).content === 'string'
|
|
248
|
+
) {
|
|
249
|
+
edits.push({ op: (e as TodoEdit).op, content: (e as TodoEdit).content });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (edits.length === 0) return null;
|
|
253
|
+
return { version: 1, edits, ts: typeof raw.ts === 'number' ? raw.ts : 0 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 从 pi 会话分支条目里折叠出最新 todo 列表(D38 双源):
|
|
258
|
+
* - toolResult(todo_write) → 快照替换(last-wins);
|
|
259
|
+
* - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
|
|
260
|
+
* 分支正确性 = 沿分支路径顺序折叠。
|
|
261
|
+
*/
|
|
262
|
+
export function foldLatestTodos(entries: readonly BranchEntryLike[]): TodoItem[] | null {
|
|
263
|
+
let found: TodoItem[] | null = null;
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
if (entry.type === 'message') {
|
|
266
|
+
const msg = entry.message;
|
|
267
|
+
if (typeof msg !== 'object' || msg === null) continue;
|
|
268
|
+
if (msg.role !== 'toolResult' || msg.toolName !== TODO_TOOL_NAME) continue;
|
|
269
|
+
const snapshot = extractSnapshotFromDetails(msg.details);
|
|
270
|
+
if (snapshot) found = snapshot.items.map((it) => ({ ...it }));
|
|
271
|
+
} else if (entry.type === 'custom' && entry.customType === TODO_EDIT_CUSTOM_TYPE) {
|
|
272
|
+
const payload = extractEditPayload(entry.data);
|
|
273
|
+
if (payload && found) found = applyTodoEdits(found, payload.edits);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return found;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
|
|
280
|
+
if (typeof details !== 'object' || details === null) return null;
|
|
281
|
+
const raw = (details as Record<string, unknown>)[TODO_DETAILS_KEY];
|
|
282
|
+
if (typeof raw !== 'object' || raw === null) return null;
|
|
283
|
+
const snap = raw as Partial<TodoSnapshot>;
|
|
284
|
+
if (snap.version !== 1 || !Array.isArray(snap.items)) return null;
|
|
285
|
+
return { version: 1, items: snap.items as TodoItem[] };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** D38:模糊匹配(精确 → 前缀 → 子串),返回候选 content 列表(多候选 = 歧义)。 */
|
|
289
|
+
export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
|
|
290
|
+
const q = query.trim();
|
|
291
|
+
if (!q) return [];
|
|
292
|
+
const exact = items.filter((it) => it.content === q);
|
|
293
|
+
if (exact.length > 0) return [exact[0].content];
|
|
294
|
+
const prefix = items.filter((it) => it.content.startsWith(q));
|
|
295
|
+
if (prefix.length === 1) return [prefix[0].content];
|
|
296
|
+
if (prefix.length > 1) return prefix.map((it) => it.content);
|
|
297
|
+
const substr = items.filter((it) => it.content.includes(q));
|
|
298
|
+
return substr.map((it) => it.content);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** D39:有界视图选择——先丢 completed、再截断尾部;返回可见条目与摘要。 */
|
|
302
|
+
export interface BoundedView {
|
|
303
|
+
visible: TodoItem[];
|
|
304
|
+
/** 被隐藏的数量按状态计数(completed 单独计,其余合并)。 */
|
|
305
|
+
hiddenCompleted: number;
|
|
306
|
+
hiddenOpen: number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function boundedView(items: readonly TodoItem[], budget: number): BoundedView {
|
|
310
|
+
if (items.length <= budget) {
|
|
311
|
+
return { visible: items.map((it) => ({ ...it })), hiddenCompleted: 0, hiddenOpen: 0 };
|
|
312
|
+
}
|
|
313
|
+
const completed = items.filter((it) => it.status === 'completed');
|
|
314
|
+
const open = items.filter((it) => it.status !== 'completed');
|
|
315
|
+
let visible = open.slice(0, budget);
|
|
316
|
+
const hiddenOpen = open.length - visible.length;
|
|
317
|
+
let hiddenCompleted = completed.length;
|
|
318
|
+
if (hiddenOpen === 0 && visible.length < budget) {
|
|
319
|
+
const room = budget - visible.length;
|
|
320
|
+
visible = [...visible, ...completed.slice(0, room)];
|
|
321
|
+
hiddenCompleted = completed.length - room;
|
|
322
|
+
}
|
|
323
|
+
return { visible, hiddenCompleted, hiddenOpen };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** 当前 in_progress 中的第一条(上报 agent 状态时作为 message,供侧边栏显示"正在做什么")。 */
|
|
327
|
+
export function currentActivity(items: TodoItem[]): string | null {
|
|
328
|
+
return items.find((it) => it.status === 'in_progress')?.content ?? null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
export { countTodos };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D69:todo 阅读钩子规划(before_agent_start + display:false)。
|
|
3
|
+
*/
|
|
4
|
+
import { boundedView, currentActivity, type TodoItem } from './todo-core.ts';
|
|
5
|
+
import { countTodos } from './vocab.ts';
|
|
6
|
+
|
|
7
|
+
export const EMPTY_GUARD_EVERY_N = 4;
|
|
8
|
+
export const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
|
|
9
|
+
|
|
10
|
+
export interface TodoReadPlan {
|
|
11
|
+
inject: boolean;
|
|
12
|
+
message: {
|
|
13
|
+
customType: string;
|
|
14
|
+
content: string;
|
|
15
|
+
display: false;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function planTodoReadHook(opts: {
|
|
20
|
+
items: readonly TodoItem[];
|
|
21
|
+
turn: number;
|
|
22
|
+
lastEmptyGuardTurn: number | null;
|
|
23
|
+
}): TodoReadPlan {
|
|
24
|
+
const empty = opts.items.length === 0;
|
|
25
|
+
if (empty) {
|
|
26
|
+
const due = opts.lastEmptyGuardTurn == null
|
|
27
|
+
|| opts.turn - opts.lastEmptyGuardTurn >= EMPTY_GUARD_EVERY_N;
|
|
28
|
+
return {
|
|
29
|
+
inject: due,
|
|
30
|
+
message: {
|
|
31
|
+
customType: TODO_READ_CUSTOM_TYPE,
|
|
32
|
+
content: 'Your todo list is empty. If the current work needs tracking, call todo_write before stopping.',
|
|
33
|
+
display: false,
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const c = countTodos(opts.items);
|
|
38
|
+
const view = boundedView(opts.items, 6);
|
|
39
|
+
const lines = view.visible.map((it) => {
|
|
40
|
+
const mark = it.status === 'in_progress' ? '▶' : it.status === 'blocked' ? '■' : it.status === 'completed' ? '✓' : '○';
|
|
41
|
+
return `${mark} ${it.content}`;
|
|
42
|
+
});
|
|
43
|
+
const activity = currentActivity(opts.items as TodoItem[]);
|
|
44
|
+
const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
|
|
45
|
+
+ (activity ? ` · ${activity}` : '');
|
|
46
|
+
return {
|
|
47
|
+
inject: true,
|
|
48
|
+
message: {
|
|
49
|
+
customType: TODO_READ_CUSTOM_TYPE,
|
|
50
|
+
content: [head, ...lines].join('\n'),
|
|
51
|
+
display: false,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档 0:todos 升为进程内 Service(D65 / D75 阶段 2)。
|
|
3
|
+
* 持久化仍是 pi 会话分支重放;D75 阶段 3:config 从 role manifest services 注入
|
|
4
|
+
* (无 manifest 的 master / 纯标签 worker 走默认——worker serial、master parallel)。
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import {
|
|
8
|
+
applyTodoEdits,
|
|
9
|
+
foldLatestTodos,
|
|
10
|
+
type TodoEdit,
|
|
11
|
+
type TodoItem,
|
|
12
|
+
} from './todo-core.ts';
|
|
13
|
+
import { countCompletedTransitions } from './progress-core.ts';
|
|
14
|
+
import type { RuntimeRoleManifest } from './tool-gate.ts';
|
|
15
|
+
|
|
16
|
+
export interface TodosConfig {
|
|
17
|
+
strict: boolean;
|
|
18
|
+
allowParallelInProgress: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class TodosService extends EventEmitter {
|
|
22
|
+
items: TodoItem[] = [];
|
|
23
|
+
readonly config: TodosConfig;
|
|
24
|
+
|
|
25
|
+
constructor(config: TodosConfig) {
|
|
26
|
+
super();
|
|
27
|
+
this.config = config;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** D75 阶段 3:role manifest services.todos(mode/reminderLimit)驱动;strict = 进程身份(worker)。 */
|
|
31
|
+
static configFromRuntime(manifest: RuntimeRoleManifest | null, isSubagent: boolean): TodosConfig {
|
|
32
|
+
const strict = isSubagent;
|
|
33
|
+
const mode = manifest?.services?.todos?.mode;
|
|
34
|
+
const allowParallelInProgress = mode === 'parallel' || mode === 'serial' ? mode === 'parallel' : !strict;
|
|
35
|
+
return { strict, allowParallelInProgress };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
replace(items: readonly TodoItem[]): void {
|
|
39
|
+
const before = this.items;
|
|
40
|
+
this.items = items.map((it) => ({ ...it }));
|
|
41
|
+
this.emitCompletedTransitions(before, this.items);
|
|
42
|
+
this.emit('todo.updated', { items: this.items });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
applyEdits(edits: readonly TodoEdit[]): void {
|
|
46
|
+
const before = this.items;
|
|
47
|
+
this.items = applyTodoEdits(this.items, edits);
|
|
48
|
+
this.emitCompletedTransitions(before, this.items);
|
|
49
|
+
this.emit('todo.edited', { edits, items: this.items });
|
|
50
|
+
this.emit('todo.updated', { items: this.items });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** M16:completed 转换数(所有编辑路径统一 diff;速率估算的原料)。 */
|
|
54
|
+
private emitCompletedTransitions(before: readonly TodoItem[], after: readonly TodoItem[]): void {
|
|
55
|
+
const count = countCompletedTransitions(before, after);
|
|
56
|
+
if (count > 0) this.emit('todo.completed', { count, at: Date.now() });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
rebuild(entries: readonly unknown[]): void {
|
|
60
|
+
// 与 v1.x rebuildFromBranch 同语义:分支上折叠不到快照时保留现值
|
|
61
|
+
//(重建失败不影响主流程;下次 todo_write 会重新锚定)。
|
|
62
|
+
const folded = foldLatestTodos(entries as Parameters<typeof foldLatestTodos>[0]);
|
|
63
|
+
if (folded) this.items = folded;
|
|
64
|
+
this.emit('todo.updated', { items: this.items, reason: 'rebuild' });
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/tool-gate.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档2 Week4-5:worker 执行期强制(2026-08-18;C7 v2 / D76)。
|
|
3
|
+
*
|
|
4
|
+
* master 合成的 manifest 经 env 下发后,这里是 worker 侧的最后一道闸:
|
|
5
|
+
* - `planToolGate`:工具不在 manifest / 权限 deny → deny(安全护栏);
|
|
6
|
+
* ask → 标记(v1 退化:放行 + APPROVAL_NEEDED 日志,硬审批留 v2 拍板);
|
|
7
|
+
* - `planActiveTools`(D77 可见层):manifest 外的工具从模型视野移除——
|
|
8
|
+
* 不进 system prompt、不诱导尝试(上下文卫生,不止权限)。
|
|
9
|
+
* 限速机制已整体移除(WS-D6):权限边界是我们的职责,资源配额是
|
|
10
|
+
* 插件引入者/provider 的职责(D79 声明式引入,用户自负责)。
|
|
11
|
+
* 纯逻辑零依赖;接缝在 index.ts 的 tool_call 钩子与 session_start。
|
|
12
|
+
*/
|
|
13
|
+
import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
|
|
14
|
+
|
|
15
|
+
/** env 下发的运行时形态(PI_HERDR_ROLE_MANIFEST JSON)。 */
|
|
16
|
+
export interface RuntimeRoleManifest {
|
|
17
|
+
role: string;
|
|
18
|
+
version?: string;
|
|
19
|
+
tools: string[];
|
|
20
|
+
permissions: Record<string, PermissionAction>;
|
|
21
|
+
/** D82:未知工具姿态(缺省 deny = 安全默认)。 */
|
|
22
|
+
unknownTools?: UnknownToolStance;
|
|
23
|
+
services?: {
|
|
24
|
+
todos?: {
|
|
25
|
+
mode?: 'serial' | 'parallel';
|
|
26
|
+
reminderLimit?: number;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 安全解析 PI_HERDR_ROLE_MANIFEST env(畸形 → null = 不强制,fail-open 到无角色态)。 */
|
|
32
|
+
export function parseRuntimeManifest(envValue: string | undefined): RuntimeRoleManifest | null {
|
|
33
|
+
if (!envValue) return null;
|
|
34
|
+
try {
|
|
35
|
+
const m = JSON.parse(envValue) as RuntimeRoleManifest;
|
|
36
|
+
if (!m || typeof m.role !== 'string' || !Array.isArray(m.tools)) return null;
|
|
37
|
+
// D82 硬化:姿态非枚举值 → 收敛为 deny(安全默认),不整个丢弃 manifest
|
|
38
|
+
if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
|
|
39
|
+
m.unknownTools = 'deny';
|
|
40
|
+
}
|
|
41
|
+
return m;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type ToolGatePlan =
|
|
48
|
+
| { kind: 'open' }
|
|
49
|
+
| { kind: 'allow' }
|
|
50
|
+
| { kind: 'ask'; notice: string }
|
|
51
|
+
| { kind: 'deny'; reason: string };
|
|
52
|
+
|
|
53
|
+
export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | null): ToolGatePlan {
|
|
54
|
+
if (!manifest) return { kind: 'open' };
|
|
55
|
+
const perm = manifest.permissions[toolName] ?? manifest.permissions['*'] ?? 'allow';
|
|
56
|
+
const known = manifest.tools.includes(toolName);
|
|
57
|
+
const stance = manifest.unknownTools ?? 'deny';
|
|
58
|
+
// D82:deny 规则最高优先;未知工具按姿态(deny=拒,allow=按规则走,* 兜底)
|
|
59
|
+
if (perm === 'deny' || (!known && stance === 'deny')) {
|
|
60
|
+
return {
|
|
61
|
+
kind: 'deny',
|
|
62
|
+
reason:
|
|
63
|
+
`role "${manifest.role}" does not permit tool "${toolName}" ` +
|
|
64
|
+
`(manifest tools: ${manifest.tools.join(', ')}). ` +
|
|
65
|
+
`If the task needs it, ask the master to delegate differently or adjust the role profile.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
if (perm === 'ask') {
|
|
69
|
+
return { kind: 'ask', notice: `[APPROVAL_NEEDED] ${manifest.role}.${toolName}` };
|
|
70
|
+
}
|
|
71
|
+
return { kind: 'allow' };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* D77 可见层:计算 setActiveTools 的下一状态。
|
|
76
|
+
* 交集语义(而非替换)——空交集返回 null(fail-open 保留现状),
|
|
77
|
+
* 防"manifest 列了但插件没装"把工具集清空(如 web_search 装之前的 websearch 档案)。
|
|
78
|
+
* 保持 currentActive 原顺序;与现状相同 → changed:false(调用方可跳过 set)。
|
|
79
|
+
*
|
|
80
|
+
* D82 姿态分支:unknownTools=allow → 不按 manifest 交集裁剪,只隐藏
|
|
81
|
+
* permissions 中显式 deny 的工具(worker 排除族在继承制下仍不可见);
|
|
82
|
+
* 未知工具(用户装的扩展)保持可见。deny(缺省)→ 原交集语义。
|
|
83
|
+
*/
|
|
84
|
+
export function planActiveTools(
|
|
85
|
+
manifestTools: string[],
|
|
86
|
+
currentActive: string[],
|
|
87
|
+
opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
|
|
88
|
+
): { next: string[]; changed: boolean } | null {
|
|
89
|
+
if (manifestTools.length === 0 || currentActive.length === 0) return null;
|
|
90
|
+
if ((opts?.unknownTools ?? 'deny') === 'allow') {
|
|
91
|
+
const denied = new Set(
|
|
92
|
+
Object.entries(opts?.permissions ?? {})
|
|
93
|
+
.filter(([, v]) => v === 'deny')
|
|
94
|
+
.map(([k]) => k),
|
|
95
|
+
);
|
|
96
|
+
const next = currentActive.filter((t) => !denied.has(t));
|
|
97
|
+
if (next.length === 0) return null;
|
|
98
|
+
return { next, changed: next.length !== currentActive.length };
|
|
99
|
+
}
|
|
100
|
+
const wanted = new Set(manifestTools);
|
|
101
|
+
const next = currentActive.filter((t) => wanted.has(t));
|
|
102
|
+
if (next.length === 0) return null;
|
|
103
|
+
const changed = next.length !== currentActive.length;
|
|
104
|
+
return { next, changed };
|
|
105
|
+
}
|
|
106
|
+
|
package/src/vocab.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-herdr 桥接词汇(M22:标题即看板)。
|
|
3
|
+
*
|
|
4
|
+
* 这是 DESIGN.md §4.2 定义的"词汇约定":不发明新传输协议。
|
|
5
|
+
* 传输层 = herdr socket API(NDJSON);todo 权威 = pi 会话 JSONL。
|
|
6
|
+
* 显示投影 = pane.report_metadata 的 title / state_labels(见 pane-title.ts)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** todo 条目五态(D34:三态 + blocked/abandoned,对齐 OMP)。 */
|
|
10
|
+
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'blocked' | 'abandoned';
|
|
11
|
+
|
|
12
|
+
export const TODO_STATUSES: readonly TodoStatus[] = ['pending', 'in_progress', 'completed', 'blocked', 'abandoned'];
|
|
13
|
+
|
|
14
|
+
/** todo 条目形状:content + 五态 status;可选 blocker(仅 blocked)、可选 phase(D43 分组)。无稳定 id(全量替换语义)。 */
|
|
15
|
+
export interface TodoItem {
|
|
16
|
+
content: string;
|
|
17
|
+
status: TodoStatus;
|
|
18
|
+
/** 仅 status==='blocked' 时允许非空字符串(D34)。 */
|
|
19
|
+
blocker?: string;
|
|
20
|
+
/** 可选分组名(D43);非空、≤30 字符。 */
|
|
21
|
+
phase?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 完整 todo 快照(每次 todo_write 提交的就是它,last-wins)。 */
|
|
25
|
+
export interface TodoSnapshot {
|
|
26
|
+
/** 语义版本,形状变化时递增;消费方据此丢弃陈旧数据。 */
|
|
27
|
+
version: 1;
|
|
28
|
+
items: TodoItem[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 快照计数(反馈文案与标题投影都用它)。 */
|
|
32
|
+
export interface TodoCounts {
|
|
33
|
+
pending: number;
|
|
34
|
+
inProgress: number;
|
|
35
|
+
completed: number;
|
|
36
|
+
/** D91:blocked 单列(title 四件套 ▶○■✓)。 */
|
|
37
|
+
blocked: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** pi 扩展经 `pane.report-agent` 上报语义状态时使用的 source 名。 */
|
|
41
|
+
export const REPORT_AGENT_SOURCE = 'pi-herdr';
|
|
42
|
+
|
|
43
|
+
/** 升级后首次上报用来清掉旧 16-key 分块的头键(M13b 残留)。 */
|
|
44
|
+
export const PI_HERDR_META_KEY = 'pi-herdr';
|
|
45
|
+
|
|
46
|
+
/** todo 工具面向模型的名称(与 DSH 一致)。 */
|
|
47
|
+
export const TODO_TOOL_NAME = 'todo_write';
|
|
48
|
+
|
|
49
|
+
/** 工具结果 details 里存放快照的键(随 pi 会话 JSONL 持久化,分支正确)。 */
|
|
50
|
+
export const TODO_DETAILS_KEY = 'pi-herdr.todo';
|
|
51
|
+
|
|
52
|
+
/** 反馈文案(与 DSH 逐字一致,模型词汇可互迁移)。 */
|
|
53
|
+
export function formatTodoConfirmation(items: TodoItem[]): string {
|
|
54
|
+
const c = countTodos(items);
|
|
55
|
+
return `Updated todo list: ${c.pending} pending, ${c.inProgress} in progress, ${c.completed} completed.`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function countTodos(items: TodoItem[]): TodoCounts {
|
|
59
|
+
let pending = 0;
|
|
60
|
+
let inProgress = 0;
|
|
61
|
+
let completed = 0;
|
|
62
|
+
let blocked = 0;
|
|
63
|
+
for (const it of items) {
|
|
64
|
+
if (it.status === 'pending') pending++;
|
|
65
|
+
else if (it.status === 'in_progress') inProgress++;
|
|
66
|
+
else if (it.status === 'completed') completed++;
|
|
67
|
+
else if (it.status === 'blocked') blocked++;
|
|
68
|
+
// abandoned 不计入计数(D34);blocked 单列(D91 图标四件套进 title)
|
|
69
|
+
}
|
|
70
|
+
return { pending, inProgress, completed, blocked };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 子代理结算通知文案(对齐 DSH 的 settlement notice)。 */
|
|
74
|
+
export function formatSettlementNotice(
|
|
75
|
+
agentId: string,
|
|
76
|
+
closingMessage: string | null,
|
|
77
|
+
): string {
|
|
78
|
+
const head = `Background subagent ${agentId} finished and will do no further work unless you send it more.`;
|
|
79
|
+
return closingMessage
|
|
80
|
+
? `${head} Its closing message: ${closingMessage}`
|
|
81
|
+
: `${head} It left no closing message.`;
|
|
82
|
+
}
|