pi-pier 0.1.5 → 0.2.1

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 +116 -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
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Task-tab pane spawn, launch-line construction, and trust-flag decisions.
3
+ *
4
+ * Why: spawn mixed herdr layout I/O with the subagent tool body. Placement
5
+ * races (D26 mutex) and D86 worktree grouping belong in one adapter.
6
+ */
7
+ import type { HerdrClientLike } from './herdr-client.ts';
8
+ import { pingUntilReady, pipeNameCandidates } from './pipe-channel.ts';
9
+ import { runtimePolicy } from './runtime-policy.ts';
10
+ import {
11
+ Semaphore,
12
+ buildLaunchLine,
13
+ buildLaunchParts,
14
+ classifyWorktreeZone,
15
+ isPathUnder,
16
+ planTabPlacement,
17
+ type TabPlacementPlan,
18
+ type WorktreeZone,
19
+ } from './subagent-core.ts';
20
+ import { parseShapeTree, pickGridSplit } from './core/grid-shape.ts';
21
+ import type { GitIo } from './subagent-git-io.ts';
22
+
23
+ export interface SpawnEnv {
24
+ paneId: string;
25
+ tabId: string;
26
+ workspaceId: string;
27
+ }
28
+
29
+ export interface SpawnRuntime {
30
+ nodePath: string;
31
+ cliPath: string;
32
+ extPath: string;
33
+ }
34
+
35
+ export interface SpawnerHost {
36
+ client: HerdrClientLike;
37
+ env: SpawnEnv | null;
38
+ runtime: SpawnRuntime;
39
+ git: GitIo;
40
+ }
41
+
42
+ export interface Spawner {
43
+ spawnPaneInTaskTab(
44
+ placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
45
+ cwd: string,
46
+ envOver: Record<string, string>,
47
+ launch: string,
48
+ ): Promise<{ tabId: string; paneId: string; tabName: string }>;
49
+ launchLine(resumeFile?: string | null, roleModel?: string | null, approve?: boolean): string;
50
+ approveFor(cwd: string, masterCwd: string): Promise<boolean>;
51
+ waitSubReady(cwd: string, paneId: string): Promise<boolean>;
52
+ liveTabs(): Promise<Array<{ tabName: string; tabId: string }>>;
53
+ findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null>;
54
+ }
55
+
56
+ export function createSpawner(h: SpawnerHost): Spawner {
57
+ const tabMutex = new Semaphore(1);
58
+ const readyTimeoutMs = runtimePolicy.readinessTimeoutMs;
59
+
60
+ async function liveTabs(): Promise<Array<{ tabName: string; tabId: string }>> {
61
+ try {
62
+ const ws = h.env?.workspaceId ?? '';
63
+ return (await h.client.tabList())
64
+ .filter((t) => !ws || t.workspaceId === ws)
65
+ .map((t) => ({ tabName: t.label, tabId: t.tabId }));
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
72
+ return buildLaunchLine(buildLaunchParts(h.runtime, { resumeFile, roleModel, approve }));
73
+ }
74
+
75
+ async function approveFor(cwd: string, masterCwd: string): Promise<boolean> {
76
+ if (isPathUnder(cwd, masterCwd)) return true;
77
+ const zone = classifyWorktreeZone({ cwd, masterCwd, worktrees: await h.git.listWorktrees(masterCwd) });
78
+ return zone.zone === 'worktree';
79
+ }
80
+
81
+ async function waitSubReady(cwd: string, paneId: string): Promise<boolean> {
82
+ return pingUntilReady(pipeNameCandidates(cwd, paneId), readyTimeoutMs);
83
+ }
84
+
85
+ async function spawnPaneInTaskTab(
86
+ placement: { desiredTab?: string | null; description: string; zone?: WorktreeZone },
87
+ cwd: string,
88
+ envOver: Record<string, string>,
89
+ launch: string,
90
+ ): Promise<{ tabId: string; paneId: string; tabName: string }> {
91
+ const release = await tabMutex.acquire();
92
+ try {
93
+ const allPanes = await h.client.listPanes();
94
+ const mainTabId = allPanes.find((p) => p.paneId === h.env?.paneId)?.tabId
95
+ ?? (h.env?.tabId ? h.env.tabId : null);
96
+ let plan: TabPlacementPlan = planTabPlacement({
97
+ desiredTab: placement.desiredTab,
98
+ description: placement.description,
99
+ knownTabs: await liveTabs(),
100
+ zone: placement.zone,
101
+ mainTabId,
102
+ });
103
+ if (plan.mode === 'append' && plan.tabId) {
104
+ const exclude = new Set(
105
+ allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
106
+ );
107
+ let pick: { targetPaneId: string; direction: 'right' | 'down' } | null = null;
108
+ try {
109
+ const snapshot = await h.client.exportLayout({ tabId: plan.tabId });
110
+ const tree = snapshot?.root ? parseShapeTree(snapshot.root) : null;
111
+ if (tree) pick = pickGridSplit(tree, { exclude });
112
+ } catch { /* layout export failure → legacy anchor split */ }
113
+ const anchorPaneId = pick?.targetPaneId
114
+ ?? allPanes.find((p) => p.tabId === plan.tabId && p.agentStatus !== 'unknown')?.paneId
115
+ ?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
116
+ if (anchorPaneId) {
117
+ const paneId = await h.client.splitPane({
118
+ direction: pick?.direction ?? 'down',
119
+ cwd,
120
+ env: envOver,
121
+ targetPaneId: anchorPaneId,
122
+ });
123
+ await h.client.sendPaneText(paneId, launch);
124
+ return { tabId: plan.tabId, paneId, tabName: plan.tabName };
125
+ }
126
+ plan = { mode: 'new', tabName: plan.tabName, tabId: null };
127
+ }
128
+ const created = await h.client.createTab({
129
+ workspaceId: h.env?.workspaceId ?? '',
130
+ label: plan.tabName,
131
+ cwd,
132
+ env: envOver,
133
+ });
134
+ await h.client.sendPaneText(created.paneId, launch);
135
+ return { tabId: created.tabId, paneId: created.paneId, tabName: plan.tabName };
136
+ } finally {
137
+ release();
138
+ }
139
+ }
140
+
141
+ async function findExistingPane(sessionFile: string | null): Promise<{ paneId: string; tabId: string } | null> {
142
+ if (!sessionFile) return null;
143
+ try {
144
+ const agents = await h.client.listAgents();
145
+ const match = agents.find((a) => a.session === sessionFile && a.status !== 'unknown');
146
+ if (!match) return null;
147
+ const panes = await h.client.listPanes();
148
+ const pane = panes.find((p) => p.paneId === match.paneId);
149
+ return pane ? { paneId: match.paneId, tabId: pane.tabId } : null;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ return { spawnPaneInTaskTab, launchLine, approveFor, waitSubReady, liveTabs, findExistingPane };
156
+ }
@@ -1,47 +1,53 @@
1
- /**
2
- * M14 常驻交互终端纯核心(D71 / T1–T6,2026-08-18)。
3
- *
4
- * 六工具的判定逻辑全部在此(无 pi/herdr 依赖 → 可单测):
5
- * - 注册表:terminal_id 稳定(term-<n>)、上限、幂等关闭;
6
- * - T6 检测:send 文本校验(拒 ANSI/控制字符)、signal 白名单、
7
- * 全屏 TUI(alternate screen 序列)识别;
8
- * - 读增量:环形缓冲语义(append / reset / none)+ 有界输出;
9
- * - T3 readiness:PS1 尾匹配 + 静默期两档(超时兜底在 adapter);
10
- * - 会话汇总:stale pane 检测(跨重启边界注记);
11
- * - 持久化:custom 条目 last-wins(与 subs 注册表同构)。
12
- *
13
- * 后端映射(adapter = index.ts + herdr-client):
14
- * open=pane.split 常驻 shell / send=pane.send_text+\r /
15
- * read=pane.read(recent, strip_ansi:false)+本地 strip /
16
- * signal=pane.send_keys / close=pane.close。
17
- */
1
+ /** Why: Preserve the established compatibility and safety behavior (D71, M14, T1–T6, T3, T6). */
18
2
  import { basename } from 'node:path';
19
3
 
20
- /* ── 常量(v1 默认;env 可调点在 adapter 读) ───────────────────── */
4
+ /** Why: Preserve the established compatibility and safety behavior. */
21
5
 
22
6
  export const TERMINALS_CUSTOM_TYPE = 'pi-herdr.terminals';
23
7
  export const MAX_TERMINALS = 8;
24
8
  export const READ_MAX_CHARS = 8000;
25
- /** 读返回的硬截断长度(含截断标记自身的预算)。 */
9
+ /** Why: Preserve the established compatibility and safety behavior. */
26
10
  export const READ_HARD_CAP_CHARS = 200;
27
11
  export const READINESS_TIMEOUT_MS = 30_000;
28
12
  export const SILENCE_IDLE_MS = 2000;
29
- /** T3 PS1 尾匹配:PowerShell `>` / POSIX `$` `#` / Nushell `❯` 结尾(允许尾空白)。 */
13
+ /** POSIX / PowerShell / Nushell prompt tail. Why: herdr waitForOutput and local classify must share one pattern. */
30
14
  export const PROMPT_TAIL_RE = /[$>#❯]\s*$/;
31
- /** T6 signal 白名单(Herdr key-combo 字符串;ctrl+c 中断 / ctrl+d EOF / ctrl+z 挂起 / esc / enter)。 */
15
+
16
+ export interface PromptStrategy {
17
+ /** herdr waitForOutput regex source. */
18
+ readonly waitPattern: string;
19
+ readonly tailRe: RegExp;
20
+ }
21
+
22
+ export const POSIX_PROMPT: PromptStrategy = {
23
+ waitPattern: '[$>#❯]\\s*$',
24
+ tailRe: PROMPT_TAIL_RE,
25
+ };
26
+
27
+ /** cmd.exe / PowerShell: `>` still matches POSIX; this also accepts `PS C:\\>` without a trailing `$`. */
28
+ export const POWERSHELL_PROMPT: PromptStrategy = {
29
+ waitPattern: '(PS [^\\n>]+|>)\\s*$',
30
+ tailRe: /(PS [^\n>]+|>)\s*$/,
31
+ };
32
+
33
+ /** `PIER_TERMINAL_PROMPT=powershell` selects the Windows strategy; default stays POSIX so existing panes do not flip. */
34
+ export function promptStrategyFor(env: NodeJS.ProcessEnv = process.env): PromptStrategy {
35
+ return env.PIER_TERMINAL_PROMPT === 'powershell' ? POWERSHELL_PROMPT : POSIX_PROMPT;
36
+ }
37
+ /** Why: Preserve the established compatibility and safety behavior (T6). */
32
38
  export const SIGNAL_KEYS = ['ctrl+c', 'ctrl+d', 'ctrl+z', 'esc', 'enter'] as const;
33
39
  export type SignalKey = (typeof SIGNAL_KEYS)[number];
34
40
 
35
41
  const FULLSCREEN_SEQUENCES = [
36
- '\x1b[?1049h', // 进入 alternate screen(xterm)
37
- '\x1b[?47h', // 老式进入 alternate screen
38
- '\x1b[2J\x1b[H', // 清屏 + 光标归位(vim/less 常见模式)
42
+ '\x1b[?1049h', // Why: Preserve the established compatibility and safety behavior.
43
+ '\x1b[?47h', // Why: Preserve the established compatibility and safety behavior.
44
+ '\x1b[2J\x1b[H', // Why: Preserve the established compatibility and safety behavior.
39
45
  ] as const;
40
46
 
41
47
  export const RESET_MARKER = '…[buffer reset — earlier output scrolled away or cleared]…';
42
48
  export const TRUNCATE_MARKER = '…[truncated]…';
43
49
 
44
- /* ── 类型 ─────────────────────────────────────────────────────────── */
50
+ /** Why: Preserve the established compatibility and safety behavior. */
45
51
 
46
52
  export interface TerminalEntry {
47
53
  terminalId: string;
@@ -53,7 +59,7 @@ export interface TerminalEntry {
53
59
  lastActivityAt: number;
54
60
  status: 'open' | 'closed';
55
61
  closedAt: number | null;
56
- /** pane.read 游标:revision(展示)+ 稳定区长度 + 尾段锚 + 缓冲尾探测器(增量判定用)。 */
62
+ /** Why: Preserve the established compatibility and safety behavior. */
57
63
  readRevision: number | null;
58
64
  readLen: number;
59
65
  readTail: string;
@@ -65,7 +71,7 @@ export interface TerminalsRegistry {
65
71
  terminals: TerminalEntry[];
66
72
  }
67
73
 
68
- /* ── 注册表 ───────────────────────────────────────────────────────── */
74
+ /** Why: Preserve the established compatibility and safety behavior. */
69
75
 
70
76
  export function nextTerminalId(existingIds: readonly string[]): string {
71
77
  let max = 0;
@@ -111,7 +117,7 @@ export function registerTerminal(
111
117
  return { ok: true, entries: [...entries, entry], entry };
112
118
  }
113
119
 
114
- /** 幂等关闭(closedAt 不被二次覆盖);不存在的 id 原样返回。 */
120
+ /** Why: Preserve the established compatibility and safety behavior. */
115
121
  export function closeTerminal(
116
122
  entries: readonly TerminalEntry[],
117
123
  terminalId: string,
@@ -126,12 +132,12 @@ export function closeTerminal(
126
132
  };
127
133
  }
128
134
 
129
- /** 活跃(open)终端的 paneId 集合——GC 豁免判据(D71)。 */
135
+ /** Why: Preserve the established compatibility and safety behavior (D71). */
130
136
  export function activeTerminalPaneIds(entries: readonly TerminalEntry[]): Set<string> {
131
137
  return new Set(entries.filter((e) => e.status === 'open').map((e) => e.paneId));
132
138
  }
133
139
 
134
- /* ── T6:send 文本校验 / signal 白名单 ───────────────────────────── */
140
+ /** Why: Preserve the established compatibility and safety behavior (T6). */
135
141
 
136
142
  export function validateSendText(text: string): { ok: true; text: string } | { ok: false; error: string } {
137
143
  if (text.includes('\x1b')) {
@@ -140,7 +146,7 @@ export function validateSendText(text: string): { ok: true; text: string } | { o
140
146
  error: 'unsupported input: ANSI escape sequences cannot be sent as text; use terminal_signal for control keys, or plain commands only',
141
147
  };
142
148
  }
143
- // 允许 \t;拒绝其余 C0 控制字符(\r\n 由发送方统一补 Enter,先剥尾部)
149
+ // Why: Preserve the established compatibility and safety behavior (C0).
144
150
  const stripped = text.replace(/[\r\n]+$/, '');
145
151
  const bad = stripped.match(/[\x00-\x08\x0b-\x1f\x7f]/);
146
152
  if (bad) {
@@ -163,7 +169,7 @@ export function validateSignal(key: string): { ok: true; key: SignalKey } | { ok
163
169
  return { ok: true, key: k };
164
170
  }
165
171
 
166
- /* ── T6:全屏 TUI 检测 + ANSI 剥离 ───────────────────────────────── */
172
+ /** Why: Preserve the established compatibility and safety behavior (T6). */
167
173
 
168
174
  export function detectFullscreenTUI(raw: string): { detected: boolean; sequence: string | null } {
169
175
  for (const seq of FULLSCREEN_SEQUENCES) {
@@ -174,21 +180,21 @@ export function detectFullscreenTUI(raw: string): { detected: boolean; sequence:
174
180
 
175
181
  export function stripAnsi(raw: string): string {
176
182
  return raw
177
- .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // CSI 序列
178
- .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC 序列
179
- .replace(/\x1b[@-Z\\-_]/g, '') // 其余两字符转义
180
- .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); // 残余控制字符
183
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '') // Why: Preserve the established compatibility and safety behavior.
184
+ .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // Why: Preserve the established compatibility and safety behavior.
185
+ .replace(/\x1b[@-Z\\-_]/g, '') // Why: Preserve the established compatibility and safety behavior.
186
+ .replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); // Why: Preserve the established compatibility and safety behavior.
181
187
  }
182
188
 
183
- /* ── 读增量(环形缓冲语义) ───────────────────────────────────────── */
189
+ /** Why: Preserve the established compatibility and safety behavior. */
184
190
 
185
191
  export interface ReadCursor {
186
192
  revision: number;
187
- /** 已消费长度(落在最后完整行边界:lastIndexOf('\n')+1;活动行不占)。 */
193
+ /** Why: Preserve the established compatibility and safety behavior. */
188
194
  len: number;
189
- /** 连续性锚:稳定区尾段(≤64 字符;与下次读按 trimEnd 比较——活动行行尾 \n 会变空格,实测)。 */
195
+ /** Why: Preserve the established compatibility and safety behavior. */
190
196
  tail: string;
191
- /** 无变化探测器:上次整个缓冲的尾段(trimEnd;相同 → none)。 */
197
+ /** Why: Preserve the established compatibility and safety behavior. */
192
198
  eoTail: string;
193
199
  }
194
200
 
@@ -196,19 +202,14 @@ export interface ReadIncrement {
196
202
  mode: 'none' | 'append' | 'reset';
197
203
  text: string;
198
204
  cursor: ReadCursor;
199
- /** 达到硬上限被截断(调用方应提示分页)。 */
205
+ /** Why: Preserve the established compatibility and safety behavior. */
200
206
  hardCapped: boolean;
201
207
  }
202
208
 
203
- /** 游标指纹长度(tail 采样上限)。 */
209
+ /** Why: Preserve the established compatibility and safety behavior. */
204
210
  const CURSOR_TAIL_CHARS = 64;
205
211
 
206
- /**
207
- * 实测(M14 live)两条屏幕缓冲语义,决定了本实现:
208
- * 1) pane.read 的 revision 不随输出增长(恒 0)→ 变化探测用 eoTail,不用 revision;
209
- * 2) `recent` 是屏幕重建而非追加日志:活动行行尾 \n 在续写时变空格
210
- * ("PS>x\n" → "PS>x echo")→ 连续性锚按 trimEnd 比较,len 落在最后完整行边界。
211
- */
212
+ /** Why: Preserve the established compatibility and safety behavior (M14). */
212
213
  export function computeIncrement(
213
214
  prev: ReadCursor | null,
214
215
  next: { text: string; revision: number },
@@ -233,7 +234,7 @@ export function computeIncrement(
233
234
  const bounded = boundText(suffix, maxChars);
234
235
  return { mode: 'append', text: bounded.text, cursor, hardCapped: bounded.capped };
235
236
  }
236
- // 首读(prev=null)= 有界全量;缓冲回卷/清屏 = 有界全量 + reset 标记
237
+ // Why: Preserve the established compatibility and safety behavior.
237
238
  const bounded = boundText(next.text, maxChars);
238
239
  const text = prev ? `${RESET_MARKER}\n${bounded.text}` : bounded.text;
239
240
  return { mode: 'reset', text, cursor, hardCapped: bounded.capped };
@@ -244,21 +245,22 @@ function boundText(text: string, maxChars: number): { text: string; capped: bool
244
245
  return { text: `${text.slice(text.length - maxChars)}\n${TRUNCATE_MARKER}`, capped: true };
245
246
  }
246
247
 
247
- /* ── T3 readiness(两档 + busy;超时兜底在 adapter) ─────────────── */
248
+ /** Why: Preserve the established compatibility and safety behavior (T3). */
248
249
 
249
250
  export type ReadinessTier = 'prompt' | 'silent' | 'busy';
250
251
 
251
252
  export function classifyReadiness(
252
253
  rawOrStripped: string,
253
- opts: { silentMs: number; silenceThresholdMs?: number },
254
+ opts: { silentMs: number; silenceThresholdMs?: number; prompt?: PromptStrategy },
254
255
  ): ReadinessTier {
255
256
  const tail = rawOrStripped.trimEnd();
256
- if (tail.length > 0 && PROMPT_TAIL_RE.test(tail)) return 'prompt';
257
+ const re = opts.prompt?.tailRe ?? PROMPT_TAIL_RE;
258
+ if (tail.length > 0 && re.test(tail)) return 'prompt';
257
259
  if (opts.silentMs >= (opts.silenceThresholdMs ?? SILENCE_IDLE_MS)) return 'silent';
258
260
  return 'busy';
259
261
  }
260
262
 
261
- /* ── 会话汇总(T6 跨重启边界) ───────────────────────────────────── */
263
+ /** Why: Preserve the established compatibility and safety behavior (T6). */
262
264
 
263
265
  export interface TerminalSummary {
264
266
  terminalId: string;
@@ -288,7 +290,7 @@ export function summarizeSessions(
288
290
  return { terminals, stalePaneIds };
289
291
  }
290
292
 
291
- /* ── 持久化(custom 条目 last-wins,与 subs 同构) ────────────────── */
293
+ /** Why: Preserve the established compatibility and safety behavior. */
292
294
 
293
295
  export interface BranchEntryLike3 {
294
296
  type?: string;
package/src/todo-core.ts CHANGED
@@ -1,9 +1,4 @@
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
- */
1
+ /** Why: Preserve the established compatibility and safety behavior (D34–D43, M10). */
7
2
  import {
8
3
  TODO_DETAILS_KEY,
9
4
  TODO_STATUSES,
@@ -17,13 +12,13 @@ import { countTodos } from './vocab.ts';
17
12
 
18
13
  export type { TodoItem, TodoSnapshot, TodoStatus, TodoCounts };
19
14
 
20
- /** D43:phase 名长度上限。 */
15
+ /** Why: Preserve the established compatibility and safety behavior (D43). */
21
16
  export const PHASE_MAX_LEN = 30;
22
17
 
23
- /** D38:人类编辑 custom 条目类型(/todos done/drop/rm 写回权威的通道)。 */
18
+ /** Why: Preserve the established compatibility and safety behavior (D38). */
24
19
  export const TODO_EDIT_CUSTOM_TYPE = 'pi-herdr.todo-edit';
25
20
 
26
- /** 人类编辑操作(D38):done=completed、drop=abandoned、rm=移除、unblock=blocked→pending(M17)。 */
21
+ /** Why: Preserve the established compatibility and safety behavior (D38, M17). */
27
22
  export type TodoEditOp = 'done' | 'drop' | 'rm' | 'unblock';
28
23
 
29
24
  export interface TodoEdit {
@@ -43,16 +38,7 @@ export interface TodoValidationResult {
43
38
  error?: string;
44
39
  }
45
40
 
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
- */
41
+ /** Why: Preserve the established compatibility and safety behavior (B2, D34, D43). */
56
42
  export function validateTodos(
57
43
  todos: unknown,
58
44
  allowParallelInProgress = true,
@@ -93,9 +79,9 @@ export function validateTodos(
93
79
  const item: TodoItem = { content, status: status as TodoStatus };
94
80
  if ('blocker' in entry) {
95
81
  const blocker = entry.blocker;
96
- // 容忍归一(用户实证修复):Optional 字段用空串/null 填充是常见模型行为
97
- //(grok 实测 9 连拒死循环);status 是权威——非 blocked 态的 blocker 一律视为
98
- // 缺省丢弃(无论是否有值),空值同样丢弃。仅类型畸形仍硬拒。
82
+ // Why: Preserve the established compatibility and safety behavior.
83
+ // Why: Preserve the established compatibility and safety behavior.
84
+ // Why: Preserve the established compatibility and safety behavior.
99
85
  if (blocker != null && typeof blocker !== 'string') {
100
86
  return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
101
87
  }
@@ -111,7 +97,7 @@ export function validateTodos(
111
97
  if (phase != null && typeof phase !== 'string') {
112
98
  return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
113
99
  }
114
- // 空串/纯空白 = 缺省(实证循环里 phase:"" 同样是被掩盖的雷);超长是真实约束仍硬拒
100
+ // Why: Preserve the established compatibility and safety behavior.
115
101
  if (typeof phase === 'string') {
116
102
  if (phase.length > PHASE_MAX_LEN) {
117
103
  return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
@@ -133,13 +119,7 @@ export function validateTodos(
133
119
  return { ok: true, items };
134
120
  }
135
121
 
136
- /**
137
- * D42 严格模式归一化(OMP normalizeInProgressTask 语义,按 pane 角色自动推导):
138
- * - 多余 in_progress(列表顺序第二条起)退回 pending;
139
- * - 无 in_progress 且有 pending → 晋升列表顺序第一条 pending;
140
- * - blocked/abandoned 不参与晋升。
141
- * 返回新数组(不改入参)。
142
- */
122
+ /** Why: Preserve the established compatibility and safety behavior (D42). */
143
123
  export function normalizeStrict(items: readonly TodoItem[]): TodoItem[] {
144
124
  if (items.length === 0) return [];
145
125
  const out = items.map((it) => ({ ...it }));
@@ -157,7 +137,7 @@ export function normalizeStrict(items: readonly TodoItem[]): TodoItem[] {
157
137
  return out;
158
138
  }
159
139
 
160
- /** D35:逐项全等比较(content/status/blocker/phase),用于 no-op 检测。 */
140
+ /** Why: Preserve the established compatibility and safety behavior (D35). */
161
141
  export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
162
142
  if (a.length !== b.length) return false;
163
143
  for (let i = 0; i < a.length; i++) {
@@ -168,7 +148,7 @@ export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): bool
168
148
  return true;
169
149
  }
170
150
 
171
- /** D36:本轮新变为 completed 的 content 列表(prev → next)。 */
151
+ /** Why: Preserve the established compatibility and safety behavior (D36). */
172
152
  export function completionTransitions(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
173
153
  const prevMap = new Map(prev.map((it) => [it.content, it.status]));
174
154
  const out: string[] = [];
@@ -178,7 +158,7 @@ export function completionTransitions(prev: readonly TodoItem[], next: readonly
178
158
  return out;
179
159
  }
180
160
 
181
- /** D37:上轮 completed、本轮退回 pending/in_progress 的 content 列表(软警告用)。 */
161
+ /** Why: Preserve the established compatibility and safety behavior (D37). */
182
162
  export function revertedCompleted(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
183
163
  const nextMap = new Map(next.map((it) => [it.content, it.status]));
184
164
  const out: string[] = [];
@@ -190,15 +170,15 @@ export function revertedCompleted(prev: readonly TodoItem[], next: readonly Todo
190
170
  return out;
191
171
  }
192
172
 
193
- /** 构造持久化快照(version 字段让消费方在未来形状变化时丢弃陈旧数据)。 */
173
+ /** Why: Preserve the established compatibility and safety behavior. */
194
174
  export function makeSnapshot(items: TodoItem[]): TodoSnapshot {
195
175
  return { version: 1, items };
196
176
  }
197
177
 
198
178
  export interface BranchEntryLike {
199
- /** pi 会话条目:type 为 'message' 的消息条目或 'custom' 的自定义条目。 */
179
+ /** Why: Preserve the established compatibility and safety behavior. */
200
180
  type?: string;
201
- /** 会话 JSONL 顶层时间戳(ISO 字符串或 epoch ms;重建 lastWriteAt 用)。 */
181
+ /** Why: Preserve the established compatibility and safety behavior. */
202
182
  timestamp?: unknown;
203
183
  message?: {
204
184
  role?: string;
@@ -209,13 +189,7 @@ export interface BranchEntryLike {
209
189
  data?: unknown;
210
190
  }
211
191
 
212
- /**
213
- * D38:在列表上应用人类编辑(幂等:目标不存在则 no-op)。
214
- * - done:pending/in_progress/blocked → completed;completed 保持(completed 不可逆,D37);
215
- * - drop:非 completed/abandoned → abandoned;
216
- * - rm:移除(任意状态);
217
- * - unblock(M17):blocked → pending(清 blocker);非 blocked 为 no-op。
218
- */
192
+ /** Why: Preserve the established compatibility and safety behavior (D37, D38, M17). */
219
193
  export function applyTodoEdits(items: readonly TodoItem[], edits: readonly TodoEdit[]): TodoItem[] {
220
194
  let out = items.map((it) => ({ ...it }));
221
195
  for (const edit of edits) {
@@ -266,15 +240,10 @@ function extractEditPayload(data: unknown): TodoEditPayload | null {
266
240
  return { version: 1, edits, ts: typeof raw.ts === 'number' ? raw.ts : 0 };
267
241
  }
268
242
 
269
- /**
270
- * 从 pi 会话分支条目里折叠出最新 todo 列表(D38 双源):
271
- * - toolResult(todo_write) → 快照替换(last-wins);
272
- * - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
273
- * 分支正确性 = 沿分支路径顺序折叠。
274
- */
243
+ /** Why: Preserve the established compatibility and safety behavior (D38). */
275
244
  export interface FoldedTodos {
276
245
  items: TodoItem[];
277
- /** 产生该列表的最后一个条目时间戳(epoch ms);条目无时间戳 → null。 */
246
+ /** Why: Preserve the established compatibility and safety behavior. */
278
247
  writtenAt: number | null;
279
248
  }
280
249
 
@@ -288,13 +257,7 @@ function entryTimestamp(entry: BranchEntryLike): number | null {
288
257
  return null;
289
258
  }
290
259
 
291
- /**
292
- * 从 pi 会话分支条目里折叠出最新 todo 列表(D38 双源):
293
- * - toolResult(todo_write) → 快照替换(last-wins);
294
- * - custom(pi-herdr.todo-edit) → 在当前列表上应用 edits(人类编辑)。
295
- * 分支正确性 = 沿分支路径顺序折叠。writtenAt = 最后一次触碰该列表的条目时间戳
296
- * (无时间戳的旧条目 → null,陈旧度判定保守判 fresh)。
297
- */
260
+ /** Why: Preserve the established compatibility and safety behavior (D38). */
298
261
  export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
299
262
  let found: TodoItem[] | null = null;
300
263
  let writtenAt: number | null = null;
@@ -319,7 +282,7 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
319
282
  return found ? { items: found, writtenAt } : null;
320
283
  }
321
284
 
322
- /** foldLatestTodosMeta 的条目-only 视图(兼容既有调用方)。 */
285
+ /** Why: Preserve the established compatibility and safety behavior. */
323
286
  export function foldLatestTodos(entries: readonly BranchEntryLike[]): TodoItem[] | null {
324
287
  return foldLatestTodosMeta(entries)?.items ?? null;
325
288
  }
@@ -333,7 +296,7 @@ export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | nul
333
296
  return { version: 1, items: snap.items as TodoItem[] };
334
297
  }
335
298
 
336
- /** D38:模糊匹配(精确 → 前缀 → 子串),返回候选 content 列表(多候选 = 歧义)。 */
299
+ /** Why: Preserve the established compatibility and safety behavior (D38). */
337
300
  export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
338
301
  const q = query.trim();
339
302
  if (!q) return [];
@@ -346,10 +309,10 @@ export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
346
309
  return substr.map((it) => it.content);
347
310
  }
348
311
 
349
- /** D39:有界视图选择——先丢 completed、再截 open;两端都超额时保最新(尾部切片)。 */
312
+ /** Why: Preserve the established compatibility and safety behavior (D39). */
350
313
  export interface BoundedView {
351
314
  visible: TodoItem[];
352
- /** 被隐藏的数量按状态计数(completed 单独计,其余合并)。 */
315
+ /** Why: Preserve the established compatibility and safety behavior. */
353
316
  hiddenCompleted: number;
354
317
  hiddenOpen: number;
355
318
  }
@@ -360,8 +323,8 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
360
323
  }
361
324
  const completed = items.filter((it) => it.status === 'completed');
362
325
  const open = items.filter((it) => it.status !== 'completed');
363
- // 修正(用户实证):widget 是头部可见窗口——超预算必须隐最老(丢头部),保最新可见;
364
- // 旧实现 slice(0, budget) 保最老,最新任务永远落在窗口外。
326
+ // Why: Preserve the established compatibility and safety behavior.
327
+ // Why: Preserve the established compatibility and safety behavior.
365
328
  const visible = open.length > budget ? open.slice(-budget) : open;
366
329
  const hiddenOpen = open.length - visible.length;
367
330
  let hiddenCompleted = completed.length;
@@ -374,7 +337,7 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
374
337
  return { visible: final, hiddenCompleted, hiddenOpen };
375
338
  }
376
339
 
377
- /** 当前 in_progress 中的第一条(上报 agent 状态时作为 message,供侧边栏显示"正在做什么")。 */
340
+ /** Why: Preserve the established compatibility and safety behavior. */
378
341
  export function currentActivity(items: TodoItem[]): string | null {
379
342
  return items.find((it) => it.status === 'in_progress')?.content ?? null;
380
343
  }