pi-pier 0.1.4 → 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,13 +1,10 @@
1
1
  /**
2
- * 档1 core/terminal(M14 六工具迁 loader entry —— D78 挂载树 / D81 三分法)。
2
+ * M14 terminal loader entry for the D78 mount tree and D81 responsibility split.
3
3
  *
4
- * 形态:cordis 插件(loader entry,热换面 = 本文件)。依赖全部经服务注入:
5
- * - `pi-herdr.surface`:pi 注册面代理(D79 tombstone —— hmr reload 翻墓碑,
6
- * 新版本同 key 重挂载、同重注册覆盖 pi 工具表 Map);
7
- * - `pi-herdr.terminal-deps`:client/env(外层 createHerdrClient 产物)+
8
- * state 槽(index 的 GC 豁免查询 activePaneIds)。
9
- *
10
- * 本文件不 import index.ts(无环);terminal-core 为纯逻辑层照旧。
4
+ * Keeping this as a Cordis loader plugin makes the terminal surface hot-swappable. Services
5
+ * provide the D79 tombstone-aware pi surface, herdr client/environment, and the state slot
6
+ * used by index GC. Avoiding an index.ts import preserves an acyclic dependency graph, while
7
+ * terminal-core remains independently testable pure logic.
11
8
  */
12
9
  import { Context } from '@deepseek-ai/cordis';
13
10
  import { Type } from 'typebox';
