pi-pier 0.3.0 → 0.4.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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
package/src/todo-core.ts CHANGED
@@ -1,24 +1,14 @@
1
- /** Why: Preserve the established compatibility and safety behavior (D34–D43, M10). */
2
- import {
3
- TODO_DETAILS_KEY,
4
- TODO_STATUSES,
5
- TODO_TOOL_NAME,
6
- type TodoCounts,
7
- type TodoItem,
8
- type TodoSnapshot,
9
- type TodoStatus,
10
- } from './vocab.ts';
11
- import { countTodos } from './vocab.ts';
1
+ /** Pure todo-list core: validation, diffing, edit and branch-replay folding (D34–D43, M10). */
2
+ import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, type TodoItem, type TodoSnapshot, type TodoStatus } from './vocab.ts';
12
3
 
13
- export type { TodoItem, TodoSnapshot, TodoStatus, TodoCounts };
4
+ export type { TodoItem, TodoSnapshot, TodoStatus };
14
5
 
15
- /** Why: Preserve the established compatibility and safety behavior (D43). */
16
- export const PHASE_MAX_LEN = 30;
6
+ /** D43 phase label cap, mirrored by the todo_write tool description. */
7
+ const PHASE_MAX_LEN = 30;
17
8
 
18
- /** Why: Preserve the established compatibility and safety behavior (D38). */
9
+ /** Session entry type for human/automated edits, replayed during branch rebuild (D38). */
19
10
  export const TODO_EDIT_CUSTOM_TYPE = 'pi-herdr.todo-edit';
20
11
 
21
- /** Why: Preserve the established compatibility and safety behavior (D38, M17). */
22
12
  export type TodoEditOp = 'done' | 'drop' | 'rm' | 'unblock';
23
13
 
