@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/render.ts
CHANGED
|
@@ -7,14 +7,16 @@
|
|
|
7
7
|
* call line and result lines separately. Strings, glyphs, colors, roman
|
|
8
8
|
* numerals, per-phase progress, touched-phase collapsing, and the collapsed
|
|
9
9
|
* walking viewport all match omp:
|
|
10
|
-
* - call: ⏳(muted) Todo · <op> <task>
|
|
10
|
+
* - call: ⏳(muted) Todo · <op> <task> <phase> N items (dim meta; the
|
|
11
|
+
* op's fields join with spaces, multiple ops join with ·)
|
|
11
12
|
* - result: ☑(accent) Todo · N tasks + per-phase tree
|
|
12
13
|
* - tasks: ☑ success strikethrough / ☐ accent in-progress / ☐ error
|
|
13
14
|
* strikethrough abandoned / ☐ warning blocked (note) / ☐ dim pending
|
|
14
15
|
* - tree: ├─ / └─ dim branch glyphs with the muted trailing summary
|
|
15
16
|
* Dropped (host-internal, no pi counterpart): strike animations and spinner
|
|
16
|
-
* frames (final strike state renders), subagent-match lighting (
|
|
17
|
-
*
|
|
17
|
+
* frames (final strike state renders), subagent-match lighting (matcher
|
|
18
|
+
* removed with the cognitive-neutral refactor; pending renders dim), framed
|
|
19
|
+
* block chrome.
|
|
18
20
|
*/
|
|
19
21
|
|
|
20
22
|
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
@@ -24,9 +26,8 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
|
24
26
|
import {
|
|
25
27
|
type TodoCompletionTransition,
|
|
26
28
|
type TodoItem,
|
|
27
|
-
|
|
29
|
+
isClosedTodo,
|
|
28
30
|
phaseRomanNumeral,
|
|
29
|
-
phasesToMarkdown,
|
|
30
31
|
selectCollapsedTodos,
|
|
31
32
|
type TodoPhase,
|
|
32
33
|
type TodoToolDetails,
|
|
@@ -83,7 +84,7 @@ function formatPhaseDisplayName(name: string, oneBasedIndex: number): string {
|
|
|
83
84
|
|
|
84
85
|
/** Dim `closed/total` suffix — counts completed + abandoned (omp isClosedTodo). */
|
|
85
86
|
function formatPhaseProgress(phase: TodoPhase, theme: Theme): string {
|
|
86
|
-
const done = phase.tasks.filter(
|
|
87
|
+
const done = phase.tasks.filter(isClosedTodo).length;
|
|
87
88
|
return theme.fg("dim", ` ${done}/${phase.tasks.length}`);
|
|
88
89
|
}
|
|
89
90
|
|
|
@@ -100,7 +101,7 @@ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, theme: Them
|
|
|
100
101
|
/** omp theme.checkbox unicode set (checkbox.checked / checkbox.unchecked). */
|
|
101
102
|
const CHECKBOX = { checked: "☑", unchecked: "☐" } as const;
|
|
102
103
|
|
|
103
|
-
function formatTodoLine(item: TodoItem, theme: Theme
|
|
104
|
+
function formatTodoLine(item: TodoItem, theme: Theme): string {
|
|
104
105
|
const label = clip(item.content);
|
|
105
106
|
const box = CHECKBOX.unchecked;
|
|
106
107
|
switch (item.status) {
|
|
@@ -115,9 +116,7 @@ function formatTodoLine(item: TodoItem, theme: Theme, matched = false): string {
|
|
|
115
116
|
return theme.fg("warning", `${box} ${label} (${note})`);
|
|
116
117
|
}
|
|
117
118
|
default:
|
|
118
|
-
|
|
119
|
-
// the subagent description provider is host-internal, so pending stays dim.
|
|
120
|
-
return theme.fg(matched ? "accent" : "dim", `${box} ${label}`);
|
|
119
|
+
return theme.fg("dim", `${box} ${label}`);
|
|
121
120
|
}
|
|
122
121
|
}
|
|
123
122
|
|
|
@@ -136,8 +135,7 @@ interface TreeLineOptions {
|
|
|
136
135
|
theme: Theme;
|
|
137
136
|
}
|
|
138
137
|
|
|
139
|
-
function renderTreeLines({ items, trailingSummary, renderItem, theme }: TreeLineOptions): string[] {
|
|
140
|
-
const summary = trailingSummary;
|
|
138
|
+
function renderTreeLines({ items, trailingSummary: summary, renderItem, theme }: TreeLineOptions): string[] {
|
|
141
139
|
const lines: string[] = [];
|
|
142
140
|
for (let i = 0; i < items.length; i++) {
|
|
143
141
|
const rendered = renderItem(items[i]);
|
|
@@ -157,9 +155,11 @@ function renderTreeLines({ items, trailingSummary, renderItem, theme }: TreeLine
|
|
|
157
155
|
// =============================================================================
|
|
158
156
|
|
|
159
157
|
/**
|
|
160
|
-
* Phases the latest update touched, plus the
|
|
161
|
-
*
|
|
162
|
-
*
|
|
158
|
+
* Phases the latest update touched, plus the phase where the work now sits:
|
|
159
|
+
* the earliest phase still holding open work (the same "active phase" the
|
|
160
|
+
* summary text reports). Returns `null` when there is no usable signal,
|
|
161
|
+
* meaning "render every phase fully" — this preserves the legacy view and the
|
|
162
|
+
* manual-expand path.
|
|
163
163
|
*/
|
|
164
164
|
function computeTouchedPhases(
|
|
165
165
|
args: TodoRenderArgs | undefined,
|
|
@@ -167,18 +167,23 @@ function computeTouchedPhases(
|
|
|
167
167
|
completedTasks: TodoCompletionTransition[],
|
|
168
168
|
): Set<string> | null {
|
|
169
169
|
const touched = new Set<string>();
|
|
170
|
-
//
|
|
171
|
-
// auto-promotion that follows every completion.
|
|
170
|
+
// Explicitly started work stays expanded.
|
|
172
171
|
for (const phase of phases) {
|
|
173
172
|
if (phase.tasks.some(task => task.status === "in_progress")) touched.add(phase.name);
|
|
174
173
|
}
|
|
174
|
+
// Without the omp auto-promotion pointer there may be no in_progress task at
|
|
175
|
+
// all; keep the earliest open-work phase expanded so the phase the agent
|
|
176
|
+
// works on next never collapses to a one-line summary.
|
|
177
|
+
const activePhase = phases.find(phase =>
|
|
178
|
+
phase.tasks.some(task => task.status === "pending" || task.status === "in_progress"),
|
|
179
|
+
);
|
|
180
|
+
if (activePhase) touched.add(activePhase.name);
|
|
175
181
|
// Phases with a task that just transitioned to completed in this update.
|
|
176
182
|
for (const transition of completedTasks) touched.add(transition.phase);
|
|
177
183
|
// Phases explicitly named by the ops that ran. `init` replaces the whole
|
|
178
184
|
// list, so the entire plan is fresh and every phase counts as touched.
|
|
179
185
|
const ops = normalizeTodoArg(args);
|
|
180
186
|
for (const op of ops) {
|
|
181
|
-
if (!op || typeof op !== "object") continue;
|
|
182
187
|
if (op.op === "init") {
|
|
183
188
|
for (const phase of phases) touched.add(phase.name);
|
|
184
189
|
break;
|
|
@@ -198,11 +203,7 @@ function computeTouchedPhases(
|
|
|
198
203
|
// Call / result renderers
|
|
199
204
|
// =============================================================================
|
|
200
205
|
|
|
201
|
-
export
|
|
202
|
-
op?: string;
|
|
203
|
-
task?: string;
|
|
204
|
-
phase?: string;
|
|
205
|
-
}
|
|
206
|
+
export type TodoRenderArgsPublic = Omit<TodoRenderOp, "items">;
|
|
206
207
|
|
|
207
208
|
export function renderTodoCall(args: TodoRenderArgsPublic, theme: Theme): Component {
|
|
208
209
|
// omp renderCall: renderStatusLine({icon:"pending", title:"Todo", meta})
|
|
@@ -253,8 +254,6 @@ export function renderTodoResult(
|
|
|
253
254
|
// single task flip doesn't redraw every phase's full task list. The manual
|
|
254
255
|
// expand toggle (and the no-signal fallback) still shows all.
|
|
255
256
|
const touched = options.expanded || !multiPhase ? null : computeTouchedPhases(args as TodoRenderArgs, phases, completedTasks);
|
|
256
|
-
// Subagent description matching is host-internal; pending renders dim.
|
|
257
|
-
const isMatched = (_task: TodoItem): boolean => false;
|
|
258
257
|
|
|
259
258
|
for (let p = 0; p < phases.length; p++) {
|
|
260
259
|
const phase = phases[p];
|
|
@@ -274,11 +273,11 @@ export function renderTodoResult(
|
|
|
274
273
|
const treeLines = options.expanded
|
|
275
274
|
? renderTreeLines({ items: phase.tasks, renderItem: todo => formatTodoLine(todo, theme), theme })
|
|
276
275
|
: (() => {
|
|
277
|
-
const selection = selectCollapsedTodos(phase.tasks,
|
|
276
|
+
const selection = selectCollapsedTodos(phase.tasks, COLLAPSED_ITEMS);
|
|
278
277
|
return renderTreeLines({
|
|
279
278
|
items: selection.items,
|
|
280
279
|
trailingSummary: selection.summary,
|
|
281
|
-
renderItem: todo => formatTodoLine(todo, theme
|
|
280
|
+
renderItem: todo => formatTodoLine(todo, theme),
|
|
282
281
|
theme,
|
|
283
282
|
});
|
|
284
283
|
})();
|
|
@@ -286,10 +285,6 @@ export function renderTodoResult(
|
|
|
286
285
|
bodyLines.push(`${indent}${line}`);
|
|
287
286
|
}
|
|
288
287
|
}
|
|
289
|
-
while (bodyLines.length > 0 && bodyLines[0].trim() === "") bodyLines.shift();
|
|
290
288
|
|
|
291
289
|
return new Text([header, ...bodyLines].join("\n"), 0, 0);
|
|
292
290
|
}
|
|
293
|
-
|
|
294
|
-
// Re-exported for consumers that compose the pieces (tests).
|
|
295
|
-
export { formatMoreItems, phasesToMarkdown };
|
package/src/restore.ts
CHANGED
|
@@ -13,9 +13,6 @@ import { clonePhases, isTodoPhaseSnapshot, type TodoPhase } from "./state.ts";
|
|
|
13
13
|
/** Session entry customType for todo snapshots (see pi.appendEntry). */
|
|
14
14
|
export const TODO_PHASES_ENTRY_TYPE = "todo-phases";
|
|
15
15
|
|
|
16
|
-
/** Session entry customType for stop-time reminders. */
|
|
17
|
-
export const TODO_REMINDER_ENTRY_TYPE = "todo-reminder";
|
|
18
|
-
|
|
19
16
|
/**
|
|
20
17
|
* Reconstruct todo state from session entries: the latest valid
|
|
21
18
|
* `todo-phases` snapshot reachable on the current branch wins; malformed
|
package/src/state.ts
CHANGED
|
@@ -4,11 +4,17 @@
|
|
|
4
4
|
* Ported from oh-my-pi (github.com/can1357/oh-my-pi, a fork of badlogic/pi-mono)
|
|
5
5
|
* `packages/coding-agent/src/tools/todo.ts` — the data model, the nine
|
|
6
6
|
* operations, their validation semantics, and the summary text are carried
|
|
7
|
-
* over behavior-for-behavior
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* -
|
|
7
|
+
* over behavior-for-behavior, with deliberate deviations (cognitive-neutral
|
|
8
|
+
* notepad refactor):
|
|
9
|
+
* - no single-`in_progress` invariant / auto-promotion pointer: statuses are
|
|
10
|
+
* set only by explicit ops (`start`/`done`/`drop`/`block`/`unblock`)
|
|
11
|
+
* - no stop-reminder machinery (question guards, nag cycle)
|
|
12
|
+
* Dropped from the omp source (host-internal surfaces with no pi counterpart):
|
|
13
|
+
* - prompt-line analysis machines - subagent description matching
|
|
14
|
+
* - nextActionableTask (prewalk consumer) - tool examples (omp-only field)
|
|
15
|
+
* Kept despite the omp header listing them as host-internal: the markdown
|
|
16
|
+
* round-trip (commands.ts /todo edit|import) and the collapsed-viewport
|
|
17
|
+
* selection (render.ts) — both live below.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
20
|
import * as os from "node:os";
|
|
@@ -112,7 +118,15 @@ function todoTransitionKey(phase: string, content: string): string {
|
|
|
112
118
|
return `${phase}\u0000${content}`;
|
|
113
119
|
}
|
|
114
120
|
|
|
115
|
-
|
|
121
|
+
/** A task whose status changed in a mutation (old → new). */
|
|
122
|
+
export interface TodoStatusTransition {
|
|
123
|
+
phase: string;
|
|
124
|
+
content: string;
|
|
125
|
+
from: TodoStatus;
|
|
126
|
+
to: TodoStatus;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getStatusTransitions(previous: TodoPhase[], updated: TodoPhase[]): TodoStatusTransition[] {
|
|
116
130
|
const previousStatuses = new Map<string, TodoStatus>();
|
|
117
131
|
for (const phase of previous) {
|
|
118
132
|
for (const task of phase.tasks) {
|
|
@@ -120,41 +134,18 @@ export function getCompletionTransitions(previous: TodoPhase[], updated: TodoPha
|
|
|
120
134
|
}
|
|
121
135
|
}
|
|
122
136
|
|
|
123
|
-
const transitions:
|
|
137
|
+
const transitions: TodoStatusTransition[] = [];
|
|
124
138
|
for (const phase of updated) {
|
|
125
139
|
for (const task of phase.tasks) {
|
|
126
|
-
if (task.status !== "completed") continue;
|
|
127
140
|
const previousStatus = previousStatuses.get(todoTransitionKey(phase.name, task.content));
|
|
128
|
-
if (previousStatus && previousStatus !==
|
|
129
|
-
transitions.push({ phase: phase.name, content: task.content });
|
|
141
|
+
if (previousStatus && previousStatus !== task.status) {
|
|
142
|
+
transitions.push({ phase: phase.name, content: task.content, from: previousStatus, to: task.status });
|
|
130
143
|
}
|
|
131
144
|
}
|
|
132
145
|
}
|
|
133
146
|
return transitions;
|
|
134
147
|
}
|
|
135
148
|
|
|
136
|
-
/**
|
|
137
|
-
* Enforce the single-`in_progress` invariant after every mutation: demote
|
|
138
|
-
* surplus in-progress tasks, then auto-promote the earliest pending task when
|
|
139
|
-
* none is in progress (the "pointer" the todo prompt documents).
|
|
140
|
-
*/
|
|
141
|
-
export function normalizeInProgressTask(phases: TodoPhase[]): void {
|
|
142
|
-
const orderedTasks = phases.flatMap(phase => phase.tasks);
|
|
143
|
-
if (orderedTasks.length === 0) return;
|
|
144
|
-
|
|
145
|
-
const inProgressTasks = orderedTasks.filter(task => task.status === "in_progress");
|
|
146
|
-
if (inProgressTasks.length > 1) {
|
|
147
|
-
for (const task of inProgressTasks.slice(1)) {
|
|
148
|
-
task.status = "pending";
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (inProgressTasks.length > 0) return;
|
|
153
|
-
|
|
154
|
-
const firstPendingTask = orderedTasks.find(task => task.status === "pending");
|
|
155
|
-
if (firstPendingTask) firstPendingTask.status = "in_progress";
|
|
156
|
-
}
|
|
157
|
-
|
|
158
149
|
/** Whether a todo is settled: completed or deliberately abandoned. Shared so
|
|
159
150
|
* the collapsed viewport and the summary counters can never disagree about
|
|
160
151
|
* what "done" hides. */
|
|
@@ -166,39 +157,6 @@ export function isClosedTodo<T extends { status: TodoStatus }>(task: T): boolean
|
|
|
166
157
|
// Collapsed-viewport selection (omp #5873 walking viewport)
|
|
167
158
|
// =============================================================================
|
|
168
159
|
|
|
169
|
-
/** Minimum overlap (after normalization) required for a substring match.
|
|
170
|
-
* Picked at six chars to admit single-word identifiers like "review" /
|
|
171
|
-
* "Sonnet" without admitting tiny common substrings like "test" / "fix"
|
|
172
|
-
* that would collide across unrelated todos. */
|
|
173
|
-
const TODO_DESCRIPTION_MIN_OVERLAP = 6;
|
|
174
|
-
|
|
175
|
-
function normalizeForTodoMatch(value: string): string {
|
|
176
|
-
return value
|
|
177
|
-
.toLowerCase()
|
|
178
|
-
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
179
|
-
.trim();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Report whether `content` likely names the same work as any entry in
|
|
184
|
-
* `descriptions`. Used by the todo renderer to light up a pending todo when an
|
|
185
|
-
* in-flight subagent is doing the work for it. Matching is normalize-then-equal
|
|
186
|
-
* first, with a substring fallback in either direction requiring at least
|
|
187
|
-
* {@link TODO_DESCRIPTION_MIN_OVERLAP} chars on the contained side.
|
|
188
|
-
*/
|
|
189
|
-
export function todoMatchesAnyDescription(content: string, descriptions: readonly string[]): boolean {
|
|
190
|
-
const target = normalizeForTodoMatch(content);
|
|
191
|
-
if (!target) return false;
|
|
192
|
-
for (const desc of descriptions) {
|
|
193
|
-
const candidate = normalizeForTodoMatch(desc);
|
|
194
|
-
if (!candidate) continue;
|
|
195
|
-
if (target === candidate) return true;
|
|
196
|
-
if (target.length >= TODO_DESCRIPTION_MIN_OVERLAP && candidate.includes(target)) return true;
|
|
197
|
-
if (candidate.length >= TODO_DESCRIPTION_MIN_OVERLAP && target.includes(candidate)) return true;
|
|
198
|
-
}
|
|
199
|
-
return false;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
160
|
/** omp pluralize (packages/utils/src/format.ts). */
|
|
203
161
|
export function pluralize(label: string, count: number): string {
|
|
204
162
|
if (count === 1) return label;
|
|
@@ -214,9 +172,10 @@ export function formatMoreItems(remaining: number, itemType: string): string {
|
|
|
214
172
|
}
|
|
215
173
|
|
|
216
174
|
/** A todo the collapsed viewport treats as current work: the literal
|
|
217
|
-
* `in_progress` task
|
|
218
|
-
|
|
219
|
-
|
|
175
|
+
* `in_progress` task. (omp also matched pending tasks a live subagent was
|
|
176
|
+
* executing; that matcher was removed with the cognitive-neutral refactor.) */
|
|
177
|
+
function isActiveTodo<T extends { status: TodoStatus }>(task: T): boolean {
|
|
178
|
+
return task.status === "in_progress";
|
|
220
179
|
}
|
|
221
180
|
|
|
222
181
|
/** Result of {@link selectCollapsedTodos}: the rows to render plus an optional
|
|
@@ -230,14 +189,10 @@ export interface CollapsedTodoSelection<T> {
|
|
|
230
189
|
* visible as it happens (omp COLLAPSED_CLOSED_CONTEXT). */
|
|
231
190
|
const COLLAPSED_CLOSED_CONTEXT = 1;
|
|
232
191
|
|
|
233
|
-
function selectWithinCap<T extends { status: TodoStatus }>(
|
|
234
|
-
base: T[],
|
|
235
|
-
isMatched: (task: T) => boolean,
|
|
236
|
-
cap: number,
|
|
237
|
-
): CollapsedTodoSelection<T> {
|
|
192
|
+
function selectWithinCap<T extends { status: TodoStatus }>(base: T[], cap: number): CollapsedTodoSelection<T> {
|
|
238
193
|
if (base.length <= cap) return { items: base, summary: "" };
|
|
239
194
|
|
|
240
|
-
const active = base.filter(task => isActiveTodo(task
|
|
195
|
+
const active = base.filter(task => isActiveTodo(task));
|
|
241
196
|
// Only when active work strictly exceeds the cap do we drop pending rows and
|
|
242
197
|
// count hidden *actives*. At exactly `cap` actives, fall through so the normal
|
|
243
198
|
// branch still surfaces any following pending work in the summary.
|
|
@@ -250,12 +205,12 @@ function selectWithinCap<T extends { status: TodoStatus }>(
|
|
|
250
205
|
}
|
|
251
206
|
|
|
252
207
|
// Fill trailing rows with tasks following the first active one, so the
|
|
253
|
-
//
|
|
208
|
+
// active task leads and its successors follow in todo order.
|
|
254
209
|
const firstActiveIdx = active.length > 0 ? base.indexOf(active[0]) : 0;
|
|
255
210
|
const fill: T[] = [];
|
|
256
211
|
for (let i = firstActiveIdx; i < base.length && active.length + fill.length < cap; i++) {
|
|
257
212
|
const task = base[i];
|
|
258
|
-
if (isActiveTodo(task
|
|
213
|
+
if (isActiveTodo(task)) continue;
|
|
259
214
|
fill.push(task);
|
|
260
215
|
}
|
|
261
216
|
const items = [...active, ...fill];
|
|
@@ -273,79 +228,17 @@ function selectWithinCap<T extends { status: TodoStatus }>(
|
|
|
273
228
|
*/
|
|
274
229
|
export function selectCollapsedTodos<T extends { status: TodoStatus }>(
|
|
275
230
|
tasks: T[],
|
|
276
|
-
isMatched: (task: T) => boolean,
|
|
277
231
|
cap: number,
|
|
278
232
|
): CollapsedTodoSelection<T> {
|
|
279
233
|
const open = tasks.filter(task => !isClosedTodo(task));
|
|
280
234
|
// Closed tasks are never active, so a settled phase selects over itself.
|
|
281
|
-
if (open.length === 0) return selectWithinCap(tasks,
|
|
235
|
+
if (open.length === 0) return selectWithinCap(tasks, cap);
|
|
282
236
|
// `done` accepts any named task, so closed tasks are not necessarily a prefix.
|
|
283
237
|
const lead = tasks.filter(isClosedTodo).slice(-COLLAPSED_CLOSED_CONTEXT);
|
|
284
|
-
const selected = selectWithinCap(open,
|
|
238
|
+
const selected = selectWithinCap(open, cap);
|
|
285
239
|
return { items: [...lead, ...selected.items], summary: selected.summary };
|
|
286
240
|
}
|
|
287
241
|
|
|
288
|
-
// =============================================================================
|
|
289
|
-
// Stop-reminder guards (omp TodoTracker.isAwaitingUserAnswer)
|
|
290
|
-
// =============================================================================
|
|
291
|
-
|
|
292
|
-
const MARKDOWN_PROMPT_PREFIX_RE = /^(?:>\s*)?(?:(?:[-*+]|\d+[.)])\s+)*/;
|
|
293
|
-
const PROMPT_LABEL_RE = /^(?:q(?:uestion)?|ask)\s*\d*\s*[:.)-]\s*/i;
|
|
294
|
-
const QUESTION_PROMPT_RE =
|
|
295
|
-
/^(?:what|which|when|where|why|how|who|whom|whose|do|does|did|can|could|would|will|should|is|are|am|may|shall)\b/i;
|
|
296
|
-
const USER_DIRECTED_PROMPT_RE = /\b(?:you|your|we|our)\b/i;
|
|
297
|
-
const USER_RESPONSE_CUE_RE =
|
|
298
|
-
/^(?:please\s+)?(?:confirm|reply|choose|pick|decide|advise)\b|^(?:please\s+)?answer\b|^(?:please\s+)?(?:let\s+me\s+know|tell\s+me)\b/i;
|
|
299
|
-
/**
|
|
300
|
-
* A trailing question mark is the universal signal that a line is a question, but
|
|
301
|
-
* the English word/pronoun gates above exist to filter incidental "?" out of prose
|
|
302
|
-
* (e.g. a TypeScript `foo?: string` tail). Non-English text has no cheap word list,
|
|
303
|
-
* yet any non-ASCII character in a "?"/"?"-terminated line reliably marks it as
|
|
304
|
-
* genuine prose — so treat it as a real user-directed question (omp #7803).
|
|
305
|
-
*/
|
|
306
|
-
const NON_ASCII_TEXT_RE = /[^\x00-\x7F]/;
|
|
307
|
-
|
|
308
|
-
interface PromptLine {
|
|
309
|
-
text: string;
|
|
310
|
-
hadPromptLabel: boolean;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
function promptLine(line: string): PromptLine {
|
|
314
|
-
const withoutMarkdownPrefix = line.trim().replace(MARKDOWN_PROMPT_PREFIX_RE, "").trim();
|
|
315
|
-
const withoutPromptLabel = withoutMarkdownPrefix.replace(PROMPT_LABEL_RE, "").trim();
|
|
316
|
-
return {
|
|
317
|
-
text: withoutPromptLabel,
|
|
318
|
-
hadPromptLabel: withoutPromptLabel !== withoutMarkdownPrefix,
|
|
319
|
-
};
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function isQuestionPromptLine(line: string): boolean {
|
|
323
|
-
const candidate = promptLine(line);
|
|
324
|
-
if (!/[??]\s*$/.test(candidate.text)) return false;
|
|
325
|
-
return (
|
|
326
|
-
candidate.hadPromptLabel ||
|
|
327
|
-
QUESTION_PROMPT_RE.test(candidate.text) ||
|
|
328
|
-
USER_DIRECTED_PROMPT_RE.test(candidate.text) ||
|
|
329
|
-
NON_ASCII_TEXT_RE.test(candidate.text)
|
|
330
|
-
);
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function isResponseCueLine(line: string): boolean {
|
|
334
|
-
const candidate = promptLine(line)
|
|
335
|
-
.text.replace(/[.!?。!?]+$/, "")
|
|
336
|
-
.trim();
|
|
337
|
-
return USER_RESPONSE_CUE_RE.test(candidate);
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** Whether an assistant reply ends by asking the user something — omp skips
|
|
341
|
-
* the stop reminder in that case (the ball is in the user's court). */
|
|
342
|
-
export function isAwaitingUserAnswer(assistantText: string): boolean {
|
|
343
|
-
const text = assistantText.trim();
|
|
344
|
-
if (!text) return false;
|
|
345
|
-
const lastLine = text.split(/\r?\n/).at(-1)?.trim();
|
|
346
|
-
return lastLine !== undefined && (isQuestionPromptLine(lastLine) || isResponseCueLine(lastLine));
|
|
347
|
-
}
|
|
348
|
-
|
|
349
242
|
// =============================================================================
|
|
350
243
|
// Phase numbering (display-only, omp phaseRomanNumeral)
|
|
351
244
|
// =============================================================================
|
|
@@ -473,7 +366,6 @@ export function markdownToPhases(md: string): { phases: TodoPhase[]; errors: str
|
|
|
473
366
|
errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
|
|
474
367
|
}
|
|
475
368
|
|
|
476
|
-
normalizeInProgressTask(phases);
|
|
477
369
|
return { phases, errors };
|
|
478
370
|
}
|
|
479
371
|
|
|
@@ -493,18 +385,6 @@ export function resolveTodoMarkdownPath(input: string, cwd: string): string {
|
|
|
493
385
|
return path.isAbsolute(expanded) ? expanded : path.resolve(cwd, expanded);
|
|
494
386
|
}
|
|
495
387
|
|
|
496
|
-
/**
|
|
497
|
-
* Actionable open work (pending / in_progress): what stop-time reminders count.
|
|
498
|
-
* Blocked tasks are excluded — they are parked awaiting external input, so
|
|
499
|
-
* nagging about them would be noise (omp todo prompt: "excluded from
|
|
500
|
-
* stop-time incomplete-todo reminder").
|
|
501
|
-
*/
|
|
502
|
-
export function openTasks(phases: TodoPhase[]): TodoItem[] {
|
|
503
|
-
return phases.flatMap(phase =>
|
|
504
|
-
phase.tasks.filter(task => task.status === "pending" || task.status === "in_progress"),
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
388
|
// =============================================================================
|
|
509
389
|
// Operation resolution
|
|
510
390
|
// =============================================================================
|
|
@@ -655,13 +535,6 @@ function applyEntry(phases: TodoPhase[], entry: TodoOpEntry, errors: string[]):
|
|
|
655
535
|
case "start": {
|
|
656
536
|
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
657
537
|
if (!hit) return phases;
|
|
658
|
-
for (const phase of phases) {
|
|
659
|
-
for (const candidate of phase.tasks) {
|
|
660
|
-
if (candidate.status === "in_progress" && candidate !== hit.task) {
|
|
661
|
-
candidate.status = "pending";
|
|
662
|
-
}
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
538
|
hit.task.status = "in_progress";
|
|
666
539
|
return phases;
|
|
667
540
|
}
|
|
@@ -745,7 +618,6 @@ export function inferTodoOp(args: Record<string, unknown>, hasExistingPhases: bo
|
|
|
745
618
|
export function applyParams(phases: TodoPhase[], params: TodoOpEntry): { phases: TodoPhase[]; errors: string[] } {
|
|
746
619
|
const errors: string[] = [];
|
|
747
620
|
const next = applyEntry(phases, params, errors);
|
|
748
|
-
normalizeInProgressTask(next);
|
|
749
621
|
return { phases: next, errors };
|
|
750
622
|
}
|
|
751
623
|
|
|
@@ -759,7 +631,6 @@ export function applyOpsToPhases(
|
|
|
759
631
|
for (const op of ops) {
|
|
760
632
|
next = applyEntry(next, op, errors);
|
|
761
633
|
}
|
|
762
|
-
normalizeInProgressTask(next);
|
|
763
634
|
return { phases: next, errors };
|
|
764
635
|
}
|
|
765
636
|
|
|
@@ -767,6 +638,16 @@ export function applyOpsToPhases(
|
|
|
767
638
|
// Summary text (tool result body)
|
|
768
639
|
// =============================================================================
|
|
769
640
|
|
|
641
|
+
/** Fold the per-task dumps above this size: mutation results carry the counts
|
|
642
|
+
* and the leading open tasks, not the whole list — on big lists every
|
|
643
|
+
* `done`/`start` would otherwise flood the model's context with a
|
|
644
|
+
* full-checklist echo. `view` (readOnly) always echoes in full; this folds
|
|
645
|
+
* only mutation results without errors (error results must carry the
|
|
646
|
+
* unchanged list for the model's retry). */
|
|
647
|
+
const SUMMARY_FULL_LIST_LIMIT = 20;
|
|
648
|
+
/** Open tasks shown when the remaining dump is folded. */
|
|
649
|
+
const SUMMARY_FOLDED_SHOWN = 10;
|
|
650
|
+
|
|
770
651
|
export function formatSummary(phases: TodoPhase[], errors: string[], readOnly = false): string {
|
|
771
652
|
const tasks = phases.flatMap(phase => phase.tasks);
|
|
772
653
|
if (tasks.length === 0) {
|
|
@@ -787,55 +668,60 @@ export function formatSummary(phases: TodoPhase[], errors: string[], readOnly =
|
|
|
787
668
|
);
|
|
788
669
|
if (currentIdx === -1) currentIdx = phases.length - 1;
|
|
789
670
|
const current = phases[currentIdx];
|
|
790
|
-
const done = current.tasks.filter(
|
|
671
|
+
const done = current.tasks.filter(isClosedTodo).length;
|
|
791
672
|
|
|
792
673
|
const lines: string[] = [];
|
|
674
|
+
const fold = !readOnly && errors.length === 0 && tasks.length > SUMMARY_FULL_LIST_LIMIT;
|
|
793
675
|
if (errors.length > 0) lines.push(`Errors: ${errors.join("; ")}`);
|
|
794
676
|
if (remainingTasks.length === 0) {
|
|
795
677
|
lines.push("Remaining items: none.");
|
|
796
678
|
} else {
|
|
797
679
|
lines.push(`Remaining items (${remainingTasks.length}):`);
|
|
798
|
-
|
|
680
|
+
const shown = fold ? remainingTasks.slice(0, SUMMARY_FOLDED_SHOWN) : remainingTasks;
|
|
681
|
+
for (const task of shown) {
|
|
799
682
|
lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
|
|
800
683
|
}
|
|
684
|
+
if (shown.length < remainingTasks.length) {
|
|
685
|
+
lines.push(` … ${remainingTasks.length - shown.length} more open — call \`view\` for the full list.`);
|
|
686
|
+
}
|
|
801
687
|
}
|
|
802
688
|
// Closed = completed + abandoned, mirroring the per-phase `done` count.
|
|
803
|
-
const closedAll = tasks.filter(
|
|
689
|
+
const closedAll = tasks.filter(isClosedTodo).length;
|
|
804
690
|
const blockedAll = tasks.filter(task => task.status === "blocked").length;
|
|
805
|
-
// The active phase is the EARLIEST one still holding open work, so
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
|
|
810
|
-
const workedAhead = phases.some(
|
|
811
|
-
(phase, idx) =>
|
|
812
|
-
idx > currentIdx && phase.tasks.some(task => task.status === "completed" || task.status === "abandoned"),
|
|
813
|
-
);
|
|
691
|
+
// The active phase is the EARLIEST one still holding open work, so it can
|
|
692
|
+
// sit behind phases that already hold completed tasks from out-of-order
|
|
693
|
+
// execution. Say so instead of letting it read as a completed task
|
|
694
|
+
// reverting to pending.
|
|
695
|
+
const workedAhead = phases.some((phase, idx) => idx > currentIdx && phase.tasks.some(isClosedTodo));
|
|
814
696
|
lines.push(
|
|
815
697
|
`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open${blockedAll > 0 ? `, ${blockedAll} blocked` : ""}.`,
|
|
816
698
|
);
|
|
817
699
|
lines.push(
|
|
818
700
|
`Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${
|
|
819
701
|
workedAhead
|
|
820
|
-
? " — earliest phase with open tasks;
|
|
702
|
+
? " — earliest phase with open tasks; later phases may already hold completed work from out-of-order execution (nothing was un-completed)."
|
|
821
703
|
: "."
|
|
822
704
|
}`,
|
|
823
705
|
);
|
|
824
|
-
|
|
825
|
-
lines.push(`
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
706
|
+
if (fold) {
|
|
707
|
+
lines.push(`Full checklist omitted (${tasks.length} tasks) — \`view\` echoes it.`);
|
|
708
|
+
} else {
|
|
709
|
+
for (const phase of phases) {
|
|
710
|
+
lines.push(` ${phase.name}:`);
|
|
711
|
+
for (const task of phase.tasks) {
|
|
712
|
+
const checkbox = task.status === "completed" ? "[X]" : "[ ]";
|
|
713
|
+
const tag =
|
|
714
|
+
task.status === "in_progress"
|
|
715
|
+
? " (in progress)"
|
|
716
|
+
: task.status === "abandoned"
|
|
717
|
+
? " (dropped)"
|
|
718
|
+
: task.status === "blocked"
|
|
719
|
+
? task.blocker
|
|
720
|
+
? ` (blocked: ${task.blocker})`
|
|
721
|
+
: " (blocked)"
|
|
722
|
+
: "";
|
|
723
|
+
lines.push(` - ${checkbox} ${task.content}${tag}`);
|
|
724
|
+
}
|
|
839
725
|
}
|
|
840
726
|
}
|
|
841
727
|
return lines.join("\n");
|