pi-pier 0.1.4 → 0.2.0
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/package.json +1 -1
- package/schemas/role-manifest.schema.json +1 -2
- package/src/bootstrap.ts +18 -22
- package/src/core/grid-shape.ts +13 -14
- package/src/core/subagent.ts +325 -857
- package/src/core/terminal.ts +25 -28
- package/src/core/todo.ts +47 -53
- package/src/gc-core.ts +15 -15
- package/src/git-adapter.ts +104 -0
- package/src/herdr-client.ts +76 -68
- package/src/history-store.ts +70 -36
- package/src/index-locks.ts +105 -0
- package/src/index-master.ts +113 -0
- package/src/index-notices.ts +43 -0
- package/src/index-pipe.ts +73 -0
- package/src/index-runtime.ts +23 -0
- package/src/index-worker.ts +39 -0
- package/src/index.ts +134 -386
- package/src/ledger.ts +15 -17
- package/src/lock-core.ts +15 -29
- package/src/manifest-compose.ts +9 -23
- package/src/notice-buffer.ts +9 -14
- package/src/pane-title.ts +24 -24
- package/src/pi-surface.ts +22 -29
- package/src/pipe-channel.ts +67 -34
- package/src/platform-paths.ts +72 -0
- package/src/progress-core.ts +26 -24
- package/src/reconcile-core.ts +25 -24
- package/src/role-loader.ts +32 -35
- package/src/role-manifest.ts +24 -28
- package/src/roles/master.json +2 -2
- package/src/roles/worker-default.json +3 -3
- package/src/runtime-policy.ts +66 -0
- package/src/session-tail.ts +46 -45
- package/src/settle-wake-core.ts +19 -18
- package/src/slim-frame.ts +45 -44
- package/src/stale-core.ts +23 -21
- package/src/storage-layout.ts +80 -0
- package/src/subagent-core.ts +151 -94
- package/src/subagent-gc.ts +237 -0
- package/src/subagent-git-io.ts +80 -0
- package/src/subagent-launch.ts +110 -0
- package/src/subagent-poll-loop.ts +285 -0
- package/src/subagent-poller.ts +90 -0
- package/src/subagent-port.ts +27 -0
- package/src/subagent-scope.ts +4 -4
- package/src/subagent-session-io.ts +143 -0
- package/src/subagent-spawn.ts +156 -0
- package/src/terminal-core.ts +56 -54
- package/src/todo-core.ts +27 -64
- package/src/todo-read-hook.ts +28 -24
- package/src/todo-reminder-core.ts +89 -0
- package/src/todos-service.ts +36 -25
- package/src/tool-gate.ts +7 -28
- package/src/vocab.ts +18 -19
package/src/core/terminal.ts
CHANGED
|
@@ -1,13 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* M14 terminal loader entry for the D78 mount tree and D81 responsibility split.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* state 槽(index 的 GC 豁免查询 activePaneIds)。
|
|
9
|
-
*
|
|
10
|
-
* 本文件不 import index.ts(无环);terminal-core 为纯逻辑层照旧。
|
|
4
|
+
* Keeping this as a Cordis loader plugin makes the terminal surface hot-swappable. Services
|
|
5
|
+
* provide the D79 tombstone-aware pi surface, herdr client/environment, and the state slot
|
|
6
|
+
* used by index GC. Avoiding an index.ts import preserves an acyclic dependency graph, while
|
|
7
|
+
* terminal-core remains independently testable pure logic.
|
|
11
8
|
*/
|
|
12
9
|
import { Context } from '@deepseek-ai/cordis';
|
|
13
10
|
import { Type } from 'typebox';
|
|
@@ -24,6 +21,7 @@ import {
|
|
|
24
21
|
detectFullscreenTUI,
|
|
25
22
|
foldTerminalsRegistry,
|
|
26
23
|
makeTerminalsRegistry,
|
|
24
|
+
promptStrategyFor,
|
|
27
25
|
registerTerminal,
|
|
28
26
|
stripAnsi,
|
|
29
27
|
summarizeSessions,
|
|
@@ -34,11 +32,11 @@ import {
|
|
|
34
32
|
} from '../terminal-core.ts';
|
|
35
33
|
|
|
36
34
|
export interface TerminalStateSlot {
|
|
37
|
-
/** index
|
|
35
|
+
/** Lets index GC preserve panes that host active terminals. */
|
|
38
36
|
activePaneIds: () => Set<string>;
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
|
|
39
|
+
interface TerminalDeps {
|
|
42
40
|
client: HerdrClientLike;
|
|
43
41
|
env: { paneId: string; tabId: string } | null;
|
|
44
42
|
state: TerminalStateSlot;
|
|
@@ -50,13 +48,12 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
50
48
|
const pi = surface.raw as {
|
|
51
49
|
appendEntry?: (customType: string, data: unknown) => void;
|
|
52
50
|
};
|
|
53
|
-
//
|
|
51
|
+
// Keying the surface by this file lets HMR tombstone and replace exactly this registration.
|
|
54
52
|
const scoped = surface.forModule(import.meta.url);
|
|
55
53
|
|
|
56
|
-
/* ── M14
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* close=pane.close。有活跃终端的 pane 豁免 GC(index gcPass 查 state.activePaneIds)。 */
|
|
54
|
+
/* ── M14 resident terminal tools (D71 / T1–T6) ────────────────────
|
|
55
|
+
* Dedicated herdr panes preserve shell state without reusing the pi TUI pane, and active
|
|
56
|
+
* terminal panes remain exempt from index GC. Workers omit this master-side entry. */
|
|
60
57
|
|
|
61
58
|
let terminals: TerminalEntry[] = [];
|
|
62
59
|
|
|
@@ -64,7 +61,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
64
61
|
try {
|
|
65
62
|
pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
|
|
66
63
|
} catch {
|
|
67
|
-
/*
|
|
64
|
+
/* Persistence is best-effort because terminal operation must not depend on session logging. */
|
|
68
65
|
}
|
|
69
66
|
}
|
|
70
67
|
|
|
@@ -74,7 +71,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
74
71
|
?.sessionManager?.getBranch?.() ?? [];
|
|
75
72
|
terminals = foldTerminalsRegistry(entries as Parameters<typeof foldTerminalsRegistry>[0]);
|
|
76
73
|
} catch {
|
|
77
|
-
/*
|
|
74
|
+
/* A malformed snapshot must not block the live terminal surface. */
|
|
78
75
|
}
|
|
79
76
|
}
|
|
80
77
|
|
|
@@ -88,7 +85,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
88
85
|
persistTerminals();
|
|
89
86
|
}
|
|
90
87
|
|
|
91
|
-
//
|
|
88
|
+
// Expose active panes so index GC does not collect resident terminal shells.
|
|
92
89
|
state.activePaneIds = () => activeTerminalPaneIds(terminals);
|
|
93
90
|
|
|
94
91
|
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
@@ -119,7 +116,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
119
116
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
120
117
|
: (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
121
118
|
const r = registerTerminal(terminals, {
|
|
122
|
-
paneId: env.paneId, //
|
|
119
|
+
paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
|
|
123
120
|
tabId: env.tabId,
|
|
124
121
|
cwd,
|
|
125
122
|
createdAt: Date.now(),
|
|
@@ -134,17 +131,17 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
134
131
|
r.entry.paneId = paneId;
|
|
135
132
|
terminals = r.entries;
|
|
136
133
|
persistTerminals();
|
|
137
|
-
|
|
134
|
+
const prompt = promptStrategyFor();
|
|
138
135
|
let readiness: 'prompt' | 'silent' | 'busy' = 'busy';
|
|
139
136
|
try {
|
|
140
|
-
const matched = await client.waitForOutput(paneId, { type: 'regex', value:
|
|
137
|
+
const matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
141
138
|
if (matched) readiness = 'prompt';
|
|
142
139
|
else {
|
|
143
140
|
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
144
|
-
readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0 });
|
|
141
|
+
readiness = classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
145
142
|
}
|
|
146
143
|
} catch {
|
|
147
|
-
/*
|
|
144
|
+
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
148
145
|
}
|
|
149
146
|
const text = [
|
|
150
147
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
@@ -204,7 +201,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
204
201
|
if (!entry) return errText(`unknown or closed terminal "${params.terminal_id}" (see terminal_list)`);
|
|
205
202
|
paneId = entry.paneId;
|
|
206
203
|
} else if (typeof params?.pane_id === 'string') {
|
|
207
|
-
// T5
|
|
204
|
+
// T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
|
|
208
205
|
try {
|
|
209
206
|
const panes = await client.listPanes();
|
|
210
207
|
const target = panes.find((p) => p.paneId === params.pane_id);
|
|
@@ -228,7 +225,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
228
225
|
} catch (e) {
|
|
229
226
|
return errText(`read failed (pane may be closed): ${(e as Error).message}`);
|
|
230
227
|
}
|
|
231
|
-
// T6
|
|
228
|
+
// T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
|
|
232
229
|
const tui = detectFullscreenTUI(read.text);
|
|
233
230
|
if (tui.detected) {
|
|
234
231
|
const head = stripAnsi(read.text).slice(0, 200);
|
|
@@ -310,7 +307,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
310
307
|
try {
|
|
311
308
|
await client.closePane(entry.paneId);
|
|
312
309
|
} catch {
|
|
313
|
-
/* pane
|
|
310
|
+
/* The pane may have been closed by a person or with its tab; record the terminal as closed anyway. */
|
|
314
311
|
}
|
|
315
312
|
}
|
|
316
313
|
terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
|
|
@@ -332,10 +329,10 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
332
329
|
try {
|
|
333
330
|
livePaneIds = (await client.listPanes()).map((p) => p.paneId);
|
|
334
331
|
} catch {
|
|
335
|
-
/*
|
|
332
|
+
/* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
|
|
336
333
|
}
|
|
337
334
|
const s = summarizeSessions(terminals, livePaneIds);
|
|
338
|
-
// T6
|
|
335
|
+
// T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
|
|
339
336
|
if (s.stalePaneIds.length > 0) {
|
|
340
337
|
terminals = terminals.map((t) =>
|
|
341
338
|
s.stalePaneIds.includes(t.paneId)
|
package/src/core/todo.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* D78/D81 todo loader entry.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* 本文件承接:todo_write 工具 + /todos 命令(查看/编辑)+ TUI widget(widgetLines/
|
|
11
|
-
* renderWidget)+ 读钩(before_agent_start planTodoReadHook,
|
|
12
|
-
* 轮次计数器内聚)。reconcile / 镜像 / reportAgent 仍在 index(session 状态层)。
|
|
4
|
+
* A Cordis plugin keeps the master surface hot-swappable while allowing workers to mount the
|
|
5
|
+
* one-shot subset directly. Injected services preserve the D79 registration boundary and keep
|
|
6
|
+
* session-owned todo state in index. This module owns the tool, command, widget, and read hook;
|
|
7
|
+
* reconciliation, mirroring, and agent reporting stay with the session state layer to avoid a
|
|
8
|
+
* dependency cycle.
|
|
13
9
|
*/
|
|
14
10
|
import { Context } from '@deepseek-ai/cordis';
|
|
15
11
|
import { Type } from 'typebox';
|
|
@@ -32,14 +28,14 @@ import {
|
|
|
32
28
|
} from '../todo-core.ts';
|
|
33
29
|
|
|
34
30
|
export interface TodoUiSlot {
|
|
35
|
-
/** index
|
|
31
|
+
/** Lets index lifecycle events render through the currently mounted plugin. */
|
|
36
32
|
renderWidget: (ctx: unknown) => void;
|
|
37
33
|
}
|
|
38
34
|
|
|
39
|
-
|
|
35
|
+
interface TodoDeps {
|
|
40
36
|
todos: TodosService;
|
|
41
37
|
allowParallelInProgress: boolean;
|
|
42
|
-
/**
|
|
38
|
+
/** Warn above this size without rejecting otherwise valid plans. */
|
|
43
39
|
maxItems: number;
|
|
44
40
|
mirrorTodos: () => void;
|
|
45
41
|
appendEntry: (customType: string, data: unknown) => void;
|
|
@@ -51,7 +47,7 @@ const TOOL_DESCRIPTION = [
|
|
|
51
47
|
'Entries: `content` (non-empty, unique; short imperative, what-not-how; no numbering prefixes), `status` (pending | in_progress | completed | blocked | abandoned), optional `blocker` (only when blocked: what it is waiting for), optional `phase` (group name for multi-stage plans; omit for a flat list).',
|
|
52
48
|
'Keep the list short (at most 15 entries): one entry per meaningful unit of work, not per micro-step.',
|
|
53
49
|
'Mark tasks you are actively working on as in_progress; several may be in_progress when work genuinely runs in parallel.',
|
|
54
|
-
'
|
|
50
|
+
'Before stopping, reconcile open entries: anything waiting on a human (decision, approval, or an ops action you cannot perform) must be marked blocked with a blocker note — never left pending, and never executed just to clear the list; finished or obsolete entries must be completed or removed.',
|
|
55
51
|
'Do not make todo_write the only tool call of a turn — batch it together with real work.',
|
|
56
52
|
'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.',
|
|
57
53
|
].join(' ');
|
|
@@ -64,10 +60,10 @@ const TODO_MARKS: Record<TodoStatus, string> = {
|
|
|
64
60
|
abandoned: '✗',
|
|
65
61
|
};
|
|
66
62
|
|
|
67
|
-
/**
|
|
63
|
+
/** Stay within pi interactive mode's ten-line widget cap instead of relying on head truncation. */
|
|
68
64
|
export const WIDGET_MAX_LINES = 10;
|
|
69
65
|
|
|
70
|
-
/**
|
|
66
|
+
/** Share phase rendering between the widget and /todos so both views order entries identically. */
|
|
71
67
|
function renderGroups(items: readonly TodoItem[]): string[] {
|
|
72
68
|
const groups = new Map<string, TodoItem[]>();
|
|
73
69
|
for (const it of items) {
|
|
@@ -88,18 +84,15 @@ function renderGroups(items: readonly TodoItem[]): string[] {
|
|
|
88
84
|
}
|
|
89
85
|
|
|
90
86
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
* 正在执行的条目滚出视窗,01a03c0d 实证"显示最后几条,不管当前执行到哪");
|
|
96
|
-
* - 无 in_progress → 锚定最后一条 open;全完成 → 尾部切片(刚完成的可见);
|
|
97
|
-
* - +N 行指路 /todos(全量视图)。
|
|
87
|
+
* Keep the active task visible within WIDGET_MAX_LINES instead of truncating to a fixed head or
|
|
88
|
+
* tail. The window anchors on the first in-progress item, then the last open item, then recent
|
|
89
|
+
* completions. A +N line points to /todos when the full plan cannot fit. This fixes 01a03c0d,
|
|
90
|
+
* where active work scrolled out while only the final entries remained visible.
|
|
98
91
|
*/
|
|
99
92
|
export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
|
|
100
93
|
if (items.length === 0) return [];
|
|
101
94
|
const c = countTodos(items);
|
|
102
|
-
//
|
|
95
|
+
// Collapse archived plans to two lines so stale work cannot monopolize the widget.
|
|
103
96
|
if (opts?.archivedAgeMs != null) {
|
|
104
97
|
return [
|
|
105
98
|
`todo: ${c.inProgress}▶ ${c.pending}○ ${c.blocked}■ ${c.completed}✓ · archived ${formatAge(opts.archivedAgeMs)}`,
|
|
@@ -107,8 +100,7 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
|
|
|
107
100
|
];
|
|
108
101
|
}
|
|
109
102
|
|
|
110
|
-
//
|
|
111
|
-
// 迭代收缩:锚定窗口切片 → 超行则两端各收一格(保锚点),直到装得下。
|
|
103
|
+
// Reserve summary and overflow lines, then shrink around the anchor because phase headers also consume the budget.
|
|
112
104
|
const renderBudget = WIDGET_MAX_LINES - 1 - 1;
|
|
113
105
|
const [start, end] = anchorRange(items, renderBudget);
|
|
114
106
|
const kept = items.slice(start, end);
|
|
@@ -122,9 +114,8 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
|
|
|
122
114
|
}
|
|
123
115
|
|
|
124
116
|
/**
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
* 可见),装不下即停;锚点条目永远在窗内。
|
|
117
|
+
* Expand around the active anchor within the rendered-line budget, favoring upcoming work while
|
|
118
|
+
* guaranteeing that the anchor remains visible.
|
|
128
119
|
*/
|
|
129
120
|
function anchorRange(items: readonly TodoItem[], renderBudget: number): [number, number] {
|
|
130
121
|
if (renderGroups(items).length <= renderBudget) return [0, items.length];
|
|
@@ -133,7 +124,7 @@ function anchorRange(items: readonly TodoItem[], renderBudget: number): [number,
|
|
|
133
124
|
? anchor
|
|
134
125
|
: items.reduce((acc, it, i) => (it.status !== 'completed' ? i : acc), -1);
|
|
135
126
|
if (anchorIdx < 0) {
|
|
136
|
-
//
|
|
127
|
+
// Completed plans keep the most recent tail visible within the line budget.
|
|
137
128
|
let end = items.length;
|
|
138
129
|
while (renderGroups(items.slice(0, end)).length > renderBudget && end > 1) end -= 1;
|
|
139
130
|
return [0, end];
|
|
@@ -155,7 +146,7 @@ function anchorRange(items: readonly TodoItem[], renderBudget: number): [number,
|
|
|
155
146
|
} else {
|
|
156
147
|
break;
|
|
157
148
|
}
|
|
158
|
-
growTail = !growTail; //
|
|
149
|
+
growTail = !growTail; // Alternate sides so both context and upcoming work remain visible.
|
|
159
150
|
}
|
|
160
151
|
return [start, end];
|
|
161
152
|
}
|
|
@@ -166,17 +157,17 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
166
157
|
ctx.get('pi-herdr.todo-deps') as TodoDeps;
|
|
167
158
|
const scoped = surface.forModule(import.meta.url);
|
|
168
159
|
|
|
169
|
-
/**
|
|
160
|
+
/** Share one archive-age decision between the widget and /todos. */
|
|
170
161
|
function archivedAgeMs(now = Date.now()): number | null {
|
|
171
162
|
return todos.lastWriteAt != null && isArchived(todos.items, todos.lastWriteAt, now)
|
|
172
163
|
? now - todos.lastWriteAt
|
|
173
164
|
: null;
|
|
174
165
|
}
|
|
175
166
|
|
|
176
|
-
/**
|
|
167
|
+
/** Name the guarded event UI once so callers do not repeat unsafe inline assertions. */
|
|
177
168
|
function widgetUi(eventCtx: unknown): { setWidget?: (id: string, lines: string[]) => void } | undefined {
|
|
178
169
|
if (eventCtx === null || typeof eventCtx !== 'object' || !('ui' in eventCtx)) return undefined;
|
|
179
|
-
const ui = (eventCtx as { ui: unknown }).ui; //
|
|
170
|
+
const ui = (eventCtx as { ui: unknown }).ui; // Safe after the property guard above.
|
|
180
171
|
return ui !== null && typeof ui === 'object'
|
|
181
172
|
? (ui as { setWidget?: (id: string, lines: string[]) => void })
|
|
182
173
|
: undefined;
|
|
@@ -186,23 +177,23 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
186
177
|
try {
|
|
187
178
|
widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, { archivedAgeMs: archivedAgeMs() }));
|
|
188
179
|
} catch {
|
|
189
|
-
/*
|
|
180
|
+
/* Older pi versions may omit widget support without disabling todo tracking. */
|
|
190
181
|
}
|
|
191
182
|
}
|
|
192
183
|
|
|
193
|
-
//
|
|
184
|
+
// Let index lifecycle hooks call the current plugin implementation.
|
|
194
185
|
state.renderWidget = renderWidget;
|
|
195
186
|
|
|
196
|
-
/* ──
|
|
187
|
+
/* ── Read hook: D39 reminders and stale-plan thawing ────────────── */
|
|
197
188
|
let todoReadTurn = 0;
|
|
198
189
|
let lastEmptyGuardTurn: number | null = null;
|
|
199
|
-
//
|
|
190
|
+
// Track writes and notice cadence so a stale plan cannot remain frozen indefinitely.
|
|
200
191
|
let lastWriteTurn: number | null = null;
|
|
201
192
|
let staleNotices = 0;
|
|
202
193
|
let lastStaleGuardTurn: number | null = null;
|
|
203
194
|
let lastArchivedMirror = false;
|
|
204
195
|
|
|
205
|
-
//
|
|
196
|
+
// Any real edit ends the current stale period because the plan is active again.
|
|
206
197
|
todos.on('todo.updated', () => {
|
|
207
198
|
lastWriteTurn = todoReadTurn;
|
|
208
199
|
staleNotices = 0;
|
|
@@ -223,20 +214,20 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
223
214
|
staleNotices += 1;
|
|
224
215
|
lastStaleGuardTurn = todoReadTurn;
|
|
225
216
|
}
|
|
226
|
-
//
|
|
227
|
-
//
|
|
217
|
+
// Share cadence across empty, archive, and R2 rewrite notices so adjacent notices do not
|
|
218
|
+
// consume the grace window.
|
|
228
219
|
if (plan.inject && (plan.effect === 'empty-guard' || plan.archived)) {
|
|
229
220
|
lastEmptyGuardTurn = todoReadTurn;
|
|
230
221
|
}
|
|
231
|
-
// R1
|
|
232
|
-
//
|
|
233
|
-
//
|
|
222
|
+
// R1 clears archived entries after notice because retaining dead items suppresses the empty
|
|
223
|
+
// guard that restored tracking after the 01a03c0d two-hour gap. Persist removals for replay;
|
|
224
|
+
// the session log still retains history.
|
|
234
225
|
if (plan.inject && plan.effect === 'archive-notice' && plan.clearArchived && todos.items.length > 0) {
|
|
235
226
|
const edits = todos.items.map((it) => ({ op: 'rm' as const, content: it.content }));
|
|
236
227
|
try {
|
|
237
228
|
appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits, ts: Date.now() });
|
|
238
229
|
} catch {
|
|
239
|
-
/*
|
|
230
|
+
/* Persistence is best-effort because the in-memory clear must still proceed. */
|
|
240
231
|
}
|
|
241
232
|
todos.replace([]);
|
|
242
233
|
}
|
|
@@ -251,11 +242,14 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
251
242
|
};
|
|
252
243
|
});
|
|
253
244
|
|
|
254
|
-
/* ──
|
|
245
|
+
/* ── todo_write tool ───────────────────────────────────────────── */
|
|
255
246
|
scoped.registerTool({
|
|
256
247
|
name: TODO_TOOL_NAME,
|
|
257
248
|
label: 'Todo',
|
|
258
249
|
description: TOOL_DESCRIPTION,
|
|
250
|
+
promptGuidelines: [
|
|
251
|
+
'Before stopping with open todo_write entries, reconcile them: entries waiting on a human decision, approval, or ops action must be marked blocked with a blocker note — never left pending, and never executed just to clear the list.',
|
|
252
|
+
],
|
|
259
253
|
parameters: Type.Object({
|
|
260
254
|
todos: Type.Array(
|
|
261
255
|
Type.Object(
|
|
@@ -286,7 +280,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
286
280
|
details: {},
|
|
287
281
|
};
|
|
288
282
|
}
|
|
289
|
-
const strictMode = todos.config.strict; // D75
|
|
283
|
+
const strictMode = todos.config.strict; // D75 phase 2 keeps policy in the service config.
|
|
290
284
|
let next = result.items!;
|
|
291
285
|
const strictNotes: string[] = [];
|
|
292
286
|
if (strictMode) {
|
|
@@ -300,11 +294,11 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
300
294
|
if (promoted) strictNotes.push(`auto-promoted to in_progress: ${promoted}`);
|
|
301
295
|
next = strict;
|
|
302
296
|
}
|
|
303
|
-
// D35
|
|
297
|
+
// D35 avoids persistence and mirroring when the authoritative list is unchanged.
|
|
304
298
|
if (listsEqual(todos.items, next)) {
|
|
305
299
|
return { content: [{ type: 'text', text: 'No change: todo list already matches.' }], details: {} };
|
|
306
300
|
}
|
|
307
|
-
// D36/D37
|
|
301
|
+
// D36/D37 surface completion transitions and regressions to the caller.
|
|
308
302
|
const completed = completionTransitions(todos.items, next);
|
|
309
303
|
const reverted = revertedCompleted(todos.items, next);
|
|
310
304
|
todos.replace(next);
|
|
@@ -329,7 +323,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
329
323
|
},
|
|
330
324
|
});
|
|
331
325
|
|
|
332
|
-
/* ──
|
|
326
|
+
/* ── /todos command: D38 human viewing and edits ───────────────── */
|
|
333
327
|
scoped.registerCommand('todos', {
|
|
334
328
|
description: 'Show the todo list, or edit it: /todos done|drop|rm|unblock <fuzzy content match>',
|
|
335
329
|
handler: async (args: unknown, eventCtx: unknown) => {
|
|
@@ -362,11 +356,11 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
362
356
|
ui?.notify?.(`no change: "${content}" is already in that state`, 'info');
|
|
363
357
|
return;
|
|
364
358
|
}
|
|
365
|
-
// D38
|
|
359
|
+
// D38 persists human edits so branch replay rebuilds the same authoritative state.
|
|
366
360
|
try {
|
|
367
361
|
appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits: [edit], ts: Date.now() });
|
|
368
362
|
} catch {
|
|
369
|
-
/*
|
|
363
|
+
/* Persistence failure must not discard the live human edit. */
|
|
370
364
|
}
|
|
371
365
|
mirrorTodos();
|
|
372
366
|
renderWidget(eventCtx);
|
|
@@ -377,7 +371,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
377
371
|
ui?.notify?.(`"${content}" ${verb}`, 'info');
|
|
378
372
|
return;
|
|
379
373
|
}
|
|
380
|
-
//
|
|
374
|
+
// /todos is the unbounded view because the widget intentionally prioritizes active context.
|
|
381
375
|
const body = renderGroups(todos.items);
|
|
382
376
|
if (body.length === 0) {
|
|
383
377
|
ui?.notify?.('todo list is empty', 'info');
|
package/src/gc-core.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Task-tab GC decisions (D29 rules as a pure function; M22 dropped resident exemption).
|
|
3
|
+
* No I/O, no pi/herdr — unit-testable.
|
|
4
4
|
*/
|
|
5
5
|
export type GcEntryKind = string;
|
|
6
6
|
export type GcEntryStatus = 'running' | 'settled' | 'consumed' | 'closed';
|
|
7
7
|
|
|
8
8
|
export interface GcEntryLike {
|
|
9
|
-
/** 'task'
|
|
9
|
+
/** 'task' or a role name; legacy short/resident is a label only. */
|
|
10
10
|
kind: GcEntryKind;
|
|
11
11
|
status: GcEntryStatus;
|
|
12
|
-
/**
|
|
12
|
+
/** Consume timestamp (GC grace); closed rows keep the original value. */
|
|
13
13
|
consumedAt?: number | null;
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -18,12 +18,12 @@ function isFinished(status: GcEntryStatus): boolean {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
*
|
|
22
|
-
* -
|
|
23
|
-
* -
|
|
24
|
-
* -
|
|
25
|
-
* -
|
|
26
|
-
* -
|
|
21
|
+
* Close a task tab only when every condition holds:
|
|
22
|
+
* - ≥1 work pane (the main tab with no delegated entries never closes);
|
|
23
|
+
* - every work pane is settled/consumed/closed (kind is not an exemption);
|
|
24
|
+
* - grace TTL elapsed (`ttlMs=0` means never auto-close);
|
|
25
|
+
* - no blocked pane (human-gate exemption);
|
|
26
|
+
* - remaining panes are idle/done/unknown (non-work panes may be unknown).
|
|
27
27
|
*/
|
|
28
28
|
export function shouldCloseTaskTab(opts: {
|
|
29
29
|
entries: readonly GcEntryLike[];
|
|
@@ -40,17 +40,17 @@ export function shouldCloseTaskTab(opts: {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
* -
|
|
45
|
-
* - herdr
|
|
46
|
-
* - pane
|
|
43
|
+
* Pane-level collection (orphan / compat path):
|
|
44
|
+
* - consumed before the previous turn (grace so the settlement notice is still visible);
|
|
45
|
+
* - herdr status idle/done (unknown/working/blocked retry next turn);
|
|
46
|
+
* - missing pane (status undefined) → record closed (caller handles the write).
|
|
47
47
|
*/
|
|
48
48
|
export function shouldClosePane(opts: {
|
|
49
49
|
consumedAt: number | null;
|
|
50
50
|
herdrStatus: string | undefined;
|
|
51
51
|
prevTurnStart: number;
|
|
52
52
|
}): boolean {
|
|
53
|
-
if (opts.herdrStatus === undefined) return true; // pane
|
|
53
|
+
if (opts.herdrStatus === undefined) return true; // pane gone → record closed
|
|
54
54
|
if (opts.herdrStatus !== 'idle' && opts.herdrStatus !== 'done') return false;
|
|
55
55
|
return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart;
|
|
56
56
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { execFile as nodeExecFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
3
|
+
import { runtimePolicy } from './runtime-policy.ts'
|
|
4
|
+
|
|
5
|
+
const execFile = promisify(nodeExecFile)
|
|
6
|
+
|
|
7
|
+
export interface GitResult {
|
|
8
|
+
readonly stdout: string
|
|
9
|
+
readonly stderr: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GitError extends Error {
|
|
13
|
+
readonly code?: string | number
|
|
14
|
+
readonly stdout?: string
|
|
15
|
+
readonly stderr?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Injectable execFile used by NodeGitAdapter.
|
|
20
|
+
* Why: tests drive git without a real process.
|
|
21
|
+
*/
|
|
22
|
+
export type GitExecFile = (
|
|
23
|
+
file: string,
|
|
24
|
+
args: readonly string[],
|
|
25
|
+
options: { timeout: number; encoding: 'utf8' },
|
|
26
|
+
) => Promise<{ stdout: string; stderr: string }>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Minimal Git adapter for worktree and status operations.
|
|
30
|
+
*
|
|
31
|
+
* Why: Centralizes platform-specific git executable resolution, timeout policy,
|
|
32
|
+
* and error normalization; makes tests independent of real git processes.
|
|
33
|
+
*/
|
|
34
|
+
export interface GitAdapter {
|
|
35
|
+
/**
|
|
36
|
+
* List worktrees in porcelain format.
|
|
37
|
+
* Returns stdout containing worktree entries.
|
|
38
|
+
*/
|
|
39
|
+
listWorktrees(cwd: string): Promise<GitResult>
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get status of worktree in short format.
|
|
43
|
+
* Returns stdout containing status entries.
|
|
44
|
+
*/
|
|
45
|
+
status(cwd: string): Promise<GitResult>
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run an arbitrary git command in `cwd` (`git -C <cwd> ...args`).
|
|
49
|
+
* Throws a GitError on failure/timeout — callers that want "null on error"
|
|
50
|
+
* (isolate sweep, placement) catch at the call site.
|
|
51
|
+
*/
|
|
52
|
+
run(cwd: string, args: readonly string[]): Promise<GitResult>
|
|
53
|
+
}
|
|
54
|
+
export class NodeGitAdapter implements GitAdapter {
|
|
55
|
+
private readonly gitExecutable: string
|
|
56
|
+
private readonly timeoutMs: number
|
|
57
|
+
private readonly exec: GitExecFile
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
gitExecutable: string = 'git',
|
|
61
|
+
timeoutMs: number = runtimePolicy.gitTimeoutMs,
|
|
62
|
+
exec: GitExecFile = execFile as unknown as GitExecFile,
|
|
63
|
+
) {
|
|
64
|
+
this.gitExecutable = gitExecutable
|
|
65
|
+
this.timeoutMs = timeoutMs
|
|
66
|
+
this.exec = exec
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
listWorktrees(cwd: string): Promise<GitResult> {
|
|
70
|
+
return this.run(cwd, ['worktree', 'list', '--porcelain'])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
status(cwd: string): Promise<GitResult> {
|
|
74
|
+
return this.run(cwd, ['status', '--short'])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async run(cwd: string, args: readonly string[]): Promise<GitResult> {
|
|
78
|
+
try {
|
|
79
|
+
const { stdout, stderr } = await this.exec(
|
|
80
|
+
this.gitExecutable,
|
|
81
|
+
['-C', cwd, ...args],
|
|
82
|
+
{ timeout: this.timeoutMs, encoding: 'utf8' },
|
|
83
|
+
)
|
|
84
|
+
return { stdout, stderr }
|
|
85
|
+
} catch (err) {
|
|
86
|
+
throw this.normalizeError(err, args[0] ?? 'command')
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private normalizeError(err: unknown, operation: string): GitError {
|
|
91
|
+
if (err instanceof Error) {
|
|
92
|
+
const gitError = err as GitError
|
|
93
|
+
gitError.message = `Git ${operation} failed: ${gitError.message}`
|
|
94
|
+
return gitError
|
|
95
|
+
}
|
|
96
|
+
return new Error(`Git ${operation} failed: ${String(err)}`) as GitError
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Default production git adapter.
|
|
102
|
+
* Tests should inject fake implementations or a fake exec.
|
|
103
|
+
*/
|
|
104
|
+
export const defaultGitAdapter = new NodeGitAdapter()
|