@yagni-app/code-staging 1.1.3-staging.1377.1 → 1.1.3-staging.1378.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/dist/extension/index.d.ts +1 -1
- package/dist/extension/index.js +1 -1
- package/dist/extension/todos.d.ts +62 -3
- package/dist/extension/todos.js +249 -21
- package/package.json +2 -2
|
@@ -199,7 +199,7 @@ export type { ComparisonReport, LaneFit, LaneOutcome } from "./pipeline/eval.js"
|
|
|
199
199
|
export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
200
200
|
export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
|
|
201
201
|
export type { SubagentDef, SubagentSource } from "./subagents.js";
|
|
202
|
-
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
|
|
202
|
+
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, clampSingleInProgress, formatAgeMs, oldestInProgress, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, TODO_STALE_MS, MAX_TODOS, } from "./todos.js";
|
|
203
203
|
export type { TodoItem, TodoStatus, TodoTheme } from "./todos.js";
|
|
204
204
|
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
|
|
205
205
|
export { classifyCommand, DEFAULT_EXEC_POLICY, } from "./permission/execPolicy.js";
|
package/dist/extension/index.js
CHANGED
|
@@ -1844,7 +1844,7 @@ export { registerGoCompareCommand } from "./pipeline/goCompareCommand.js";
|
|
|
1844
1844
|
// The general subagent tool: Claude Code-format agent discovery + fan-out.
|
|
1845
1845
|
export { registerSubagents, makeSubagentTool, discoverSubagents, parseAgentMarkdown, buildSubagentStage, formatAgentList, mapModelTier, SUBAGENT_TOOL_NAME, GENERAL_AGENT_NAME, MAX_PARALLEL_SUBAGENTS, MAX_PARALLEL_SUBAGENTS_ULTRA, DEFAULT_SUBAGENT_TOOLS, } from "./subagents.js";
|
|
1846
1846
|
// The session todo checklist: TodoWrite tool, widget renderer, /todos.
|
|
1847
|
-
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, MAX_TODOS, } from "./todos.js";
|
|
1847
|
+
export { registerTodos, makeTodoTool, normalizeTodos, reconstructTodos, renderTodoWidget, formatTodoList, formatTodoReminder, shouldRemindTodos, clampSingleInProgress, formatAgeMs, oldestInProgress, todoSummary, TODO_TOOL_NAME, TODO_REMINDER_TURNS, TODO_STALE_MS, MAX_TODOS, } from "./todos.js";
|
|
1848
1848
|
// P3 + W4: the permission gate seam (decideGate is pure; policy injectable) plus
|
|
1849
1849
|
// the session bless-with-remember capture hook.
|
|
1850
1850
|
export { decideGate, registerPermissionGate, filterStaleModeContext, filterStalePlanContext, buildModeContextMessage, DEFAULT_PERMISSION_POLICY, MODE_CONTEXT_TYPE, PLAN_CONTEXT_TYPE, PLAN_CONTEXT_MESSAGE, } from "./permission/gate.js";
|
|
@@ -55,6 +55,19 @@ export declare const TODO_REMINDER_TURNS = 10;
|
|
|
55
55
|
* dropping behind open work — Claude Code's TaskList RECENT_COMPLETED_TTL.
|
|
56
56
|
*/
|
|
57
57
|
export declare const TODO_COMPLETED_LINGER_MS = 30000;
|
|
58
|
+
/**
|
|
59
|
+
* Age at which an in-progress step is treated as possibly stale: the aged
|
|
60
|
+
* reminder names the item and its age instead of the generic nudge, and the
|
|
61
|
+
* widget row picks up a dim `(23m)` suffix. Below this the board reads as
|
|
62
|
+
* normal active work.
|
|
63
|
+
*/
|
|
64
|
+
export declare const TODO_STALE_MS: number;
|
|
65
|
+
/**
|
|
66
|
+
* The board repaints at this cadence while an active step can age, so the
|
|
67
|
+
* `(23m)` suffix stays live. Under the staleness threshold the suffix is
|
|
68
|
+
* hidden entirely, so a slow tick costs nothing.
|
|
69
|
+
*/
|
|
70
|
+
export declare const TODO_REPAINT_MS = 30000;
|
|
58
71
|
/**
|
|
59
72
|
* The desktop's structured state record rides its own widget key, like the
|
|
60
73
|
* `/go` run state: one JSON line the app parses and renders itself, never
|
|
@@ -75,15 +88,31 @@ export interface TodoItem {
|
|
|
75
88
|
* Validate a full replacement list. Strict: this is model input rendered
|
|
76
89
|
* straight into the terminal. An empty list is valid (it clears the board).
|
|
77
90
|
* Accepts both the current shape ({content, activeForm}) and the legacy
|
|
78
|
-
* {text} shape so old sessions replay cleanly.
|
|
91
|
+
* {text} shape so old sessions replay cleanly. The single-active clamp runs
|
|
92
|
+
* here so every path (tool writes, branch replay, legacy sessions) enforces
|
|
93
|
+
* it; `demoted` reports what the clamp changed so callers can tell the model.
|
|
79
94
|
*/
|
|
80
95
|
export declare function normalizeTodos(raw: unknown): {
|
|
81
96
|
ok: true;
|
|
82
97
|
todos: TodoItem[];
|
|
98
|
+
demoted: string[];
|
|
83
99
|
} | {
|
|
84
100
|
ok: false;
|
|
85
101
|
error: string;
|
|
86
102
|
};
|
|
103
|
+
/**
|
|
104
|
+
* PURE: enforce the single-active invariant on an already-valid list. The
|
|
105
|
+
* model occasionally marks several steps in_progress at once (parallel
|
|
106
|
+
* sub-parts of one block, recorded 4-at-a-time in real sessions); tools
|
|
107
|
+
* execute sequentially so only one can be truthfully "being worked on".
|
|
108
|
+
* Keep the FIRST in list order, demote the rest to pending — quieter than
|
|
109
|
+
* rejecting the write, and the model self-corrects on the next pass since
|
|
110
|
+
* the result echoes the normalized list.
|
|
111
|
+
*/
|
|
112
|
+
export declare function clampSingleInProgress(todos: TodoItem[]): {
|
|
113
|
+
todos: TodoItem[];
|
|
114
|
+
demoted: string[];
|
|
115
|
+
};
|
|
87
116
|
export declare function todoSummary(todos: TodoItem[]): {
|
|
88
117
|
done: number;
|
|
89
118
|
total: number;
|
|
@@ -130,9 +159,26 @@ export declare function formatTodoOverflow(hidden: TodoItem[]): string | null;
|
|
|
130
159
|
* The in-progress row shows the active form in bold (the live "what am I
|
|
131
160
|
* doing" signal); pending and completed rows show the imperative content.
|
|
132
161
|
*/
|
|
133
|
-
export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme, completedAtCache?: Map<string, number>, nowMs?: number
|
|
162
|
+
export declare function renderTodoWidget(todos: TodoItem[], theme: TodoTheme, completedAtCache?: Map<string, number>, nowMs?: number, opts?: {
|
|
163
|
+
idle?: boolean;
|
|
164
|
+
startedAt?: (content: string) => number | undefined;
|
|
165
|
+
}): string[];
|
|
134
166
|
/** The desktop state record: exactly one JSON line under TODO_STATE_KEY. */
|
|
135
167
|
export declare function todoStateLine(todos: TodoItem[]): string;
|
|
168
|
+
/**
|
|
169
|
+
* PURE: render an in-progress step's age as a dim suffix, empty while the
|
|
170
|
+
* step is younger than {@link TODO_STALE_MS}. `47m` under an hour, `1h 47m`
|
|
171
|
+
* after — the "is it stuck?" signal the board owes the user at a glance.
|
|
172
|
+
*/
|
|
173
|
+
export declare function formatAgeMs(ms: number): string;
|
|
174
|
+
/**
|
|
175
|
+
* PURE: the oldest in-progress step past the staleness threshold, if any —
|
|
176
|
+
* the concrete anchor the aged reminder names instead of the generic nudge.
|
|
177
|
+
*/
|
|
178
|
+
export declare function oldestInProgress(todos: TodoItem[], startedAt: ((content: string) => number | undefined) | undefined, nowMs: number): {
|
|
179
|
+
todo: TodoItem;
|
|
180
|
+
age: string;
|
|
181
|
+
} | null;
|
|
136
182
|
/**
|
|
137
183
|
* PURE: is a staleness reminder due? Only when the board has open work (an
|
|
138
184
|
* empty or fully-completed list never nags) and BOTH throttle counters have
|
|
@@ -149,7 +195,20 @@ export declare function shouldRemindTodos(input: {
|
|
|
149
195
|
* read, and explicitly licenses ignoring it, so an accurate board costs one
|
|
150
196
|
* glance rather than a spurious TodoWrite.
|
|
151
197
|
*/
|
|
152
|
-
export declare function formatTodoReminder(todos: TodoItem[]
|
|
198
|
+
export declare function formatTodoReminder(todos: TodoItem[], opts?: {
|
|
199
|
+
startedAt?: (content: string) => number | undefined;
|
|
200
|
+
nowMs?: number;
|
|
201
|
+
}): string;
|
|
202
|
+
/**
|
|
203
|
+
* PURE: reconcile the two timestamp caches against the next board. Both
|
|
204
|
+
* share the eviction contract — a stamp leaves when its content leaves the
|
|
205
|
+
* board — but startedAt is stricter: a step that stops being in_progress
|
|
206
|
+
* (completed, or demoted to pending by the single-active clamp) drops its
|
|
207
|
+
* stamp, so a later re-activation counts as a NEW active span. Otherwise an
|
|
208
|
+
* in_progress → pending → in_progress gap would bill the idle time between
|
|
209
|
+
* spans to the second one's age.
|
|
210
|
+
*/
|
|
211
|
+
export declare function observeTimestamps(next: TodoItem[], completedAt: Map<string, number>, startedAt: Map<string, number>, now: number): void;
|
|
153
212
|
/** Replay the branch: the last todo-tool result is the canonical list. */
|
|
154
213
|
export declare function reconstructTodos(entries: unknown[]): TodoItem[];
|
|
155
214
|
type TodoParams = {
|
package/dist/extension/todos.js
CHANGED
|
@@ -56,6 +56,19 @@ export const TODO_REMINDER_TURNS = 10;
|
|
|
56
56
|
* dropping behind open work — Claude Code's TaskList RECENT_COMPLETED_TTL.
|
|
57
57
|
*/
|
|
58
58
|
export const TODO_COMPLETED_LINGER_MS = 30_000;
|
|
59
|
+
/**
|
|
60
|
+
* Age at which an in-progress step is treated as possibly stale: the aged
|
|
61
|
+
* reminder names the item and its age instead of the generic nudge, and the
|
|
62
|
+
* widget row picks up a dim `(23m)` suffix. Below this the board reads as
|
|
63
|
+
* normal active work.
|
|
64
|
+
*/
|
|
65
|
+
export const TODO_STALE_MS = 10 * 60 * 1000;
|
|
66
|
+
/**
|
|
67
|
+
* The board repaints at this cadence while an active step can age, so the
|
|
68
|
+
* `(23m)` suffix stays live. Under the staleness threshold the suffix is
|
|
69
|
+
* hidden entirely, so a slow tick costs nothing.
|
|
70
|
+
*/
|
|
71
|
+
export const TODO_REPAINT_MS = 30_000;
|
|
59
72
|
const WIDGET_KEY = "yagni-todos";
|
|
60
73
|
/**
|
|
61
74
|
* The desktop's structured state record rides its own widget key, like the
|
|
@@ -92,7 +105,9 @@ function coerceItem(raw) {
|
|
|
92
105
|
* Validate a full replacement list. Strict: this is model input rendered
|
|
93
106
|
* straight into the terminal. An empty list is valid (it clears the board).
|
|
94
107
|
* Accepts both the current shape ({content, activeForm}) and the legacy
|
|
95
|
-
* {text} shape so old sessions replay cleanly.
|
|
108
|
+
* {text} shape so old sessions replay cleanly. The single-active clamp runs
|
|
109
|
+
* here so every path (tool writes, branch replay, legacy sessions) enforces
|
|
110
|
+
* it; `demoted` reports what the clamp changed so callers can tell the model.
|
|
96
111
|
*/
|
|
97
112
|
export function normalizeTodos(raw) {
|
|
98
113
|
if (!Array.isArray(raw))
|
|
@@ -108,7 +123,32 @@ export function normalizeTodos(raw) {
|
|
|
108
123
|
return { ok: false, error: coerced.error };
|
|
109
124
|
todos.push(coerced);
|
|
110
125
|
}
|
|
111
|
-
|
|
126
|
+
const clamped = clampSingleInProgress(todos);
|
|
127
|
+
return { ok: true, todos: clamped.todos, demoted: clamped.demoted };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* PURE: enforce the single-active invariant on an already-valid list. The
|
|
131
|
+
* model occasionally marks several steps in_progress at once (parallel
|
|
132
|
+
* sub-parts of one block, recorded 4-at-a-time in real sessions); tools
|
|
133
|
+
* execute sequentially so only one can be truthfully "being worked on".
|
|
134
|
+
* Keep the FIRST in list order, demote the rest to pending — quieter than
|
|
135
|
+
* rejecting the write, and the model self-corrects on the next pass since
|
|
136
|
+
* the result echoes the normalized list.
|
|
137
|
+
*/
|
|
138
|
+
export function clampSingleInProgress(todos) {
|
|
139
|
+
const demoted = [];
|
|
140
|
+
let keptActive = false;
|
|
141
|
+
const next = todos.map((t) => {
|
|
142
|
+
if (t.status !== "in_progress")
|
|
143
|
+
return t;
|
|
144
|
+
if (keptActive) {
|
|
145
|
+
demoted.push(t.content);
|
|
146
|
+
return { ...t, status: "pending" };
|
|
147
|
+
}
|
|
148
|
+
keptActive = true;
|
|
149
|
+
return t;
|
|
150
|
+
});
|
|
151
|
+
return demoted.length > 0 ? { todos: next, demoted } : { todos, demoted };
|
|
112
152
|
}
|
|
113
153
|
export function todoSummary(todos) {
|
|
114
154
|
return {
|
|
@@ -191,7 +231,8 @@ export function formatTodoOverflow(hidden) {
|
|
|
191
231
|
* The in-progress row shows the active form in bold (the live "what am I
|
|
192
232
|
* doing" signal); pending and completed rows show the imperative content.
|
|
193
233
|
*/
|
|
194
|
-
export function renderTodoWidget(todos, theme, completedAtCache = new Map(), nowMs = Date.now()) {
|
|
234
|
+
export function renderTodoWidget(todos, theme, completedAtCache = new Map(), nowMs = Date.now(), opts = {}) {
|
|
235
|
+
const { idle, startedAt } = opts;
|
|
195
236
|
const { total, done } = todoCounts(todos);
|
|
196
237
|
if (total === 0 || done === total)
|
|
197
238
|
return [];
|
|
@@ -204,8 +245,16 @@ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), now
|
|
|
204
245
|
lines.push(`${theme.fg("success", "✔ ")}${theme.fg("dim", text)}`);
|
|
205
246
|
}
|
|
206
247
|
else if (todo.status === "in_progress") {
|
|
207
|
-
const
|
|
208
|
-
|
|
248
|
+
const age = staleAgeSuffix(todo, startedAt, nowMs);
|
|
249
|
+
if (idle) {
|
|
250
|
+
// An idle agent has no "actively doing" claim; keep the row but
|
|
251
|
+
// drop the live-work styling so the board stops pretending.
|
|
252
|
+
lines.push(theme.fg("dim", `◼ ${todo.activeForm}…${age}`));
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
const active = theme.bold ? theme.bold(`${todo.activeForm}…`) : `${todo.activeForm}…`;
|
|
256
|
+
lines.push(`${theme.fg("accent", "◼ ")}${theme.fg("text", active)}${theme.fg("dim", age)}`);
|
|
257
|
+
}
|
|
209
258
|
}
|
|
210
259
|
else {
|
|
211
260
|
lines.push(`${theme.fg("dim", "◻ ")}${theme.fg("muted", todo.content)}`);
|
|
@@ -220,6 +269,50 @@ export function renderTodoWidget(todos, theme, completedAtCache = new Map(), now
|
|
|
220
269
|
export function todoStateLine(todos) {
|
|
221
270
|
return JSON.stringify({ v: TODO_STATE_VERSION, todos });
|
|
222
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* PURE: render an in-progress step's age as a dim suffix, empty while the
|
|
274
|
+
* step is younger than {@link TODO_STALE_MS}. `47m` under an hour, `1h 47m`
|
|
275
|
+
* after — the "is it stuck?" signal the board owes the user at a glance.
|
|
276
|
+
*/
|
|
277
|
+
export function formatAgeMs(ms) {
|
|
278
|
+
const totalMinutes = Math.floor(ms / 60_000);
|
|
279
|
+
if (totalMinutes < 1)
|
|
280
|
+
return "";
|
|
281
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
282
|
+
const minutes = totalMinutes % 60;
|
|
283
|
+
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
|
|
284
|
+
}
|
|
285
|
+
function staleAgeSuffix(todo, startedAt, nowMs) {
|
|
286
|
+
const at = startedAt?.(todo.content);
|
|
287
|
+
if (at === undefined)
|
|
288
|
+
return "";
|
|
289
|
+
const age = nowMs - at;
|
|
290
|
+
if (age < TODO_STALE_MS)
|
|
291
|
+
return "";
|
|
292
|
+
const text = formatAgeMs(age);
|
|
293
|
+
return text ? ` (${text})` : "";
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* PURE: the oldest in-progress step past the staleness threshold, if any —
|
|
297
|
+
* the concrete anchor the aged reminder names instead of the generic nudge.
|
|
298
|
+
*/
|
|
299
|
+
export function oldestInProgress(todos, startedAt, nowMs) {
|
|
300
|
+
let worst = null;
|
|
301
|
+
for (const t of todos) {
|
|
302
|
+
if (t.status !== "in_progress")
|
|
303
|
+
continue;
|
|
304
|
+
const at = startedAt?.(t.content);
|
|
305
|
+
if (at === undefined)
|
|
306
|
+
continue;
|
|
307
|
+
const ageMs = nowMs - at;
|
|
308
|
+
if (ageMs >= TODO_STALE_MS && (!worst || ageMs > worst.ageMs))
|
|
309
|
+
worst = { todo: t, ageMs };
|
|
310
|
+
}
|
|
311
|
+
if (!worst)
|
|
312
|
+
return null;
|
|
313
|
+
const age = formatAgeMs(worst.ageMs);
|
|
314
|
+
return age ? { todo: worst.todo, age } : null;
|
|
315
|
+
}
|
|
223
316
|
/**
|
|
224
317
|
* PURE: is a staleness reminder due? Only when the board has open work (an
|
|
225
318
|
* empty or fully-completed list never nags) and BOTH throttle counters have
|
|
@@ -240,13 +333,54 @@ export function shouldRemindTodos(input) {
|
|
|
240
333
|
* read, and explicitly licenses ignoring it, so an accurate board costs one
|
|
241
334
|
* glance rather than a spurious TodoWrite.
|
|
242
335
|
*/
|
|
243
|
-
export function formatTodoReminder(todos) {
|
|
336
|
+
export function formatTodoReminder(todos, opts = {}) {
|
|
337
|
+
const stale = oldestInProgress(todos, opts.startedAt, opts.nowMs ?? Date.now());
|
|
338
|
+
if (stale) {
|
|
339
|
+
// A concrete, named accusation with a number on it — the generic nudge
|
|
340
|
+
// lost 20 times in a row to a busy context in the recorded session that
|
|
341
|
+
// motivated this.
|
|
342
|
+
return (`⟦YAGNI todos⟧ "${stale.todo.content}" has been in_progress for ${stale.age} — if it is ` +
|
|
343
|
+
"done or superseded, mark it completed or remove it; if you are still working on it, " +
|
|
344
|
+
"ignore this and keep the board current as you go.\n" +
|
|
345
|
+
formatTodoList(todos));
|
|
346
|
+
}
|
|
244
347
|
return ("⟦YAGNI todos⟧ The TodoWrite checklist has not been updated for a while. " +
|
|
245
348
|
"If the work has moved on, bring it current now: mark finished steps completed, " +
|
|
246
|
-
"set the step you are on to in_progress, and add newly discovered steps. " +
|
|
349
|
+
"set the step you are on to in_progress, and add newly discovered follow-up steps. " +
|
|
247
350
|
"If the list is already accurate, ignore this.\n" +
|
|
248
351
|
formatTodoList(todos));
|
|
249
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* PURE: reconcile the two timestamp caches against the next board. Both
|
|
355
|
+
* share the eviction contract — a stamp leaves when its content leaves the
|
|
356
|
+
* board — but startedAt is stricter: a step that stops being in_progress
|
|
357
|
+
* (completed, or demoted to pending by the single-active clamp) drops its
|
|
358
|
+
* stamp, so a later re-activation counts as a NEW active span. Otherwise an
|
|
359
|
+
* in_progress → pending → in_progress gap would bill the idle time between
|
|
360
|
+
* spans to the second one's age.
|
|
361
|
+
*/
|
|
362
|
+
export function observeTimestamps(next, completedAt, startedAt, now) {
|
|
363
|
+
const seen = new Set(next.map((t) => t.content));
|
|
364
|
+
for (const [content] of completedAt) {
|
|
365
|
+
if (!seen.has(content))
|
|
366
|
+
completedAt.delete(content);
|
|
367
|
+
}
|
|
368
|
+
for (const [content] of startedAt) {
|
|
369
|
+
if (!seen.has(content))
|
|
370
|
+
startedAt.delete(content);
|
|
371
|
+
}
|
|
372
|
+
const activeNow = new Set(next.filter((t) => t.status === "in_progress").map((t) => t.content));
|
|
373
|
+
for (const [content] of startedAt) {
|
|
374
|
+
if (!activeNow.has(content))
|
|
375
|
+
startedAt.delete(content);
|
|
376
|
+
}
|
|
377
|
+
for (const t of next) {
|
|
378
|
+
if (t.status === "completed" && !completedAt.has(t.content))
|
|
379
|
+
completedAt.set(t.content, now);
|
|
380
|
+
if (t.status === "in_progress" && !startedAt.has(t.content))
|
|
381
|
+
startedAt.set(t.content, now);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
250
384
|
/** Replay the branch: the last todo-tool result is the canonical list. */
|
|
251
385
|
export function reconstructTodos(entries) {
|
|
252
386
|
let todos = [];
|
|
@@ -303,7 +437,7 @@ function todoRenderers() {
|
|
|
303
437
|
},
|
|
304
438
|
};
|
|
305
439
|
}
|
|
306
|
-
function paintWidget(ctx, todos, completedAt) {
|
|
440
|
+
function paintWidget(ctx, todos, completedAt, opts = {}) {
|
|
307
441
|
if (!ctx?.hasUI)
|
|
308
442
|
return;
|
|
309
443
|
try {
|
|
@@ -318,7 +452,10 @@ function paintWidget(ctx, todos, completedAt) {
|
|
|
318
452
|
return;
|
|
319
453
|
}
|
|
320
454
|
const theme = ctx.ui.theme;
|
|
321
|
-
const lines = renderTodoWidget(todos, theme, completedAt)
|
|
455
|
+
const lines = renderTodoWidget(todos, theme, completedAt, Date.now(), {
|
|
456
|
+
idle: opts.idle,
|
|
457
|
+
startedAt: opts.startedAt ? (c) => opts.startedAt.get(c) : undefined,
|
|
458
|
+
});
|
|
322
459
|
ctx.ui.setWidget?.(WIDGET_KEY, lines.length > 0 ? lines : undefined, {
|
|
323
460
|
placement: "aboveEditor",
|
|
324
461
|
});
|
|
@@ -373,10 +510,24 @@ export function makeTodoTool(get, set, completedAt) {
|
|
|
373
510
|
// error.
|
|
374
511
|
throw new Error(`Error: ${normalized.error}`);
|
|
375
512
|
}
|
|
513
|
+
if (normalized.demoted.length > 0) {
|
|
514
|
+
logEvent({
|
|
515
|
+
source: "todos",
|
|
516
|
+
level: "info",
|
|
517
|
+
event: "multi_in_progress_clamped",
|
|
518
|
+
fields: { demoted: normalized.demoted, count: normalized.demoted.length },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
376
521
|
set(normalized.todos);
|
|
377
522
|
paintWidget(ctx, normalized.todos, completedAt);
|
|
523
|
+
const note = normalized.demoted.length > 0
|
|
524
|
+
? `Normalized: kept "${normalized.todos.find((t) => t.status === "in_progress")?.content}" ` +
|
|
525
|
+
`as the single in_progress task; demoted ${normalized.demoted
|
|
526
|
+
.map((c) => `"${c}"`)
|
|
527
|
+
.join(", ")} to pending.\n\n`
|
|
528
|
+
: "";
|
|
378
529
|
return {
|
|
379
|
-
content: [{ type: "text", text: `${formatTodoList(normalized.todos)}\n\n${TODO_RESULT_ECHO}` }],
|
|
530
|
+
content: [{ type: "text", text: `${formatTodoList(normalized.todos)}\n\n${note}${TODO_RESULT_ECHO}` }],
|
|
380
531
|
details: { todos: normalized.todos },
|
|
381
532
|
};
|
|
382
533
|
},
|
|
@@ -397,18 +548,49 @@ export function registerTodos(pi) {
|
|
|
397
548
|
// passes), re-floating it every few minutes. prioritizeTodos already treats
|
|
398
549
|
// an aged-out stamp as "older"; the cache never needs a sweeper.
|
|
399
550
|
const completedAt = new Map();
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
551
|
+
// When each open step entered in_progress — the aged reminder and the
|
|
552
|
+
// widget's `(23m)` suffix key off this. Same eviction contract as
|
|
553
|
+
// completedAt: entries leave when the content leaves the board; a
|
|
554
|
+
// re-entering item gets a fresh stamp (a restart of the same work is
|
|
555
|
+
// genuinely a new active span).
|
|
556
|
+
const startedAt = new Map();
|
|
557
|
+
// True while the agent is NOT running (between turns, awaiting input) —
|
|
558
|
+
// an idle board must not style its active row as live work.
|
|
559
|
+
let idle = true;
|
|
560
|
+
let repaintTimer;
|
|
561
|
+
const hasActive = () => todos.some((t) => t.status === "in_progress");
|
|
562
|
+
// Repaint while an active step can age: the (23m) suffix and idle styling
|
|
563
|
+
// would otherwise freeze at the last write. TUI only (desktop gets a
|
|
564
|
+
// paint on every state change and renders its own board); unref'd so a
|
|
565
|
+
// batched test process never lingers on it, and torn down whenever the
|
|
566
|
+
// board empties or the session restarts.
|
|
567
|
+
const ensureRepaintTimer = (ctx) => {
|
|
568
|
+
if (!ctx.hasUI || isDesktopSurface() || repaintTimer || !hasActive())
|
|
569
|
+
return;
|
|
570
|
+
repaintTimer = setInterval(() => {
|
|
571
|
+
// hasUI is captured at registration; ctx here is the tool/refresh ctx.
|
|
572
|
+
paintWidget(latestCtx ?? undefined, todos, completedAt, { idle, startedAt });
|
|
573
|
+
}, TODO_REPAINT_MS);
|
|
574
|
+
repaintTimer.unref?.();
|
|
575
|
+
};
|
|
576
|
+
const clearRepaintTimer = () => {
|
|
577
|
+
if (repaintTimer) {
|
|
578
|
+
clearInterval(repaintTimer);
|
|
579
|
+
repaintTimer = undefined;
|
|
409
580
|
}
|
|
410
581
|
};
|
|
582
|
+
let latestCtx;
|
|
583
|
+
const rememberCtx = (ctx) => {
|
|
584
|
+
latestCtx = ctx;
|
|
585
|
+
};
|
|
586
|
+
const observe = (next, now = Date.now()) => {
|
|
587
|
+
observeTimestamps(next, completedAt, startedAt, now);
|
|
588
|
+
};
|
|
411
589
|
const reconstruct = (ctx) => {
|
|
590
|
+
// Clear-then-ensure: a session switch/fork must never inherit the
|
|
591
|
+
// previous session's repaint interval — an empty replayed board would
|
|
592
|
+
// otherwise leave the old 30s timer firing at a dead pane forever.
|
|
593
|
+
clearRepaintTimer();
|
|
412
594
|
try {
|
|
413
595
|
todos = reconstructTodos(ctx.sessionManager.getBranch());
|
|
414
596
|
}
|
|
@@ -427,17 +609,48 @@ export function registerTodos(pi) {
|
|
|
427
609
|
// completed items from history rank as "older" (outside the linger
|
|
428
610
|
// window), exactly like a live item whose tick has aged out.
|
|
429
611
|
completedAt.clear();
|
|
612
|
+
startedAt.clear();
|
|
430
613
|
for (const t of todos) {
|
|
431
614
|
if (t.status === "completed") {
|
|
432
615
|
completedAt.set(t.content, Date.now() - TODO_COMPLETED_LINGER_MS - 1);
|
|
433
616
|
}
|
|
617
|
+
if (t.status === "in_progress") {
|
|
618
|
+
// Age counts from resume — the true start time is unknowable
|
|
619
|
+
// post-hoc, and stamping fresh keeps the suffix from instantly
|
|
620
|
+
// showing a fabricated age.
|
|
621
|
+
startedAt.set(t.content, Date.now());
|
|
622
|
+
}
|
|
434
623
|
}
|
|
435
624
|
turnsSinceWrite = 0;
|
|
436
625
|
turnsSinceReminder = 0;
|
|
437
|
-
|
|
626
|
+
rememberCtx(ctx);
|
|
627
|
+
idle = true;
|
|
628
|
+
paintWidget(ctx, todos, completedAt, { idle, startedAt });
|
|
629
|
+
if (hasActive())
|
|
630
|
+
ensureRepaintTimer(ctx);
|
|
438
631
|
};
|
|
439
632
|
pi.on("session_start", async (_event, ctx) => reconstruct(ctx));
|
|
440
633
|
pi.on("session_tree", async (_event, ctx) => reconstruct(ctx));
|
|
634
|
+
// The live-work signal: an idle agent's active row dims. Fires on every
|
|
635
|
+
// agent loop, cheap on both sides, and repaints immediately so the dim
|
|
636
|
+
// lands without waiting for the next paint-on-write.
|
|
637
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
638
|
+
idle = false;
|
|
639
|
+
rememberCtx(ctx);
|
|
640
|
+
if (hasActive())
|
|
641
|
+
ensureRepaintTimer(ctx);
|
|
642
|
+
paintWidget(ctx, todos, completedAt, { idle, startedAt });
|
|
643
|
+
});
|
|
644
|
+
// Idle keeps the timer alive on purpose: the aging suffix on a dimmed row
|
|
645
|
+
// is exactly the "is it stuck?" signal the user watches while the agent
|
|
646
|
+
// waits for input. The timer dies when the board empties, not here.
|
|
647
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
648
|
+
idle = true;
|
|
649
|
+
rememberCtx(ctx);
|
|
650
|
+
if (hasActive())
|
|
651
|
+
ensureRepaintTimer(ctx);
|
|
652
|
+
paintWidget(ctx, todos, completedAt, { idle, startedAt });
|
|
653
|
+
});
|
|
441
654
|
// Turn counting: one tick per finalized assistant message, the same "turn"
|
|
442
655
|
// the model experiences between opportunities to call TodoWrite.
|
|
443
656
|
pi.on("message_end", async (event) => {
|
|
@@ -460,7 +673,10 @@ export function registerTodos(pi) {
|
|
|
460
673
|
return {
|
|
461
674
|
content: [
|
|
462
675
|
...event.content,
|
|
463
|
-
{
|
|
676
|
+
{
|
|
677
|
+
type: "text",
|
|
678
|
+
text: `\n\n${formatTodoReminder(todos, { startedAt: (c) => startedAt.get(c) })}`,
|
|
679
|
+
},
|
|
464
680
|
],
|
|
465
681
|
};
|
|
466
682
|
}
|
|
@@ -472,6 +688,18 @@ export function registerTodos(pi) {
|
|
|
472
688
|
todos = next;
|
|
473
689
|
observe(next);
|
|
474
690
|
turnsSinceWrite = 0;
|
|
691
|
+
if (!hasActive())
|
|
692
|
+
clearRepaintTimer();
|
|
693
|
+
// Repaint here with the full live opts (idle state, startedAt ages)
|
|
694
|
+
// — the tool's own paint is ctx-bound but opts-less, so the age
|
|
695
|
+
// suffix and idle dimming land through this pass. Also (re)arm the
|
|
696
|
+
// repaint timer: a write that introduces the first active step
|
|
697
|
+
// shouldn't wait for the next agent event to start ticking.
|
|
698
|
+
if (latestCtx) {
|
|
699
|
+
paintWidget(latestCtx, todos, completedAt, { idle, startedAt });
|
|
700
|
+
if (hasActive())
|
|
701
|
+
ensureRepaintTimer(latestCtx);
|
|
702
|
+
}
|
|
475
703
|
}, completedAt));
|
|
476
704
|
pi.registerCommand("todos", {
|
|
477
705
|
description: "Show the agent's current task list for this session.",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.3-staging.
|
|
3
|
+
"version": "1.1.3-staging.1378.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "cc4bcf38093fc70fc51ed4b712777566307a1e72"
|
|
62
62
|
}
|