@fyeeme/pi-todo 1.0.0 → 1.0.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/CHANGELOG.md +20 -1
- package/README.md +11 -12
- package/index.ts +12 -156
- package/package.json +2 -2
- package/src/commands.ts +34 -84
- package/src/prompts/todo.md +2 -20
- package/src/render.ts +26 -31
- package/src/restore.ts +0 -3
- package/src/state.ts +76 -190
- package/src/tool.ts +47 -18
- package/test/omp-alignment.test.ts +20 -79
- package/test/render.test.ts +4 -2
- package/test/state.test.ts +47 -48
- package/test/tool.test.ts +58 -91
package/src/tool.ts
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
* AgentTool class + ToolSession → ToolDefinition + closure deps
|
|
10
10
|
* concurrency: "exclusive" → executionMode: "sequential"
|
|
11
11
|
* session getTodoPhases/setTodoPhases → getPhases/setPhases/persist/broadcast
|
|
12
|
+
* eager-todo promptGuidelines → dropped (cognitive-neutral notepad: the
|
|
13
|
+
* tool description documents the mechanics; the system prompt is not
|
|
14
|
+
* steered)
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
17
|
import { readFileSync } from "node:fs";
|
|
@@ -22,7 +25,7 @@ import {
|
|
|
22
25
|
applyParams,
|
|
23
26
|
clonePhases,
|
|
24
27
|
formatSummary,
|
|
25
|
-
|
|
28
|
+
getStatusTransitions,
|
|
26
29
|
inferTodoOp,
|
|
27
30
|
type TodoOperation,
|
|
28
31
|
type TodoOpEntry,
|
|
@@ -66,13 +69,6 @@ export const TodoParamsSchema = Type.Object({
|
|
|
66
69
|
|
|
67
70
|
export type TodoParams = Static<typeof TodoParamsSchema>;
|
|
68
71
|
|
|
69
|
-
/** Advisory nudge appended to the default system prompt while the tool is active. */
|
|
70
|
-
export const TODO_PROMPT_GUIDELINES = [
|
|
71
|
-
"Consider calling the todo tool first to lay out a phased plan with a single `init` op. A good list covers the whole request — investigation through implementation and verification — not just the next step, with specific task descriptions a future turn could execute without re-planning.",
|
|
72
|
-
"A useful list keeps each task to a concise, specific 5-10 word label; the `init` op only accepts phase names and task-label strings, so don't invent extra task metadata fields.",
|
|
73
|
-
"If you create the list, continue the request in the same turn and avoid re-calling the todo tool unless task state materially changes.",
|
|
74
|
-
];
|
|
75
|
-
|
|
76
72
|
export interface TodoToolDeps {
|
|
77
73
|
/** Current in-memory phases (already cloned on write). */
|
|
78
74
|
getPhases(): TodoPhase[];
|
|
@@ -84,16 +80,23 @@ export interface TodoToolDeps {
|
|
|
84
80
|
broadcast(phases: TodoPhase[]): void;
|
|
85
81
|
}
|
|
86
82
|
|
|
83
|
+
/** The one write-back invariant: replace state, persist, broadcast — in that
|
|
84
|
+
* order. Shared by the tool path and the /todo command path so the two can
|
|
85
|
+
* never drift (persist/broadcast semantics live in index.ts's deps). */
|
|
86
|
+
export function commitPhases(deps: TodoToolDeps, next: TodoPhase[]): void {
|
|
87
|
+
deps.setPhases(next);
|
|
88
|
+
deps.persist(next);
|
|
89
|
+
deps.broadcast(next);
|
|
90
|
+
}
|
|
91
|
+
|
|
87
92
|
export function createTodoTool(deps: TodoToolDeps): ToolDefinition<typeof TodoParamsSchema, TodoToolDetails> {
|
|
88
93
|
const definition: ToolDefinition<typeof TodoParamsSchema, TodoToolDetails> = {
|
|
89
94
|
name: "todo",
|
|
90
95
|
label: "Todo",
|
|
91
96
|
description: todoDescription,
|
|
92
|
-
promptSnippet: "Write a structured todo list to track progress within a session",
|
|
93
|
-
promptGuidelines: TODO_PROMPT_GUIDELINES,
|
|
94
97
|
parameters: TodoParamsSchema,
|
|
95
98
|
executionMode: "sequential",
|
|
96
|
-
renderCall:
|
|
99
|
+
renderCall: renderTodoCall,
|
|
97
100
|
// omp's renderer folds call args into the result view (touched-phase
|
|
98
101
|
// diffing); pi passes them through the render context.
|
|
99
102
|
renderResult: (result, options, theme, context) => renderTodoResult(result, options, theme, context?.args),
|
|
@@ -118,7 +121,7 @@ export function createTodoTool(deps: TodoToolDeps): ToolDefinition<typeof TodoPa
|
|
|
118
121
|
_onUpdate: undefined,
|
|
119
122
|
_ctx: ExtensionContext,
|
|
120
123
|
): Promise<AgentToolResult<TodoToolDetails>> {
|
|
121
|
-
const previousPhases =
|
|
124
|
+
const previousPhases = deps.getPhases();
|
|
122
125
|
const op: TodoOperation = params.op;
|
|
123
126
|
// Pure-view calls are reads: no normalization, no state write.
|
|
124
127
|
const readOnly = op === "view";
|
|
@@ -130,21 +133,47 @@ export function createTodoTool(deps: TodoToolDeps): ToolDefinition<typeof TodoPa
|
|
|
130
133
|
// the ops that did land. State stays at previous. pi's tool contract
|
|
131
134
|
// is throw-on-failure (no isError field on AgentToolResult), and the
|
|
132
135
|
// thrown message carries the errors plus the unchanged list so the
|
|
133
|
-
// model can retry with correct content.
|
|
136
|
+
// model can retry with correct content. formatSummary does not fold
|
|
137
|
+
// error results (the fold is gated on empty errors), so the full list
|
|
138
|
+
// is present even on big lists.
|
|
134
139
|
if (errors.length > 0) {
|
|
135
140
|
throw new Error(formatSummary(previousPhases, errors, readOnly));
|
|
136
141
|
}
|
|
137
142
|
if (!readOnly) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
143
|
+
// `updated` is a fresh clone nothing else references: hand ownership
|
|
144
|
+
// to the closure, and let persist/broadcast make their own
|
|
145
|
+
// entry-shaped snapshots (clonePhases inside index.ts).
|
|
146
|
+
commitPhases(deps, updated);
|
|
141
147
|
}
|
|
142
|
-
|
|
148
|
+
// Per-write confirmation, independent of summary folding: on big lists
|
|
149
|
+
// the folded summary may not show the operated tasks at all, so the
|
|
150
|
+
// model needs an explicit record of what changed (status + blocker note).
|
|
151
|
+
const transitions = readOnly ? [] : getStatusTransitions(previousPhases, updated);
|
|
143
152
|
const details: TodoToolDetails = { op, phases: clonePhases(updated), storage: "session" };
|
|
153
|
+
const completedTasks = transitions
|
|
154
|
+
.filter(transition => transition.to === "completed")
|
|
155
|
+
.map(({ phase, content }) => ({ phase, content }));
|
|
144
156
|
if (completedTasks.length > 0) details.completedTasks = completedTasks;
|
|
145
157
|
|
|
158
|
+
let text = formatSummary(updated, [], readOnly);
|
|
159
|
+
if (transitions.length > 0) {
|
|
160
|
+
const changed = transitions
|
|
161
|
+
.map(transition => {
|
|
162
|
+
const blocker =
|
|
163
|
+
transition.to === "blocked"
|
|
164
|
+
? updated
|
|
165
|
+
.find(phase => phase.name === transition.phase)
|
|
166
|
+
?.tasks.find(task => task.content === transition.content)?.blocker
|
|
167
|
+
: undefined;
|
|
168
|
+
const note = blocker ? ` (blocked: ${blocker})` : "";
|
|
169
|
+
return ` - ${transition.content} [${transition.from} → ${transition.to}]${note} (${transition.phase})`;
|
|
170
|
+
})
|
|
171
|
+
.join("\n");
|
|
172
|
+
text = `Changed:\n${changed}\n\n${text}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
146
175
|
return {
|
|
147
|
-
content: [{ type: "text", text
|
|
176
|
+
content: [{ type: "text", text }],
|
|
148
177
|
details,
|
|
149
178
|
};
|
|
150
179
|
},
|
|
@@ -11,31 +11,33 @@
|
|
|
11
11
|
* MarkdownPath | escaped brackets, errors)
|
|
12
12
|
* tools/todo.ts phaseRomanNumeral | algorithmic roman numerals
|
|
13
13
|
* tools/todo.ts selectCollapsedTodos / | collapsed walking viewport
|
|
14
|
-
* selectWithinCap
|
|
15
|
-
* Description |
|
|
14
|
+
* selectWithinCap |
|
|
16
15
|
* tools/todo.ts formatSummary | exact summary text
|
|
17
16
|
* tools/todo.ts formatMoreItems / pluralize | ellipsis summaries
|
|
18
|
-
* session/todo-tracker.ts checkCompletion / | reminder text, attempt
|
|
19
|
-
* isAwaitingUserAnswer | cycle, question guard
|
|
20
17
|
* modes/controllers/todo-command-controller.ts | /todo verb surface, fuzzy
|
|
21
|
-
* | matching
|
|
18
|
+
* | matching
|
|
22
19
|
*
|
|
23
|
-
*
|
|
20
|
+
* Expected strings are copied verbatim from the omp sources listed, except
|
|
21
|
+
* where the deviations note below applies.
|
|
22
|
+
*
|
|
23
|
+
* Deliberate omp deviations NOT pinned here (cognitive-neutral refactor):
|
|
24
|
+
* normalizeInProgressTask / auto-promotion, stop-reminder machinery, and the
|
|
25
|
+
* subagent description matcher were removed; formatSummary's worked-ahead
|
|
26
|
+
* wording and the >20-task fold with its `view` hints are pi-todo deviations
|
|
27
|
+
* from omp (assertions below pin the pi-todo wording, not omp's). See
|
|
28
|
+
* src/state.ts header.
|
|
24
29
|
*/
|
|
25
30
|
|
|
26
31
|
import { homedir } from "node:os";
|
|
27
32
|
import { describe, expect, it } from "vitest";
|
|
28
33
|
import {
|
|
29
34
|
formatSummary,
|
|
30
|
-
isAwaitingUserAnswer,
|
|
31
35
|
markdownToPhases,
|
|
32
|
-
normalizeInProgressTask,
|
|
33
36
|
phaseRomanNumeral,
|
|
34
37
|
phasesToMarkdown,
|
|
35
38
|
pluralize,
|
|
36
39
|
resolveTodoMarkdownPath,
|
|
37
40
|
selectCollapsedTodos,
|
|
38
|
-
todoMatchesAnyDescription,
|
|
39
41
|
type TodoItem,
|
|
40
42
|
type TodoPhase,
|
|
41
43
|
} from "../src/state.ts";
|
|
@@ -126,9 +128,9 @@ describe("markdown round-trip matches omp", () => {
|
|
|
126
128
|
]);
|
|
127
129
|
});
|
|
128
130
|
|
|
129
|
-
it("
|
|
131
|
+
it("markdownToPhases parses statuses literally (no normalization)", () => {
|
|
130
132
|
const { phases } = markdownToPhases("- [ ] one\n- [ ] two");
|
|
131
|
-
expect(phases[0]?.tasks[0]?.status).toBe("
|
|
133
|
+
expect(phases[0]?.tasks[0]?.status).toBe("pending");
|
|
132
134
|
expect(phases[0]?.tasks[1]?.status).toBe("pending");
|
|
133
135
|
});
|
|
134
136
|
|
|
@@ -169,11 +171,9 @@ function task(content: string, status: TodoItem["status"]): TodoItem {
|
|
|
169
171
|
}
|
|
170
172
|
|
|
171
173
|
describe("selectCollapsedTodos matches omp", () => {
|
|
172
|
-
const never = () => false;
|
|
173
|
-
|
|
174
174
|
it("returns everything within the cap", () => {
|
|
175
175
|
const tasks = [task("a", "in_progress"), task("b", "pending")];
|
|
176
|
-
const selection = selectCollapsedTodos(tasks,
|
|
176
|
+
const selection = selectCollapsedTodos(tasks, 8);
|
|
177
177
|
expect(selection.items).toEqual(tasks);
|
|
178
178
|
expect(selection.summary).toBe("");
|
|
179
179
|
});
|
|
@@ -191,7 +191,7 @@ describe("selectCollapsedTodos matches omp", () => {
|
|
|
191
191
|
task("g", "pending"),
|
|
192
192
|
task("h", "pending"),
|
|
193
193
|
];
|
|
194
|
-
const selection = selectCollapsedTodos(tasks,
|
|
194
|
+
const selection = selectCollapsedTodos(tasks, 8);
|
|
195
195
|
// Open work = 8 (a..h) ≤ cap; lead adds the LAST closed row additively.
|
|
196
196
|
expect(selection.items.map(t => t.content)).toEqual([
|
|
197
197
|
"closed-new",
|
|
@@ -209,7 +209,7 @@ describe("selectCollapsedTodos matches omp", () => {
|
|
|
209
209
|
|
|
210
210
|
it("counts hidden open work with the omp ellipsis summary", () => {
|
|
211
211
|
const tasks = [task("done", "completed"), ...Array.from({ length: 10 }, (_, i) => task(`t${i}`, "pending"))];
|
|
212
|
-
const selection = selectCollapsedTodos(tasks,
|
|
212
|
+
const selection = selectCollapsedTodos(tasks, 8);
|
|
213
213
|
expect(selection.items.filter(t => t.content === "done")).toHaveLength(1);
|
|
214
214
|
expect(selection.summary).toBe("… 2 more todos");
|
|
215
215
|
});
|
|
@@ -221,54 +221,19 @@ describe("selectCollapsedTodos matches omp", () => {
|
|
|
221
221
|
task("w3", "in_progress"),
|
|
222
222
|
task("p", "pending"),
|
|
223
223
|
];
|
|
224
|
-
const selection = selectCollapsedTodos(tasks,
|
|
224
|
+
const selection = selectCollapsedTodos(tasks, 2);
|
|
225
225
|
expect(selection.items.map(t => t.content)).toEqual(["w1", "w2"]);
|
|
226
226
|
expect(selection.summary).toBe("… 1 more active todo");
|
|
227
227
|
});
|
|
228
228
|
|
|
229
229
|
it("a settled phase selects over its closed tasks", () => {
|
|
230
230
|
const tasks = [task("a", "completed"), task("b", "abandoned"), task("c", "completed")];
|
|
231
|
-
const selection = selectCollapsedTodos(tasks,
|
|
231
|
+
const selection = selectCollapsedTodos(tasks, 8);
|
|
232
232
|
expect(selection.items).toEqual(tasks);
|
|
233
233
|
expect(selection.summary).toBe("");
|
|
234
234
|
});
|
|
235
235
|
});
|
|
236
236
|
|
|
237
|
-
describe("todoMatchesAnyDescription matches omp", () => {
|
|
238
|
-
it("matches normalized equality and ≥6-char substrings both ways", () => {
|
|
239
|
-
expect(todoMatchesAnyDescription("Fix the build", ["fix the build!"])).toBe(true);
|
|
240
|
-
expect(todoMatchesAnyDescription("Sonnet #2: bug scan", ["Sonnet #2"])).toBe(true);
|
|
241
|
-
expect(todoMatchesAnyDescription("review", ["code review phase"])).toBe(true);
|
|
242
|
-
expect(todoMatchesAnyDescription("test", ["testing"])).toBe(false); // <6 overlap
|
|
243
|
-
expect(todoMatchesAnyDescription("", ["anything"])).toBe(false);
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// ---------------------------------------------------------------------------
|
|
248
|
-
// Stop-reminder guards — omp TodoTracker
|
|
249
|
-
// ---------------------------------------------------------------------------
|
|
250
|
-
|
|
251
|
-
describe("isAwaitingUserAnswer matches omp", () => {
|
|
252
|
-
it("detects question endings (word gates + non-ASCII)", () => {
|
|
253
|
-
expect(isAwaitingUserAnswer("Done for now.\nWhich database should I use?")).toBe(true);
|
|
254
|
-
expect(isAwaitingUserAnswer("Should I proceed?")).toBe(true);
|
|
255
|
-
expect(isAwaitingUserAnswer("要继续吗?")).toBe(true); // CJK question
|
|
256
|
-
expect(isAwaitingUserAnswer("Q1: ready?")).toBe(true); // prompt label
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
it("detects response cues", () => {
|
|
260
|
-
expect(isAwaitingUserAnswer("Please confirm the plan.")).toBe(true);
|
|
261
|
-
expect(isAwaitingUserAnswer("Let me know if that works")).toBe(true);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it("ignores prose with incidental question marks", () => {
|
|
265
|
-
// No question word/pronoun/non-ASCII gate fires; omp treats it as prose.
|
|
266
|
-
expect(isAwaitingUserAnswer("Fixed the optional foo?: string handling.")).toBe(false);
|
|
267
|
-
expect(isAwaitingUserAnswer("All checks pass.")).toBe(false);
|
|
268
|
-
expect(isAwaitingUserAnswer("")).toBe(false);
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
|
|
272
237
|
// ---------------------------------------------------------------------------
|
|
273
238
|
// formatSummary — omp tools/todo.ts (tool result body)
|
|
274
239
|
// ---------------------------------------------------------------------------
|
|
@@ -313,14 +278,14 @@ describe("formatSummary matches omp verbatim", () => {
|
|
|
313
278
|
);
|
|
314
279
|
});
|
|
315
280
|
|
|
316
|
-
it("renders the worked-ahead explanation (
|
|
281
|
+
it("renders the worked-ahead explanation (out-of-order completion note)", () => {
|
|
317
282
|
const phases: TodoPhase[] = [
|
|
318
283
|
{ name: "A", tasks: [{ content: "a", status: "pending" }] },
|
|
319
284
|
{ name: "B", tasks: [{ content: "b", status: "completed" }] },
|
|
320
285
|
];
|
|
321
286
|
const out = formatSummary(phases, []);
|
|
322
287
|
expect(out).toContain(
|
|
323
|
-
'Active phase 1/2 "A" (0/1) — earliest phase with open tasks;
|
|
288
|
+
'Active phase 1/2 "A" (0/1) — earliest phase with open tasks; later phases may already hold completed work from out-of-order execution (nothing was un-completed).',
|
|
324
289
|
);
|
|
325
290
|
});
|
|
326
291
|
|
|
@@ -343,27 +308,3 @@ describe("pluralize matches omp", () => {
|
|
|
343
308
|
expect(pluralize("entry", 2)).toBe("entries");
|
|
344
309
|
});
|
|
345
310
|
});
|
|
346
|
-
|
|
347
|
-
// ---------------------------------------------------------------------------
|
|
348
|
-
// normalizeInProgressTask invariant (omp applyParams tail)
|
|
349
|
-
// ---------------------------------------------------------------------------
|
|
350
|
-
|
|
351
|
-
describe("normalizeInProgressTask matches omp", () => {
|
|
352
|
-
it("demotes surplus in-progress tasks", () => {
|
|
353
|
-
const phases: TodoPhase[] = [
|
|
354
|
-
{ name: "A", tasks: [task("a", "in_progress"), task("b", "in_progress")] },
|
|
355
|
-
];
|
|
356
|
-
normalizeInProgressTask(phases);
|
|
357
|
-
expect(phases[0]?.tasks.map(t => t.status)).toEqual(["in_progress", "pending"]);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
it("auto-promotes the earliest pending when none is in progress", () => {
|
|
361
|
-
const phases: TodoPhase[] = [
|
|
362
|
-
{ name: "A", tasks: [task("a", "completed"), task("b", "pending")] },
|
|
363
|
-
{ name: "B", tasks: [task("c", "pending")] },
|
|
364
|
-
];
|
|
365
|
-
normalizeInProgressTask(phases);
|
|
366
|
-
expect(phases[0]?.tasks[1]?.status).toBe("in_progress");
|
|
367
|
-
expect(phases[1]?.tasks[0]?.status).toBe("pending");
|
|
368
|
-
});
|
|
369
|
-
});
|
package/test/render.test.ts
CHANGED
|
@@ -140,7 +140,8 @@ describe("todo render: result (omp renderer)", () => {
|
|
|
140
140
|
});
|
|
141
141
|
|
|
142
142
|
it("computeTouchedPhases consumes the call args via the render context (omp args fold)", () => {
|
|
143
|
-
// done on phase Two:
|
|
143
|
+
// done on phase Two: Two is touched (completion + op phase); One is the
|
|
144
|
+
// earliest phase with open work, so it stays expanded too (no pointer).
|
|
144
145
|
const phases = [phase("One", ["pending"]), phase("Two", ["completed", "pending"])];
|
|
145
146
|
const completed: NonNullable<TodoToolDetails["completedTasks"]> = [{ phase: "Two", content: "two-1" }];
|
|
146
147
|
const out = text(
|
|
@@ -152,7 +153,8 @@ describe("todo render: result (omp renderer)", () => {
|
|
|
152
153
|
),
|
|
153
154
|
);
|
|
154
155
|
expect(out).toContain("I. One 0/1");
|
|
155
|
-
|
|
156
|
+
// Active phase (earliest open work) never collapses to a summary line.
|
|
157
|
+
expect(out).toContain("one-1");
|
|
156
158
|
expect(out).toContain("II. Two 1/2");
|
|
157
159
|
expect(out).toContain("two-2");
|
|
158
160
|
});
|
package/test/state.test.ts
CHANGED
|
@@ -7,8 +7,6 @@ import {
|
|
|
7
7
|
inferTodoOp,
|
|
8
8
|
isTodoPhase,
|
|
9
9
|
isTodoPhaseSnapshot,
|
|
10
|
-
normalizeInProgressTask,
|
|
11
|
-
openTasks,
|
|
12
10
|
type TodoOpEntry,
|
|
13
11
|
type TodoPhase,
|
|
14
12
|
} from "../src/state.ts";
|
|
@@ -18,7 +16,7 @@ function phasesWithTasks(...entries: Array<[string, ...string[]]>): TodoPhase[]
|
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
describe("todo state: init", () => {
|
|
21
|
-
it("installs a phased list, all tasks pending, in order", () => {
|
|
19
|
+
it("installs a phased list, all tasks pending, in order (no auto-promotion)", () => {
|
|
22
20
|
const { phases, errors } = applyParams(
|
|
23
21
|
[],
|
|
24
22
|
{
|
|
@@ -31,8 +29,8 @@ describe("todo state: init", () => {
|
|
|
31
29
|
);
|
|
32
30
|
expect(errors).toEqual([]);
|
|
33
31
|
expect(phases.map(p => p.name)).toEqual(["Foundation", "Verify"]);
|
|
34
|
-
//
|
|
35
|
-
expect(phases[0].tasks[0]).toEqual({ content: "Scaffold", status: "
|
|
32
|
+
// Statuses change only through explicit ops: nothing auto-promotes.
|
|
33
|
+
expect(phases[0].tasks[0]).toEqual({ content: "Scaffold", status: "pending" });
|
|
36
34
|
expect(phases[0].tasks[1].status).toBe("pending");
|
|
37
35
|
expect(phases[1].tasks[0].status).toBe("pending");
|
|
38
36
|
});
|
|
@@ -73,7 +71,7 @@ describe("todo state: init", () => {
|
|
|
73
71
|
{
|
|
74
72
|
name: "Tasks",
|
|
75
73
|
tasks: [
|
|
76
|
-
{ content: "one", status: "
|
|
74
|
+
{ content: "one", status: "pending" },
|
|
77
75
|
{ content: "two", status: "pending" },
|
|
78
76
|
],
|
|
79
77
|
},
|
|
@@ -82,14 +80,15 @@ describe("todo state: init", () => {
|
|
|
82
80
|
});
|
|
83
81
|
|
|
84
82
|
describe("todo state: start", () => {
|
|
85
|
-
it("
|
|
83
|
+
it("marks the target in progress without touching other tasks (no invariant)", () => {
|
|
86
84
|
const phases: TodoPhase[] = [
|
|
87
85
|
{ name: "A", tasks: [{ content: "a1", status: "in_progress" }] },
|
|
88
86
|
{ name: "B", tasks: [{ content: "b1", status: "pending" }] },
|
|
89
87
|
];
|
|
90
88
|
const { phases: updated, errors } = applyParams(phases, { op: "start", task: "b1" });
|
|
91
89
|
expect(errors).toEqual([]);
|
|
92
|
-
|
|
90
|
+
// Multiple in_progress tasks are allowed: statuses are explicit-only.
|
|
91
|
+
expect(updated[0].tasks[0].status).toBe("in_progress");
|
|
93
92
|
expect(updated[1].tasks[0].status).toBe("in_progress");
|
|
94
93
|
});
|
|
95
94
|
|
|
@@ -274,60 +273,30 @@ describe("todo state: batch application", () => {
|
|
|
274
273
|
];
|
|
275
274
|
const { phases: updated, errors } = applyOpsToPhases(phases, [{ op: "rm", phase: "A" }, { op: "append", phase: "A", items: ["fresh"] }]);
|
|
276
275
|
expect(errors).toEqual([]);
|
|
277
|
-
expect(updated[0].tasks).toEqual([{ content: "fresh", status: "
|
|
276
|
+
expect(updated[0].tasks).toEqual([{ content: "fresh", status: "pending" }]);
|
|
278
277
|
});
|
|
279
278
|
});
|
|
280
279
|
|
|
281
|
-
describe("todo state: normalization", () => {
|
|
282
|
-
it("
|
|
283
|
-
const phases: TodoPhase[] = [
|
|
284
|
-
{
|
|
285
|
-
name: "A",
|
|
286
|
-
tasks: [
|
|
287
|
-
{ content: "a1", status: "in_progress" },
|
|
288
|
-
{ content: "a2", status: "in_progress" },
|
|
289
|
-
],
|
|
290
|
-
},
|
|
291
|
-
];
|
|
292
|
-
normalizeInProgressTask(phases);
|
|
293
|
-
expect(phases[0].tasks.map(t => t.status)).toEqual(["in_progress", "pending"]);
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
it("auto-promotes the earliest pending task when none is in progress", () => {
|
|
280
|
+
describe("todo state: explicit-status semantics (no normalization)", () => {
|
|
281
|
+
it("completion never auto-promotes another task", () => {
|
|
297
282
|
const phases: TodoPhase[] = [
|
|
298
283
|
{ name: "A", tasks: [{ content: "a1", status: "completed" }] },
|
|
299
284
|
{ name: "B", tasks: [{ content: "b1", status: "pending" }] },
|
|
300
285
|
];
|
|
301
|
-
|
|
302
|
-
expect(
|
|
286
|
+
const { phases: updated } = applyParams(clonePhases(phases), { op: "done", task: "b1" });
|
|
287
|
+
expect(updated[1].tasks[0].status).toBe("completed");
|
|
288
|
+
// No hidden pointer: no task was touched besides the target.
|
|
289
|
+
expect(updated[0].tasks[0].status).toBe("completed");
|
|
303
290
|
});
|
|
304
291
|
|
|
305
|
-
it("out-of-order completion
|
|
306
|
-
let { phases } = applyParams(phasesWithTasks(["A", "a1", "a2"], ["B", "b1"]), { op: "done", task: "
|
|
307
|
-
(
|
|
308
|
-
// b1 done; pointer returns to a2 (earliest open task).
|
|
309
|
-
expect(phases[0].tasks[1].status).toBe("in_progress");
|
|
292
|
+
it("out-of-order completion leaves every other status untouched", () => {
|
|
293
|
+
let { phases } = applyParams(phasesWithTasks(["A", "a1", "a2"], ["B", "b1"]), { op: "done", task: "b1" });
|
|
294
|
+
expect(phases[0].tasks.map(t => t.status)).toEqual(["pending", "pending"]);
|
|
310
295
|
expect(phases[1].tasks[0].status).toBe("completed");
|
|
311
296
|
});
|
|
312
297
|
});
|
|
313
298
|
|
|
314
299
|
describe("todo state: helpers", () => {
|
|
315
|
-
it("counts actionable open tasks, excluding settled and blocked work", () => {
|
|
316
|
-
const phases: TodoPhase[] = [
|
|
317
|
-
{
|
|
318
|
-
name: "A",
|
|
319
|
-
tasks: [
|
|
320
|
-
{ content: "a1", status: "completed" },
|
|
321
|
-
{ content: "a2", status: "abandoned" },
|
|
322
|
-
{ content: "a3", status: "pending" },
|
|
323
|
-
{ content: "a4", status: "in_progress" },
|
|
324
|
-
{ content: "a5", status: "blocked", blocker: "ci" },
|
|
325
|
-
],
|
|
326
|
-
},
|
|
327
|
-
];
|
|
328
|
-
expect(openTasks(phases).map(t => t.content)).toEqual(["a3", "a4"]);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
300
|
it("validates phase snapshots for restore", () => {
|
|
332
301
|
expect(isTodoPhase({ name: "A", tasks: [{ content: "a", status: "pending" }] })).toBe(true);
|
|
333
302
|
expect(isTodoPhase({ name: "A", tasks: [{ content: "a", status: "bogus" }] })).toBe(false);
|
|
@@ -363,6 +332,36 @@ describe("todo state: summary text", () => {
|
|
|
363
332
|
expect(formatSummary([], ["boom"])).toBe("Errors: boom");
|
|
364
333
|
expect(formatSummary([], [])).toBe("Todo list cleared.");
|
|
365
334
|
});
|
|
335
|
+
|
|
336
|
+
it("folds the per-task dumps on big mutation results but view still echoes in full", () => {
|
|
337
|
+
// 25 tasks (> SUMMARY_FULL_LIST_LIMIT): 5 done, 20 open.
|
|
338
|
+
const statuses: TodoPhase[] = [
|
|
339
|
+
{
|
|
340
|
+
name: "Big",
|
|
341
|
+
tasks: Array.from({ length: 25 }, (_, i) => ({
|
|
342
|
+
content: `task ${i + 1}`,
|
|
343
|
+
status: (i < 5 ? "completed" : "pending") as TodoPhase["tasks"][number]["status"],
|
|
344
|
+
})),
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
// Mutation result: remaining capped at 10 + fold hints, no phase checklist.
|
|
349
|
+
const mutation = formatSummary(statuses, []);
|
|
350
|
+
expect(mutation).toContain("Remaining items (20):");
|
|
351
|
+
expect(mutation).toContain("- task 6 [pending] (Big)");
|
|
352
|
+
expect(mutation).toContain("… 10 more open — call `view` for the full list.");
|
|
353
|
+
expect(mutation).toContain("Overall: 5/25 done, 20 open.");
|
|
354
|
+
expect(mutation).toContain("Full checklist omitted (25 tasks) — `view` echoes it.");
|
|
355
|
+
expect(mutation).not.toContain(" Big:");
|
|
356
|
+
expect(mutation).not.toContain("- task 16 [pending]");
|
|
357
|
+
|
|
358
|
+
// view (readOnly): full echo, no folding, no omission notice.
|
|
359
|
+
const view = formatSummary(statuses, [], true);
|
|
360
|
+
expect(view).toContain("- task 25 [pending] (Big)");
|
|
361
|
+
expect(view).toContain("[X] task 1");
|
|
362
|
+
expect(view).not.toContain("Full checklist omitted");
|
|
363
|
+
expect(view).not.toContain("more open — call");
|
|
364
|
+
});
|
|
366
365
|
});
|
|
367
366
|
|
|
368
367
|
describe("todo state: params type", () => {
|