@fyeeme/pi-todo 1.0.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/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/index.ts +222 -0
- package/package.json +61 -0
- package/src/commands.ts +457 -0
- package/src/prompts/todo.md +44 -0
- package/src/render.ts +295 -0
- package/src/restore.ts +33 -0
- package/src/state.ts +842 -0
- package/src/tool.ts +153 -0
- package/test/omp-alignment.test.ts +369 -0
- package/test/render.test.ts +213 -0
- package/test/state.test.ts +373 -0
- package/test/tool.test.ts +653 -0
package/src/tool.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-todo — tool definition.
|
|
3
|
+
*
|
|
4
|
+
* Ported from oh-my-pi `packages/coding-agent/src/tools/todo.ts` (TodoTool).
|
|
5
|
+
* Adaptations for pi's extension API:
|
|
6
|
+
* ArkType schema + lenientArgValidation + resolveTodoParams repair
|
|
7
|
+
* → TypeBox schema + prepareArguments shim (op inference runs before
|
|
8
|
+
* schema validation; the closure supplies `hasExistingPhases`)
|
|
9
|
+
* AgentTool class + ToolSession → ToolDefinition + closure deps
|
|
10
|
+
* concurrency: "exclusive" → executionMode: "sequential"
|
|
11
|
+
* session getTodoPhases/setTodoPhases → getPhases/setPhases/persist/broadcast
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { Type, type Static } from "typebox";
|
|
18
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
19
|
+
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
21
|
+
import {
|
|
22
|
+
applyParams,
|
|
23
|
+
clonePhases,
|
|
24
|
+
formatSummary,
|
|
25
|
+
getCompletionTransitions,
|
|
26
|
+
inferTodoOp,
|
|
27
|
+
type TodoOperation,
|
|
28
|
+
type TodoOpEntry,
|
|
29
|
+
type TodoPhase,
|
|
30
|
+
type TodoToolDetails,
|
|
31
|
+
} from "./state.ts";
|
|
32
|
+
import { renderTodoCall, renderTodoResult } from "./render.ts";
|
|
33
|
+
|
|
34
|
+
// Prompt lives in a static .md asset next to this module (published with the
|
|
35
|
+
// package). Loaded at runtime: pi loads extensions through jiti, which does
|
|
36
|
+
// not support bundler-style text imports (pi-dynamic-workflows precedent).
|
|
37
|
+
const todoDescription = readFileSync(
|
|
38
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "prompts", "todo.md"),
|
|
39
|
+
"utf8",
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export const TodoParamsSchema = Type.Object({
|
|
43
|
+
op: StringEnum(["init", "start", "done", "rm", "drop", "block", "unblock", "append", "view"] as const, {
|
|
44
|
+
description: "operation to apply",
|
|
45
|
+
}),
|
|
46
|
+
list: Type.Optional(
|
|
47
|
+
Type.Array(
|
|
48
|
+
Type.Object({
|
|
49
|
+
phase: Type.String({ description: "phase name" }),
|
|
50
|
+
items: Type.Array(Type.String({ description: "task content" }), {
|
|
51
|
+
minItems: 1,
|
|
52
|
+
description: "tasks for this phase",
|
|
53
|
+
}),
|
|
54
|
+
}),
|
|
55
|
+
{ description: "phased task list (init)" },
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
task: Type.Optional(Type.String({ description: "task content" })),
|
|
59
|
+
phase: Type.Optional(Type.String({ description: "phase name" })),
|
|
60
|
+
// No minItems here: `items` is only meaningful for `init`/`append`, and both
|
|
61
|
+
// enforce non-empty with op-specific errors. A stray `items: []` on an op
|
|
62
|
+
// that ignores it (e.g. `view`) must not be a hard schema rejection.
|
|
63
|
+
items: Type.Optional(Type.Array(Type.String({ description: "task content" }), { description: "tasks to append" })),
|
|
64
|
+
reason: Type.Optional(Type.String({ description: "blocker note (block op)" })),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export type TodoParams = Static<typeof TodoParamsSchema>;
|
|
68
|
+
|
|
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
|
+
export interface TodoToolDeps {
|
|
77
|
+
/** Current in-memory phases (already cloned on write). */
|
|
78
|
+
getPhases(): TodoPhase[];
|
|
79
|
+
/** Replace in-memory phases. */
|
|
80
|
+
setPhases(phases: TodoPhase[]): void;
|
|
81
|
+
/** Persist a full snapshot to the session. */
|
|
82
|
+
persist(phases: TodoPhase[]): void;
|
|
83
|
+
/** Broadcast a todo_updated event on the shared extension event bus. */
|
|
84
|
+
broadcast(phases: TodoPhase[]): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function createTodoTool(deps: TodoToolDeps): ToolDefinition<typeof TodoParamsSchema, TodoToolDetails> {
|
|
88
|
+
const definition: ToolDefinition<typeof TodoParamsSchema, TodoToolDetails> = {
|
|
89
|
+
name: "todo",
|
|
90
|
+
label: "Todo",
|
|
91
|
+
description: todoDescription,
|
|
92
|
+
promptSnippet: "Write a structured todo list to track progress within a session",
|
|
93
|
+
promptGuidelines: TODO_PROMPT_GUIDELINES,
|
|
94
|
+
parameters: TodoParamsSchema,
|
|
95
|
+
executionMode: "sequential",
|
|
96
|
+
renderCall: (args, theme) => renderTodoCall(args, theme),
|
|
97
|
+
// omp's renderer folds call args into the result view (touched-phase
|
|
98
|
+
// diffing); pi passes them through the render context.
|
|
99
|
+
renderResult: (result, options, theme, context) => renderTodoResult(result, options, theme, context?.args),
|
|
100
|
+
|
|
101
|
+
prepareArguments(args: unknown): TodoParams {
|
|
102
|
+
// Repair the one recoverable shape: a missing `op` alongside an
|
|
103
|
+
// unambiguous payload (models routinely send `{list:[...]}` with no op).
|
|
104
|
+
if (args && typeof args === "object" && !Array.isArray(args)) {
|
|
105
|
+
const raw = args as Record<string, unknown>;
|
|
106
|
+
if (raw.op === undefined) {
|
|
107
|
+
const inferred = inferTodoOp(raw, deps.getPhases().length > 0);
|
|
108
|
+
if (inferred) return { ...raw, op: inferred } as TodoParams;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return args as TodoParams;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async execute(
|
|
115
|
+
_toolCallId: string,
|
|
116
|
+
params: TodoParams,
|
|
117
|
+
_signal: AbortSignal | undefined,
|
|
118
|
+
_onUpdate: undefined,
|
|
119
|
+
_ctx: ExtensionContext,
|
|
120
|
+
): Promise<AgentToolResult<TodoToolDetails>> {
|
|
121
|
+
const previousPhases = clonePhases(deps.getPhases());
|
|
122
|
+
const op: TodoOperation = params.op;
|
|
123
|
+
// Pure-view calls are reads: no normalization, no state write.
|
|
124
|
+
const readOnly = op === "view";
|
|
125
|
+
const { phases: updated, errors } = readOnly
|
|
126
|
+
? { phases: previousPhases, errors: [] as string[] }
|
|
127
|
+
: applyParams(clonePhases(previousPhases), params as TodoOpEntry);
|
|
128
|
+
// A batch with any error is discarded wholesale: persisting a
|
|
129
|
+
// half-applied batch makes the natural retry hit "already exists" for
|
|
130
|
+
// the ops that did land. State stays at previous. pi's tool contract
|
|
131
|
+
// is throw-on-failure (no isError field on AgentToolResult), and the
|
|
132
|
+
// thrown message carries the errors plus the unchanged list so the
|
|
133
|
+
// model can retry with correct content.
|
|
134
|
+
if (errors.length > 0) {
|
|
135
|
+
throw new Error(formatSummary(previousPhases, errors, readOnly));
|
|
136
|
+
}
|
|
137
|
+
if (!readOnly) {
|
|
138
|
+
deps.setPhases(clonePhases(updated));
|
|
139
|
+
deps.persist(clonePhases(updated));
|
|
140
|
+
deps.broadcast(clonePhases(updated));
|
|
141
|
+
}
|
|
142
|
+
const completedTasks = readOnly ? [] : getCompletionTransitions(previousPhases, updated);
|
|
143
|
+
const details: TodoToolDetails = { op, phases: clonePhases(updated), storage: "session" };
|
|
144
|
+
if (completedTasks.length > 0) details.completedTasks = completedTasks;
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: formatSummary(updated, [], readOnly) }],
|
|
148
|
+
details,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
return definition;
|
|
153
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-todo ≡ oh-my-pi /todo alignment suite.
|
|
3
|
+
*
|
|
4
|
+
* Pins pi-todo's observable behavior to oh-my-pi (omp)
|
|
5
|
+
* `packages/coding-agent` todo mode, source by source:
|
|
6
|
+
*
|
|
7
|
+
* omp source | pinned here
|
|
8
|
+
* ---------------------------------------------+------------------------------
|
|
9
|
+
* tools/todo.ts phasesToMarkdown / | markdown round-trip
|
|
10
|
+
* markdownToPhases / resolveTodo- | (markers, blocker comments,
|
|
11
|
+
* MarkdownPath | escaped brackets, errors)
|
|
12
|
+
* tools/todo.ts phaseRomanNumeral | algorithmic roman numerals
|
|
13
|
+
* tools/todo.ts selectCollapsedTodos / | collapsed walking viewport
|
|
14
|
+
* selectWithinCap / todoMatchesAny- |
|
|
15
|
+
* Description |
|
|
16
|
+
* tools/todo.ts formatSummary | exact summary text
|
|
17
|
+
* tools/todo.ts formatMoreItems / pluralize | ellipsis summaries
|
|
18
|
+
* session/todo-tracker.ts checkCompletion / | reminder text, attempt
|
|
19
|
+
* isAwaitingUserAnswer | cycle, question guard
|
|
20
|
+
* modes/controllers/todo-command-controller.ts | /todo verb surface, fuzzy
|
|
21
|
+
* | matching, system reminder
|
|
22
|
+
*
|
|
23
|
+
* Every expected string is copied verbatim from the omp sources listed.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { describe, expect, it } from "vitest";
|
|
28
|
+
import {
|
|
29
|
+
formatSummary,
|
|
30
|
+
isAwaitingUserAnswer,
|
|
31
|
+
markdownToPhases,
|
|
32
|
+
normalizeInProgressTask,
|
|
33
|
+
phaseRomanNumeral,
|
|
34
|
+
phasesToMarkdown,
|
|
35
|
+
pluralize,
|
|
36
|
+
resolveTodoMarkdownPath,
|
|
37
|
+
selectCollapsedTodos,
|
|
38
|
+
todoMatchesAnyDescription,
|
|
39
|
+
type TodoItem,
|
|
40
|
+
type TodoPhase,
|
|
41
|
+
} from "../src/state.ts";
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Markdown round-trip — omp tools/todo.ts
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
describe("markdown round-trip matches omp", () => {
|
|
48
|
+
it("phasesToMarkdown renders the omp checklist format", () => {
|
|
49
|
+
const phases: TodoPhase[] = [
|
|
50
|
+
{
|
|
51
|
+
name: "Foundation",
|
|
52
|
+
tasks: [
|
|
53
|
+
{ content: "Scaffold crate", status: "completed" },
|
|
54
|
+
{ content: "Wire workspace", status: "in_progress" },
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "Verify",
|
|
59
|
+
tasks: [
|
|
60
|
+
{ content: "Run tests", status: "pending" },
|
|
61
|
+
{ content: "Wait for CI", status: "blocked", blocker: "ci down" },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
// omp output verbatim: # headings, blank line between phases, marker set
|
|
66
|
+
// [ ] [/] [x] [-] [!], blocker in a trailing HTML comment.
|
|
67
|
+
expect(phasesToMarkdown(phases)).toBe(
|
|
68
|
+
[
|
|
69
|
+
"# Foundation",
|
|
70
|
+
"- [x] Scaffold crate",
|
|
71
|
+
"- [/] Wire workspace",
|
|
72
|
+
"",
|
|
73
|
+
"# Verify",
|
|
74
|
+
"- [ ] Run tests",
|
|
75
|
+
"- [!] Wait for CI <!-- blocker: ci down -->",
|
|
76
|
+
].join("\n") + "\n",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("empty phases render the omp '# Todos' stub", () => {
|
|
81
|
+
expect(phasesToMarkdown([])).toBe("# Todos\n");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("markdownToPhases parses markers back, including blocked reasons", () => {
|
|
85
|
+
const { phases, errors } = markdownToPhases(
|
|
86
|
+
["# A", "- [x] done-one", "- [/] doing", "- [-] gone", "- [!] stuck <!-- blocker: upstream -->", "- [ ] open"].join(
|
|
87
|
+
"\n",
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
expect(errors).toEqual([]);
|
|
91
|
+
expect(phases).toEqual([
|
|
92
|
+
{
|
|
93
|
+
name: "A",
|
|
94
|
+
tasks: [
|
|
95
|
+
{ content: "done-one", status: "completed" },
|
|
96
|
+
{ content: "doing", status: "in_progress" },
|
|
97
|
+
{ content: "gone", status: "abandoned" },
|
|
98
|
+
{ content: "stuck", status: "blocked", blocker: "upstream" },
|
|
99
|
+
{ content: "open", status: "pending" },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("round-trips losslessly", () => {
|
|
106
|
+
const phases: TodoPhase[] = [
|
|
107
|
+
{ name: "P", tasks: [{ content: "a", status: "blocked", blocker: "why" }] },
|
|
108
|
+
{ name: "Q", tasks: [{ content: "b", status: "in_progress" }] },
|
|
109
|
+
];
|
|
110
|
+
const parsed = markdownToPhases(phasesToMarkdown(phases));
|
|
111
|
+
expect(parsed.errors).toEqual([]);
|
|
112
|
+
expect(parsed.phases).toEqual(phases);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("tolerates backslash-escaped brackets and alternate bullets (omp tolerance)", () => {
|
|
116
|
+
const { phases, errors } = markdownToPhases("- \\[x\\] escaped\n* [ ] starred\n+ [/] plus");
|
|
117
|
+
expect(errors).toEqual([]);
|
|
118
|
+
expect(phases[0]?.tasks.map(t => t.content)).toEqual(["escaped", "starred", "plus"]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("reports omp error lines for unknown markers and stray syntax", () => {
|
|
122
|
+
const { errors } = markdownToPhases("- [?] mystery\njust text");
|
|
123
|
+
expect(errors).toEqual([
|
|
124
|
+
'Line 1: unknown status marker "[?]" (use [ ], [x], [/], [-], [!])',
|
|
125
|
+
'Line 2: unrecognized syntax "just text"',
|
|
126
|
+
]);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("normalizeInProgressTask runs on parse (omp markdownToPhases tail)", () => {
|
|
130
|
+
const { phases } = markdownToPhases("- [ ] one\n- [ ] two");
|
|
131
|
+
expect(phases[0]?.tasks[0]?.status).toBe("in_progress");
|
|
132
|
+
expect(phases[0]?.tasks[1]?.status).toBe("pending");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("resolveTodoMarkdownPath matches omp: quotes stripped, TODO.md default, ~ and cwd", () => {
|
|
136
|
+
expect(resolveTodoMarkdownPath("", "/w")).toBe("/w/TODO.md");
|
|
137
|
+
expect(resolveTodoMarkdownPath(' "plans.md" ', "/w")).toBe("/w/plans.md");
|
|
138
|
+
expect(resolveTodoMarkdownPath("/abs/t.md", "/w")).toBe("/abs/t.md");
|
|
139
|
+
expect(resolveTodoMarkdownPath("~/t.md", "/w")).toBe(`${homedir()}/t.md`);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// phaseRomanNumeral — omp algorithmic version (no fixed cap)
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
describe("phaseRomanNumeral matches omp", () => {
|
|
148
|
+
it("renders standard numerals", () => {
|
|
149
|
+
expect(phaseRomanNumeral(1)).toBe("I");
|
|
150
|
+
expect(phaseRomanNumeral(4)).toBe("IV");
|
|
151
|
+
expect(phaseRomanNumeral(9)).toBe("IX");
|
|
152
|
+
expect(phaseRomanNumeral(14)).toBe("XIV");
|
|
153
|
+
expect(phaseRomanNumeral(40)).toBe("XL");
|
|
154
|
+
expect(phaseRomanNumeral(1994)).toBe("MCMXCIV");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns empty for non-positive indices", () => {
|
|
158
|
+
expect(phaseRomanNumeral(0)).toBe("");
|
|
159
|
+
expect(phaseRomanNumeral(-3)).toBe("");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Collapsed walking viewport — omp selectCollapsedTodos (#5873)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
function task(content: string, status: TodoItem["status"]): TodoItem {
|
|
168
|
+
return { content, status };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe("selectCollapsedTodos matches omp", () => {
|
|
172
|
+
const never = () => false;
|
|
173
|
+
|
|
174
|
+
it("returns everything within the cap", () => {
|
|
175
|
+
const tasks = [task("a", "in_progress"), task("b", "pending")];
|
|
176
|
+
const selection = selectCollapsedTodos(tasks, never, 8);
|
|
177
|
+
expect(selection.items).toEqual(tasks);
|
|
178
|
+
expect(selection.summary).toBe("");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("leads with the last closed task and fills with following pending work", () => {
|
|
182
|
+
const tasks = [
|
|
183
|
+
task("closed-old", "completed"),
|
|
184
|
+
task("closed-new", "completed"),
|
|
185
|
+
task("a", "in_progress"),
|
|
186
|
+
task("b", "pending"),
|
|
187
|
+
task("c", "pending"),
|
|
188
|
+
task("d", "pending"),
|
|
189
|
+
task("e", "pending"),
|
|
190
|
+
task("f", "pending"),
|
|
191
|
+
task("g", "pending"),
|
|
192
|
+
task("h", "pending"),
|
|
193
|
+
];
|
|
194
|
+
const selection = selectCollapsedTodos(tasks, never, 8);
|
|
195
|
+
// Open work = 8 (a..h) ≤ cap; lead adds the LAST closed row additively.
|
|
196
|
+
expect(selection.items.map(t => t.content)).toEqual([
|
|
197
|
+
"closed-new",
|
|
198
|
+
"a",
|
|
199
|
+
"b",
|
|
200
|
+
"c",
|
|
201
|
+
"d",
|
|
202
|
+
"e",
|
|
203
|
+
"f",
|
|
204
|
+
"g",
|
|
205
|
+
"h",
|
|
206
|
+
]);
|
|
207
|
+
expect(selection.summary).toBe("");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("counts hidden open work with the omp ellipsis summary", () => {
|
|
211
|
+
const tasks = [task("done", "completed"), ...Array.from({ length: 10 }, (_, i) => task(`t${i}`, "pending"))];
|
|
212
|
+
const selection = selectCollapsedTodos(tasks, never, 8);
|
|
213
|
+
expect(selection.items.filter(t => t.content === "done")).toHaveLength(1);
|
|
214
|
+
expect(selection.summary).toBe("… 2 more todos");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("never drops active rows; hides only actives when they exceed the cap", () => {
|
|
218
|
+
const tasks = [
|
|
219
|
+
task("w1", "in_progress"),
|
|
220
|
+
task("w2", "in_progress"),
|
|
221
|
+
task("w3", "in_progress"),
|
|
222
|
+
task("p", "pending"),
|
|
223
|
+
];
|
|
224
|
+
const selection = selectCollapsedTodos(tasks, never, 2);
|
|
225
|
+
expect(selection.items.map(t => t.content)).toEqual(["w1", "w2"]);
|
|
226
|
+
expect(selection.summary).toBe("… 1 more active todo");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("a settled phase selects over its closed tasks", () => {
|
|
230
|
+
const tasks = [task("a", "completed"), task("b", "abandoned"), task("c", "completed")];
|
|
231
|
+
const selection = selectCollapsedTodos(tasks, never, 8);
|
|
232
|
+
expect(selection.items).toEqual(tasks);
|
|
233
|
+
expect(selection.summary).toBe("");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
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
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// formatSummary — omp tools/todo.ts (tool result body)
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
describe("formatSummary matches omp verbatim", () => {
|
|
277
|
+
it("renders the empty-read text", () => {
|
|
278
|
+
expect(markdownToPhases("").phases).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("renders the full omp summary block", () => {
|
|
282
|
+
const phases: TodoPhase[] = [
|
|
283
|
+
{
|
|
284
|
+
name: "Foundation",
|
|
285
|
+
tasks: [
|
|
286
|
+
{ content: "Scaffold", status: "completed" },
|
|
287
|
+
{ content: "Wire", status: "in_progress" },
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
name: "Verify",
|
|
292
|
+
tasks: [
|
|
293
|
+
{ content: "Tests", status: "pending" },
|
|
294
|
+
{ content: "CI", status: "blocked", blocker: "down" },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
expect(formatSummary(phases, [])).toBe(
|
|
299
|
+
[
|
|
300
|
+
// omp counts only pending + in_progress as remaining; blocked is parked.
|
|
301
|
+
"Remaining items (2):",
|
|
302
|
+
" - Wire [in_progress] (Foundation)",
|
|
303
|
+
" - Tests [pending] (Verify)",
|
|
304
|
+
"Overall: 1/4 done, 2 open, 1 blocked.",
|
|
305
|
+
'Active phase 1/2 "Foundation" (1/2).',
|
|
306
|
+
" Foundation:",
|
|
307
|
+
" - [X] Scaffold",
|
|
308
|
+
" - [ ] Wire (in progress)",
|
|
309
|
+
" Verify:",
|
|
310
|
+
" - [ ] Tests",
|
|
311
|
+
" - [ ] CI (blocked: down)",
|
|
312
|
+
].join("\n"),
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("renders the worked-ahead explanation (omp backward-pointer note)", () => {
|
|
317
|
+
const phases: TodoPhase[] = [
|
|
318
|
+
{ name: "A", tasks: [{ content: "a", status: "pending" }] },
|
|
319
|
+
{ name: "B", tasks: [{ content: "b", status: "completed" }] },
|
|
320
|
+
];
|
|
321
|
+
const out = formatSummary(phases, []);
|
|
322
|
+
expect(out).toContain(
|
|
323
|
+
'Active phase 1/2 "A" (0/1) — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed).',
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("error-only batches report Errors first (omp formatSummary)", () => {
|
|
328
|
+
expect(formatSummary([], ['Task "x" not found'], false)).toBe("Errors: Task \"x\" not found");
|
|
329
|
+
expect(formatSummary([], [], true)).toBe("Todo list is empty.");
|
|
330
|
+
expect(formatSummary([], [], false)).toBe("Todo list cleared.");
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// pluralize — omp packages/utils pluralize
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
describe("pluralize matches omp", () => {
|
|
339
|
+
it("handles regular, s-ending, and y-ending nouns", () => {
|
|
340
|
+
expect(pluralize("todo", 1)).toBe("todo");
|
|
341
|
+
expect(pluralize("todo", 2)).toBe("todos");
|
|
342
|
+
expect(pluralize("status", 2)).toBe("statuses");
|
|
343
|
+
expect(pluralize("entry", 2)).toBe("entries");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
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
|
+
});
|