pi-pier 0.3.0 → 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.
- package/README.md +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/todo-core.ts
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
/**
|
|
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
|
|
4
|
+
export type { TodoItem, TodoSnapshot, TodoStatus };
|
|
14
5
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
6
|
+
/** D43 phase label cap, mirrored by the todo_write tool description. */
|
|
7
|
+
const PHASE_MAX_LEN = 30;
|
|
17
8
|
|
|
18
|
-
/**
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
98
|
-
|
|
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
|
-
/**
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
-
/**
|
|
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
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 };
|
package/src/todo-read-hook.ts
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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' //
|
|
19
|
-
| 'archive-notice' //
|
|
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
|
-
|
|
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
|
-
/**
|
|
28
|
-
*
|
|
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
|
|
79
|
+
const c = countTodos(opts.items);
|
|
90
80
|
const age = st.ageMs == null ? '' : ` ${formatAge(st.ageMs)} ago`;
|
|
91
|
-
//
|
|
92
|
-
//
|
|
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) => ` ${
|
|
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
|
-
//
|
|
110
|
-
//
|
|
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
|
|
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) => ` ${
|
|
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) => `${
|
|
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,90 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
23
|
-
|
|
10
|
+
/** Cap reminders for the lifetime of the process. */
|
|
11
|
+
const REMINDERS_MAX = 3;
|
|
24
12
|
|
|
25
|
-
/** Custom
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
//
|
|
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
|
-
|
|
40
|
-
/** Stop reason of the last assistant turn
|
|
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
|
-
/**
|
|
31
|
+
/** Positive means the master is already waiting on a subagent. */
|
|
49
32
|
runningSubs: number;
|
|
50
|
-
/** ask_user_question wait depth;
|
|
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
|
-
|
|
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
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
77
|
-
if (input.
|
|
78
|
-
if (input.
|
|
79
|
-
if (input.
|
|
80
|
-
if (input.
|
|
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
|
|
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}/${
|
|
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;',
|
package/src/todo-window.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Activity-anchored todo window
|
|
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
|
|
19
|
+
const c = countTodos(items);
|
|
20
20
|
return `todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓`;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
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(` ${
|
|
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)`
|
|
45
|
-
*
|
|
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[],
|