24
14
  export interface TodoEdit {
@@ -38,28 +28,20 @@ export interface TodoValidationResult {
38
28
  error?: string;
39
29
  }
40
30
 
41
- /** Why: Preserve the established compatibility and safety behavior (B2, D34, D43). */
42
31
  export function validateTodos(
43
32
  todos: unknown,
44
33
  allowParallelInProgress = true,
45
34
  ): TodoValidationResult {
46
- if (!Array.isArray(todos)) {
47
- return { ok: false, error: 'invalid todos: `todos` must be an array' };
48
- }
35
+ if (!Array.isArray(todos)) return { ok: false, error: 'invalid todos: `todos` must be an array' };
49
36
  const seen = new Set<string>();
50
37
  let inProgress = 0;
51
38
  const items: TodoItem[] = [];
52
39
  for (const raw of todos) {
53
- if (typeof raw !== 'object' || raw === null) {
54
- return { ok: false, error: 'invalid todos: each todo must be an object' };
55
- }
40
+ if (typeof raw !== 'object' || raw === null) return { ok: false, error: 'invalid todos: each todo must be an object' };
56
41
  const entry = raw as Record<string, unknown>;
57
- const allowed = ['content', 'status', 'blocker', 'phase'];
58
- const keys = Object.keys(entry);
59
- for (const k of keys) {
60
- if (!allowed.includes(k)) {
61
- return { ok: false, error: `invalid todos: unknown field "${k}" (allowed: content, status, blocker, phase)` };
62
- }
42
+ const unknownField = Object.keys(entry).find((k) => !['content', 'status', 'blocker', 'phase'].includes(k));
43
+ if (unknownField) {
44
+ return { ok: false, error: `invalid todos: unknown field "${unknownField}" (allowed: content, status, blocker, phase)` };
63
45
  }
64
46
  if (!('content' in entry) || !('status' in entry)) {
65
47
  return { ok: false, error: 'invalid todos: each todo must have `content` and `status`' };
@@ -68,20 +50,17 @@ export function validateTodos(
68
50
  if (typeof content !== 'string' || content.trim() === '') {
69
51
  return { ok: false, error: 'invalid todo: `content` must be a non-empty string' };
70
52
  }
71
- if (seen.has(content)) {
72
- return { ok: false, error: `invalid todos: duplicate content "${content}"` };
73
- }
53
+ if (seen.has(content)) return { ok: false, error: `invalid todos: duplicate content "${content}"` };
74
54
  seen.add(content);
75
55
  const status = entry.status;
76
56
  if (typeof status !== 'string' || !(TODO_STATUSES as readonly string[]).includes(status)) {
77
57
  return { ok: false, error: `invalid todo: \`status\` must be one of ${TODO_STATUSES.join('|')}` };
78
58
  }
79
59
  const item: TodoItem = { content, status: status as TodoStatus };
60
+ // blocker/phase tolerate empty-string filler (models fill optional fields with ''), but a
61
+ // malformed type is a hard reject: it would hide intent.
80
62
  if ('blocker' in entry) {
81
63
  const blocker = entry.blocker;
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.
85
64
  if (blocker != null && typeof blocker !== 'string') {
86
65
  return { ok: false, error: 'invalid todo: `blocker` must be a non-empty string' };
87
66
  }
@@ -94,41 +73,30 @@ export function validateTodos(
94
73
  }
95
74
  if ('phase' in entry) {
96
75
  const phase = entry.phase;
97
- if (phase != null && typeof phase !== 'string') {
98
- return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
99
- }
100
- // Why: Preserve the established compatibility and safety behavior.
76
+ const phaseError = { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
77
+ if (phase != null && typeof phase !== 'string') return phaseError;
101
78
  if (typeof phase === 'string') {
102
- if (phase.length > PHASE_MAX_LEN) {
103
- return { ok: false, error: `invalid todo: \`phase\` must be a non-empty string ≤ ${PHASE_MAX_LEN} chars` };
104
- }
79
+ if (phase.length > PHASE_MAX_LEN) return phaseError;
105
80
  if (phase.trim() !== '') item.phase = phase;
106
81
  }
107
82
  }
108
- if (status === 'in_progress') {
109
- inProgress++;
110
- if (!allowParallelInProgress && inProgress > 1) {
111
- return {
112
- ok: false,
113
- error: `invalid todos: at most one task may be in_progress (got ${inProgress})`,
114
- };
115
- }
83
+ if (status === 'in_progress' && ++inProgress > 1 && !allowParallelInProgress) {
84
+ return { ok: false, error: `invalid todos: at most one task may be in_progress (got ${inProgress})` };
116
85
  }
117
86
  items.push(item);
118
87
  }
119
88
  return { ok: true, items };
120
89
  }
121
90
 
122
- /** Why: Preserve the established compatibility and safety behavior (D42). */
91
+ /** Keep exactly one in_progress item, promoting the first pending one when none exists (D42). */
123
92
  export function normalizeStrict(items: readonly TodoItem[]): TodoItem[] {
124
93
  if (items.length === 0) return [];
125
94
  const out = items.map((it) => ({ ...it }));
126
95
  let kept = false;
127
96
  for (const it of out) {
128
- if (it.status === 'in_progress') {
129
- if (kept) it.status = 'pending';
130
- else kept = true;
131
- }
97
+ if (it.status !== 'in_progress') continue;
98
+ if (kept) it.status = 'pending';
99
+ else kept = true;
132
100
  }
133
101
  if (!kept) {
134
102
  const firstPending = out.find((it) => it.status === 'pending');
@@ -137,7 +105,6 @@ export function normalizeStrict(items: readonly TodoItem[]): TodoItem[] {
137
105
  return out;
138
106
  }
139
107
 
140
- /** Why: Preserve the established compatibility and safety behavior (D35). */
141
108
  export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
142
109
  if (a.length !== b.length) return false;
143
110
  for (let i = 0; i < a.length; i++) {
@@ -148,38 +115,34 @@ export function listsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): bool
148
115
  return true;
149
116
  }
150
117
 
151
- /** Why: Preserve the established compatibility and safety behavior (D36). */
118
+ /** Items completed by this write (D36: surfaced back to the caller). */
152
119
  export function completionTransitions(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
153
120
  const prevMap = new Map(prev.map((it) => [it.content, it.status]));
154
- const out: string[] = [];
155
- for (const it of next) {
156
- if (it.status === 'completed' && prevMap.get(it.content) !== 'completed') out.push(it.content);
157
- }
158
- return out;
121
+ return next
122
+ .filter((it) => it.status === 'completed' && prevMap.get(it.content) !== 'completed')
123
+ .map((it) => it.content);
159
124
  }
160
125
 
161
- /** Why: Preserve the established compatibility and safety behavior (D37). */
126
+ /** Completed items pushed back to open by this write (D37: reversible, but warn). */
162
127
  export function revertedCompleted(prev: readonly TodoItem[], next: readonly TodoItem[]): string[] {
163
128
  const nextMap = new Map(next.map((it) => [it.content, it.status]));
164
- const out: string[] = [];
165
- for (const it of prev) {
166
- if (it.status !== 'completed') continue;
167
- const now = nextMap.get(it.content);
168
- if (now === 'pending' || now === 'in_progress') out.push(it.content);
169
- }
170
- return out;
129
+ return prev
130
+ .filter((it) => {
131
+ if (it.status !== 'completed') return false;
132
+ const now = nextMap.get(it.content);
133
+ return now === 'pending' || now === 'in_progress';
134
+ })
135
+ .map((it) => it.content);
171
136
  }
172
137
 
173
- /** Why: Preserve the established compatibility and safety behavior. */
174
138
  export function makeSnapshot(items: readonly TodoItem[]): TodoSnapshot {
175
139
  // Copy: the snapshot is persisted, the service list stays owned by TodosService.
176
140
  return { version: 1, items: [...items] };
177
141
  }
178
142
 
179
- export interface BranchEntryLike {
180
- /** Why: Preserve the established compatibility and safety behavior. */
143
+ /** Loose shape of pi session JSONL entries we fold over; only the fields we read. */
144
+ interface BranchEntryLike {
181
145
  type?: string;
182
- /** Why: Preserve the established compatibility and safety behavior. */
183
146
  timestamp?: unknown;
184
147
  message?: {
185
148
  role?: string;
@@ -190,33 +153,31 @@ export interface BranchEntryLike {
190
153
  data?: unknown;
191
154
  }
192
155
 
193
- /** Why: Preserve the established compatibility and safety behavior (D37, D38, M17). */
156
+ /** Edit semantics (D37/D38): done/drop keep completed|abandoned terminal; unblock returns blocked to pending. */
194
157
  export function applyTodoEdits(items: readonly TodoItem[], edits: readonly TodoEdit[]): TodoItem[] {
195
158
  let out = items.map((it) => ({ ...it }));
196
159
  for (const edit of edits) {
160
+ if (edit.op === 'rm') {
161
+ out = out.filter((it) => it.content !== edit.content);
162
+ continue;
163
+ }
197
164
  const target = out.find((it) => it.content === edit.content);
198
165
  if (!target) continue;
199
166
  switch (edit.op) {
200
167
  case 'done':
201
- if (target.status !== 'completed') {
202
- target.status = 'completed';
203
- delete target.blocker;
204
- }
168
+ if (target.status === 'completed') break;
169
+ target.status = 'completed';
170
+ delete target.blocker;
205
171
  break;
206
172
  case 'drop':
207
- if (target.status !== 'completed' && target.status !== 'abandoned') {
208
- target.status = 'abandoned';
209
- delete target.blocker;
210
- }
211
- break;
212
- case 'rm':
213
- out = out.filter((it) => it.content !== edit.content);
173
+ if (target.status === 'completed' || target.status === 'abandoned') break;
174
+ target.status = 'abandoned';
175
+ delete target.blocker;
214
176
  break;
215
177
  case 'unblock':
216
- if (target.status === 'blocked') {
217
- target.status = 'pending';
218
- delete target.blocker;
219
- }
178
+ if (target.status !== 'blocked') break;
179
+ target.status = 'pending';
180
+ delete target.blocker;
220
181
  break;
221
182
  }
222
183
  }
@@ -241,10 +202,9 @@ function extractEditPayload(data: unknown): TodoEditPayload | null {
241
202
  return { version: 1, edits, ts: typeof raw.ts === 'number' ? raw.ts : 0 };
242
203
  }
243
204
 
244
- /** Why: Preserve the established compatibility and safety behavior (D38). */
245
205
  export interface FoldedTodos {
246
206
  items: TodoItem[];
247
- /** Why: Preserve the established compatibility and safety behavior. */
207
+ /** Timestamp of the last write that shaped these items; null when the entry carried none. */
248
208
  writtenAt: number | null;
249
209
  }
250
210
 
@@ -258,7 +218,7 @@ function entryTimestamp(entry: BranchEntryLike): number | null {
258
218
  return null;
259
219
  }
260
220
 
261
- /** Why: Preserve the established compatibility and safety behavior (D38). */
221
+ /** Fold the branch to the authoritative list: last todo snapshot, then interleaved edits (D38). */
262
222
  export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
263
223
  let found: TodoItem[] | null = null;
264
224
  let writtenAt: number | null = null;
@@ -283,12 +243,7 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
283
243
  return found ? { items: found, writtenAt } : null;
284
244
  }
285
245
 
286
- /** Why: Preserve the established compatibility and safety behavior. */
287
- export function foldLatestTodos(entries: readonly BranchEntryLike[]): TodoItem[] | null {
288
- return foldLatestTodosMeta(entries)?.items ?? null;
289
- }
290
-
291
- export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
246
+ function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
292
247
  if (typeof details !== 'object' || details === null) return null;
293
248
  const raw = (details as Record<string, unknown>)[TODO_DETAILS_KEY];
294
249
  if (typeof raw !== 'object' || raw === null) return null;
@@ -297,7 +252,7 @@ export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | nul
297
252
  return { version: 1, items: snap.items as TodoItem[] };
298
253
  }
299
254
 
300
- /** Why: Preserve the established compatibility and safety behavior (D38). */
255
+ /** Exact match, then unique prefix, then substring candidates (D38 `/todos <verb> <query>`). */
301
256
  export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
302
257
  const q = query.trim();
303
258
  if (!q) return [];
@@ -306,26 +261,22 @@ export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
306
261
  const prefix = items.filter((it) => it.content.startsWith(q));
307
262
  if (prefix.length === 1) return [prefix[0].content];
308
263
  if (prefix.length > 1) return prefix.map((it) => it.content);
309
- const substr = items.filter((it) => it.content.includes(q));
310
- return substr.map((it) => it.content);
264
+ return items.filter((it) => it.content.includes(q)).map((it) => it.content);
311
265
  }
312
266
 
313
- /** Why: Preserve the established compatibility and safety behavior (D39). */
314
267
  export interface BoundedView {
315
268
  visible: TodoItem[];
316
- /** Why: Preserve the established compatibility and safety behavior. */
317
269
  hiddenCompleted: number;
318
270
  hiddenOpen: number;
319
271
  }
320
272
 
273
+ /** Keep the newest open items, then fill leftover budget with the newest completions (D39). */
321
274
  export function boundedView(items: readonly TodoItem[], budget: number): BoundedView {
322
275
  if (items.length <= budget) {
323
276
  return { visible: items.map((it) => ({ ...it })), hiddenCompleted: 0, hiddenOpen: 0 };
324
277
  }
325
278
  const completed = items.filter((it) => it.status === 'completed');
326
279
  const open = items.filter((it) => it.status !== 'completed');
327
- // Why: Preserve the established compatibility and safety behavior.
328
- // Why: Preserve the established compatibility and safety behavior.
329
280
  const visible = open.length > budget ? open.slice(-budget) : open;
330
281
  const hiddenOpen = open.length - visible.length;
331
282
  let hiddenCompleted = completed.length;
@@ -338,9 +289,6 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
338
289
  return { visible: final, hiddenCompleted, hiddenOpen };
339
290
  }
340
291
 
341
- /** Why: Preserve the established compatibility and safety behavior. */
342
292
  export function currentActivity(items: readonly TodoItem[]): string | null {
343
293
  return items.find((it) => it.status === 'in_progress')?.content ?? null;
344
294
  }
345
-
346
- export { countTodos };
@@ -1,32 +1,55 @@
1
1
  /**
2
- * D69: Plan the todo read hook (before_agent_start + display:false).
3
- * Stale-core prevents a fully completed list from being recited forever:
4
- * - stale (turn-based) → warn with old entries for rewriting, rate-limited and capped;
5
- * - archived (wall-clock based) → treat it as absent, sharing the guard cadence with empty lists.
2
+ * Plan the todo read hook (before_agent_start + display:false). Stale-core keeps a fully completed list
3
+ * from being recited forever: stale (turn-based) warns with the old entries as rewrite references,
4
+ * rate-limited and capped; archived (wall-clock) treats the list as absent, sharing the guard cadence
5
+ * with empty lists.
6
6
  */
7
7
  import { boundedView, currentActivity, type TodoItem } from './todo-core.ts';
8
8
  import { countTodos } from './vocab.ts';
9
9
  import { STALE_NOTICE_MAX, evaluateStaleness, formatAge } from './stale-core.ts';
10
+ import { todoMark } from './todo-window.ts';
10
11
 
11
- /** Share one injection cadence across empty-list guards, stale warnings, and archive notices. */
12
- export const EMPTY_GUARD_EVERY_N = 4;
13
- export const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
12
+ /** Cadence shared by empty-list guards, stale warnings, and archive notices. */
13
+ const EMPTY_GUARD_EVERY_N = 4;
14
+ const TODO_READ_CUSTOM_TYPE = 'pi-herdr.todo-read';
14
15
 
15
- export type TodoReadEffect =
16
+ /**
17
+ * pi 1.2 fires `before_agent_start` for runs started by `sendMessage(..., { triggerTurn: true })`
18
+ * too. The todo hook wants the plan in every *user* run and after a compaction continuation (the
19
+ * model needs the plan to resume), but not in its own stop reminder or the config guide: those
20
+ * already carry their own text, and counting them as turns would advance the guard cadence.
21
+ * Keep this list in sync with COMPACTION_CONTINUE_TYPE in compact-coordinator.ts.
22
+ */
23
+ export const TODO_READ_CUSTOM_TYPES: readonly string[] = ['pi-herdr.compaction-continue'];
24
+
25
+ /** Whether this run should receive the todo read hook. Missing `messageType` is pi < 1.2, where
26
+ * only user runs fired `before_agent_start`; treat it as a user run for compatibility. */
27
+ export function shouldRunTodoRead(event: unknown): boolean {
28
+ if (event === null || typeof event !== 'object' || !('messageType' in event)) return true;
29
+ const messageType = (event as { messageType?: unknown }).messageType;
30
+ if (messageType === 'user') return true;
31
+ if (messageType !== 'custom') return false;
32
+ const message = (event as { message?: unknown }).message;
33
+ const customType = message !== null && typeof message === 'object' && 'customType' in message
34
+ ? (message as { customType?: unknown }).customType
35
+ : undefined;
36
+ return typeof customType === 'string' && TODO_READ_CUSTOM_TYPES.includes(customType);
37
+ }
38
+
39
+ type TodoReadEffect =
16
40
  | 'recite' // Recite normally while the list is fresh.
17
41
  | 'empty-guard' // Guard against an empty list.
18
- | 'stale-notice' // A: warn when the list is stale by turns.
19
- | 'archive-notice' // B: notify when the list is archived by wall-clock age.
42
+ | 'stale-notice' // Warn when the list is stale by turns, or when archiving is one window away.
43
+ | 'archive-notice' // Notify once the list is archived by wall-clock age.
20
44
  | 'none'; // Skip because of rate limits or caps.
21
45
 
22
- export interface TodoReadPlan {
46
+ interface TodoReadPlan {
23
47
  inject: boolean;
24
48
  effect: TodoReadEffect;
25
49
  /** Current archive state, independent of injection, so callers can refresh widget/title projections. */
26
50
  archived: boolean;
27
- /** R1: Clear the in-memory list when injecting the archive notice; callers persist the empty JSONL
28
- * so a dead list cannot block the before-stopping empty guard (01a03c0d observed 2h of untracked
29
- * multi-step work after archiving). */
51
+ /** Clear the in-memory list when injecting the archive notice (the caller persists the empty JSONL);
52
+ * a kept dead list would suppress the empty guard that resumes tracking after an idle gap. */
30
53
  clearArchived: boolean;
31
54
  message: {
32
55
  customType: string;
@@ -35,24 +58,14 @@ export interface TodoReadPlan {
35
58
  };
36
59
  }
37
60
 
38
- const MARKS: Record<TodoItem['status'], string> = {
39
- pending: '○',
40
- in_progress: '▶',
41
- completed: '✓',
42
- blocked: '■',
43
- abandoned: '✗',
44
- };
45
-
46
61
  export function planTodoReadHook(opts: {
47
62
  items: readonly TodoItem[];
48
63
  turn: number;
49
64
  lastEmptyGuardTurn: number | null;
50
65
  /** stale-core clock anchor (TodosService.lastWriteAt). */
51
66
  lastWriteAt: number | null;
52
- /** Turns since the last todo write, when known in this process. */
53
67
  turnsSinceWrite: number | null;
54
68
  now: number;
55
- /** Number of stale warnings injected during the current idle period. */
56
69
  staleNotices: number;
57
70
  lastStaleGuardTurn: number | null;
58
71
  }): TodoReadPlan {
@@ -86,16 +99,12 @@ export function planTodoReadHook(opts: {
86
99
  });
87
100
 
88
101
  if (st.kind === 'archived') {
89
- const c = countTodos(opts.items as TodoItem[]);
102
+ const c = countTodos(opts.items);
90
103
  const age = st.ageMs == null ? '' : ` ${formatAge(st.ageMs)} ago`;
91
- // R2: Preserve one rewrite window with old entries as a reference before archiving.
92
- // Idle periods do not consume turns, so the wall clock can reach one hour before a turns-based
93
- // warning fires; 01a03c0d showed that the final notice then caused five todo_write calls to be ignored.
94
- // B + R1/R3: The terminal notice no longer injects details and clears the list in the same
95
- // injection; removing the [] escape hatch ensures multi-step work gets tracked while single-step
96
- // Q&A remains explicitly allowed.
104
+ // Idle periods do not consume turns, so give one rewrite window with the old entries as reference
105
+ // before the terminal notice.
97
106
  if (opts.staleNotices === 0 && guardDue) {
98
- const lines = opts.items.map((it) => ` ${MARKS[it.status]} ${it.content}`);
107
+ const lines = opts.items.map((it) => ` ${todoMark(it.status)} ${it.content}`);
99
108
  const head = `todos ✓${c.completed} (all completed, last updated${age}) — about to be archived`;
100
109
  const warn = 'One rewrite window before archiving: if the work you are doing now is multi-step, rewrite the full list with todo_write to track it (old entries below as reference — reuse what still applies). Single-step Q&A may skip tracking. If the list stays untouched, it will be archived and cleared on the next reminder.';
101
110
  return {
@@ -106,9 +115,8 @@ export function planTodoReadHook(opts: {
106
115
  message: msg([head, ...lines, warn].join('\n')),
107
116
  };
108
117
  }
109
- // B + R1/R3: The terminal notice no longer injects details and clears the list in the same
110
- // injection; removing the [] escape hatch ensures multi-step work gets tracked while single-step
111
- // Q&A remains explicitly allowed.
118
+ // The terminal notice carries no details and no "skip tracking" escape hatch — multi-step work
119
+ // must get a fresh list, single-step Q&A may proceed untracked.
112
120
  return {
113
121
  inject: guardDue,
114
122
  effect: 'archive-notice',
@@ -123,16 +131,15 @@ export function planTodoReadHook(opts: {
123
131
  }
124
132
 
125
133
  if (st.kind === 'stale') {
126
- // A: Turn recitation into a warning while retaining old entries as rewrite references; cap at STALE_NOTICE_MAX.
127
134
  const staleDue = opts.lastStaleGuardTurn == null
128
135
  || opts.turn - opts.lastStaleGuardTurn >= EMPTY_GUARD_EVERY_N;
129
136
  if (!staleDue || opts.staleNotices >= STALE_NOTICE_MAX) {
130
137
  return { inject: false, effect: 'none', archived: false, clearArchived: false, message: msg('') };
131
138
  }
132
- const c = countTodos(opts.items as TodoItem[]);
139
+ const c = countTodos(opts.items);
133
140
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
134
141
  + ` · unchanged for ${opts.turnsSinceWrite} turns, nothing open`;
135
- const lines = opts.items.map((it) => ` ${MARKS[it.status]} ${it.content}`);
142
+ const lines = opts.items.map((it) => ` ${todoMark(it.status)} ${it.content}`);
136
143
  const warn = 'This list no longer reflects the work you are doing. Rewrite the full list with todo_write to match current work, or send [] to clear it.';
137
144
  return {
138
145
  inject: true,
@@ -146,7 +153,7 @@ export function planTodoReadHook(opts: {
146
153
  // Fresh lists are recited every turn to keep current work visible.
147
154
  const c = countTodos(opts.items);
148
155
  const view = boundedView(opts.items, 6);
149
- const lines = view.visible.map((it) => `${MARKS[it.status]} ${it.content}`);
156
+ const lines = view.visible.map((it) => `${todoMark(it.status)} ${it.content}`);
150
157
  const activity = currentActivity(opts.items);
151
158
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
152
159
  + (activity ? ` · ${activity}` : '');
@@ -1,90 +1,65 @@
1
1
  /**
2
- * Pure decision core for reminders about unfinished todos when stopping (D41 revision 2;
3
- * evidenced by session 01a040cc).
4
- *
5
- * Evidence: the model's closing prose said that pushing should be left to the user,
6
- * yet the todo list still contained `[pending] push to repository`. The old D41 injected
7
- * “Continue working on them before stopping” as a user-role message 116 ms after agent_settled;
8
- * that channel overrode the model's judgment from 20 seconds earlier, and git push ran within
9
- * 12 seconds without authorization.
10
- *
11
- * Revision 3 keeps the decision and wording here while the timing grace period is wired in
12
- * core/subagent:
13
- * - use sendMessage(custom), not the user role, so the reminder source is identifiable rather than impersonating the user;
14
- * - replace “keep working” with a reconciliation request, making human-blocked items a first-class
15
- * outcome alongside continuing authorized work and deleting obsolete entries;
16
- * - preserve the guard matrix: suppress on abort, cap reminders, and skip while a subagent is
17
- * running, a human gate is blocked, or no open items remain.
2
+ * Reminders about unfinished todos when stopping go out as sendMessage(custom) and ask for
3
+ * reconciliation rather than continuation, so human-blocked items are a first-class outcome. Guards:
4
+ * suppress on abort, cap reminders, skip while a subagent runs, a human gate is blocked, or no open
5
+ * item remains.
18
6
  */
19
7
  import { ABORT_STOP_REASON } from './settle-wake-core.ts';
20
8
  import { pierOption } from './pier-options.ts';
21
9
 
22
- /** Cap reminders for the lifetime of the process, matching the previous behavior. */
23
- export const TODO_REMINDERS_MAX = 3;
10
+ /** Cap reminders for the lifetime of the process. */
11
+ const REMINDERS_MAX = 3;
24
12
 
25
- /** Custom message type for reminder injection (registerMessageRenderer can customize its TUI appearance). */
13
+ /** Custom-type renderer hook so the TUI appearance can be customized. */
26
14
  export const TODO_REMINDER_CUSTOM_TYPE = 'pi-herdr.todo-reminder';
27
15
 
28
16
  /**
29
- * B3 grace window (milliseconds): delay from settled to injection, giving the user a chance to
30
- * read the closing answer and intervene; starting any agent during the window cancels it. This
31
- * follows the MACHINE_INJECT_GRACE_MS pattern; PI_HERDR_TODO_GRACE_MS is read per call so tests
32
- * can use a small value rather than freezing configuration at module load.
17
+ * Delay from settled to injection so the user can read the closing answer and intervene; starting
18
+ * any agent during the window cancels it. Read per call, not at module load, so tests can shrink it.
33
19
  */
34
20
  export function todoReminderGraceMs(): number {
35
- // B10: canonical PIER_TODO_GRACE_MS, legacy PI_HERDR_TODO_GRACE_MS.
21
+ // Legacy PI_HERDR_TODO_GRACE_MS is accepted as a fallback by pierOption.
36
22
  return Number(pierOption('PIER_TODO_GRACE_MS') ?? 30_000) || 30_000;
37
23
  }
38
24
 
39
- export interface TodoReminderInput {
40
- /** Stop reason of the last assistant turn before this settlement (null means treat it as natural completion). */
25
+ interface TodoReminderInput {
26
+ /** Stop reason of the last assistant turn (null = natural completion). */
41
27
  lastStopReason: string | null;
42
- /** True when abort was triggered intentionally by internal mechanisms like OCC. */
43
28
  intentionalAbort?: boolean;
44
- /** True when compaction is currently in-flight. */
45
29
  compactionInFlight?: boolean;
46
- /** Number of reminders already injected. */
47
30
  reminders: number;
48
- /** Number of running background subagents; a positive value means the master is already waiting. */
31
+ /** Positive means the master is already waiting on a subagent. */
49
32
  runningSubs: number;
50
- /** ask_user_question wait depth; a positive value means the master is waiting for a human response. */
33
+ /** ask_user_question wait depth; positive means the master waits on a human. */
51
34
  blockedDepth: number;
52
- /** Current todo list. */
53
35
  items: ReadonlyArray<{ content: string; status: string }>;
54
36
  }
55
37
 
56
- export interface TodoReminderPlan {
57
- /** Whether a reminder should be injected. */
38
+ interface TodoReminderPlan {
58
39
  due: boolean;
59
- /** Complete injected content, or null when due is false. */
60
40
  content: string | null;
61
- /** New count after successful injection, or the original count when due is false. */
62
41
  nextReminders: number;
63
42
  }
64
43
 
65
- function noInject(reminders: number): TodoReminderPlan {
66
- return { due: false, content: null, nextReminders: reminders };
67
- }
68
-
69
44
  /**
70
- * A stop reminder is due only when every guard passes and at least one open (pending/in_progress)
71
- * item exists. blocked, abandoned, and completed items are not unfinished: once human-waiting work
72
- * is modeled correctly, reminding again would only pressure the model to act without authorization
73
- * (the missing guard in the 01a040cc incident).
45
+ * Due only when every guard passes and at least one open (pending/in_progress) item exists.
46
+ * blocked/abandoned/completed are not unfinished: pressuring the model here would push it to act
47
+ * while it waits on a human.
74
48
  */
75
49
  export function planStopTodoReminder(input: TodoReminderInput): TodoReminderPlan {
76
- if (input.intentionalAbort || input.compactionInFlight) return noInject(input.reminders);
77
- if (input.lastStopReason === ABORT_STOP_REASON) return noInject(input.reminders);
78
- if (input.reminders >= TODO_REMINDERS_MAX) return noInject(input.reminders);
79
- if (input.runningSubs > 0) return noInject(input.reminders);
80
- if (input.blockedDepth > 0) return noInject(input.reminders);
50
+ const noInject: TodoReminderPlan = { due: false, content: null, nextReminders: input.reminders };
51
+ if (input.intentionalAbort || input.compactionInFlight) return noInject;
52
+ if (input.lastStopReason === ABORT_STOP_REASON) return noInject;
53
+ if (input.reminders >= REMINDERS_MAX) return noInject;
54
+ if (input.runningSubs > 0) return noInject;
55
+ if (input.blockedDepth > 0) return noInject;
81
56
  const open = input.items.filter((it) => it.status === 'pending' || it.status === 'in_progress');
82
- if (open.length === 0) return noInject(input.reminders);
57
+ if (open.length === 0) return noInject;
83
58
  const list = open
84
59
  .map((it) => (it.status === 'in_progress' ? `▶ ${it.content}` : `· ${it.content}`))
85
60
  .join('\n');
86
61
  const content = [
87
- `<system-reminder>You stopped with unfinished todos (Reminder ${input.reminders + 1}/${TODO_REMINDERS_MAX}):`,
62
+ `<system-reminder>You stopped with unfinished todos (Reminder ${input.reminders + 1}/${REMINDERS_MAX}):`,
88
63
  list,
89
64
  'Reconcile the list instead of blindly continuing:',
90
65
  '- work that is yours AND already authorized by the user → continue it;',