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
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,37 +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
- export function makeSnapshot(items: TodoItem[]): TodoSnapshot {
175
- return { version: 1, items };
138
+ export function makeSnapshot(items: readonly TodoItem[]): TodoSnapshot {
139
+ // Copy: the snapshot is persisted, the service list stays owned by TodosService.
140
+ return { version: 1, items: [...items] };
176
141
  }
177
142
 
178
- export interface BranchEntryLike {
179
- /** 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 {
180
145
  type?: string;
181
- /** Why: Preserve the established compatibility and safety behavior. */
182
146
  timestamp?: unknown;
183
147
  message?: {
184
148
  role?: string;
@@ -189,33 +153,31 @@ export interface BranchEntryLike {
189
153
  data?: unknown;
190
154
  }
191
155
 
192
- /** 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. */
193
157
  export function applyTodoEdits(items: readonly TodoItem[], edits: readonly TodoEdit[]): TodoItem[] {
194
158
  let out = items.map((it) => ({ ...it }));
195
159
  for (const edit of edits) {
160
+ if (edit.op === 'rm') {
161
+ out = out.filter((it) => it.content !== edit.content);
162
+ continue;
163
+ }
196
164
  const target = out.find((it) => it.content === edit.content);
197
165
  if (!target) continue;
198
166
  switch (edit.op) {
199
167
  case 'done':
200
- if (target.status !== 'completed') {
201
- target.status = 'completed';
202
- delete target.blocker;
203
- }
168
+ if (target.status === 'completed') break;
169
+ target.status = 'completed';
170
+ delete target.blocker;
204
171
  break;
205
172
  case 'drop':
206
- if (target.status !== 'completed' && target.status !== 'abandoned') {
207
- target.status = 'abandoned';
208
- delete target.blocker;
209
- }
210
- break;
211
- case 'rm':
212
- 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;
213
176
  break;
214
177
  case 'unblock':
215
- if (target.status === 'blocked') {
216
- target.status = 'pending';
217
- delete target.blocker;
218
- }
178
+ if (target.status !== 'blocked') break;
179
+ target.status = 'pending';
180
+ delete target.blocker;
219
181
  break;
220
182
  }
221
183
  }
@@ -240,10 +202,9 @@ function extractEditPayload(data: unknown): TodoEditPayload | null {
240
202
  return { version: 1, edits, ts: typeof raw.ts === 'number' ? raw.ts : 0 };
241
203
  }
242
204
 
243
- /** Why: Preserve the established compatibility and safety behavior (D38). */
244
205
  export interface FoldedTodos {
245
206
  items: TodoItem[];
246
- /** Why: Preserve the established compatibility and safety behavior. */
207
+ /** Timestamp of the last write that shaped these items; null when the entry carried none. */
247
208
  writtenAt: number | null;
248
209
  }
249
210
 
@@ -257,13 +218,13 @@ function entryTimestamp(entry: BranchEntryLike): number | null {
257
218
  return null;
258
219
  }
259
220
 
260
- /** Why: Preserve the established compatibility and safety behavior (D38). */
221
+ /** Fold the branch to the authoritative list: last todo snapshot, then interleaved edits (D38). */
261
222
  export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): FoldedTodos | null {
262
223
  let found: TodoItem[] | null = null;
263
224
  let writtenAt: number | null = null;
264
225
  for (const entry of entries) {
265
226
  if (entry.type === 'message') {
266
- const msg = entry.message;
227
+ const msg = entry.message as { role?: unknown; toolName?: unknown; details?: unknown } | null | undefined;
267
228
  if (typeof msg !== 'object' || msg === null) continue;
268
229
  if (msg.role !== 'toolResult' || msg.toolName !== TODO_TOOL_NAME) continue;
269
230
  const snapshot = extractSnapshotFromDetails(msg.details);
@@ -282,12 +243,7 @@ export function foldLatestTodosMeta(entries: readonly BranchEntryLike[]): Folded
282
243
  return found ? { items: found, writtenAt } : null;
283
244
  }
284
245
 
285
- /** Why: Preserve the established compatibility and safety behavior. */
286
- export function foldLatestTodos(entries: readonly BranchEntryLike[]): TodoItem[] | null {
287
- return foldLatestTodosMeta(entries)?.items ?? null;
288
- }
289
-
290
- export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
246
+ function extractSnapshotFromDetails(details: unknown): TodoSnapshot | null {
291
247
  if (typeof details !== 'object' || details === null) return null;
292
248
  const raw = (details as Record<string, unknown>)[TODO_DETAILS_KEY];
293
249
  if (typeof raw !== 'object' || raw === null) return null;
@@ -296,7 +252,7 @@ export function extractSnapshotFromDetails(details: unknown): TodoSnapshot | nul
296
252
  return { version: 1, items: snap.items as TodoItem[] };
297
253
  }
298
254
 
299
- /** Why: Preserve the established compatibility and safety behavior (D38). */
255
+ /** Exact match, then unique prefix, then substring candidates (D38 `/todos <verb> <query>`). */
300
256
  export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
301
257
  const q = query.trim();
302
258
  if (!q) return [];
@@ -305,26 +261,22 @@ export function fuzzyFind(items: readonly TodoItem[], query: string): string[] {
305
261
  const prefix = items.filter((it) => it.content.startsWith(q));
306
262
  if (prefix.length === 1) return [prefix[0].content];
307
263
  if (prefix.length > 1) return prefix.map((it) => it.content);
308
- const substr = items.filter((it) => it.content.includes(q));
309
- return substr.map((it) => it.content);
264
+ return items.filter((it) => it.content.includes(q)).map((it) => it.content);
310
265
  }
311
266
 
312
- /** Why: Preserve the established compatibility and safety behavior (D39). */
313
267
  export interface BoundedView {
314
268
  visible: TodoItem[];
315
- /** Why: Preserve the established compatibility and safety behavior. */
316
269
  hiddenCompleted: number;
317
270
  hiddenOpen: number;
318
271
  }
319
272
 
273
+ /** Keep the newest open items, then fill leftover budget with the newest completions (D39). */
320
274
  export function boundedView(items: readonly TodoItem[], budget: number): BoundedView {
321
275
  if (items.length <= budget) {
322
276
  return { visible: items.map((it) => ({ ...it })), hiddenCompleted: 0, hiddenOpen: 0 };
323
277
  }
324
278
  const completed = items.filter((it) => it.status === 'completed');
325
279
  const open = items.filter((it) => it.status !== 'completed');
326
- // Why: Preserve the established compatibility and safety behavior.
327
- // Why: Preserve the established compatibility and safety behavior.
328
280
  const visible = open.length > budget ? open.slice(-budget) : open;
329
281
  const hiddenOpen = open.length - visible.length;
330
282
  let hiddenCompleted = completed.length;
@@ -337,9 +289,6 @@ export function boundedView(items: readonly TodoItem[], budget: number): Bounded
337
289
  return { visible: final, hiddenCompleted, hiddenOpen };
338
290
  }
339
291
 
340
- /** Why: Preserve the established compatibility and safety behavior. */
341
- export function currentActivity(items: TodoItem[]): string | null {
292
+ export function currentActivity(items: readonly TodoItem[]): string | null {
342
293
  return items.find((it) => it.status === 'in_progress')?.content ?? null;
343
294
  }
344
-
345
- export { countTodos };
@@ -1,32 +1,32 @@
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
+ type TodoReadEffect =
16
17
  | 'recite' // Recite normally while the list is fresh.
17
18
  | '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.
19
+ | 'stale-notice' // Warn when the list is stale by turns, or when archiving is one window away.
20
+ | 'archive-notice' // Notify once the list is archived by wall-clock age.
20
21
  | 'none'; // Skip because of rate limits or caps.
21
22
 
22
- export interface TodoReadPlan {
23
+ interface TodoReadPlan {
23
24
  inject: boolean;
24
25
  effect: TodoReadEffect;
25
26
  /** Current archive state, independent of injection, so callers can refresh widget/title projections. */
26
27
  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). */
28
+ /** Clear the in-memory list when injecting the archive notice (the caller persists the empty JSONL);
29
+ * a kept dead list would suppress the empty guard that resumes tracking after an idle gap. */
30
30
  clearArchived: boolean;
31
31
  message: {
32
32
  customType: string;
@@ -35,24 +35,14 @@ export interface TodoReadPlan {
35
35
  };
36
36
  }
37
37
 
38
- const MARKS: Record<TodoItem['status'], string> = {
39
- pending: '○',
40
- in_progress: '▶',
41
- completed: '✓',
42
- blocked: '■',
43
- abandoned: '✗',
44
- };
45
-
46
38
  export function planTodoReadHook(opts: {
47
39
  items: readonly TodoItem[];
48
40
  turn: number;
49
41
  lastEmptyGuardTurn: number | null;
50
42
  /** stale-core clock anchor (TodosService.lastWriteAt). */
51
43
  lastWriteAt: number | null;
52
- /** Turns since the last todo write, when known in this process. */
53
44
  turnsSinceWrite: number | null;
54
45
  now: number;
55
- /** Number of stale warnings injected during the current idle period. */
56
46
  staleNotices: number;
57
47
  lastStaleGuardTurn: number | null;
58
48
  }): TodoReadPlan {
@@ -86,16 +76,12 @@ export function planTodoReadHook(opts: {
86
76
  });
87
77
 
88
78
  if (st.kind === 'archived') {
89
- const c = countTodos(opts.items as TodoItem[]);
79
+ const c = countTodos(opts.items);
90
80
  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.
81
+ // Idle periods do not consume turns, so give one rewrite window with the old entries as reference
82
+ // before the terminal notice.
97
83
  if (opts.staleNotices === 0 && guardDue) {
98
- const lines = opts.items.map((it) => ` ${MARKS[it.status]} ${it.content}`);
84
+ const lines = opts.items.map((it) => ` ${todoMark(it.status)} ${it.content}`);
99
85
  const head = `todos ✓${c.completed} (all completed, last updated${age}) — about to be archived`;
100
86
  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
87
  return {
@@ -106,9 +92,8 @@ export function planTodoReadHook(opts: {
106
92
  message: msg([head, ...lines, warn].join('\n')),
107
93
  };
108
94
  }
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.
95
+ // The terminal notice carries no details and no "skip tracking" escape hatch — multi-step work
96
+ // must get a fresh list, single-step Q&A may proceed untracked.
112
97
  return {
113
98
  inject: guardDue,
114
99
  effect: 'archive-notice',
@@ -123,16 +108,15 @@ export function planTodoReadHook(opts: {
123
108
  }
124
109
 
125
110
  if (st.kind === 'stale') {
126
- // A: Turn recitation into a warning while retaining old entries as rewrite references; cap at STALE_NOTICE_MAX.
127
111
  const staleDue = opts.lastStaleGuardTurn == null
128
112
  || opts.turn - opts.lastStaleGuardTurn >= EMPTY_GUARD_EVERY_N;
129
113
  if (!staleDue || opts.staleNotices >= STALE_NOTICE_MAX) {
130
114
  return { inject: false, effect: 'none', archived: false, clearArchived: false, message: msg('') };
131
115
  }
132
- const c = countTodos(opts.items as TodoItem[]);
116
+ const c = countTodos(opts.items);
133
117
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
134
118
  + ` · unchanged for ${opts.turnsSinceWrite} turns, nothing open`;
135
- const lines = opts.items.map((it) => ` ${MARKS[it.status]} ${it.content}`);
119
+ const lines = opts.items.map((it) => ` ${todoMark(it.status)} ${it.content}`);
136
120
  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
121
  return {
138
122
  inject: true,
@@ -146,7 +130,7 @@ export function planTodoReadHook(opts: {
146
130
  // Fresh lists are recited every turn to keep current work visible.
147
131
  const c = countTodos(opts.items);
148
132
  const view = boundedView(opts.items, 6);
149
- const lines = view.visible.map((it) => `${MARKS[it.status]} ${it.content}`);
133
+ const lines = view.visible.map((it) => `${todoMark(it.status)} ${it.content}`);
150
134
  const activity = currentActivity(opts.items);
151
135
  const head = `todos ▶${c.inProgress} ○${c.pending} ■${c.blocked} ✓${c.completed}`
152
136
  + (activity ? ` · ${activity}` : '');
@@ -1,83 +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';
8
+ import { pierOption } from './pier-options.ts';
20
9
 
21
- /** Cap reminders for the lifetime of the process, matching the previous behavior. */
22
- export const TODO_REMINDERS_MAX = 3;
10
+ /** Cap reminders for the lifetime of the process. */
11
+ const REMINDERS_MAX = 3;
23
12
 
24
- /** Custom message type for reminder injection (registerMessageRenderer can customize its TUI appearance). */
13
+ /** Custom-type renderer hook so the TUI appearance can be customized. */
25
14
  export const TODO_REMINDER_CUSTOM_TYPE = 'pi-herdr.todo-reminder';
26
15
 
27
16
  /**
28
- * B3 grace window (milliseconds): delay from settled to injection, giving the user a chance to
29
- * read the closing answer and intervene; starting any agent during the window cancels it. This
30
- * follows the MACHINE_INJECT_GRACE_MS pattern; PI_HERDR_TODO_GRACE_MS is read per call so tests
31
- * 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.
32
19
  */
33
20
  export function todoReminderGraceMs(): number {
34
- return Number(process.env.PI_HERDR_TODO_GRACE_MS ?? 30_000) || 30_000;
21
+ // Legacy PI_HERDR_TODO_GRACE_MS is accepted as a fallback by pierOption.
22
+ return Number(pierOption('PIER_TODO_GRACE_MS') ?? 30_000) || 30_000;
35
23
  }
36
24
 
37
- export interface TodoReminderInput {
38
- /** 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). */
39
27
  lastStopReason: string | null;
40
- /** Number of reminders already injected. */
28
+ intentionalAbort?: boolean;
29
+ compactionInFlight?: boolean;
41
30
  reminders: number;
42
- /** 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. */
43
32
  runningSubs: number;
44
- /** 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. */
45
34
  blockedDepth: number;
46
- /** Current todo list. */
47
35
  items: ReadonlyArray<{ content: string; status: string }>;
48
36
  }
49
37
 
50
- export interface TodoReminderPlan {
51
- /** Whether a reminder should be injected. */
38
+ interface TodoReminderPlan {
52
39
  due: boolean;
53
- /** Complete injected content, or null when due is false. */
54
40
  content: string | null;
55
- /** New count after successful injection, or the original count when due is false. */
56
41
  nextReminders: number;
57
42
  }
58
43
 
59
- function noInject(reminders: number): TodoReminderPlan {
60
- return { due: false, content: null, nextReminders: reminders };
61
- }
62
-
63
44
  /**
64
- * A stop reminder is due only when every guard passes and at least one open (pending/in_progress)
65
- * item exists. blocked, abandoned, and completed items are not unfinished: once human-waiting work
66
- * is modeled correctly, reminding again would only pressure the model to act without authorization
67
- * (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.
68
48
  */
69
49
  export function planStopTodoReminder(input: TodoReminderInput): TodoReminderPlan {
70
- if (input.lastStopReason === ABORT_STOP_REASON) return noInject(input.reminders);
71
- if (input.reminders >= TODO_REMINDERS_MAX) return noInject(input.reminders);
72
- if (input.runningSubs > 0) return noInject(input.reminders);
73
- 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;
74
56
  const open = input.items.filter((it) => it.status === 'pending' || it.status === 'in_progress');
75
- if (open.length === 0) return noInject(input.reminders);
57
+ if (open.length === 0) return noInject;
76
58
  const list = open
77
59
  .map((it) => (it.status === 'in_progress' ? `▶ ${it.content}` : `· ${it.content}`))
78
60
  .join('\n');
79
61
  const content = [
80
- `<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}):`,
81
63
  list,
82
64
  'Reconcile the list instead of blindly continuing:',
83
65
  '- work that is yours AND already authorized by the user → continue it;',
@@ -1,11 +1,8 @@
1
1
  /**
2
- * Activity-anchored todo window used by the pi widget and the slim overlay.
3
- *
4
- * Why: both surfaces must keep the in-progress row visible instead of a fixed
5
- * head/tail slice. The widget budgets unwrapped render lines; the overlay
6
- * supplies its own `fits` so wrapping can consume extra rows.
2
+ * Activity-anchored todo window for the pi widget and the slim overlay: the widget passes a budget of
3
+ * unwrapped render lines, the overlay supplies its own `fits`; the in-progress row stays visible.
7
4
  */
8
- import { countTodos, type TodoItem, type TodoStatus } from './vocab.ts';
5
+ import { countTodos, isTodoStatus, type TodoItem, type TodoStatus } from './vocab.ts';
9
6
 
10
7
  export const TODO_MARKS: Record<TodoStatus, string> = {
11
8
  pending: '○',
@@ -15,12 +12,15 @@ export const TODO_MARKS: Record<TodoStatus, string> = {
15
12
  abandoned: '✗',
16
13
  };
17
14
 
15
+ /** Mark for a snapshot's status, tolerating corrupt data (`?` instead of a prototype member). */
16
+ export const todoMark = (status: unknown): string => (isTodoStatus(status) ? TODO_MARKS[status] : '?');
17
+
18
18
  export function formatTodoSummary(items: readonly TodoItem[]): string {
19
- const c = countTodos(items as TodoItem[]);
19
+ const c = countTodos(items);
20
20
  return `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
21
21
  }
22
22
 
23
- /** Share phase rendering so widget and overlay order entries identically. */
23
+ /** Shared phase rendering so widget and overlay order entries identically. */
24
24
  export function renderTodoGroups(items: readonly TodoItem[]): string[] {
25
25
  const groups = new Map<string, TodoItem[]>();
26
26
  for (const it of items) {
@@ -34,16 +34,15 @@ export function renderTodoGroups(items: readonly TodoItem[]): string[] {
34
34
  if (phase) lines.push(` [${phase}]`);
35
35
  for (const it of list) {
36
36
  const suffix = it.status === 'blocked' && it.blocker ? ` — ${it.blocker}` : '';
37
- lines.push(` ${TODO_MARKS[it.status]} ${it.content}${suffix}`);
37
+ lines.push(` ${todoMark(it.status)} ${it.content}${suffix}`);
38
38
  }
39
39
  }
40
40
  return lines;
41
41
  }
42
42
 
43
43
  /**
44
- * Expand around the active anchor while `fits(start, end)` remains true.
45
- * Prefers upcoming work; the anchor stays visible. All-completed lists keep
46
- * the original head-shrink (widget-compatible).
44
+ * Expand around the active anchor while `fits(start, end)` holds: the first in_progress item, else the
45
+ * last open one, growing toward the tail first. All-completed lists shrink from the head instead.
47
46
  */
48
47
  export function anchorTodoRange(
49
48
  items: readonly TodoItem[],