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.
- package/README.md +16 -12
- package/package.json +3 -3
- 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 +10 -13
- package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- 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/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -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 +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- 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 +61 -22
- 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 +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -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/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- 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/focus-poller.ts +0 -244
- 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,55 @@
|
|
|
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
|
+
/**
|
|
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' //
|
|
19
|
-
| 'archive-notice' //
|
|
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
|
-
|
|
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
|
-
/**
|
|
28
|
-
*
|
|
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
|
|
102
|
+
const c = countTodos(opts.items);
|
|
90
103
|
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.
|
|
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) => ` ${
|
|
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
|
-
//
|
|
110
|
-
//
|
|
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
|
|
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) => ` ${
|
|
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) => `${
|
|
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
|
-
*
|
|
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;',
|