pi-pier 0.2.7 → 0.3.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 (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -1,11 +1,9 @@
1
1
  /**
2
2
  * D78/D81 todo loader entry.
3
- *
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, read hook,
7
- * and master stop reminder; reconciliation, mirroring, and agent reporting stay with the
8
- * session state layer to avoid a dependency cycle.
3
+ * A Cordis plugin keeps the master surface hot-swappable while workers mount the one-shot subset directly;
4
+ * injected services preserve the D79 registration boundary and keep session-owned todo state in index. This
5
+ * module owns the tool, command, widget, read hook, and master stop reminder — reconciliation, mirroring, and
6
+ * agent reporting stay in the session state layer (cycle avoidance).
9
7
  */
10
8
  import { Context } from '@deepseek-ai/cordis';
11
9
  import { Type } from 'typebox';
@@ -14,25 +12,21 @@ import type { TodosService } from '../todos-service.ts';
14
12
  import { planTodoReadHook } from '../todo-read-hook.ts';
15
13
  import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
16
14
  import { makeProgressUpdate } from '../subagent-core.ts';
17
- import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
15
+ import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
16
+ import { toolError } from '../tool-error.ts';
18
17
  import { formatAge, isArchived } from '../stale-core.ts';
19
- import {
20
- TODO_EDIT_CUSTOM_TYPE,
21
- completionTransitions,
22
- fuzzyFind,
23
- listsEqual,
24
- makeSnapshot,
25
- normalizeStrict,
26
- revertedCompleted,
27
- validateTodos,
28
- } from '../todo-core.ts';
18
+ import { TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, validateTodos, type TodoEditOp } from '../todo-core.ts';
29
19
  import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
20
+ import { swallow } from '../swallow.ts';
21
+
22
+ type ToolParams = Record<string, unknown> | undefined;
30
23
 
31
24
  export interface TodoUiSlot {
32
25
  /** Lets index lifecycle events render through the currently mounted plugin. */
33
26
  renderWidget: (ctx: unknown) => void;
34
27
  /** Re-render from the last seen context without a fresh event (human-gate open/close). */
35
28
  rerenderWidget?: () => void;
29
+ cancelReminder?: () => void;
36
30
  }
37
31
 
38
32
  interface TodoDeps {
@@ -49,6 +43,8 @@ interface TodoDeps {
49
43
  stopReminder?: {
50
44
  getBlockedDepth: () => number;
51
45
  getRunningSubs: () => number;
46
+ isCompactionInFlight?: () => boolean;
47
+ isIntentionalAbort?: () => boolean;
52
48
  };
53
49
  }
54
50
 
@@ -62,24 +58,28 @@ const TOOL_DESCRIPTION = [
62
58
  '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.',
63
59
  ].join(' ');
64
60
 
65
- /** Stay within pi interactive mode's ten-line widget cap instead of relying on head truncation. */
66
- export const WIDGET_MAX_LINES = 10;
61
+ /** pi interactive mode renders at most ten widget lines. */
62
+ const WIDGET_MAX_LINES = 10;
63
+
64
+ /** `/todos <op> <query>` verbs; the value is the user-facing past-tense feedback. */
65
+ const OP_VERBS: Record<TodoEditOp, string> = {
66
+ done: 'completed',
67
+ drop: 'abandoned',
68
+ unblock: 'unblocked (back to pending)',
69
+ rm: 'removed',
70
+ };
67
71
 
68
72
  /**
69
- * Keep the active task visible within WIDGET_MAX_LINES instead of truncating to a fixed head or
70
- * tail. The window anchors on the first in-progress item, then the last open item, then recent
71
- * completions. A +N line points to /todos when the full plan cannot fit. This fixes 01a03c0d,
72
- * where active work scrolled out while only the final entries remained visible.
73
+ * Keep the active task visible within WIDGET_MAX_LINES rather than truncating to a fixed head or tail:
74
+ * the window anchors on the first in-progress item, then the last open item, then recent completions.
73
75
  */
74
76
  export function widgetLines(
75
77
  items: readonly TodoItem[],
76
78
  opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
77
79
  ): string[] {
78
80
  if (items.length === 0) return [];
79
- // Human gate open (ask_user_question waiting): the question + editor own the fixed area, so the
80
- // widget collapses to the one-line summary — /todos still reaches the full list. Without this,
81
- // a 10-line widget plus a multi-line question leaves almost no scrollable transcript
82
- // (user-reported: both blocks visible ⇒ history range too small).
81
+ // Human gate open (ask_user_question waiting): the question and editor own the fixed area, so the widget
82
+ // collapses to one summary line — a 10-line widget plus a multi-line question would leave no scrollable transcript.
83
83
  if ((opts?.blockedDepth ?? 0) > 0) {
84
84
  return [`${formatTodoSummary(items)} · /todos 全量`];
85
85
  }
@@ -91,8 +91,8 @@ export function widgetLines(
91
91
  ];
92
92
  }
93
93
 
94
- // Reserve summary and overflow lines, then shrink around the anchor because phase headers also consume the budget.
95
- const renderBudget = WIDGET_MAX_LINES - 1 - 1;
94
+ // Reserve summary and overflow lines; phase headers consume the budget too, hence the shrink loop.
95
+ const renderBudget = WIDGET_MAX_LINES - 2;
96
96
  const [start, end] = anchorTodoRange(
97
97
  items,
98
98
  (s, e) => renderTodoGroups(items.slice(s, e)).length <= renderBudget,
@@ -120,21 +120,13 @@ export default function todoPlugin(ctx: Context): void {
120
120
  : null;
121
121
  }
122
122
 
123
- /** Name the guarded event UI once so callers do not repeat unsafe inline assertions. */
124
- function widgetUi(eventCtx: unknown): { setWidget?: (id: string, lines: string[]) => void } | undefined {
125
- if (eventCtx === null || typeof eventCtx !== 'object' || !('ui' in eventCtx)) return undefined;
126
- const ui = (eventCtx as { ui: unknown }).ui; // Safe after the property guard above.
127
- return ui !== null && typeof ui === 'object'
128
- ? (ui as { setWidget?: (id: string, lines: string[]) => void })
129
- : undefined;
130
- }
131
-
132
123
  /** Gate transitions fire outside lifecycle events; remember one ctx so rerenderWidget can target the live ui. */
133
124
  let lastEventCtx: unknown = null;
134
125
  function renderWidget(eventCtx: unknown): void {
135
126
  if (eventCtx !== null && eventCtx !== undefined) lastEventCtx = eventCtx;
127
+ const ui = (eventCtx as { ui?: { setWidget?: (id: string, lines: string[]) => void } } | null | undefined)?.ui;
136
128
  try {
137
- widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, {
129
+ ui?.setWidget?.('todos', widgetLines(todos.items, {
138
130
  archivedAgeMs: archivedAgeMs(),
139
131
  blockedDepth: getBlockedDepth?.() ?? 0,
140
132
  }));
@@ -143,7 +135,6 @@ export default function todoPlugin(ctx: Context): void {
143
135
  }
144
136
  }
145
137
 
146
- // Let index lifecycle hooks call the current plugin implementation.
147
138
  state.renderWidget = renderWidget;
148
139
  state.rerenderWidget = () => renderWidget(lastEventCtx);
149
140
 
@@ -154,9 +145,7 @@ export default function todoPlugin(ctx: Context): void {
154
145
  let lastWriteTurn: number | null = null;
155
146
  let staleNotices = 0;
156
147
  let lastStaleGuardTurn: number | null = null;
157
- let lastArchivedMirror = false;
158
148
 
159
- // Any real edit ends the current stale period because the plan is active again.
160
149
  todos.on('todo.updated', () => {
161
150
  lastWriteTurn = todoReadTurn;
162
151
  staleNotices = 0;
@@ -177,22 +166,21 @@ export default function todoPlugin(ctx: Context): void {
177
166
  staleNotices += 1;
178
167
  lastStaleGuardTurn = todoReadTurn;
179
168
  }
180
- // Share cadence across empty, archive, and R2 rewrite notices so adjacent notices do not
181
- // consume the grace window.
169
+ // Empty, archive, and R2 rewrite notices share one cadence so adjacent notices do not each consume the grace window.
182
170
  if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
183
171
  lastEmptyGuardTurn = todoReadTurn;
184
172
  }
185
- // R1 clears archived entries after notice because retaining dead items suppresses the empty
186
- // guard that restored tracking after the 01a03c0d two-hour gap. Persist removals for replay;
187
- // the session log still retains history.
173
+ // R1: persist the clear as one rm-per-item edit so replay folds to an empty list. The session
174
+ // log keeps the history; a dead in-memory list would suppress the empty guard.
188
175
  if (plan.inject && plan.effect === 'archive-notice' && plan.clearArchived && todos.items.length > 0) {
189
176
  const edits = todos.items.map((it) => ({ op: 'rm' as const, content: it.content }));
190
177
  try {
191
178
  appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits, ts: Date.now() });
192
- } catch {
193
- /* Persistence is best-effort because the in-memory clear must still proceed. */
179
+ } catch (err) {
180
+ // The in-memory clear must proceed even when this persistence fails.
181
+ swallow('todo.persist-archive', err);
194
182
  }
195
- todos.replace([]);
183
+ todos.replace([], { source: 'archive' });
196
184
  }
197
185
  todoReadTurn += 1;
198
186
  if (!plan.inject) return;
@@ -210,7 +198,7 @@ export default function todoPlugin(ctx: Context): void {
210
198
  sendMessage?: (
211
199
  message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
212
200
  opts?: { deliverAs?: string; triggerTurn?: boolean },
213
- ) => Promise<void>;
201
+ ) => void;
214
202
  };
215
203
  let todoReminders = 0;
216
204
  let lastAssistantStopReason: string | null = null;
@@ -221,21 +209,20 @@ export default function todoPlugin(ctx: Context): void {
221
209
  todoReminderTimer = null;
222
210
  }
223
211
  }
212
+ state.cancelReminder = cancelTodoReminder;
224
213
  scoped.on('turn_end', async (event: unknown) => {
225
- if (event === null || typeof event !== 'object' || !('message' in event)) return;
226
- const msg = (event as { message: unknown }).message;
214
+ const msg = (event as { message?: unknown } | null | undefined)?.message;
227
215
  if (msg === null || typeof msg !== 'object') return;
228
216
  const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
229
- if (role === 'assistant' && typeof stopReason === 'string') {
230
- lastAssistantStopReason = stopReason;
231
- }
217
+ if (role === 'assistant' && typeof stopReason === 'string') lastAssistantStopReason = stopReason;
232
218
  });
233
- scoped.on('agent_start', () => cancelTodoReminder());
234
- scoped.on('session_shutdown', () => cancelTodoReminder());
219
+ for (const ev of ['agent_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
235
220
  scoped.on('agent_settled', async () => {
236
221
  cancelTodoReminder();
237
222
  const plan = planStopTodoReminder({
238
223
  lastStopReason: lastAssistantStopReason,
224
+ intentionalAbort: stopReminder.isIntentionalAbort?.() ?? false,
225
+ compactionInFlight: stopReminder.isCompactionInFlight?.() ?? false,
239
226
  reminders: todoReminders,
240
227
  runningSubs: stopReminder.getRunningSubs(),
241
228
  blockedDepth: stopReminder.getBlockedDepth(),
@@ -245,19 +232,21 @@ export default function todoPlugin(ctx: Context): void {
245
232
  const content = plan.content;
246
233
  todoReminderTimer = setTimeout(() => {
247
234
  todoReminderTimer = null;
248
- void (async () => {
249
- const send = pi.sendMessage;
250
- if (typeof send !== 'function') return;
251
- try {
252
- await send(
253
- { customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
254
- { deliverAs: 'followUp', triggerTurn: true },
255
- );
256
- todoReminders += 1;
257
- } catch {
258
- /* Delivery failure is non-fatal. */
259
- }
260
- })();
235
+ if (stopReminder.isCompactionInFlight?.()) return;
236
+ const send = pi.sendMessage;
237
+ if (typeof send !== 'function') return;
238
+ try {
239
+ // pi's sendMessage is fire-and-forget (it returns void), so the cap counter must advance
240
+ // synchronously: `send(...).then(...)` threw, got swallowed here, and every settle re-sent
241
+ // "Reminder 1/3" forever.
242
+ send(
243
+ { customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
244
+ { deliverAs: 'followUp', triggerTurn: true },
245
+ );
246
+ todoReminders += 1;
247
+ } catch {
248
+ /* A synchronous send failure must not escape the timer callback. */
249
+ }
261
250
  }, todoReminderGraceMs());
262
251
  todoReminderTimer.unref?.();
263
252
  });
@@ -276,13 +265,10 @@ export default function todoPlugin(ctx: Context): void {
276
265
  Type.Object(
277
266
  {
278
267
  content: Type.String({ description: 'What the task is — a short imperative line' }),
279
- status: Type.Union([
280
- Type.Literal('pending'),
281
- Type.Literal('in_progress'),
282
- Type.Literal('completed'),
283
- Type.Literal('blocked'),
284
- Type.Literal('abandoned'),
285
- ], { description: 'pending | in_progress | completed | blocked | abandoned' }),
268
+ status: Type.Union(
269
+ TODO_STATUSES.map((status) => Type.Literal(status)),
270
+ { description: TODO_STATUSES.join(' | ') },
271
+ ),
286
272
  blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
287
273
  phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
288
274
  },
@@ -291,20 +277,17 @@ export default function todoPlugin(ctx: Context): void {
291
277
  { description: 'The COMPLETE task list; this call replaces the previous list' },
292
278
  ),
293
279
  }),
294
- async execute(toolCallId, params, signal, onUpdate, eventCtx) {
280
+ async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, eventCtx: unknown) {
295
281
  void toolCallId;
296
282
  void signal;
297
283
  const result = validateTodos(params?.todos, allowParallelInProgress);
298
284
  if (!result.ok) {
299
- return {
300
- content: [{ type: 'text', text: `Error: ${result.error}` }],
301
- details: {},
302
- };
285
+ // A1: an invalid list is a hard failure — throw so pi flags isError for the model.
286
+ return toolError(result.error ?? 'invalid todo list');
303
287
  }
304
- const strictMode = todos.config.strict; // D75 phase 2 keeps policy in the service config.
305
288
  let next = result.items!;
306
289
  const strictNotes: string[] = [];
307
- if (strictMode) {
290
+ if (todos.config.strict) {
308
291
  const strict = normalizeStrict(next);
309
292
  const demoted = next
310
293
  .filter((it, i) => it.status === 'in_progress' && strict[i]?.status === 'pending')
@@ -315,14 +298,13 @@ export default function todoPlugin(ctx: Context): void {
315
298
  if (promoted) strictNotes.push(`auto-promoted to in_progress: ${promoted}`);
316
299
  next = strict;
317
300
  }
318
- // D35 avoids persistence and mirroring when the authoritative list is unchanged.
301
+ // D35: skip persistence and mirroring when the authoritative list is unchanged.
319
302
  if (listsEqual(todos.items, next)) {
320
303
  return { content: [{ type: 'text', text: 'No change: todo list already matches.' }], details: {} };
321
304
  }
322
- // D36/D37 surface completion transitions and regressions to the caller.
323
305
  const completed = completionTransitions(todos.items, next);
324
306
  const reverted = revertedCompleted(todos.items, next);
325
- todos.replace(next);
307
+ todos.replace(next, { source: 'tool' });
326
308
  if (todos.items.length > maxItems) {
327
309
  onUpdate?.(makeProgressUpdate(`Note: list has ${todos.items.length} entries; consider keeping it under ${maxItems}.`));
328
310
  }
@@ -349,12 +331,11 @@ export default function todoPlugin(ctx: Context): void {
349
331
  description: 'Show the todo list, or edit it: /todos done|drop|rm|unblock <fuzzy content match>',
350
332
  handler: async (args: unknown, eventCtx: unknown) => {
351
333
  const ui = (eventCtx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
352
- const ops = ['done', 'drop', 'rm', 'unblock'] as const;
353
334
  const raw = typeof args === 'string'
354
335
  ? args.split(/\s+/).filter(Boolean)
355
336
  : Array.isArray(args) ? args.map(String) : [];
356
337
  const opArg = raw[0];
357
- if (opArg && (ops as readonly string[]).includes(opArg)) {
338
+ if (opArg && Object.hasOwn(OP_VERBS, opArg)) {
358
339
  const query = raw.slice(1).join(' ').trim();
359
340
  if (!query) {
360
341
  ui?.notify?.('usage: /todos done|drop|rm|unblock <content>', 'warning');
@@ -370,14 +351,14 @@ export default function todoPlugin(ctx: Context): void {
370
351
  return;
371
352
  }
372
353
  const content = candidates[0];
373
- const edit = { op: opArg as 'done' | 'drop' | 'rm' | 'unblock', content };
354
+ const edit = { op: opArg as TodoEditOp, content };
374
355
  const before = todos.items;
375
- todos.applyEdits([edit]);
356
+ todos.applyEdits([edit], { source: 'human' });
376
357
  if (listsEqual(before, todos.items)) {
377
358
  ui?.notify?.(`no change: "${content}" is already in that state`, 'info');
378
359
  return;
379
360
  }
380
- // D38 persists human edits so branch replay rebuilds the same authoritative state.
361
+ // D38: persist human edits so branch replay rebuilds the same authoritative state.
381
362
  try {
382
363
  appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits: [edit], ts: Date.now() });
383
364
  } catch {
@@ -385,14 +366,9 @@ export default function todoPlugin(ctx: Context): void {
385
366
  }
386
367
  mirrorTodos();
387
368
  renderWidget(eventCtx);
388
- const verb = opArg === 'done' ? 'completed'
389
- : opArg === 'drop' ? 'abandoned'
390
- : opArg === 'unblock' ? 'unblocked (back to pending)'
391
- : 'removed';
392
- ui?.notify?.(`"${content}" ${verb}`, 'info');
369
+ ui?.notify?.(`"${content}" ${OP_VERBS[edit.op]}`, 'info');
393
370
  return;
394
371
  }
395
- // /todos is the unbounded view because the widget intentionally prioritizes active context.
396
372
  const body = renderTodoGroups(todos.items);
397
373
  if (body.length === 0) {
398
374
  ui?.notify?.('todo list is empty', 'info');
@@ -405,4 +381,3 @@ export default function todoPlugin(ctx: Context): void {
405
381
  },
406
382
  });
407
383
  }
408
-
@@ -1,41 +1,27 @@
1
1
  /**
2
- * M16 pure core for progress visibility (development plan §M16; prioritize conservative output, with no new D3 protocol).
3
- *
4
- * Channels in the post-M22 world:
5
- * - Progress badge (N/M plus optional ETA) is embedded in the pane title (state_labels has only five keys, so it cannot fit there);
6
- * - Tool badge (🔧 tool) is sent through report_agent.message;
7
- * - Rate estimates follow kimi estimator semantics: rate window, unfinished cap, and confidence gate.
8
- *
9
- * Conservative principle (risk ⑥ decision): when an estimate is not trustworthy, fall back to plain `N/M` and
10
- * never display a misleading ETA.
2
+ * M16 progress visibility: conservative output only, no new D3 protocol. The progress badge
3
+ * (N/M plus optional ETA) rides in the pane title, the tool badge (🔧 tool) goes through
4
+ * report_agent.message, and an untrustworthy estimate falls back to plain `N/M` — never a
5
+ * misleading ETA.
11
6
  */
12
7
 
13
8
  import { countTodos, type TodoItem } from './vocab.ts';
14
9
 
15
10
  /** Minimum completed points needed for an ETA (<2 cannot establish a rate). */
16
11
  export const ETA_MIN_SAMPLES = 2;
17
- /** Rate sample window (kimi rate-window semantics; use the most recent K completion points). */
18
- export const RATE_WINDOW_MS = 45_000;
19
- /** Cap for displayed unfinished progress (kimi unfinished-cap semantics; prevent a false "almost done" impression). */
20
- export const UNFINISHED_CAP = 0.85;
21
- /** Completion freshness gate: if the most recent completion is older than this, data is stale and no ETA is estimated. */
12
+ /** An older latest completion means the data is stale and no ETA is estimated. */
22
13
  export const PROGRESS_STALE_MS = 5 * 60_000;
23
- /** Number of recent completion points used for rate fitting. */
24
14
  const RATE_SAMPLE_POINTS = 5;
25
15
 
26
16
  export interface EtaEstimate {
27
- /** Remaining steps. */
28
17
  remaining: number;
29
- /** Estimated remaining time (milliseconds). */
30
18
  etaMs: number;
31
19
  confidence: 'ok';
32
20
  }
33
21
 
34
22
  /**
35
- * Estimate ETA from completion timestamps and remaining steps.
36
- * - Fewer than 2 completion points → null (no rate can be inferred);
37
- * - Most recent completion older than PROGRESS_STALE_MS → null (stale after a burst followed by a pause or manual hold);
38
- * - Rate = use the latest K(≤5) points, (k-1) / (t_k - t_1); eta = remaining / rate.
23
+ * Fewer than 2 points, or a stale latest point, → null.
24
+ * Rate = latest K(≤5) points, (k-1) / (t_k - t_1); eta = remaining / rate.
39
25
  */
40
26
  export function estimateEta(opts: {
41
27
  completedAt: readonly number[];
@@ -54,11 +40,10 @@ export function estimateEta(opts: {
54
40
  const span = sample[sample.length - 1] - sample[0];
55
41
  const steps = sample.length - 1;
56
42
  if (steps < 1 || span <= 0) return null;
57
- const ratePerMs = steps / span;
58
- return { remaining, etaMs: Math.round(remaining / ratePerMs), confidence: 'ok' };
43
+ return { remaining, etaMs: Math.round(remaining / (steps / span)), confidence: 'ok' };
59
44
  }
60
45
 
61
- /** Title progress suffix: conservative `3/7`; trusted ETA gives `3/7 ~4m`; total=0 → empty; all complete → `5/5 ✓`. */
46
+ /** Title suffix: `3/7`; trusted ETA gives `3/7 ~4m`; total=0 → empty; all complete → `5/5 ✓`. */
62
47
  export function formatProgressSuffix(opts: {
63
48
  completed: number;
64
49
  total: number;
@@ -68,44 +53,22 @@ export function formatProgressSuffix(opts: {
68
53
  if (total <= 0) return '';
69
54
  const base = completed >= total ? `${completed}/${total} ✓` : `${completed}/${total}`;
70
55
  if (!eta || eta.etaMs <= 0) return base;
71
- const human = eta.etaMs < 60_000 ? '<1m'
72
- : (() => {
73
- const mins = Math.ceil(eta.etaMs / 60_000);
74
- return mins < 60 ? `~${mins}m` : `~${Math.round((mins / 60) * 10) / 10}h`;
75
- })();
56
+ const human = eta.etaMs < 60_000 ? '<1m' : (() => {
57
+ const mins = Math.ceil(eta.etaMs / 60_000);
58
+ return mins < 60 ? `~${mins}m` : `~${Math.round((mins / 60) * 10) / 10}h`;
59
+ })();
76
60
  return `${base} ${human}`;
77
61
  }
78
62
 
79
- /** Tool badge (report_agent.message): one tool name; multiple use the first plus a count; empty → null (do not overwrite). */
63
+ /** Tool badge for report_agent.message: one name, or the first plus a count; empty → null (do not overwrite). */
80
64
  export function planToolBadge(runningToolNames: readonly string[]): string | null {
81
65
  if (runningToolNames.length === 0) return null;
82
66
  const first = runningToolNames[0];
83
67
  return runningToolNames.length === 1 ? `🔧 ${first}` : `🔧 ${first} +${runningToolNames.length - 1}`;
84
68
  }
85
69
 
86
- /** Derive progress input from a list (completed count / total open-work count). */
70
+ /** Completed count over all tracked work (abandoned excluded). */
87
71
  export function progressOf(items: readonly TodoItem[]): { completed: number; total: number } {
88
72
  const c = countTodos(items);
89
- const blockedCount = items.filter((it) => it.status === 'blocked').length;
90
- return { completed: c.completed, total: c.completed + c.pending + c.inProgress + blockedCount };
91
- }
92
-
93
- /** Hide the progress badge after it has been marked for the full lifecycle (avoid persistent completed-state noise; callers compare this). */
94
- export const PROGRESS_HIDE_MS = 60_000;
95
-
96
- /**
97
- * Count completed transitions between two lists (match by content; an item completed in the new list whose old status
98
- * was not completed, including items newly added as completed). M16 ETA estimation uses this as input: every edit path
99
- * (todo_write / human edit / M17 reconciliation) passes through TodosService, so the diff is centralized here.
100
- */
101
- export function countCompletedTransitions(
102
- before: readonly TodoItem[],
103
- after: readonly TodoItem[],
104
- ): number {
105
- const oldStatus = new Map(before.map((it) => [it.content, it.status]));
106
- let n = 0;
107
- for (const it of after) {
108
- if (it.status === 'completed' && oldStatus.get(it.content) !== 'completed') n += 1;
109
- }
110
- return n;
73
+ return { completed: c.completed, total: c.completed + c.pending + c.inProgress + c.blocked };
111
74
  }
@@ -1,35 +1,31 @@
1
1
  /**
2
- * M17: pure planner for automatic todo↔subagent reconciliation (development plan §M17; P2 = do not check off low-confidence matches, only prompt).
2
+ * Pure planner for automatic todo↔subagent reconciliation, run after a settlement push and before
3
+ * the followUp is injected.
3
4
  *
4
- * Timing (adapter): after the controller receives a settlement push (pollLoop / pipe reply), before injecting followUp.
5
- * Semantics (aligned with omp reconcileTodosWithSubagents):
6
- * - Match description ↔ todo.content: normalize (lowercase + collapse whitespace), then exact → bidirectional prefix
7
- * → substring, in the same family as D38 fuzzyFind;
8
- * - Auto-complete only when settled and the unique best tier is in {exact, prefix}; at most one item per settlement;
9
- * - Unblock blocked items whose blocker ↔ description matches at any tier (exact/prefix)
10
- * (multiple allowed; failed/low-confidence matches do not unblock);
11
- * - Low confidence/ambiguity/failure: leave the list unchanged and add a noteLines prompt to the settlement notice (P2 decision);
12
- * - Persistence: edits use the D38 authoritative path (pi-herdr.todo-edit custom entry, effective during branch replay).
5
+ * Match description ↔ todo.content after normalize (lowercase + collapse whitespace) at tiers
6
+ * exact → prefix → substring, the same family as D38 fuzzyFind.
7
+ * - Auto-complete only a settled subagent's unique best candidate at exact/prefix, at most one item;
8
+ * - Unblock blocked items whose blocker matches at any tier (blockers are phrases like "waiting for
9
+ * X to finish", so substring matters and a soft return to pending is safe);
10
+ * - Low confidence / ambiguity / failure leaves the list untouched and adds a prompt line (never
11
+ * check off a guess);
12
+ * - Edits persist through the authoritative pi-herdr.todo-edit path, so branch replay reproduces them.
13
13
  */
14
14
 
15
15
  import { applyTodoEdits, type TodoEdit, type TodoItem } from './todo-core.ts';
16
16
 
17
- export type ReconcileOutcome = 'settled' | 'failed';
18
- export type MatchTier = 'exact' | 'prefix' | 'substring' | null;
19
- export interface ReconcilePlan {
20
- /** List after applying edits (original reference when there are no edits). */
17
+ type ReconcileOutcome = 'settled' | 'failed';
18
+ type MatchTier = 'exact' | 'prefix' | 'substring' | null;
19
+ interface ReconcilePlan {
21
20
  items: TodoItem[];
22
21
  /** Edits persisted through the authoritative path (at most one done plus any unblocks). */
23
22
  edits: TodoEdit[];
24
23
  /** Item automatically checked off (at most one). */
25
24
  completed: TodoItem | null;
26
- /** Best matching tier for auto-completion (null when there are no candidates). */
27
25
  tier: MatchTier;
28
- /** Items unblocked by a blocker match. */
29
26
  unblocked: TodoItem[];
30
- /** Prompt lines injected into the settlement notice (aligned with D36 feedback tone; empty when none). */
31
27
  noteLines: string[];
32
- /** Match-rate metric (P2: collected with the settlement notice in session JSONL). */
28
+ /** Match-rate metric, collected with the settlement notice in session JSONL. */
33
29
  metric: {
34
30
  description: string;
35
31
  outcome: ReconcileOutcome;
@@ -64,9 +60,9 @@ export function reconcileTodos(
64
60
  const edits: TodoEdit[] = [];
65
61
  const noteLines: string[] = [];
66
62
 
67
- // 1) Check-off candidates: pending / in_progress (completed/abandoned never participate).
68
- const matchable = prev.filter((t) => t.status === 'pending' || t.status === 'in_progress');
69
- const scored = matchable
63
+ // Check-off candidates: completed/abandoned never participate.
64
+ const scored = prev
65
+ .filter((t) => t.status === 'pending' || t.status === 'in_progress')
70
66
  .map((t) => ({ item: t, tier: matchTier(description, t.content) }))
71
67
  .filter((c): c is { item: TodoItem; tier: Exclude<MatchTier, null> } => c.tier !== null)
72
68
  .sort((a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier]);
@@ -80,35 +76,30 @@ export function reconcileTodos(
80
76
  edits.push({ op: 'done', content: completed.content });
81
77
  noteLines.push(`Reconciled: completed "${completed.content}" (${bestTier} match with subagent description).`);
82
78
  } else if (best.length > 1) {
83
- // Ambiguous match: list candidates and leave the decision to a person.
84
79
  noteLines.push(
85
80
  `Todo match ambiguous between: ${best.map((c) => `"${c.item.content}"`).join(', ')} — update todo_write yourself.`,
86
81
  );
87
- } else if (outcome === 'failed') {
88
- // High-confidence candidate, but the subagent did not settle successfully: keep it open and prompt.
82
+ } else {
83
+ // High-confidence candidate, but the subagent did not settle successfully: keep it open.
89
84
  for (const c of best) {
90
85
  noteLines.push(`Todo kept open: "${c.item.content}" (subagent did not settle successfully).`);
91
86
  }
92
87
  }
93
88
  } else if (bestTier === 'substring') {
94
- // Low-confidence match (P2): do not check it off; prompt with the candidate.
95
89
  for (const c of best) {
96
90
  noteLines.push(`Todo not auto-completed (low-confidence match): "${c.item.content}" — update todo_write if this work is done.`);
97
91
  }
98
92
  }
99
93
 
100
- // 2) Unblock on any blocker match, including substring—real blockers are phrases such as "waiting for X to finish",
101
- // so the description may be embedded in the middle and prefix would miss it. The low-confidence gate applies only
102
- // to check-offs; returning an unblocked item to pending is a soft operation. Only settled subagents can unblock (possibly multiple).
94
+ // Unblocking is a soft operation, so any tier counts; only a settled subagent can unblock.
103
95
  const unblocked: TodoItem[] = [];
104
96
  if (outcome === 'settled') {
105
97
  for (const t of prev) {
106
98
  if (t.status !== 'blocked' || typeof t.blocker !== 'string') continue;
107
- if (matchTier(description, t.blocker) !== null) {
108
- unblocked.push(t);
109
- edits.push({ op: 'unblock', content: t.content });
110
- noteLines.push(`Unblocked "${t.content}" (was waiting on: ${t.blocker}).`);
111
- }
99
+ if (matchTier(description, t.blocker) === null) continue;
100
+ unblocked.push(t);
101
+ edits.push({ op: 'unblock', content: t.content });
102
+ noteLines.push(`Unblocked "${t.content}" (was waiting on: ${t.blocker}).`);
112
103
  }
113
104
  }
114
105