@@ -24,6 +21,7 @@ import {
24
21
  detectFullscreenTUI,
25
22
  foldTerminalsRegistry,
26
23
  makeTerminalsRegistry,
24
+ promptStrategyFor,
27
25
  registerTerminal,
28
26
  stripAnsi,
29
27
  summarizeSessions,
@@ -34,11 +32,11 @@ import {
34
32
  } from '../terminal-core.ts';
35
33
 
36
34
  export interface TerminalStateSlot {
37
- /** index 的 GC 豁免查询(活跃终端 pane 不回收)。 */
35
+ /** Lets index GC preserve panes that host active terminals. */
38
36
  activePaneIds: () => Set<string>;
39
37
  }
40
38
 
41
- export interface TerminalDeps {
39
+ interface TerminalDeps {
42
40
  client: HerdrClientLike;
43
41
  env: { paneId: string; tabId: string } | null;
44
42
  state: TerminalStateSlot;
@@ -50,13 +48,12 @@ export default function terminalPlugin(ctx: Context): void {
50
48
  const pi = surface.raw as {
51
49
  appendEntry?: (customType: string, data: unknown) => void;
52
50
  };
53
- // 模块 key = 本文件(hmr/reload 的 filename 与 ledger.disposeKey 规范化互比)
51
+ // Keying the surface by this file lets HMR tombstone and replace exactly this registration.
54
52
  const scoped = surface.forModule(import.meta.url);
55
53
 
56
- /* ── M14:常驻交互终端六工具(D71 / T1–T6;master 侧 v1,worker 不挂本 entry) ──
57
- * 后端 = 独立 herdr pane(不复用 pi TUI pane):open=pane.split 常驻 shell /
58
- * send=pane.send_text+\r / read=pane.read(recent)+本地增量 / signal=pane.send_keys /
59
- * close=pane.close。有活跃终端的 pane 豁免 GC(index gcPass 查 state.activePaneIds)。 */
54
+ /* ── M14 resident terminal tools (D71 / T1–T6) ────────────────────
55
+ * Dedicated herdr panes preserve shell state without reusing the pi TUI pane, and active
56
+ * terminal panes remain exempt from index GC. Workers omit this master-side entry. */
60
57
 
61
58
  let terminals: TerminalEntry[] = [];
62
59
 
@@ -64,7 +61,7 @@ export default function terminalPlugin(ctx: Context): void {
64
61
  try {
65
62
  pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
66
63
  } catch {
67
- /* 持久化尽力而为 */
64
+ /* Persistence is best-effort because terminal operation must not depend on session logging. */
68
65
  }
69
66
  }
70
67
 
@@ -74,7 +71,7 @@ export default function terminalPlugin(ctx: Context): void {
74
71
  ?.sessionManager?.getBranch?.() ?? [];
75
72
  terminals = foldTerminalsRegistry(entries as Parameters<typeof foldTerminalsRegistry>[0]);
76
73
  } catch {
77
- /* 重建失败不影响主流程 */
74
+ /* A malformed snapshot must not block the live terminal surface. */
78
75
  }
79
76
  }
80
77
 
@@ -88,7 +85,7 @@ export default function terminalPlugin(ctx: Context): void {
88
85
  persistTerminals();
89
86
  }
90
87
 
91
- // GC 豁免槽回填(index gcPass 消费)
88
+ // Expose active panes so index GC does not collect resident terminal shells.
92
89
  state.activePaneIds = () => activeTerminalPaneIds(terminals);
93
90
 
94
91
  scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
@@ -119,7 +116,7 @@ export default function terminalPlugin(ctx: Context): void {
119
116
  const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
120
117
  : (ctx as { cwd?: string }).cwd ?? process.cwd();
121
118
  const r = registerTerminal(terminals, {
122
- paneId: env.paneId, // 占位:split 后回填真实 paneId
119
+ paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
123
120
  tabId: env.tabId,
124
121
  cwd,
125
122
  createdAt: Date.now(),
@@ -134,17 +131,17 @@ export default function terminalPlugin(ctx: Context): void {
134
131
  r.entry.paneId = paneId;
135
132
  terminals = r.entries;
136
133
  persistTerminals();
137
- // T3 readiness:PS1 尾匹配(server 端等待);超时 → 本地兜底再判一次
134
+ const prompt = promptStrategyFor();
138
135
  let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
139
136
  try {
140
- const matched = await client.waitForOutput(paneId, { type: 'regex', value: '[$>#❯]\\s*$' }, READINESS_TIMEOUT_MS);
137
+ const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
141
138
  if (matched) readiness = 'prompt';
142
139
  else {
143
140
  const read = await client.readPane(paneId, { stripAnsi: false });
144
- readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0 });
141
+ readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
145
142
  }
146
143
  } catch {
147
- /* readiness 尽力而为 */
144
+ /* Readiness is advisory, so probe failures still leave a usable terminal. */
148
145
  }
149
146
  const text = [
150
147
  `terminal ${r.entry.terminalId} open (pane ${paneId})`,
@@ -204,7 +201,7 @@ export default function terminalPlugin(ctx: Context): void {
204
201
  if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see terminal_list)`);
205
202
  paneId = entry.paneId;
206
203
  } else if (typeof params?.pane_id === 'string') {
207
- // T5 先窄:仅本任务 tab 的 pane 或自建终端 pane
204
+ // T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
208
205
  try {
209
206
  const panes = await client.listPanes();
210
207
  const target = panes.find((p) => p.paneId === params.pane_id);
@@ -228,7 +225,7 @@ export default function terminalPlugin(ctx: Context): void {
228
225
  } catch (e) {
229
226
  return errText(`read failed (pane may be closed): ${(e as Error).message}`);
230
227
  }
231
- // T6 检测点1:全屏 TUI(alternate screen)——在 raw 上识别
228
+ // T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
232
229
  const tui = detectFullscreenTUI(read.text);
233
230
  if (tui.detected) {
234
231
  const head = stripAnsi(read.text).slice(0, 200);
@@ -310,7 +307,7 @@ export default function terminalPlugin(ctx: Context): void {
310
307
  try {
311
308
  await client.closePane(entry.paneId);
312
309
  } catch {
313
- /* pane 已消失(人关/随 tab 关)→ 直接补记 */
310
+ /* The pane may have been closed by a person or with its tab; record the terminal as closed anyway. */
314
311
  }
315
312
  }
316
313
  terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
@@ -332,10 +329,10 @@ export default function terminalPlugin(ctx: Context): void {
332
329
  try {
333
330
  livePaneIds = (await client.listPanes()).map((p) => p.paneId);
334
331
  } catch {
335
- /* 查询失败:全部按未知(不误标 closed)——live 集为空只影响 stale 判定 */
332
+ /* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
336
333
  }
337
334
  const s = summarizeSessions(terminals, livePaneIds);
338
- // T6 检测点3:pane 消失 → 补记 closed(跨重启边界:不复活,提示持久化)
335
+ // T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
339
336
  if (s.stalePaneIds.length > 0) {
340
337
  terminals = terminals.map((t) =>
341
338
  s.stalePaneIds.includes(t.paneId)
package/src/core/todo.ts CHANGED
@@ -1,15 +1,11 @@
1
1
  /**
2
- * 档1 core/todo(todo 族迁 loader entry —— D78 挂载树 / D81 融合核心)。
2
+ * D78/D81 todo loader entry.
3
3
  *
4
- * 形态:cordis 插件(master 经 loader.create 挂 = 热换面;worker 裸根手动 mount,
5
- * D81 one-shot 裁剪)。依赖全部经服务注入:
6
- * - `pi-herdr.surface`:pi 注册面代理(D79 tombstone);
7
- * - `pi-herdr.todo-deps`:todos 服务(session 状态层,留在 index)+ options +
8
- * mirrorTodos 回调 + appendEntry + state 槽(renderWidget 回填给 index 生命周期)。
9
- *
10
- * 本文件承接:todo_write 工具 + /todos 命令(查看/编辑)+ TUI widget(widgetLines/
11
- * renderWidget)+ 读钩(before_agent_start planTodoReadHook,
12
- * 轮次计数器内聚)。reconcile / 镜像 / reportAgent 仍在 index(session 状态层)。
4
+ * A Cordis plugin keeps the master surface hot-swappable while allowing workers to mount the
5
+ * one-shot subset directly. Injected services preserve the D79 registration boundary and keep
6
+ * session-owned todo state in index. This module owns the tool, command, widget, and read hook;
7
+ * reconciliation, mirroring, and agent reporting stay with the session state layer to avoid a
8
+ * dependency cycle.
13
9
  */
14
10
  import { Context } from '@deepseek-ai/cordis';
15
11
  import { Type } from 'typebox';
@@ -32,14 +28,14 @@ import {
32
28
  } from '../todo-core.ts';
33
29
 
34
30
  export interface TodoUiSlot {
35
- /** index 的 session_start/session_tree 生命周期调用(plugin 回填)。 */
31
+ /** Lets index lifecycle events render through the currently mounted plugin. */
36
32
  renderWidget: (ctx: unknown) => void;
37
33
  }
38
34
 
39
- export interface TodoDeps {
35
+ interface TodoDeps {
40
36
  todos: TodosService;
41
37
  allowParallelInProgress: boolean;
42
- /** 软上限提示(不硬拒)。 */
38
+ /** Warn above this size without rejecting otherwise valid plans. */
43
39
  maxItems: number;
44
40
  mirrorTodos: () => void;
45
41
  appendEntry: (customType: string, data: unknown) => void;
@@ -51,7 +47,7 @@ const TOOL_DESCRIPTION = [
51
47
  'Entries: `content` (non-empty, unique; short imperative, what-not-how; no numbering prefixes), `status` (pending | in_progress | completed | blocked | abandoned), optional `blocker` (only when blocked: what it is waiting for), optional `phase` (group name for multi-stage plans; omit for a flat list).',
52
48
  'Keep the list short (at most 15 entries): one entry per meaningful unit of work, not per micro-step.',
53
49
  'Mark tasks you are actively working on as in_progress; several may be in_progress when work genuinely runs in parallel.',
54
- 'Mark tasks done immediately after finishing. Never mark a task completed while its tests fail or its work is incomplete. If you cannot remember the exact content of an entry, re-derive it from the previous todo_write result instead of guessing.',
50
+ 'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, or an ops action you cannot perform) must be marked blocked with a blocker note — never left pending, and never executed just to clear the list; finished or obsolete entries must be completed or removed.',
55
51
  'Do not make todo_write the only tool call of a turn — batch it together with real work.',
56
52
  'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
57
53
  ].join(' ');
@@ -64,10 +60,10 @@ const TODO_MARKS: Record<TodoStatus, string> = {
64
60
  abandoned: '✗',
65
61
  };
66
62
 
67
- /** pi TUI widget 硬上限(interactive-mode MAX_WIDGET_LINES=10,头部截断;我们自己控制在限内)。 */
63
+ /** Stay within pi interactive mode's ten-line widget cap instead of relying on head truncation. */
68
64
  export const WIDGET_MAX_LINES = 10;
69
65
 
70
- /** 分组渲染(phase 头 + 条目行;不设预算,widget 与 /todos 共用)。 */
66
+ /** Share phase rendering between the widget and /todos so both views order entries identically. */
71
67
  function renderGroups(items: readonly TodoItem[]): string[] {
72
68
  const groups = new Map<string, TodoItem[]>();
73
69
  for (const it of items) {
@@ -88,18 +84,15 @@ function renderGroups(items: readonly TodoItem[]): string[] {
88
84
  }
89
85
 
90
86
  /**
91
- * widget 行(活动锚定窗口,用户实证二修):
92
- * - 全局预算 = WIDGET_MAX_LINES(含摘要行与 +N 行);
93
- * - 窗口锚定第一条 in_progress:可见的条目是它前后的连续区段(正在做的
94
- * 上下文——前因后果可见),不再是固定头部/尾部(旧实现超窗时只剩尾部,
95
- * 正在执行的条目滚出视窗,01a03c0d 实证"显示最后几条,不管当前执行到哪");
96
- * - 无 in_progress → 锚定最后一条 open;全完成 → 尾部切片(刚完成的可见);
97
- * - +N 行指路 /todos(全量视图)。
87
+ * Keep the active task visible within WIDGET_MAX_LINES instead of truncating to a fixed head or
88
+ * tail. The window anchors on the first in-progress item, then the last open item, then recent
89
+ * completions. A +N line points to /todos when the full plan cannot fit. This fixes 01a03c0d,
90
+ * where active work scrolled out while only the final entries remained visible.
98
91
  */
99
92
  export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
100
93
  if (items.length === 0) return [];
101
94
  const c = countTodos(items);
102
- // 反冻结:归档列表不逐条渲染,两行说明 + 指路 /todos(死计划不再占满视窗)。
95
+ // Collapse archived plans to two lines so stale work cannot monopolize the widget.
103
96
  if (opts?.archivedAgeMs != null) {
104
97
  return [
105
98
  `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓ · archived ${formatAge(opts.archivedAgeMs)}`,
@@ -107,8 +100,7 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
107
100
  ];
108
101
  }
109
102
 
110
- // 条目预算 = 总预算 - 摘要行 -(隐藏时的)+N 行。phase 头也占行——按渲染行数
111
- // 迭代收缩:锚定窗口切片 → 超行则两端各收一格(保锚点),直到装得下。
103
+ // Reserve summary and overflow lines, then shrink around the anchor because phase headers also consume the budget.
112
104
  const renderBudget = WIDGET_MAX_LINES - 1 - 1;
113
105
  const [start, end] = anchorRange(items, renderBudget);
114
106
  const kept = items.slice(start, end);
@@ -122,9 +114,8 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
122
114
  }
123
115
 
124
116
  /**
125
- * 活动锚定窗口:锚点 = 第一条 in_progress(无则最后一条 open;全完成 → 尾部)。
126
- * 以渲染行数(含 phase 头)为预算,从锚点贪心扩张(尾部优先——后续工作先
127
- * 可见),装不下即停;锚点条目永远在窗内。
117
+ * Expand around the active anchor within the rendered-line budget, favoring upcoming work while
118
+ * guaranteeing that the anchor remains visible.
128
119
  */
129
120
  function anchorRange(items: readonly TodoItem[], renderBudget: number): [number, number] {
130
121
  if (renderGroups(items).length <= renderBudget) return [0, items.length];
@@ -133,7 +124,7 @@ function anchorRange(items: readonly TodoItem[], renderBudget: number): [number,
133
124
  ? anchor
134
125
  : items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
135
126
  if (anchorIdx < 0) {
136
- // 全完成:尾部切片(按行数从头收缩)
127
+ // Completed plans keep the most recent tail visible within the line budget.
137
128
  let end = items.length;
138
129
  while (renderGroups(items.slice(0, end)).length > renderBudget && end > 1) end -= 1;
139
130
  return [0, end];
@@ -155,7 +146,7 @@ function anchorRange(items: readonly TodoItem[], renderBudget: number): [number,
155
146
  } else {
156
147
  break;
157
148
  }
158
- growTail = !growTail; // 严格交替:锚点前因与后果均衡可见(用户实证诉求)
149
+ growTail = !growTail; // Alternate sides so both context and upcoming work remain visible.
159
150
  }
160
151
  return [start, end];
161
152
  }
@@ -166,17 +157,17 @@ export default function todoPlugin(ctx: Context): void {
166
157
  ctx.get('pi-herdr.todo-deps') as TodoDeps;
167
158
  const scoped = surface.forModule(import.meta.url);
168
159
 
169
- /** 归档年龄(非归档 → null);widget / /todos 共用。 */
160
+ /** Share one archive-age decision between the widget and /todos. */
170
161
  function archivedAgeMs(now = Date.now()): number | null {
171
162
  return todos.lastWriteAt != null && isArchived(todos.items, todos.lastWriteAt, now)
172
163
  ? now - todos.lastWriteAt
173
164
  : null;
174
165
  }
175
166
 
176
- /** 事件 ctx 里的 widget UI(有则 setWidget;守卫后具名使用,不做行内断言)。 */
167
+ /** Name the guarded event UI once so callers do not repeat unsafe inline assertions. */
177
168
  function widgetUi(eventCtx: unknown): { setWidget?: (id: string, lines: string[]) => void } | undefined {
178
169
  if (eventCtx === null || typeof eventCtx !== 'object' || !('ui' in eventCtx)) return undefined;
179
- const ui = (eventCtx as { ui: unknown }).ui; // 'ui' in 已守卫
170
+ const ui = (eventCtx as { ui: unknown }).ui; // Safe after the property guard above.
180
171
  return ui !== null && typeof ui === 'object'
181
172
  ? (ui as { setWidget?: (id: string, lines: string[]) => void })
182
173
  : undefined;
@@ -186,23 +177,23 @@ export default function todoPlugin(ctx: Context): void {
186
177
  try {
187
178
  widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, { archivedAgeMs: archivedAgeMs() }));
188
179
  } catch {
189
- /* 无 widget 能力的 pi 版本静默降级 */
180
+ /* Older pi versions may omit widget support without disabling todo tracking. */
190
181
  }
191
182
  }
192
183
 
193
- // 槽回填:index 的 session_start/session_tree 调用
184
+ // Let index lifecycle hooks call the current plugin implementation.
194
185
  state.renderWidget = renderWidget;
195
186
 
196
- /* ── 读钩(轮次计数器内聚;D39 提示注入 + 反冻结 stale-core) ── */
187
+ /* ── Read hook: D39 reminders and stale-plan thawing ────────────── */
197
188
  let todoReadTurn = 0;
198
189
  let lastEmptyGuardTurn: number | null = null;
199
- // 反冻结状态:距上次写入轮数 / stale 警告计数与节奏 / 归档翻转检测
190
+ // Track writes and notice cadence so a stale plan cannot remain frozen indefinitely.
200
191
  let lastWriteTurn: number | null = null;
201
192
  let staleNotices = 0;
202
193
  let lastStaleGuardTurn: number | null = null;
203
194
  let lastArchivedMirror = false;
204
195
 
205
- // 任何真实变更(todo_write / 人类编辑 / 对账 / 分支重建)都终结当前停滞期
196
+ // Any real edit ends the current stale period because the plan is active again.
206
197
  todos.on('todo.updated', () => {
207
198
  lastWriteTurn = todoReadTurn;
208
199
  staleNotices = 0;
@@ -223,20 +214,20 @@ export default function todoPlugin(ctx: Context): void {
223
214
  staleNotices += 1;
224
215
  lastStaleGuardTurn = todoReadTurn;
225
216
  }
226
- // 空守卫 / 归档通知 / R2 重写窗口共用注入节奏(archived=true 的 stale 窗口同享,
227
- // 否则终态通知会在窗口后紧跟一拍挤掉宽限)。
217
+ // Share cadence across empty, archive, and R2 rewrite notices so adjacent notices do not
218
+ // consume the grace window.
228
219
  if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
229
220
  lastEmptyGuardTurn = todoReadTurn;
230
221
  }
231
- // R1:归档通知注入即清空——明细早已不再注入,保留死列表只会挡住空守卫的
232
- // before-stopping 驱动(01a03c0d:归档后 2h 多步工作零跟踪)。rm 全量落
233
- // JSONL,重启 rebuild 同样折叠得空表;历史明细仍在会话文件里。
222
+ // R1 clears archived entries after notice because retaining dead items suppresses the empty
223
+ // guard that restored tracking after the 01a03c0d two-hour gap. Persist removals for replay;
224
+ // the session log still retains history.
234
225
  if (plan.inject && plan.effect === 'archive-notice' && plan.clearArchived && todos.items.length > 0) {
235
226
  const edits = todos.items.map((it) => ({ op: 'rm' as const, content: it.content }));
236
227
  try {
237
228
  appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits, ts: Date.now() });
238
229
  } catch {
239
- /* 尽力而为(内存清空照常) */
230
+ /* Persistence is best-effort because the in-memory clear must still proceed. */
240
231
  }
241
232
  todos.replace([]);
242
233
  }
@@ -251,11 +242,14 @@ export default function todoPlugin(ctx: Context): void {
251
242
  };
252
243
  });
253
244
 
254
- /* ── 工具:todo_write ── */
245
+ /* ── todo_write tool ───────────────────────────────────────────── */
255
246
  scoped.registerTool({
256
247
  name: TODO_TOOL_NAME,
257
248
  label: 'Todo',
258
249
  description: TOOL_DESCRIPTION,
250
+ promptGuidelines: [
251
+ 'Before stopping with open todo_write entries, reconcile them: entries waiting on a human decision, approval, or ops action must be marked blocked with a blocker note — never left pending, and never executed just to clear the list.',
252
+ ],
259
253
  parameters: Type.Object({
260
254
  todos: Type.Array(
261
255
  Type.Object(
@@ -286,7 +280,7 @@ export default function todoPlugin(ctx: Context): void {
286
280
  details: {},
287
281
  };
288
282
  }
289
- const strictMode = todos.config.strict; // D75 阶段 2:策略进 service config
283
+ const strictMode = todos.config.strict; // D75 phase 2 keeps policy in the service config.
290
284
  let next = result.items!;
291
285
  const strictNotes: string[] = [];
292
286
  if (strictMode) {
@@ -300,11 +294,11 @@ export default function todoPlugin(ctx: Context): void {
300
294
  if (promoted) strictNotes.push(`auto-promoted to in_progress: ${promoted}`);
301
295
  next = strict;
302
296
  }
303
- // D35:no-op(与当前列表全等 → 不落盘不镜像)
297
+ // D35 avoids persistence and mirroring when the authoritative list is unchanged.
304
298
  if (listsEqual(todos.items, next)) {
305
299
  return { content: [{ type: 'text', text: 'No change: todo list already matches.' }], details: {} };
306
300
  }
307
- // D36/D37:过渡信号与回退警告
301
+ // D36/D37 surface completion transitions and regressions to the caller.
308
302
  const completed = completionTransitions(todos.items, next);
309
303
  const reverted = revertedCompleted(todos.items, next);
310
304
  todos.replace(next);
@@ -329,7 +323,7 @@ export default function todoPlugin(ctx: Context): void {
329
323
  },
330
324
  });
331
325
 
332
- /* ── 命令:/todos(查看 + done/drop/rm/unblock 人类编辑,D38)── */
326
+ /* ── /todos command: D38 human viewing and edits ───────────────── */
333
327
  scoped.registerCommand('todos', {
334
328
  description: 'Show the todo list, or edit it: /todos done|drop|rm|unblock <fuzzy content match>',
335
329
  handler: async (args: unknown, eventCtx: unknown) => {
@@ -362,11 +356,11 @@ export default function todoPlugin(ctx: Context): void {
362
356
  ui?.notify?.(`no change: "${content}" is already in that state`, 'info');
363
357
  return;
364
358
  }
365
- // D38:人类编辑写回权威(custom 条目,重启后分支回放生效)
359
+ // D38 persists human edits so branch replay rebuilds the same authoritative state.
366
360
  try {
367
361
  appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits: [edit], ts: Date.now() });
368
362
  } catch {
369
- /* 尽力而为 */
363
+ /* Persistence failure must not discard the live human edit. */
370
364
  }
371
365
  mirrorTodos();
372
366
  renderWidget(eventCtx);
@@ -377,7 +371,7 @@ export default function todoPlugin(ctx: Context): void {
377
371
  ui?.notify?.(`"${content}" ${verb}`, 'info');
378
372
  return;
379
373
  }
380
- // 全量视图(/todos 不受 widget 行预算限制——"看整个列表"的正式入口)
374
+ // /todos is the unbounded view because the widget intentionally prioritizes active context.
381
375
  const body = renderGroups(todos.items);
382
376
  if (body.length === 0) {
383
377
  ui?.notify?.('todo list is empty', 'info');
package/src/gc-core.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  /**
2
- * 任务 tab GC 决策(D29 的判定规则抽成纯函数;M22 取消 resident 豁免)。
3
- * 无副作用、无 pi/herdr API 依赖 → 可单测。
2
+ * Task-tab GC decisions (D29 rules as a pure function; M22 dropped resident exemption).
3
+ * No I/O, no pi/herdr — unit-testable.
4
4
  */
5
5
  export type GcEntryKind = string;
6
6
  export type GcEntryStatus = 'running' | 'settled' | 'consumed' | 'closed';
7
7
 
8
8
  export interface GcEntryLike {
9
- /** 'task' 或 role 名;旧值 short/resident 只作标签,不参与判定。 */
9
+ /** 'task' or a role name; legacy short/resident is a label only. */
10
10
  kind: GcEntryKind;
11
11
  status: GcEntryStatus;
12
- /** 消费时间(GC 宽限期判据;closed 条目保留原值)。 */
12
+ /** Consume timestamp (GC grace); closed rows keep the original value. */
13
13
  consumedAt?: number | null;
14
14
  }
15
15
 
@@ -18,12 +18,12 @@ function isFinished(status: GcEntryStatus): boolean {
18
18
  }
19
19
 
20
20
  /**
21
- * D29 / M22:任务 tab 是否该关(全部条件同时成立):
22
- * - 含 ≥1 个工作 pane(主 tab 无委派条目 → 永不关);
23
- * - 全部工作 pane 已 settled/consumed/closed(kind 不再豁免);
24
- * - 宽限期已过(时间制 TTL;ttlMs=0 表示不自动关,由调用方把关);
25
- * - 无 blocked pane(人类闸门豁免);
26
- * - 其余 pane 全部 idle/done/unknown(非工作 pane 允许 unknown)。
21
+ * Close a task tab only when every condition holds:
22
+ * - ≥1 work pane (the main tab with no delegated entries never closes);
23
+ * - every work pane is settled/consumed/closed (kind is not an exemption);
24
+ * - grace TTL elapsed (`ttlMs=0` means never auto-close);
25
+ * - no blocked pane (human-gate exemption);
26
+ * - remaining panes are idle/done/unknown (non-work panes may be unknown).
27
27
  */
28
28
  export function shouldCloseTaskTab(opts: {
29
29
  entries: readonly GcEntryLike[];
@@ -40,17 +40,17 @@ export function shouldCloseTaskTab(opts: {
40
40
  }
41
41
 
42
42
  /**
43
- * pane 级回收判定(孤儿/兼容路径,v1.2 语义):
44
- * - 消费于上一轮之前(宽限:结算通知那一轮人可看、模型可用);
45
- * - herdr 显式状态 idle/done(unknown/working/blocked → 下轮重试,#943 容错);
46
- * - pane 已消失(status undefined)→ 补记 closed(返回 true 由调用方处理)。
43
+ * Pane-level collection (orphan / compat path):
44
+ * - consumed before the previous turn (grace so the settlement notice is still visible);
45
+ * - herdr status idle/done (unknown/working/blocked retry next turn);
46
+ * - missing pane (status undefined) → record closed (caller handles the write).
47
47
  */
48
48
  export function shouldClosePane(opts: {
49
49
  consumedAt: number | null;
50
50
  herdrStatus: string | undefined;
51
51
  prevTurnStart: number;
52
52
  }): boolean {
53
- if (opts.herdrStatus === undefined) return true; // pane 没了 → 补记 closed
53
+ if (opts.herdrStatus === undefined) return true; // pane gone → record closed
54
54
  if (opts.herdrStatus !== 'idle' && opts.herdrStatus !== 'done') return false;
55
55
  return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart;
56
56
  }
@@ -0,0 +1,104 @@
1
+ import { execFile as nodeExecFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { runtimePolicy } from './runtime-policy.ts'
4
+
5
+ const execFile = promisify(nodeExecFile)
6
+
7
+ export interface GitResult {
8
+ readonly stdout: string
9
+ readonly stderr: string
10
+ }
11
+
12
+ export interface GitError extends Error {
13
+ readonly code?: string | number
14
+ readonly stdout?: string
15
+ readonly stderr?: string
16
+ }
17
+
18
+ /**
19
+ * Injectable execFile used by NodeGitAdapter.
20
+ * Why: tests drive git without a real process.
21
+ */
22
+ export type GitExecFile = (
23
+ file: string,
24
+ args: readonly string[],
25
+ options: { timeout: number; encoding: 'utf8' },
26
+ ) => Promise<{ stdout: string; stderr: string }>
27
+
28
+ /**
29
+ * Minimal Git adapter for worktree and status operations.
30
+ *
31
+ * Why: Centralizes platform-specific git executable resolution, timeout policy,
32
+ * and error normalization; makes tests independent of real git processes.
33
+ */
34
+ export interface GitAdapter {
35
+ /**
36
+ * List worktrees in porcelain format.
37
+ * Returns stdout containing worktree entries.
38
+ */
39
+ listWorktrees(cwd: string): Promise<GitResult>
40
+
41
+ /**
42
+ * Get status of worktree in short format.
43
+ * Returns stdout containing status entries.
44
+ */
45
+ status(cwd: string): Promise<GitResult>
46
+
47
+ /**
48
+ * Run an arbitrary git command in `cwd` (`git -C <cwd> ...args`).
49
+ * Throws a GitError on failure/timeout — callers that want "null on error"
50
+ * (isolate sweep, placement) catch at the call site.
51
+ */
52
+ run(cwd: string, args: readonly string[]): Promise<GitResult>
53
+ }
54
+ export class NodeGitAdapter implements GitAdapter {
55
+ private readonly gitExecutable: string
56
+ private readonly timeoutMs: number
57
+ private readonly exec: GitExecFile
58
+
59
+ constructor(
60
+ gitExecutable: string = 'git',
61
+ timeoutMs: number = runtimePolicy.gitTimeoutMs,
62
+ exec: GitExecFile = execFile as unknown as GitExecFile,
63
+ ) {
64
+ this.gitExecutable = gitExecutable
65
+ this.timeoutMs = timeoutMs
66
+ this.exec = exec
67
+ }
68
+
69
+ listWorktrees(cwd: string): Promise<GitResult> {
70
+ return this.run(cwd, ['worktree', 'list', '--porcelain'])
71
+ }
72
+
73
+ status(cwd: string): Promise<GitResult> {
74
+ return this.run(cwd, ['status', '--short'])
75
+ }
76
+
77
+ async run(cwd: string, args: readonly string[]): Promise<GitResult> {
78
+ try {
79
+ const { stdout, stderr } = await this.exec(
80
+ this.gitExecutable,
81
+ ['-C', cwd, ...args],
82
+ { timeout: this.timeoutMs, encoding: 'utf8' },
83
+ )
84
+ return { stdout, stderr }
85
+ } catch (err) {
86
+ throw this.normalizeError(err, args[0] ?? 'command')
87
+ }
88
+ }
89
+
90
+ private normalizeError(err: unknown, operation: string): GitError {
91
+ if (err instanceof Error) {
92
+ const gitError = err as GitError
93
+ gitError.message = `Git ${operation} failed: ${gitError.message}`
94
+ return gitError
95
+ }
96
+ return new Error(`Git ${operation} failed: ${String(err)}`) as GitError
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Default production git adapter.
102
+ * Tests should inject fake implementations or a fake exec.
103
+ */
104
+ export const defaultGitAdapter = new NodeGitAdapter()