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
|
@@ -1,48 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* D78/D81 todo loader entry.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* and master stop reminder; reconciliation, mirroring, and agent reporting stay with the
|
|
8
|
-
* session state layer to avoid a dependency cycle.
|
|
3
|
+
* A Cordis plugin keeps the master surface hot-swappable while workers mount the one-shot subset directly;
|
|
4
|
+
* injected services preserve the D79 registration boundary and keep session-owned todo state in index. This
|
|
5
|
+
* module owns the tool, command, widget, read hook, and master stop reminder — reconciliation, mirroring, and
|
|
6
|
+
* agent reporting stay in the session state layer (cycle avoidance).
|
|
9
7
|
*/
|
|
10
8
|
import { Context } from '@deepseek-ai/cordis';
|
|
11
9
|
import { Type } from 'typebox';
|
|
12
|
-
import
|
|
10
|
+
import { cordisGet } from '../cordis-services.ts';
|
|
13
11
|
import type { TodosService } from '../todos-service.ts';
|
|
14
|
-
import { planTodoReadHook } from '../todo-read-hook.ts';
|
|
12
|
+
import { planTodoReadHook, shouldRunTodoRead } from '../todo-read-hook.ts';
|
|
15
13
|
import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
|
|
16
14
|
import { makeProgressUpdate } from '../subagent-core.ts';
|
|
17
|
-
import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
18
|
-
import { toolError } from '../tool-error.ts'
|
|
19
|
-
|
|
20
|
-
/** Raw tool arguments: every field is validated inside the action handlers. */
|
|
21
|
-
type ToolParams = Record<string, unknown> | undefined;;
|
|
15
|
+
import { TODO_DETAILS_KEY, TODO_STATUSES, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
16
|
+
import { toolError } from '../tool-error.ts';
|
|
22
17
|
import { formatAge, isArchived } from '../stale-core.ts';
|
|
23
|
-
import {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
fuzzyFind,
|
|
27
|
-
listsEqual,
|
|
28
|
-
makeSnapshot,
|
|
29
|
-
normalizeStrict,
|
|
30
|
-
revertedCompleted,
|
|
31
|
-
validateTodos,
|
|
32
|
-
} from '../todo-core.ts';
|
|
33
|
-
import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
|
|
18
|
+
import { TODO_EDIT_CUSTOM_TYPE, completionTransitions, fuzzyFind, listsEqual, makeSnapshot, normalizeStrict, revertedCompleted, validateTodos, type TodoEditOp } from '../todo-core.ts';
|
|
19
|
+
import { formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
|
|
20
|
+
import { unifiedWidgetLines, type WorkerWidgetRow } from '../tree-view.ts';
|
|
34
21
|
import { swallow } from '../swallow.ts';
|
|
35
22
|
|
|
23
|
+
type ToolParams = Record<string, unknown> | undefined;
|
|
24
|
+
|
|
36
25
|
export interface TodoUiSlot {
|
|
37
26
|
/** Lets index lifecycle events render through the currently mounted plugin. */
|
|
38
27
|
renderWidget: (ctx: unknown) => void;
|
|
39
28
|
/** Re-render from the last seen context without a fresh event (human-gate open/close). */
|
|
40
29
|
rerenderWidget?: () => void;
|
|
41
|
-
/** Cancel any pending unfinished-todo reminder timer. */
|
|
42
30
|
cancelReminder?: () => void;
|
|
43
31
|
}
|
|
44
32
|
|
|
45
|
-
interface TodoDeps {
|
|
33
|
+
export interface TodoDeps {
|
|
46
34
|
todos: TodosService;
|
|
47
35
|
allowParallelInProgress: boolean;
|
|
48
36
|
/** Warn above this size without rejecting otherwise valid plans. */
|
|
@@ -52,6 +40,8 @@ interface TodoDeps {
|
|
|
52
40
|
state: TodoUiSlot;
|
|
53
41
|
/** Human-gate depth (ask_user_question / herdr:blocked); >0 collapses the widget to one line. */
|
|
54
42
|
getBlockedDepth?: () => number;
|
|
43
|
+
/** D113: running subagent rows for the unified widget; master mounts only. */
|
|
44
|
+
getWorkers?: () => readonly WorkerWidgetRow[];
|
|
55
45
|
/** Master-only unfinished-todo reminder; omitted on worker panes. */
|
|
56
46
|
stopReminder?: {
|
|
57
47
|
getBlockedDepth: () => number;
|
|
@@ -71,55 +61,30 @@ const TOOL_DESCRIPTION = [
|
|
|
71
61
|
'Delegated work belongs on this list too. When you hand an entry to a subagent, append ` <sub>` to its content (the subagent is doing it, not you); when the subagent settles, a matching entry is auto-completed — you will see "Reconciled:" in the settlement note. If no auto-match fired, update the entry yourself.',
|
|
72
62
|
].join(' ');
|
|
73
63
|
|
|
74
|
-
/** Stay within pi interactive mode's ten-line widget cap instead of relying on head truncation. */
|
|
75
|
-
export const WIDGET_MAX_LINES = 10;
|
|
76
64
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
65
|
+
/** `/todos <op> <query>` verbs; the value is the user-facing past-tense feedback. */
|
|
66
|
+
const OP_VERBS: Record<TodoEditOp, string> = {
|
|
67
|
+
done: 'completed',
|
|
68
|
+
drop: 'abandoned',
|
|
69
|
+
unblock: 'unblocked (back to pending)',
|
|
70
|
+
rm: 'removed',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** D113 unified resident widget (see tree-view.ts for the line rules). */
|
|
83
74
|
export function widgetLines(
|
|
84
75
|
items: readonly TodoItem[],
|
|
85
|
-
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
|
|
76
|
+
opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null; workers?: readonly WorkerWidgetRow[] },
|
|
86
77
|
): string[] {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// (user-reported: both blocks visible ⇒ history range too small).
|
|
92
|
-
if ((opts?.blockedDepth ?? 0) > 0) {
|
|
93
|
-
return [`${formatTodoSummary(items)} · /todos 全量`];
|
|
94
|
-
}
|
|
95
|
-
// Collapse archived plans to two lines so stale work cannot monopolize the widget.
|
|
96
|
-
if (opts?.archivedAgeMs != null) {
|
|
97
|
-
return [
|
|
98
|
-
`${formatTodoSummary(items)} · archived ${formatAge(opts.archivedAgeMs)}`,
|
|
99
|
-
' archived — /todos 全量',
|
|
100
|
-
];
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Reserve summary and overflow lines, then shrink around the anchor because phase headers also consume the budget.
|
|
104
|
-
const renderBudget = WIDGET_MAX_LINES - 1 - 1;
|
|
105
|
-
const [start, end] = anchorTodoRange(
|
|
106
|
-
items,
|
|
107
|
-
(s, e) => renderTodoGroups(items.slice(s, e)).length <= renderBudget,
|
|
108
|
-
);
|
|
109
|
-
const kept = items.slice(start, end);
|
|
110
|
-
const hidden = items.filter((_, i) => i < start || i >= end);
|
|
111
|
-
const lines = [formatTodoSummary(items), ...renderTodoGroups(kept)];
|
|
112
|
-
if (hidden.length > 0) {
|
|
113
|
-
const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
|
|
114
|
-
lines.push(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos 全量`);
|
|
115
|
-
}
|
|
116
|
-
return lines;
|
|
78
|
+
return unifiedWidgetLines(items, opts?.workers ?? [], {
|
|
79
|
+
archivedAgeMs: opts?.archivedAgeMs,
|
|
80
|
+
blockedDepth: opts?.blockedDepth,
|
|
81
|
+
});
|
|
117
82
|
}
|
|
118
83
|
|
|
119
84
|
export default function todoPlugin(ctx: Context): void {
|
|
120
|
-
const surface = ctx
|
|
121
|
-
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
|
|
122
|
-
ctx
|
|
85
|
+
const surface = cordisGet(ctx, 'pi-herdr.surface');
|
|
86
|
+
const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth, getWorkers } =
|
|
87
|
+
cordisGet(ctx, 'pi-herdr.todo-deps');
|
|
123
88
|
const scoped = surface.forModule(import.meta.url);
|
|
124
89
|
|
|
125
90
|
/** Share one archive-age decision between the widget and /todos. */
|
|
@@ -129,30 +94,22 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
129
94
|
: null;
|
|
130
95
|
}
|
|
131
96
|
|
|
132
|
-
/** Name the guarded event UI once so callers do not repeat unsafe inline assertions. */
|
|
133
|
-
function widgetUi(eventCtx: unknown): { setWidget?: (id: string, lines: string[]) => void } | undefined {
|
|
134
|
-
if (eventCtx === null || typeof eventCtx !== 'object' || !('ui' in eventCtx)) return undefined;
|
|
135
|
-
const ui = (eventCtx as { ui: unknown }).ui; // Safe after the property guard above.
|
|
136
|
-
return ui !== null && typeof ui === 'object'
|
|
137
|
-
? (ui as { setWidget?: (id: string, lines: string[]) => void })
|
|
138
|
-
: undefined;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
97
|
/** Gate transitions fire outside lifecycle events; remember one ctx so rerenderWidget can target the live ui. */
|
|
142
98
|
let lastEventCtx: unknown = null;
|
|
143
99
|
function renderWidget(eventCtx: unknown): void {
|
|
144
100
|
if (eventCtx !== null && eventCtx !== undefined) lastEventCtx = eventCtx;
|
|
101
|
+
const ui = (eventCtx as { ui?: { setWidget?: (id: string, lines: string[]) => void } } | null | undefined)?.ui;
|
|
145
102
|
try {
|
|
146
|
-
|
|
103
|
+
ui?.setWidget?.('todos', widgetLines(todos.items, {
|
|
147
104
|
archivedAgeMs: archivedAgeMs(),
|
|
148
105
|
blockedDepth: getBlockedDepth?.() ?? 0,
|
|
106
|
+
workers: getWorkers?.() ?? [],
|
|
149
107
|
}));
|
|
150
108
|
} catch {
|
|
151
109
|
/* Older pi versions may omit widget support without disabling todo tracking. */
|
|
152
110
|
}
|
|
153
111
|
}
|
|
154
112
|
|
|
155
|
-
// Let index lifecycle hooks call the current plugin implementation.
|
|
156
113
|
state.renderWidget = renderWidget;
|
|
157
114
|
state.rerenderWidget = () => renderWidget(lastEventCtx);
|
|
158
115
|
|
|
@@ -164,13 +121,15 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
164
121
|
let staleNotices = 0;
|
|
165
122
|
let lastStaleGuardTurn: number | null = null;
|
|
166
123
|
|
|
167
|
-
// Any real edit ends the current stale period because the plan is active again.
|
|
168
124
|
todos.on('todo.updated', () => {
|
|
169
125
|
lastWriteTurn = todoReadTurn;
|
|
170
126
|
staleNotices = 0;
|
|
171
127
|
});
|
|
172
128
|
|
|
173
129
|
scoped.on('before_agent_start', async (eventCtx?: unknown) => {
|
|
130
|
+
// pi 1.2+ also fires this for extension-triggered runs; skip the ones that would double-inject
|
|
131
|
+
// or consume the guard cadence (stop reminder, config guide, third-party custom runs).
|
|
132
|
+
if (!shouldRunTodoRead(eventCtx)) return;
|
|
174
133
|
const plan = planTodoReadHook({
|
|
175
134
|
items: todos.items,
|
|
176
135
|
turn: todoReadTurn,
|
|
@@ -185,21 +144,18 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
185
144
|
staleNotices += 1;
|
|
186
145
|
lastStaleGuardTurn = todoReadTurn;
|
|
187
146
|
}
|
|
188
|
-
//
|
|
189
|
-
// consume the grace window.
|
|
147
|
+
// Empty, archive, and R2 rewrite notices share one cadence so adjacent notices do not each consume the grace window.
|
|
190
148
|
if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
|
|
191
149
|
lastEmptyGuardTurn = todoReadTurn;
|
|
192
150
|
}
|
|
193
|
-
// R1
|
|
194
|
-
//
|
|
195
|
-
// the session log still retains history.
|
|
151
|
+
// R1: persist the clear as one rm-per-item edit so replay folds to an empty list. The session
|
|
152
|
+
// log keeps the history; a dead in-memory list would suppress the empty guard.
|
|
196
153
|
if (plan.inject && plan.effect === 'archive-notice' && plan.clearArchived && todos.items.length > 0) {
|
|
197
154
|
const edits = todos.items.map((it) => ({ op: 'rm' as const, content: it.content }));
|
|
198
155
|
try {
|
|
199
156
|
appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits, ts: Date.now() });
|
|
200
157
|
} catch (err) {
|
|
201
|
-
//
|
|
202
|
-
// failure here is exactly how the archive/replay path broke unnoticed before (SA-13).
|
|
158
|
+
// The in-memory clear must proceed even when this persistence fails.
|
|
203
159
|
swallow('todo.persist-archive', err);
|
|
204
160
|
}
|
|
205
161
|
todos.replace([], { source: 'archive' });
|
|
@@ -220,7 +176,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
220
176
|
sendMessage?: (
|
|
221
177
|
message: { customType: string; content: string; display?: boolean; details?: Record<string, unknown> },
|
|
222
178
|
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
223
|
-
) =>
|
|
179
|
+
) => void;
|
|
224
180
|
};
|
|
225
181
|
let todoReminders = 0;
|
|
226
182
|
let lastAssistantStopReason: string | null = null;
|
|
@@ -233,24 +189,18 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
233
189
|
}
|
|
234
190
|
state.cancelReminder = cancelTodoReminder;
|
|
235
191
|
scoped.on('turn_end', async (event: unknown) => {
|
|
236
|
-
|
|
237
|
-
const msg = (event as { message: unknown }).message;
|
|
192
|
+
const msg = (event as { message?: unknown } | null | undefined)?.message;
|
|
238
193
|
if (msg === null || typeof msg !== 'object') return;
|
|
239
194
|
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
240
|
-
if (role === 'assistant' && typeof stopReason === 'string')
|
|
241
|
-
lastAssistantStopReason = stopReason;
|
|
242
|
-
}
|
|
195
|
+
if (role === 'assistant' && typeof stopReason === 'string') lastAssistantStopReason = stopReason;
|
|
243
196
|
});
|
|
244
|
-
|
|
245
|
-
scoped.on('session_shutdown', () => cancelTodoReminder());
|
|
197
|
+
for (const ev of ['agent_start', 'session_shutdown']) scoped.on(ev, () => cancelTodoReminder());
|
|
246
198
|
scoped.on('agent_settled', async () => {
|
|
247
199
|
cancelTodoReminder();
|
|
248
|
-
const inFlight = stopReminder.isCompactionInFlight?.() ?? false;
|
|
249
|
-
const intentional = stopReminder.isIntentionalAbort?.() ?? false;
|
|
250
200
|
const plan = planStopTodoReminder({
|
|
251
201
|
lastStopReason: lastAssistantStopReason,
|
|
252
|
-
intentionalAbort:
|
|
253
|
-
compactionInFlight:
|
|
202
|
+
intentionalAbort: stopReminder.isIntentionalAbort?.() ?? false,
|
|
203
|
+
compactionInFlight: stopReminder.isCompactionInFlight?.() ?? false,
|
|
254
204
|
reminders: todoReminders,
|
|
255
205
|
runningSubs: stopReminder.getRunningSubs(),
|
|
256
206
|
blockedDepth: stopReminder.getBlockedDepth(),
|
|
@@ -261,19 +211,20 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
261
211
|
todoReminderTimer = setTimeout(() => {
|
|
262
212
|
todoReminderTimer = null;
|
|
263
213
|
if (stopReminder.isCompactionInFlight?.()) return;
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
214
|
+
const send = pi.sendMessage;
|
|
215
|
+
if (typeof send !== 'function') return;
|
|
216
|
+
try {
|
|
217
|
+
// pi's sendMessage is fire-and-forget (it returns void), so the cap counter must advance
|
|
218
|
+
// synchronously: `send(...).then(...)` threw, got swallowed here, and every settle re-sent
|
|
219
|
+
// "Reminder 1/3" forever.
|
|
220
|
+
send(
|
|
221
|
+
{ customType: TODO_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
222
|
+
{ deliverAs: 'followUp', triggerTurn: true },
|
|
223
|
+
);
|
|
224
|
+
todoReminders += 1;
|
|
225
|
+
} catch {
|
|
226
|
+
/* A synchronous send failure must not escape the timer callback. */
|
|
227
|
+
}
|
|
277
228
|
}, todoReminderGraceMs());
|
|
278
229
|
todoReminderTimer.unref?.();
|
|
279
230
|
});
|
|
@@ -292,13 +243,10 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
292
243
|
Type.Object(
|
|
293
244
|
{
|
|
294
245
|
content: Type.String({ description: 'What the task is — a short imperative line' }),
|
|
295
|
-
status: Type.Union(
|
|
296
|
-
Type.Literal(
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
Type.Literal('blocked'),
|
|
300
|
-
Type.Literal('abandoned'),
|
|
301
|
-
], { description: 'pending | in_progress | completed | blocked | abandoned' }),
|
|
246
|
+
status: Type.Union(
|
|
247
|
+
TODO_STATUSES.map((status) => Type.Literal(status)),
|
|
248
|
+
{ description: TODO_STATUSES.join(' | ') },
|
|
249
|
+
),
|
|
302
250
|
blocker: Type.Optional(Type.String({ description: 'Only when status is "blocked": what it is waiting for. Omit the field entirely (never an empty string) for other statuses' })),
|
|
303
251
|
phase: Type.Optional(Type.String({ description: 'Optional group name (≤30 chars); omit for a flat list (never an empty string)' })),
|
|
304
252
|
},
|
|
@@ -315,10 +263,9 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
315
263
|
// A1: an invalid list is a hard failure — throw so pi flags isError for the model.
|
|
316
264
|
return toolError(result.error ?? 'invalid todo list');
|
|
317
265
|
}
|
|
318
|
-
const strictMode = todos.config.strict; // D75 phase 2 keeps policy in the service config.
|
|
319
266
|
let next = result.items!;
|
|
320
267
|
const strictNotes: string[] = [];
|
|
321
|
-
if (
|
|
268
|
+
if (todos.config.strict) {
|
|
322
269
|
const strict = normalizeStrict(next);
|
|
323
270
|
const demoted = next
|
|
324
271
|
.filter((it, i) => it.status === 'in_progress' && strict[i]?.status === 'pending')
|
|
@@ -329,11 +276,10 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
329
276
|
if (promoted) strictNotes.push(`auto-promoted to in_progress: ${promoted}`);
|
|
330
277
|
next = strict;
|
|
331
278
|
}
|
|
332
|
-
// D35
|
|
279
|
+
// D35: skip persistence and mirroring when the authoritative list is unchanged.
|
|
333
280
|
if (listsEqual(todos.items, next)) {
|
|
334
281
|
return { content: [{ type: 'text', text: 'No change: todo list already matches.' }], details: {} };
|
|
335
282
|
}
|
|
336
|
-
// D36/D37 surface completion transitions and regressions to the caller.
|
|
337
283
|
const completed = completionTransitions(todos.items, next);
|
|
338
284
|
const reverted = revertedCompleted(todos.items, next);
|
|
339
285
|
todos.replace(next, { source: 'tool' });
|
|
@@ -363,12 +309,11 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
363
309
|
description: 'Show the todo list, or edit it: /todos done|drop|rm|unblock <fuzzy content match>',
|
|
364
310
|
handler: async (args: unknown, eventCtx: unknown) => {
|
|
365
311
|
const ui = (eventCtx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
|
|
366
|
-
const ops = ['done', 'drop', 'rm', 'unblock'] as const;
|
|
367
312
|
const raw = typeof args === 'string'
|
|
368
313
|
? args.split(/\s+/).filter(Boolean)
|
|
369
314
|
: Array.isArray(args) ? args.map(String) : [];
|
|
370
315
|
const opArg = raw[0];
|
|
371
|
-
if (opArg && (
|
|
316
|
+
if (opArg && Object.hasOwn(OP_VERBS, opArg)) {
|
|
372
317
|
const query = raw.slice(1).join(' ').trim();
|
|
373
318
|
if (!query) {
|
|
374
319
|
ui?.notify?.('usage: /todos done|drop|rm|unblock <content>', 'warning');
|
|
@@ -384,14 +329,14 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
384
329
|
return;
|
|
385
330
|
}
|
|
386
331
|
const content = candidates[0];
|
|
387
|
-
const edit = { op: opArg as
|
|
332
|
+
const edit = { op: opArg as TodoEditOp, content };
|
|
388
333
|
const before = todos.items;
|
|
389
334
|
todos.applyEdits([edit], { source: 'human' });
|
|
390
335
|
if (listsEqual(before, todos.items)) {
|
|
391
336
|
ui?.notify?.(`no change: "${content}" is already in that state`, 'info');
|
|
392
337
|
return;
|
|
393
338
|
}
|
|
394
|
-
// D38
|
|
339
|
+
// D38: persist human edits so branch replay rebuilds the same authoritative state.
|
|
395
340
|
try {
|
|
396
341
|
appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits: [edit], ts: Date.now() });
|
|
397
342
|
} catch {
|
|
@@ -399,14 +344,9 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
399
344
|
}
|
|
400
345
|
mirrorTodos();
|
|
401
346
|
renderWidget(eventCtx);
|
|
402
|
-
|
|
403
|
-
: opArg === 'drop' ? 'abandoned'
|
|
404
|
-
: opArg === 'unblock' ? 'unblocked (back to pending)'
|
|
405
|
-
: 'removed';
|
|
406
|
-
ui?.notify?.(`"${content}" ${verb}`, 'info');
|
|
347
|
+
ui?.notify?.(`"${content}" ${OP_VERBS[edit.op]}`, 'info');
|
|
407
348
|
return;
|
|
408
349
|
}
|
|
409
|
-
// /todos is the unbounded view because the widget intentionally prioritizes active context.
|
|
410
350
|
const body = renderTodoGroups(todos.items);
|
|
411
351
|
if (body.length === 0) {
|
|
412
352
|
ui?.notify?.('todo list is empty', 'info');
|
|
@@ -419,4 +359,3 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
419
359
|
},
|
|
420
360
|
});
|
|
421
361
|
}
|
|
422
|
-
|
package/src/progress-core.ts
CHANGED
|
@@ -1,41 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* M16
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - Tool badge (🔧 tool) is sent through report_agent.message;
|
|
7
|
-
* - Rate estimates follow kimi estimator semantics: rate window, unfinished cap, and confidence gate.
|
|
8
|
-
*
|
|
9
|
-
* Conservative principle (risk ⑥ decision): when an estimate is not trustworthy, fall back to plain `N/M` and
|
|
10
|
-
* never display a misleading ETA.
|
|
2
|
+
* M16 progress visibility: conservative output only, no new D3 protocol. The progress badge
|
|
3
|
+
* (N/M plus optional ETA) rides in the pane title, the tool badge (🔧 tool) goes through
|
|
4
|
+
* report_agent.message, and an untrustworthy estimate falls back to plain `N/M` — never a
|
|
5
|
+
* misleading ETA.
|
|
11
6
|
*/
|
|
12
7
|
|
|
13
8
|
import { countTodos, type TodoItem } from './vocab.ts';
|
|
14
9
|
|
|
15
10
|
/** Minimum completed points needed for an ETA (<2 cannot establish a rate). */
|
|
16
11
|
export const ETA_MIN_SAMPLES = 2;
|
|
17
|
-
/**
|
|
18
|
-
export const RATE_WINDOW_MS = 45_000;
|
|
19
|
-
/** Cap for displayed unfinished progress (kimi unfinished-cap semantics; prevent a false "almost done" impression). */
|
|
20
|
-
export const UNFINISHED_CAP = 0.85;
|
|
21
|
-
/** Completion freshness gate: if the most recent completion is older than this, data is stale and no ETA is estimated. */
|
|
12
|
+
/** An older latest completion means the data is stale and no ETA is estimated. */
|
|
22
13
|
export const PROGRESS_STALE_MS = 5 * 60_000;
|
|
23
|
-
/** Number of recent completion points used for rate fitting. */
|
|
24
14
|
const RATE_SAMPLE_POINTS = 5;
|
|
25
15
|
|
|
26
16
|
export interface EtaEstimate {
|
|
27
|
-
/** Remaining steps. */
|
|
28
17
|
remaining: number;
|
|
29
|
-
/** Estimated remaining time (milliseconds). */
|
|
30
18
|
etaMs: number;
|
|
31
19
|
confidence: 'ok';
|
|
32
20
|
}
|
|
33
21
|
|
|
34
22
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* - Most recent completion older than PROGRESS_STALE_MS → null (stale after a burst followed by a pause or manual hold);
|
|
38
|
-
* - Rate = use the latest K(≤5) points, (k-1) / (t_k - t_1); eta = remaining / rate.
|
|
23
|
+
* Fewer than 2 points, or a stale latest point, → null.
|
|
24
|
+
* Rate = latest K(≤5) points, (k-1) / (t_k - t_1); eta = remaining / rate.
|
|
39
25
|
*/
|
|
40
26
|
export function estimateEta(opts: {
|
|
41
27
|
completedAt: readonly number[];
|
|
@@ -54,11 +40,10 @@ export function estimateEta(opts: {
|
|
|
54
40
|
const span = sample[sample.length - 1] - sample[0];
|
|
55
41
|
const steps = sample.length - 1;
|
|
56
42
|
if (steps < 1 || span <= 0) return null;
|
|
57
|
-
|
|
58
|
-
return { remaining, etaMs: Math.round(remaining / ratePerMs), confidence: 'ok' };
|
|
43
|
+
return { remaining, etaMs: Math.round(remaining / (steps / span)), confidence: 'ok' };
|
|
59
44
|
}
|
|
60
45
|
|
|
61
|
-
/** Title
|
|
46
|
+
/** Title suffix: `3/7`; trusted ETA gives `3/7 ~4m`; total=0 → empty; all complete → `5/5 ✓`. */
|
|
62
47
|
export function formatProgressSuffix(opts: {
|
|
63
48
|
completed: number;
|
|
64
49
|
total: number;
|
|
@@ -68,44 +53,22 @@ export function formatProgressSuffix(opts: {
|
|
|
68
53
|
if (total <= 0) return '';
|
|
69
54
|
const base = completed >= total ? `${completed}/${total} ✓` : `${completed}/${total}`;
|
|
70
55
|
if (!eta || eta.etaMs <= 0) return base;
|
|
71
|
-
const human = eta.etaMs < 60_000 ? '<1m'
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
})();
|
|
56
|
+
const human = eta.etaMs < 60_000 ? '<1m' : (() => {
|
|
57
|
+
const mins = Math.ceil(eta.etaMs / 60_000);
|
|
58
|
+
return mins < 60 ? `~${mins}m` : `~${Math.round((mins / 60) * 10) / 10}h`;
|
|
59
|
+
})();
|
|
76
60
|
return `${base} ${human}`;
|
|
77
61
|
}
|
|
78
62
|
|
|
79
|
-
/** Tool badge
|
|
63
|
+
/** Tool badge for report_agent.message: one name, or the first plus a count; empty → null (do not overwrite). */
|
|
80
64
|
export function planToolBadge(runningToolNames: readonly string[]): string | null {
|
|
81
65
|
if (runningToolNames.length === 0) return null;
|
|
82
66
|
const first = runningToolNames[0];
|
|
83
67
|
return runningToolNames.length === 1 ? `🔧 ${first}` : `🔧 ${first} +${runningToolNames.length - 1}`;
|
|
84
68
|
}
|
|
85
69
|
|
|
86
|
-
/**
|
|
70
|
+
/** Completed count over all tracked work (abandoned excluded). */
|
|
87
71
|
export function progressOf(items: readonly TodoItem[]): { completed: number; total: number } {
|
|
88
72
|
const c = countTodos(items);
|
|
89
|
-
|
|
90
|
-
return { completed: c.completed, total: c.completed + c.pending + c.inProgress + blockedCount };
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Hide the progress badge after it has been marked for the full lifecycle (avoid persistent completed-state noise; callers compare this). */
|
|
94
|
-
export const PROGRESS_HIDE_MS = 60_000;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Count completed transitions between two lists (match by content; an item completed in the new list whose old status
|
|
98
|
-
* was not completed, including items newly added as completed). M16 ETA estimation uses this as input: every edit path
|
|
99
|
-
* (todo_write / human edit / M17 reconciliation) passes through TodosService, so the diff is centralized here.
|
|
100
|
-
*/
|
|
101
|
-
export function countCompletedTransitions(
|
|
102
|
-
before: readonly TodoItem[],
|
|
103
|
-
after: readonly TodoItem[],
|
|
104
|
-
): number {
|
|
105
|
-
const oldStatus = new Map(before.map((it) => [it.content, it.status]));
|
|
106
|
-
let n = 0;
|
|
107
|
-
for (const it of after) {
|
|
108
|
-
if (it.status === 'completed' && oldStatus.get(it.content) !== 'completed') n += 1;
|
|
109
|
-
}
|
|
110
|
-
return n;
|
|
73
|
+
return { completed: c.completed, total: c.completed + c.pending + c.inProgress + c.blocked };
|
|
111
74
|
}
|
package/src/reconcile-core.ts
CHANGED
|
@@ -1,35 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Pure planner for automatic todo↔subagent reconciliation, run after a settlement push and before
|
|
3
|
+
* the followUp is injected.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* -
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
12
|
-
* - Persistence: edits use the D38 authoritative path (pi-herdr.todo-edit custom entry, effective during branch replay).
|
|
5
|
+
* Match description ↔ todo.content after normalize (lowercase + collapse whitespace) at tiers
|
|
6
|
+
* exact → prefix → substring, the same family as D38 fuzzyFind.
|
|
7
|
+
* - Auto-complete only a settled subagent's unique best candidate at exact/prefix, at most one item;
|
|
8
|
+
* - Unblock blocked items whose blocker matches at any tier (blockers are phrases like "waiting for
|
|
9
|
+
* X to finish", so substring matters and a soft return to pending is safe);
|
|
10
|
+
* - Low confidence / ambiguity / failure leaves the list untouched and adds a prompt line (never
|
|
11
|
+
* check off a guess);
|
|
12
|
+
* - Edits persist through the authoritative pi-herdr.todo-edit path, so branch replay reproduces them.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { applyTodoEdits, type TodoEdit, type TodoItem } from './todo-core.ts';
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** List after applying edits (original reference when there are no edits). */
|
|
17
|
+
type ReconcileOutcome = 'settled' | 'failed';
|
|
18
|
+
type MatchTier = 'exact' | 'prefix' | 'substring' | null;
|
|
19
|
+
interface ReconcilePlan {
|
|
21
20
|
items: TodoItem[];
|
|
22
21
|
/** Edits persisted through the authoritative path (at most one done plus any unblocks). */
|
|
23
22
|
edits: TodoEdit[];
|
|
24
23
|
/** Item automatically checked off (at most one). */
|
|
25
24
|
completed: TodoItem | null;
|
|
26
|
-
/** Best matching tier for auto-completion (null when there are no candidates). */
|
|
27
25
|
tier: MatchTier;
|
|
28
|
-
/** Items unblocked by a blocker match. */
|
|
29
26
|
unblocked: TodoItem[];
|
|
30
|
-
/** Prompt lines injected into the settlement notice (aligned with D36 feedback tone; empty when none). */
|
|
31
27
|
noteLines: string[];
|
|
32
|
-
/** Match-rate metric
|
|
28
|
+
/** Match-rate metric, collected with the settlement notice in session JSONL. */
|
|
33
29
|
metric: {
|
|
34
30
|
description: string;
|
|
35
31
|
outcome: ReconcileOutcome;
|
|
@@ -64,9 +60,9 @@ export function reconcileTodos(
|
|
|
64
60
|
const edits: TodoEdit[] = [];
|
|
65
61
|
const noteLines: string[] = [];
|
|
66
62
|
|
|
67
|
-
//
|
|
68
|
-
const
|
|
69
|
-
|
|
63
|
+
// Check-off candidates: completed/abandoned never participate.
|
|
64
|
+
const scored = prev
|
|
65
|
+
.filter((t) => t.status === 'pending' || t.status === 'in_progress')
|
|
70
66
|
.map((t) => ({ item: t, tier: matchTier(description, t.content) }))
|
|
71
67
|
.filter((c): c is { item: TodoItem; tier: Exclude<MatchTier, null> } => c.tier !== null)
|
|
72
68
|
.sort((a, b) => TIER_ORDER[a.tier] - TIER_ORDER[b.tier]);
|
|
@@ -80,35 +76,30 @@ export function reconcileTodos(
|
|
|
80
76
|
edits.push({ op: 'done', content: completed.content });
|
|
81
77
|
noteLines.push(`Reconciled: completed "${completed.content}" (${bestTier} match with subagent description).`);
|
|
82
78
|
} else if (best.length > 1) {
|
|
83
|
-
// Ambiguous match: list candidates and leave the decision to a person.
|
|
84
79
|
noteLines.push(
|
|
85
80
|
`Todo match ambiguous between: ${best.map((c) => `"${c.item.content}"`).join(', ')} — update todo_write yourself.`,
|
|
86
81
|
);
|
|
87
|
-
} else
|
|
88
|
-
// High-confidence candidate, but the subagent did not settle successfully: keep it open
|
|
82
|
+
} else {
|
|
83
|
+
// High-confidence candidate, but the subagent did not settle successfully: keep it open.
|
|
89
84
|
for (const c of best) {
|
|
90
85
|
noteLines.push(`Todo kept open: "${c.item.content}" (subagent did not settle successfully).`);
|
|
91
86
|
}
|
|
92
87
|
}
|
|
93
88
|
} else if (bestTier === 'substring') {
|
|
94
|
-
// Low-confidence match (P2): do not check it off; prompt with the candidate.
|
|
95
89
|
for (const c of best) {
|
|
96
90
|
noteLines.push(`Todo not auto-completed (low-confidence match): "${c.item.content}" — update todo_write if this work is done.`);
|
|
97
91
|
}
|
|
98
92
|
}
|
|
99
93
|
|
|
100
|
-
//
|
|
101
|
-
// so the description may be embedded in the middle and prefix would miss it. The low-confidence gate applies only
|
|
102
|
-
// to check-offs; returning an unblocked item to pending is a soft operation. Only settled subagents can unblock (possibly multiple).
|
|
94
|
+
// Unblocking is a soft operation, so any tier counts; only a settled subagent can unblock.
|
|
103
95
|
const unblocked: TodoItem[] = [];
|
|
104
96
|
if (outcome === 'settled') {
|
|
105
97
|
for (const t of prev) {
|
|
106
98
|
if (t.status !== 'blocked' || typeof t.blocker !== 'string') continue;
|
|
107
|
-
if (matchTier(description, t.blocker)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
99
|
+
if (matchTier(description, t.blocker) === null) continue;
|
|
100
|
+
unblocked.push(t);
|
|
101
|
+
edits.push({ op: 'unblock', content: t.content });
|
|
102
|
+
noteLines.push(`Unblocked "${t.content}" (was waiting on: ${t.blocker}).`);
|
|
112
103
|
}
|
|
113
104
|
}
|
|
114
105
|
|