@dieulc/workflow 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +66 -0
- package/checkpoint.ts +302 -0
- package/index.ts +5219 -0
- package/package.json +56 -0
- package/roles.ts +325 -0
- package/utils.ts +1203 -0
package/utils.ts
ADDED
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utils for workflow extension — copied from plan-mode example
|
|
3
|
+
* Pure functions, testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const DESTRUCTIVE_PATTERNS = [
|
|
7
|
+
/\brm\b/i,
|
|
8
|
+
/\brmdir\b/i,
|
|
9
|
+
/\bmv\b/i,
|
|
10
|
+
/\bcp\b/i,
|
|
11
|
+
/\bmkdir\b/i,
|
|
12
|
+
/\btouch\b/i,
|
|
13
|
+
/\bchmod\b/i,
|
|
14
|
+
/\bchown\b/i,
|
|
15
|
+
/\bchgrp\b/i,
|
|
16
|
+
/\bln\b/i,
|
|
17
|
+
/\btee\b/i,
|
|
18
|
+
/\btruncate\b/i,
|
|
19
|
+
/\bdd\b/i,
|
|
20
|
+
/\bshred\b/i,
|
|
21
|
+
/(^|[^<])>(?!>)/,
|
|
22
|
+
/>>/,
|
|
23
|
+
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
|
24
|
+
/\byarn\s+(add|remove|install|publish)/i,
|
|
25
|
+
/\bpnpm\s+(add|remove|install|publish)/i,
|
|
26
|
+
/\bpip\s+(install|uninstall)/i,
|
|
27
|
+
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
|
28
|
+
/\bbrew\s+(install|uninstall|upgrade)/i,
|
|
29
|
+
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
|
30
|
+
/\bsudo\b/i,
|
|
31
|
+
/\bsu\b/i,
|
|
32
|
+
/\bkill\b/i,
|
|
33
|
+
/\bpkill\b/i,
|
|
34
|
+
/\bkillall\b/i,
|
|
35
|
+
/\breboot\b/i,
|
|
36
|
+
/\bshutdown\b/i,
|
|
37
|
+
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
|
38
|
+
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
|
39
|
+
/\b(vim?|nano|emacs|code|subl)\b/i,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const SAFE_PATTERNS = [
|
|
43
|
+
/^\s*cat\b/,
|
|
44
|
+
/^\s*head\b/,
|
|
45
|
+
/^\s*tail\b/,
|
|
46
|
+
/^\s*less\b/,
|
|
47
|
+
/^\s*more\b/,
|
|
48
|
+
/^\s*grep\b/,
|
|
49
|
+
/^\s*find\b/,
|
|
50
|
+
/^\s*ls\b/,
|
|
51
|
+
/^\s*pwd\b/,
|
|
52
|
+
/^\s*echo\b/,
|
|
53
|
+
/^\s*printf\b/,
|
|
54
|
+
/^\s*wc\b/,
|
|
55
|
+
/^\s*sort\b/,
|
|
56
|
+
/^\s*uniq\b/,
|
|
57
|
+
/^\s*diff\b/,
|
|
58
|
+
/^\s*file\b/,
|
|
59
|
+
/^\s*stat\b/,
|
|
60
|
+
/^\s*du\b/,
|
|
61
|
+
/^\s*df\b/,
|
|
62
|
+
/^\s*tree\b/,
|
|
63
|
+
/^\s*which\b/,
|
|
64
|
+
/^\s*whereis\b/,
|
|
65
|
+
/^\s*type\b/,
|
|
66
|
+
/^\s*env\b/,
|
|
67
|
+
/^\s*printenv\b/,
|
|
68
|
+
/^\s*uname\b/,
|
|
69
|
+
/^\s*whoami\b/,
|
|
70
|
+
/^\s*id\b/,
|
|
71
|
+
/^\s*date\b/,
|
|
72
|
+
/^\s*cal\b/,
|
|
73
|
+
/^\s*uptime\b/,
|
|
74
|
+
/^\s*ps\b/,
|
|
75
|
+
/^\s*top\b/,
|
|
76
|
+
/^\s*htop\b/,
|
|
77
|
+
/^\s*free\b/,
|
|
78
|
+
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
|
79
|
+
/^\s*git\s+ls-/i,
|
|
80
|
+
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
|
81
|
+
/^\s*yarn\s+(list|info|why|audit)/i,
|
|
82
|
+
/^\s*node\s+--version/i,
|
|
83
|
+
/^\s*python\s+--version/i,
|
|
84
|
+
/^\s*curl\s/i,
|
|
85
|
+
/^\s*wget\s+-O\s*-/i,
|
|
86
|
+
/^\s*jq\b/,
|
|
87
|
+
/^\s*sed\s+-n/i,
|
|
88
|
+
/^\s*awk\b/,
|
|
89
|
+
/^\s*rg\b/,
|
|
90
|
+
/^\s*fd\b/,
|
|
91
|
+
/^\s*bat\b/,
|
|
92
|
+
/^\s*eza\b/,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
export function isSafeCommand(command: string): boolean {
|
|
96
|
+
const isDestructive = DESTRUCTIVE_PATTERNS.some((p) => p.test(command));
|
|
97
|
+
const isSafe = SAFE_PATTERNS.some((p) => p.test(command));
|
|
98
|
+
return !isDestructive && isSafe;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type TodoStatus = "pending" | "in_progress" | "completed" | "cancelled";
|
|
102
|
+
|
|
103
|
+
export interface TodoItem {
|
|
104
|
+
step: number;
|
|
105
|
+
text: string;
|
|
106
|
+
/** Legacy boolean flag; kept derived from `status` for old sessions/external readers. */
|
|
107
|
+
completed: boolean;
|
|
108
|
+
/** Industry-style status. Legacy items derive it from `completed`. */
|
|
109
|
+
status?: TodoStatus;
|
|
110
|
+
/** Stable, globally-unique identity ref assigned at creation and never renumbered. */
|
|
111
|
+
ref?: string;
|
|
112
|
+
/** Plan-faithful label, e.g. "1", "2.3". Legacy items derive it from step. */
|
|
113
|
+
label?: string;
|
|
114
|
+
/** Optional group/phase heading the step belongs to. */
|
|
115
|
+
group?: string;
|
|
116
|
+
/** Where the item came from. */
|
|
117
|
+
source?: "plan" | "agent" | "synthesized";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const TODO_STATUS_RANK: Record<TodoStatus, number> = {
|
|
121
|
+
completed: 3,
|
|
122
|
+
in_progress: 2,
|
|
123
|
+
pending: 1,
|
|
124
|
+
cancelled: 0,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Resolve an item's status; legacy `completed` booleans are backfilled. */
|
|
128
|
+
export function itemStatus(it: TodoItem): TodoStatus {
|
|
129
|
+
if (it?.status) return it.status;
|
|
130
|
+
return it?.completed ? "completed" : "pending";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** True when the item is done. */
|
|
134
|
+
export function isDone(it: TodoItem): boolean {
|
|
135
|
+
return itemStatus(it) === "completed";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Set an item's status and keep the legacy `completed` flag in sync. */
|
|
139
|
+
export function setTodoStatus(it: TodoItem, status: TodoStatus): TodoItem {
|
|
140
|
+
it.status = status;
|
|
141
|
+
it.completed = status === "completed";
|
|
142
|
+
return it;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Merge precedence for two sources disagreeing on status: the more advanced
|
|
147
|
+
* rank wins (completed > in_progress > pending > cancelled), so a plan-file
|
|
148
|
+
* `- [x]` can mark an item done but nothing can silently un-complete it.
|
|
149
|
+
*/
|
|
150
|
+
export function mergeStatusByRank(
|
|
151
|
+
a: TodoStatus | undefined,
|
|
152
|
+
b: TodoStatus | undefined,
|
|
153
|
+
): TodoStatus {
|
|
154
|
+
const ra = a ? TODO_STATUS_RANK[a] : 0;
|
|
155
|
+
const rb = b ? TODO_STATUS_RANK[b] : 0;
|
|
156
|
+
return rb > ra ? (b as TodoStatus) : ((a ?? "pending") as TodoStatus);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Canonical, globally-unique ref for an item (stable across merges). */
|
|
160
|
+
export function todoRef(it: Pick<TodoItem, "ref" | "step">): string {
|
|
161
|
+
return it?.ref || String(it?.step ?? 0);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Numeric value of a ref, if it is an integer (for range math). */
|
|
165
|
+
export function todoRefNumber(ref: string): number | undefined {
|
|
166
|
+
const n = Number(String(ref ?? "").trim());
|
|
167
|
+
return Number.isFinite(n) && Number.isInteger(n) ? n : undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Normalize step text for display and identity. Strips markdown emphasis
|
|
172
|
+
* (including orphan `**` left by partially consumed bold) and collapses
|
|
173
|
+
* whitespace. Deliberately does NOT strip imperative verbs — the todo list
|
|
174
|
+
* must mirror the plan's wording.
|
|
175
|
+
*/
|
|
176
|
+
export function normalizeStepText(text: string): string {
|
|
177
|
+
let cleaned = String(text ?? "")
|
|
178
|
+
.replace(/\*\*(.+?)\*\*/g, "$1")
|
|
179
|
+
.replace(/__(.+?)__/g, "$1")
|
|
180
|
+
.replace(/\*{1,2}/g, "")
|
|
181
|
+
.replace(/\s+/g, " ")
|
|
182
|
+
.trim()
|
|
183
|
+
.replace(/[:\-\u2013\u2014]\s*$/, "")
|
|
184
|
+
.trim();
|
|
185
|
+
if (cleaned.length > 200) cleaned = `${cleaned.slice(0, 197)}...`;
|
|
186
|
+
return cleaned;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @deprecated kept for compatibility; use normalizeStepText. */
|
|
190
|
+
export function cleanStepText(text: string): string {
|
|
191
|
+
return normalizeStepText(text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Stable identity key for dedupe/merge (case/punctuation-insensitive). */
|
|
195
|
+
export function todoKey(item: Pick<TodoItem, "text" | "group">): string {
|
|
196
|
+
const norm = (s: string | undefined) =>
|
|
197
|
+
String(s ?? "")
|
|
198
|
+
.toLowerCase()
|
|
199
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
200
|
+
.trim();
|
|
201
|
+
const g = norm(item.group);
|
|
202
|
+
const t = norm(item.text);
|
|
203
|
+
return g ? `${g}|${t}` : t;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Backfill labels/ref/status/source for items restored from older sessions.
|
|
208
|
+
* Global refs are canonical positions: `step`/`ref` are recomputed as 1..N in
|
|
209
|
+
* array order, so persisted lists with inflated refs (e.g. 17..23 written by
|
|
210
|
+
* an older build) heal on restore. Plan labels are preserved as display
|
|
211
|
+
* metadata and default to the position when absent.
|
|
212
|
+
*/
|
|
213
|
+
export function ensureTodoLabels(items: TodoItem[]): TodoItem[] {
|
|
214
|
+
return (items ?? []).map((it, i) => {
|
|
215
|
+
const step = i + 1;
|
|
216
|
+
// Preserve the original positional label for legacy rows without a label.
|
|
217
|
+
const originalStep = Number.isFinite(it?.step) ? Number(it.step) : step;
|
|
218
|
+
const status = itemStatus(it);
|
|
219
|
+
return {
|
|
220
|
+
...it,
|
|
221
|
+
step,
|
|
222
|
+
label: it?.label || String(originalStep),
|
|
223
|
+
ref: String(step),
|
|
224
|
+
status,
|
|
225
|
+
completed: status === "completed",
|
|
226
|
+
source: it?.source ?? "plan",
|
|
227
|
+
};
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Stable dedupe — first occurrence wins. */
|
|
232
|
+
export function dedupeTodoItems(items: TodoItem[]): TodoItem[] {
|
|
233
|
+
const seen = new Set<string>();
|
|
234
|
+
const out: TodoItem[] = [];
|
|
235
|
+
for (const it of items ?? []) {
|
|
236
|
+
const key = todoKey(it);
|
|
237
|
+
if (!key || seen.has(key)) continue;
|
|
238
|
+
seen.add(key);
|
|
239
|
+
out.push(it);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface TodoMergeResult {
|
|
245
|
+
items: TodoItem[];
|
|
246
|
+
added: number;
|
|
247
|
+
removed: number;
|
|
248
|
+
kept: number;
|
|
249
|
+
/** Number of unmatched `source:"agent"` items preserved from the previous list. */
|
|
250
|
+
preserved: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Merge freshly extracted plan steps into the existing list while preserving
|
|
255
|
+
* completion state for steps that are still present. Extracted order wins.
|
|
256
|
+
*
|
|
257
|
+
* Match order: exact `todoKey` (text+group) first, then — for existing
|
|
258
|
+
* `source:"plan"` rows — the plan label (group-aware, collision-safe), so a
|
|
259
|
+
* step whose wording the model rewrote in `workflow_todo update` still
|
|
260
|
+
* reunites with its plan row instead of being duplicated. On a label match the
|
|
261
|
+
* extracted text is authoritative and status merges by rank (a plan-file
|
|
262
|
+
* `- [x]` can mark an item done, nothing un-completes it). Unmatched
|
|
263
|
+
* `source:"agent"` items are preserved.
|
|
264
|
+
*
|
|
265
|
+
* Global refs are canonical positions: `step`/`ref` are recomputed as 1..N in
|
|
266
|
+
* the returned list order, so displayed numbers always equal `todoRef`.
|
|
267
|
+
*/
|
|
268
|
+
export function mergeTodoItems(
|
|
269
|
+
existing: TodoItem[],
|
|
270
|
+
extracted: TodoItem[],
|
|
271
|
+
): TodoMergeResult {
|
|
272
|
+
const prev = ensureTodoLabels(existing ?? []);
|
|
273
|
+
const next = dedupeTodoItems((extracted ?? []).map((n) => ({ ...n })));
|
|
274
|
+
const normLabel = (s: string | undefined) =>
|
|
275
|
+
String(s ?? "")
|
|
276
|
+
.toLowerCase()
|
|
277
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
278
|
+
.trim();
|
|
279
|
+
const byLabel = new Map<string, TodoItem[]>();
|
|
280
|
+
const byKey = new Map<string, TodoItem>();
|
|
281
|
+
for (const p of prev) {
|
|
282
|
+
const l = normLabel(p.label || String(p.step));
|
|
283
|
+
if (l) {
|
|
284
|
+
const arr = byLabel.get(l);
|
|
285
|
+
if (arr) arr.push(p);
|
|
286
|
+
else byLabel.set(l, [p]);
|
|
287
|
+
}
|
|
288
|
+
const k = todoKey(p);
|
|
289
|
+
if (k && !byKey.has(k)) byKey.set(k, p);
|
|
290
|
+
}
|
|
291
|
+
const used = new Set<TodoItem>();
|
|
292
|
+
let added = 0;
|
|
293
|
+
let kept = 0;
|
|
294
|
+
/**
|
|
295
|
+
* Collision-safe plan-label match for a re-extracted step. Only existing
|
|
296
|
+
* `source:"plan"` rows are eligible; when duplicate labels exist across
|
|
297
|
+
* phases the group must disambiguate (otherwise the label is not trusted).
|
|
298
|
+
*/
|
|
299
|
+
const matchByPlanLabel = (n: TodoItem): TodoItem | undefined => {
|
|
300
|
+
const l = normLabel(n.label);
|
|
301
|
+
if (!l) return undefined;
|
|
302
|
+
const cands = (byLabel.get(l) ?? []).filter(
|
|
303
|
+
(p) => !used.has(p) && p.source === "plan",
|
|
304
|
+
);
|
|
305
|
+
if (cands.length === 0) return undefined;
|
|
306
|
+
if (cands.length === 1) return cands[0];
|
|
307
|
+
const ng = normLabel(n.group);
|
|
308
|
+
const grouped = cands.filter((p) => normLabel(p.group) === ng);
|
|
309
|
+
return grouped.length === 1 ? grouped[0] : undefined;
|
|
310
|
+
};
|
|
311
|
+
const items = next.map((n) => {
|
|
312
|
+
const nKey = todoKey(n);
|
|
313
|
+
let match: TodoItem | undefined = nKey ? byKey.get(nKey) : undefined;
|
|
314
|
+
if (match && used.has(match)) match = undefined;
|
|
315
|
+
if (!match) match = matchByPlanLabel(n);
|
|
316
|
+
if (match && !used.has(match)) {
|
|
317
|
+
used.add(match);
|
|
318
|
+
kept++;
|
|
319
|
+
const item: TodoItem = {
|
|
320
|
+
...n,
|
|
321
|
+
group: n.group ?? match.group,
|
|
322
|
+
source: n.source ?? match.source,
|
|
323
|
+
};
|
|
324
|
+
const status = mergeStatusByRank(itemStatus(match), itemStatus(item));
|
|
325
|
+
setTodoStatus(item, status);
|
|
326
|
+
return item;
|
|
327
|
+
}
|
|
328
|
+
added++;
|
|
329
|
+
const item: TodoItem = { ...n };
|
|
330
|
+
if (item.status) item.completed = item.status === "completed";
|
|
331
|
+
else setTodoStatus(item, item.completed ? "completed" : "pending");
|
|
332
|
+
return item;
|
|
333
|
+
});
|
|
334
|
+
// Preserve unmatched agent-added items (progress + ad-hoc steps survive sync).
|
|
335
|
+
const preserved: TodoItem[] = [];
|
|
336
|
+
for (const p of prev) {
|
|
337
|
+
if (used.has(p)) continue;
|
|
338
|
+
if (p.source === "agent") preserved.push({ ...p });
|
|
339
|
+
}
|
|
340
|
+
const all = [...items, ...preserved];
|
|
341
|
+
all.forEach((it, i) => {
|
|
342
|
+
it.step = i + 1;
|
|
343
|
+
it.ref = String(i + 1);
|
|
344
|
+
if (!it.label) it.label = String(i + 1);
|
|
345
|
+
});
|
|
346
|
+
const removed = prev.filter(
|
|
347
|
+
(p) => !used.has(p) && p.source !== "agent",
|
|
348
|
+
).length;
|
|
349
|
+
return { items: all, added, removed, kept, preserved: preserved.length };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export interface TodoRefResolution {
|
|
353
|
+
item?: TodoItem;
|
|
354
|
+
ambiguous?: TodoItem[];
|
|
355
|
+
unknown?: string;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Resolve a `[DONE:ref]` / toggle reference to a todo item.
|
|
359
|
+
* Precedence: (a) pure integer → canonical `ref`, then positional `step`, then
|
|
360
|
+
* unique `label`; (b) dotted `N.M` → unique `label`; (c) group-qualified
|
|
361
|
+
* `Group/Label`, `Label@Group`, `#N`; (d) unique normalized-text match ≥8 chars.
|
|
362
|
+
*/
|
|
363
|
+
export function resolveTodoRef(
|
|
364
|
+
items: TodoItem[],
|
|
365
|
+
ref: string,
|
|
366
|
+
): TodoRefResolution {
|
|
367
|
+
const list = items ?? [];
|
|
368
|
+
const raw = String(ref ?? "").trim();
|
|
369
|
+
if (!raw) return { unknown: raw };
|
|
370
|
+
let norm = raw.replace(/[.)]+$/, "").trim();
|
|
371
|
+
const clean = (s: string | undefined) =>
|
|
372
|
+
String(s ?? "")
|
|
373
|
+
.toLowerCase()
|
|
374
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
375
|
+
.trim();
|
|
376
|
+
const unique = (arr: TodoItem[]): TodoRefResolution | null => {
|
|
377
|
+
if (arr.length === 1) return { item: arr[0] };
|
|
378
|
+
if (arr.length > 1) return { ambiguous: arr };
|
|
379
|
+
return null;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// `#N` → ref N
|
|
383
|
+
const hash = norm.match(/^#(\d+)$/);
|
|
384
|
+
if (hash) norm = hash[1];
|
|
385
|
+
|
|
386
|
+
// Group-qualified: "Phase B/2", "2@Phase A"
|
|
387
|
+
const gq = norm.match(/^(.+)\/(\S+)$/) || norm.match(/^(\S+)@(.+)$/);
|
|
388
|
+
if (gq) {
|
|
389
|
+
const g = clean(gq[1]);
|
|
390
|
+
const l = clean(gq[2]);
|
|
391
|
+
const inGroup = list.filter((it) => clean(it.group) === g);
|
|
392
|
+
if (inGroup.length === 0) return { unknown: raw };
|
|
393
|
+
const byLabel = unique(inGroup.filter((it) => clean(it.label) === l));
|
|
394
|
+
if (byLabel) return byLabel;
|
|
395
|
+
const byRef = unique(
|
|
396
|
+
inGroup.filter((it) => clean(todoRef(it)) === l || todoRef(it) === l),
|
|
397
|
+
);
|
|
398
|
+
if (byRef) return byRef;
|
|
399
|
+
const num = Number(l);
|
|
400
|
+
if (Number.isFinite(num))
|
|
401
|
+
return (
|
|
402
|
+
unique(inGroup.filter((it) => it.step === num)) ?? { unknown: raw }
|
|
403
|
+
);
|
|
404
|
+
return { unknown: raw };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Pure integer: canonical ref → positional step → unique label
|
|
408
|
+
if (/^\d+$/.test(norm)) {
|
|
409
|
+
const n = Number(norm);
|
|
410
|
+
const byRef = unique(
|
|
411
|
+
list.filter((it) => todoRef(it) === norm || todoRef(it) === String(n)),
|
|
412
|
+
);
|
|
413
|
+
if (byRef) return byRef;
|
|
414
|
+
const byStep = unique(list.filter((it) => it.step === n));
|
|
415
|
+
if (byStep) return byStep;
|
|
416
|
+
const byLabel = unique(list.filter((it) => clean(it.label) === norm));
|
|
417
|
+
if (byLabel) return byLabel;
|
|
418
|
+
// Duplicated per-phase labels (the historical failure): surface them all so
|
|
419
|
+
// the error can teach the global step numbers.
|
|
420
|
+
const labeled = list.filter((it) => String(it.label) === norm);
|
|
421
|
+
if (labeled.length > 0) return { ambiguous: labeled };
|
|
422
|
+
return { unknown: raw };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Dotted plan label: N.M
|
|
426
|
+
if (/^\d+\.\d+$/.test(norm)) {
|
|
427
|
+
const byLabel = unique(list.filter((it) => it.label === norm));
|
|
428
|
+
if (byLabel) return byLabel;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Unique normalized-text substring (≥8 chars)
|
|
432
|
+
if (norm.length >= 8) {
|
|
433
|
+
const needle = clean(norm);
|
|
434
|
+
const textMatches = list.filter((it) => clean(it.text).includes(needle));
|
|
435
|
+
const res = unique(textMatches);
|
|
436
|
+
if (res) return res;
|
|
437
|
+
}
|
|
438
|
+
return { unknown: raw };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Leniently parse completion refs from assistant text: `[DONE:3]`,
|
|
443
|
+
* `[DONE: 3.1]`, `[DONE:2-4]`, `[DONE #2]`, `[DONE:1,2]`,
|
|
444
|
+
* `[DONE:Phase B/2]` (group-qualified).
|
|
445
|
+
*/
|
|
446
|
+
export function parseDoneRefs(text: string): string[] {
|
|
447
|
+
const refs: string[] = [];
|
|
448
|
+
if (!text) return refs;
|
|
449
|
+
const re = /\[DONE\s*[:#]?\s*([^\]]+)\]/gi;
|
|
450
|
+
for (const m of text.matchAll(re)) {
|
|
451
|
+
const body = String(m[1] ?? "").trim();
|
|
452
|
+
if (!body) continue;
|
|
453
|
+
const range = body.match(/^(\d+)\s*(?:-|\u2013|\u2014|\.\.)\s*(\d+)$/);
|
|
454
|
+
if (range) {
|
|
455
|
+
const a = Number(range[1]);
|
|
456
|
+
const b = Number(range[2]);
|
|
457
|
+
if (Number.isFinite(a) && Number.isFinite(b)) {
|
|
458
|
+
const [lo, hi] = a <= b ? [a, b] : [b, a];
|
|
459
|
+
for (let i = lo; i <= hi && i - lo < 200; i++) refs.push(String(i));
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const numericParts: string[] = [];
|
|
464
|
+
let hasNonNumeric = false;
|
|
465
|
+
for (const part of body.split(/[,\s]+/)) {
|
|
466
|
+
const p = part.trim().replace(/[.)]+$/, "");
|
|
467
|
+
if (/^\d+(?:\.\d+)?$/.test(p)) numericParts.push(p);
|
|
468
|
+
else if (p) hasNonNumeric = true;
|
|
469
|
+
}
|
|
470
|
+
if (numericParts.length > 0) refs.push(...numericParts);
|
|
471
|
+
else if (hasNonNumeric) refs.push(body);
|
|
472
|
+
}
|
|
473
|
+
return refs;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export interface MarkCompletedResult {
|
|
477
|
+
marked: number;
|
|
478
|
+
unknown: string[];
|
|
479
|
+
ambiguous: string[];
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** Mark items completed from `[DONE:ref]` tags. Only sets true. */
|
|
483
|
+
export function markCompletedRefs(
|
|
484
|
+
text: string,
|
|
485
|
+
items: TodoItem[],
|
|
486
|
+
): MarkCompletedResult {
|
|
487
|
+
const result: MarkCompletedResult = {
|
|
488
|
+
marked: 0,
|
|
489
|
+
unknown: [],
|
|
490
|
+
ambiguous: [],
|
|
491
|
+
};
|
|
492
|
+
for (const ref of parseDoneRefs(text)) {
|
|
493
|
+
const res = resolveTodoRef(items, ref);
|
|
494
|
+
if (res.item) {
|
|
495
|
+
setTodoStatus(res.item, "completed");
|
|
496
|
+
result.marked++;
|
|
497
|
+
} else if (res.ambiguous) {
|
|
498
|
+
result.ambiguous.push(ref);
|
|
499
|
+
} else {
|
|
500
|
+
result.unknown.push(ref);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Legacy numeric API retained for compatibility. */
|
|
507
|
+
export function extractDoneSteps(message: string): number[] {
|
|
508
|
+
return parseDoneRefs(message)
|
|
509
|
+
.map(Number)
|
|
510
|
+
.filter((n) => Number.isFinite(n));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Legacy API retained for compatibility. */
|
|
514
|
+
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
|
515
|
+
return markCompletedRefs(text, items).marked;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── Wholesale update (industry-style replace) ────────────────────────
|
|
519
|
+
|
|
520
|
+
export interface TodoUpdateEntry {
|
|
521
|
+
/** Canonical ref to an existing item (optional for new items). */
|
|
522
|
+
ref?: string;
|
|
523
|
+
/** Step text. Exact/normalized match is used when no `ref` is given. */
|
|
524
|
+
text?: string;
|
|
525
|
+
status?: TodoStatus;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export interface TodoUpdateResult {
|
|
529
|
+
items: TodoItem[];
|
|
530
|
+
added: number;
|
|
531
|
+
removed: number;
|
|
532
|
+
kept: number;
|
|
533
|
+
/** Extra `in_progress` items demoted to `pending` (only one allowed). */
|
|
534
|
+
normalized: number;
|
|
535
|
+
/** Non-fatal resolution notes (refs matched by label/text or added as new). */
|
|
536
|
+
warnings: string[];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Industry-style replace update (TodoWrite / todowrite / update_plan semantics):
|
|
541
|
+
* the incoming entries become the new list.
|
|
542
|
+
*
|
|
543
|
+
* - Identify existing items by canonical `ref` first, then by normalized text
|
|
544
|
+
* (group-insensitive), then leniently via `resolveTodoRef` (plan label /
|
|
545
|
+
* group-qualified ref / unique text). A non-empty `ref` that had to be
|
|
546
|
+
* resolved leniently, or that matched nothing, is reported in `warnings`.
|
|
547
|
+
* - Omitted items are removed; new entries are appended.
|
|
548
|
+
* - Exactly one item may be `in_progress`; extras are demoted (counted as
|
|
549
|
+
* `normalized`).
|
|
550
|
+
* - Global refs are canonical positions: `step`/`ref` are recomputed as 1..N in
|
|
551
|
+
* the returned list order, so displayed numbers always equal `todoRef`.
|
|
552
|
+
*/
|
|
553
|
+
export function applyTodoUpdate(
|
|
554
|
+
existing: TodoItem[],
|
|
555
|
+
incoming: TodoUpdateEntry[],
|
|
556
|
+
): TodoUpdateResult {
|
|
557
|
+
const prev = ensureTodoLabels(existing ?? []);
|
|
558
|
+
const byRef = new Map<string, TodoItem>();
|
|
559
|
+
const byTextKey = new Map<string, TodoItem>();
|
|
560
|
+
for (const p of prev) {
|
|
561
|
+
const r = todoRef(p);
|
|
562
|
+
if (r) byRef.set(r.toLowerCase(), p);
|
|
563
|
+
const k = todoKey({ text: p.text });
|
|
564
|
+
if (k && !byTextKey.has(k)) byTextKey.set(k, p);
|
|
565
|
+
}
|
|
566
|
+
const used = new Set<TodoItem>();
|
|
567
|
+
const items: TodoItem[] = [];
|
|
568
|
+
const warnings: string[] = [];
|
|
569
|
+
const short = (s: string) => (s.length > 60 ? `${s.slice(0, 57)}...` : s);
|
|
570
|
+
let added = 0;
|
|
571
|
+
let kept = 0;
|
|
572
|
+
for (const raw of incoming ?? []) {
|
|
573
|
+
const text = normalizeStepText(raw?.text ?? "");
|
|
574
|
+
const rawRef =
|
|
575
|
+
raw?.ref !== undefined && String(raw.ref).trim()
|
|
576
|
+
? String(raw.ref).trim()
|
|
577
|
+
: undefined;
|
|
578
|
+
let match: TodoItem | undefined;
|
|
579
|
+
let matchedBy: "ref" | "label" | "text" | undefined;
|
|
580
|
+
if (rawRef) {
|
|
581
|
+
match = byRef.get(rawRef.toLowerCase());
|
|
582
|
+
if (match) matchedBy = "ref";
|
|
583
|
+
}
|
|
584
|
+
if (!match && text) {
|
|
585
|
+
const k = todoKey({ text });
|
|
586
|
+
match = k ? byTextKey.get(k) : undefined;
|
|
587
|
+
if (match) matchedBy = "text";
|
|
588
|
+
}
|
|
589
|
+
if (!match && rawRef) {
|
|
590
|
+
const resolved = resolveTodoRef(prev, rawRef);
|
|
591
|
+
if (resolved.item && !used.has(resolved.item)) {
|
|
592
|
+
match = resolved.item;
|
|
593
|
+
matchedBy = "label";
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (match && used.has(match)) match = undefined;
|
|
597
|
+
const status = raw?.status ?? "pending";
|
|
598
|
+
if (match) {
|
|
599
|
+
used.add(match);
|
|
600
|
+
kept++;
|
|
601
|
+
if (rawRef && matchedBy !== "ref")
|
|
602
|
+
warnings.push(
|
|
603
|
+
`ref "${rawRef}" not found; matched "${short(match.text)}" by ${matchedBy ?? "text"}`,
|
|
604
|
+
);
|
|
605
|
+
const item: TodoItem = {
|
|
606
|
+
...match,
|
|
607
|
+
text: text || match.text,
|
|
608
|
+
status,
|
|
609
|
+
completed: status === "completed",
|
|
610
|
+
source: match.source ?? "agent",
|
|
611
|
+
};
|
|
612
|
+
items.push(item);
|
|
613
|
+
} else {
|
|
614
|
+
// Skip empty ghost entries (blank text, no ref, already-used ref).
|
|
615
|
+
if (!text) continue;
|
|
616
|
+
added++;
|
|
617
|
+
if (rawRef)
|
|
618
|
+
warnings.push(
|
|
619
|
+
`ref "${rawRef}" not found; added "${short(text)}" as a new step`,
|
|
620
|
+
);
|
|
621
|
+
const item: TodoItem = {
|
|
622
|
+
step: 0,
|
|
623
|
+
text,
|
|
624
|
+
completed: status === "completed",
|
|
625
|
+
status,
|
|
626
|
+
source: "agent",
|
|
627
|
+
};
|
|
628
|
+
items.push(item);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
// Normalize: at most one in_progress.
|
|
632
|
+
let normalized = 0;
|
|
633
|
+
let seenInProgress = false;
|
|
634
|
+
for (const it of items) {
|
|
635
|
+
if (it.status === "in_progress") {
|
|
636
|
+
if (seenInProgress) {
|
|
637
|
+
it.status = "pending";
|
|
638
|
+
it.completed = false;
|
|
639
|
+
normalized++;
|
|
640
|
+
} else {
|
|
641
|
+
seenInProgress = true;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
items.forEach((it, i) => {
|
|
646
|
+
it.step = i + 1;
|
|
647
|
+
it.ref = String(i + 1);
|
|
648
|
+
if (!it.label) it.label = String(i + 1);
|
|
649
|
+
});
|
|
650
|
+
const removed = prev.filter((p) => !used.has(p)).length;
|
|
651
|
+
return { items, added, removed, kept, normalized, warnings };
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// ── Display + reminder builders (pure, exported for tests) ────────────
|
|
655
|
+
|
|
656
|
+
/** Status glyph for a todo row. */
|
|
657
|
+
export function todoGlyph(it: TodoItem): string {
|
|
658
|
+
const s = itemStatus(it);
|
|
659
|
+
return s === "completed"
|
|
660
|
+
? "☑"
|
|
661
|
+
: s === "in_progress"
|
|
662
|
+
? "▶"
|
|
663
|
+
: s === "cancelled"
|
|
664
|
+
? "✖"
|
|
665
|
+
: "☐";
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** One display line: `☑ 6. text (plan label B2)` when the label differs. */
|
|
669
|
+
export function formatTodoLine(
|
|
670
|
+
it: TodoItem,
|
|
671
|
+
opts?: { showLabel?: boolean },
|
|
672
|
+
): string {
|
|
673
|
+
const ref = todoRef(it);
|
|
674
|
+
const labelPart =
|
|
675
|
+
opts?.showLabel !== false &&
|
|
676
|
+
it.label !== undefined &&
|
|
677
|
+
String(it.label) !== ref
|
|
678
|
+
? ` (plan label ${it.label})`
|
|
679
|
+
: "";
|
|
680
|
+
return `${todoGlyph(it)} ${ref}. ${it.text}${labelPart}`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Numbered list for model-facing messages (handoff preview, execute prompt).
|
|
685
|
+
* Uses each row's canonical global ref, so the numbers shown always match the
|
|
686
|
+
* refs `workflow_todo` resolves. Single source for such lists.
|
|
687
|
+
*/
|
|
688
|
+
export function formatTodoNumberedList(
|
|
689
|
+
items: TodoItem[],
|
|
690
|
+
opts?: { glyph?: boolean },
|
|
691
|
+
): string {
|
|
692
|
+
return (items ?? [])
|
|
693
|
+
.map((it) =>
|
|
694
|
+
opts?.glyph
|
|
695
|
+
? `${todoGlyph(it)} ${todoRef(it)}. ${it.text}`
|
|
696
|
+
: `${todoRef(it)}. ${it.text}`,
|
|
697
|
+
)
|
|
698
|
+
.join("\n");
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export interface TodoContextBlockOptions {
|
|
702
|
+
/** Unresolved refs from the previous turn (fed back so the model can fix them). */
|
|
703
|
+
misses?: { refs: string[] };
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* The per-turn reminder block (context injection / before_agent_start).
|
|
708
|
+
* Canonical global refs only; teaches the update protocol.
|
|
709
|
+
*/
|
|
710
|
+
export function buildTodoContextBlock(
|
|
711
|
+
items: TodoItem[],
|
|
712
|
+
opts?: TodoContextBlockOptions,
|
|
713
|
+
): string {
|
|
714
|
+
const list = items ?? [];
|
|
715
|
+
if (list.length === 0) return "";
|
|
716
|
+
const done = list.filter((t) => isDone(t)).length;
|
|
717
|
+
const total = list.length;
|
|
718
|
+
const remaining = list.filter(
|
|
719
|
+
(t) => !isDone(t) && itemStatus(t) !== "cancelled",
|
|
720
|
+
);
|
|
721
|
+
const current =
|
|
722
|
+
list.find((t) => itemStatus(t) === "in_progress") ?? remaining[0];
|
|
723
|
+
const preview = remaining
|
|
724
|
+
.slice(0, 6)
|
|
725
|
+
.map((t) => `${todoRef(t)}. ${t.text}`)
|
|
726
|
+
.join("\n");
|
|
727
|
+
const more = remaining.length > 6 ? `\n… +${remaining.length - 6} more` : "";
|
|
728
|
+
const missBlock = opts?.misses?.refs?.length
|
|
729
|
+
? `\n\n[REFS UNRESOLVED last turn: ${opts.misses.refs.join(", ")} matched multiple steps. Use the GLOBAL STEP numbers below (e.g. step ${current ? todoRef(current) : "N"}).]`
|
|
730
|
+
: "";
|
|
731
|
+
return [
|
|
732
|
+
`[TODO LIST] ${done}/${total} done. Steps are numbered GLOBALLY 1..${total}; use these GLOBAL STEP numbers as refs.`,
|
|
733
|
+
current
|
|
734
|
+
? `Current/next: ${todoRef(current)}. ${current.text}`
|
|
735
|
+
: "All steps done.",
|
|
736
|
+
`Remaining:\n${preview}${more}`,
|
|
737
|
+
`Finish a step → workflow_todo {action:"update", todos:[…COMPLETE LIST…]}, or {action:"done", step:${current ? todoRef(current) : "<N>"}}, or [DONE:${current ? todoRef(current) : "<N>"}].`,
|
|
738
|
+
`After finishing a step, mark it in the SAME turn. Never leave it unchecked.`,
|
|
739
|
+
missBlock,
|
|
740
|
+
]
|
|
741
|
+
.filter(Boolean)
|
|
742
|
+
.join("\n");
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** One-line footer appended to successful mutating tool results. */
|
|
746
|
+
export function buildTodoFooter(items: TodoItem[]): string {
|
|
747
|
+
const list = items ?? [];
|
|
748
|
+
if (list.length === 0) return "";
|
|
749
|
+
const done = list.filter((t) => isDone(t)).length;
|
|
750
|
+
const total = list.length;
|
|
751
|
+
const remaining = list.filter(
|
|
752
|
+
(t) => !isDone(t) && itemStatus(t) !== "cancelled",
|
|
753
|
+
);
|
|
754
|
+
if (remaining.length === 0) return "";
|
|
755
|
+
const current =
|
|
756
|
+
list.find((t) => itemStatus(t) === "in_progress") ?? remaining[0];
|
|
757
|
+
return `[TODO ${done}/${total} — sync status now: if the step you just worked on (${todoRef(current)}. ${current.text.slice(0, 50)}) is finished, call workflow_todo {action:"done", step:${todoRef(current)}} or [DONE:${todoRef(current)}].]`;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** Ambiguity error that teaches the global step numbers. */
|
|
761
|
+
export function formatAmbiguity(items: TodoItem[], ref: string): string {
|
|
762
|
+
const list = items ?? [];
|
|
763
|
+
if (list.length === 0)
|
|
764
|
+
return `Todo "${ref}" is ambiguous but no items match.`;
|
|
765
|
+
const lines = list
|
|
766
|
+
.slice(0, 12)
|
|
767
|
+
.map((t) => `${todoRef(t)}. ${t.text}`)
|
|
768
|
+
.join("\n");
|
|
769
|
+
const more = list.length > 12 ? `\n… +${list.length - 12} more` : "";
|
|
770
|
+
return `Todo "${ref}" is ambiguous (matches ${list.length} items). Use the GLOBAL STEP numbers:\n${lines}${more}`;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export interface RemindState {
|
|
774
|
+
turnsSinceLastTodoWrite: number;
|
|
775
|
+
turnsSinceLastReminder: number;
|
|
776
|
+
remaining: number;
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Claude Code-style throttled cadence: no reminder until enough turns have
|
|
780
|
+
* passed since both the last todo write and the last reminder. */
|
|
781
|
+
export function shouldRemind(state: RemindState): boolean {
|
|
782
|
+
const s = state ?? {
|
|
783
|
+
turnsSinceLastTodoWrite: 0,
|
|
784
|
+
turnsSinceLastReminder: 0,
|
|
785
|
+
remaining: 0,
|
|
786
|
+
};
|
|
787
|
+
return (
|
|
788
|
+
s.remaining > 0 &&
|
|
789
|
+
s.turnsSinceLastTodoWrite >= TURNS_SINCE_WRITE &&
|
|
790
|
+
s.turnsSinceLastReminder >= TURNS_BETWEEN_REMINDERS
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
export const TURNS_SINCE_WRITE = 3;
|
|
795
|
+
export const TURNS_BETWEEN_REMINDERS = 3;
|
|
796
|
+
|
|
797
|
+
// ── Plan extraction (section-scoped) ─────────────────────────────
|
|
798
|
+
|
|
799
|
+
const EXCLUDED_SECTION_RE =
|
|
800
|
+
/^(context|decisions?|exploration(\s+summary)?|risks?|verification|notes?|root\s+cause|background|summary|alternatives?|open\s+questions?|references?|appendix)\b/i;
|
|
801
|
+
|
|
802
|
+
function headingMatch(line: string): { level: number; title: string } | null {
|
|
803
|
+
const m = line.match(/^(#{1,6})\s+(.+?)\s*$/);
|
|
804
|
+
if (!m) return null;
|
|
805
|
+
return { level: m[1].length, title: m[2].trim() };
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** Step-shaped heading: `### 1. Title`, `### Step 2: Title`. */
|
|
809
|
+
function stepFromHeading(line: string): { label: string; text: string } | null {
|
|
810
|
+
const m = line.match(
|
|
811
|
+
/^(#{3,6})\s*(?:(\d+(?:\.\d+)?)[.)]\s+|Step\s+(\d+(?:\.\d+)?)\s*[:\uFF1A\-\u2013\u2014.)]?\s+)(.+?)\s*$/i,
|
|
812
|
+
);
|
|
813
|
+
if (!m) return null;
|
|
814
|
+
const label = m[2] ?? m[3];
|
|
815
|
+
const text = normalizeStepText(m[4] ?? "");
|
|
816
|
+
if (!label || text.length <= 3) return null;
|
|
817
|
+
return { label, text };
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Bold step paragraph: `**Step 1.1: Scaffold**`. */
|
|
821
|
+
function stepFromBold(line: string): { label: string; text: string } | null {
|
|
822
|
+
const m = line.match(
|
|
823
|
+
/^\s*\*\*Step\s+(\d+(?:\.\d+)?)\s*[:\uFF1A\-\u2013\u2014.)]?\s*(.+?)\*\*\s*$/i,
|
|
824
|
+
);
|
|
825
|
+
if (!m) return null;
|
|
826
|
+
const text = normalizeStepText(m[2]);
|
|
827
|
+
if (text.length <= 3) return null;
|
|
828
|
+
return { label: m[1], text };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Numbered list item: `1. Title`. */
|
|
832
|
+
function stepFromList(line: string): { label: string; text: string } | null {
|
|
833
|
+
const m = line.match(/^\s*(\d+(?:\.\d+)?)[.)]\s+(.+?)\s*$/);
|
|
834
|
+
if (!m) return null;
|
|
835
|
+
const text = normalizeStepText(m[2]);
|
|
836
|
+
if (text.length <= 3) return null;
|
|
837
|
+
return { label: m[1], text };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/** Checkbox item: `- [ ] Title` / `- [x] Title`. */
|
|
841
|
+
function stepFromCheckbox(
|
|
842
|
+
line: string,
|
|
843
|
+
): { text: string; completed: boolean } | null {
|
|
844
|
+
const m = line.match(/^\s*[-*]\s*\[([ xX])\]\s+(.+?)\s*$/);
|
|
845
|
+
if (!m) return null;
|
|
846
|
+
const text = normalizeStepText(m[2]);
|
|
847
|
+
if (text.length <= 3) return null;
|
|
848
|
+
return { text, completed: m[1].toLowerCase() === "x" };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function parseStepsFromLines(lines: string[]): TodoItem[] {
|
|
852
|
+
type RawStep = {
|
|
853
|
+
label?: string;
|
|
854
|
+
text: string;
|
|
855
|
+
completed?: boolean;
|
|
856
|
+
group?: string;
|
|
857
|
+
};
|
|
858
|
+
let usedExplicit = false;
|
|
859
|
+
for (const line of lines) {
|
|
860
|
+
if (stepFromHeading(line) || stepFromBold(line)) {
|
|
861
|
+
usedExplicit = true;
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
let numberedCount = 0;
|
|
866
|
+
if (!usedExplicit) {
|
|
867
|
+
for (const line of lines) if (stepFromList(line)) numberedCount++;
|
|
868
|
+
}
|
|
869
|
+
const useNumberedList = !usedExplicit && numberedCount >= 2;
|
|
870
|
+
const raw: RawStep[] = [];
|
|
871
|
+
let group: string | undefined;
|
|
872
|
+
for (const line of lines) {
|
|
873
|
+
const bold = stepFromBold(line);
|
|
874
|
+
if (bold) {
|
|
875
|
+
raw.push({ ...bold, group });
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
const heading = headingMatch(line);
|
|
879
|
+
if (heading) {
|
|
880
|
+
const step = stepFromHeading(line);
|
|
881
|
+
if (step) {
|
|
882
|
+
raw.push({ ...step, group });
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
const title = heading.title;
|
|
886
|
+
if (heading.level >= 3 && !EXCLUDED_SECTION_RE.test(title)) group = title;
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
if (useNumberedList) {
|
|
890
|
+
const list = stepFromList(line);
|
|
891
|
+
if (list) {
|
|
892
|
+
raw.push({ ...list, group });
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
if (!usedExplicit && !useNumberedList) {
|
|
897
|
+
for (const line of lines) {
|
|
898
|
+
const cb = stepFromCheckbox(line);
|
|
899
|
+
if (cb) raw.push({ text: cb.text, completed: cb.completed });
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const items: TodoItem[] = raw.map((r, i) => ({
|
|
903
|
+
step: i + 1,
|
|
904
|
+
text: r.text,
|
|
905
|
+
completed: !!r.completed,
|
|
906
|
+
label: r.label,
|
|
907
|
+
group: r.group,
|
|
908
|
+
source: "plan" as const,
|
|
909
|
+
}));
|
|
910
|
+
const deduped = dedupeTodoItems(items);
|
|
911
|
+
deduped.forEach((it, i) => {
|
|
912
|
+
it.step = i + 1;
|
|
913
|
+
if (!it.label) it.label = String(i + 1);
|
|
914
|
+
});
|
|
915
|
+
return deduped;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Extract plan steps from a plan markdown file. Section-scoped: only the
|
|
920
|
+
* `## Plan Steps` section is parsed (fallback: step-shaped headings across
|
|
921
|
+
* the document, skipping Context/Decisions/Exploration/Risks/Verification).
|
|
922
|
+
* Labels and phase groups are preserved. Verification is never extracted.
|
|
923
|
+
*/
|
|
924
|
+
export function extractPlanStepsFromMarkdown(md: string): TodoItem[] {
|
|
925
|
+
if (!md) return [];
|
|
926
|
+
const lines = md.split(/\r?\n/);
|
|
927
|
+
let start = -1;
|
|
928
|
+
let level = 0;
|
|
929
|
+
for (let i = 0; i < lines.length; i++) {
|
|
930
|
+
const h = headingMatch(lines[i]);
|
|
931
|
+
if (!h || h.level < 2 || h.level > 4) continue;
|
|
932
|
+
if (/plan\s+steps/i.test(h.title)) {
|
|
933
|
+
start = i + 1;
|
|
934
|
+
level = h.level;
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
let section: string[];
|
|
939
|
+
if (start >= 0) {
|
|
940
|
+
let end = lines.length;
|
|
941
|
+
for (let i = start; i < lines.length; i++) {
|
|
942
|
+
const h = headingMatch(lines[i]);
|
|
943
|
+
if (h && h.level <= level && !/plan\s+steps/i.test(h.title)) {
|
|
944
|
+
end = i;
|
|
945
|
+
break;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
section = lines.slice(start, end);
|
|
949
|
+
} else {
|
|
950
|
+
section = [];
|
|
951
|
+
let skipping = false;
|
|
952
|
+
for (const line of lines) {
|
|
953
|
+
const h = headingMatch(line);
|
|
954
|
+
if (h && h.level <= 2) skipping = EXCLUDED_SECTION_RE.test(h.title);
|
|
955
|
+
else if (h && skipping) continue;
|
|
956
|
+
if (!skipping) section.push(line);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
return parseStepsFromLines(section);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Extract plan steps from assistant chat text (used when no plan file exists).
|
|
964
|
+
* Finds a `Plan:` header, then parses section-scoped steps.
|
|
965
|
+
*/
|
|
966
|
+
export function extractTodoItems(message: string): TodoItem[] {
|
|
967
|
+
if (!message) return [];
|
|
968
|
+
let headerIdx = -1;
|
|
969
|
+
let headerLen = 0;
|
|
970
|
+
const strict = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
|
971
|
+
if (strict && strict.index !== undefined) {
|
|
972
|
+
headerIdx = strict.index;
|
|
973
|
+
headerLen = strict[0].length;
|
|
974
|
+
} else {
|
|
975
|
+
const hashPlan = message.match(/^#{1,4}\s*\*{0,2}Plan\b[^\n]*\n/im);
|
|
976
|
+
if (hashPlan && hashPlan.index !== undefined) {
|
|
977
|
+
headerIdx = hashPlan.index;
|
|
978
|
+
headerLen = hashPlan[0].length;
|
|
979
|
+
} else {
|
|
980
|
+
const planColon = message.match(/Plan:\s*[^\n]*\n/i);
|
|
981
|
+
if (planColon && planColon.index !== undefined) {
|
|
982
|
+
headerIdx = planColon.index;
|
|
983
|
+
headerLen = planColon[0].length;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (headerIdx === -1) return [];
|
|
988
|
+
const body = message.slice(headerIdx + headerLen).split(/\r?\n/);
|
|
989
|
+
const filtered: string[] = [];
|
|
990
|
+
let skipping = false;
|
|
991
|
+
for (const line of body) {
|
|
992
|
+
const h = headingMatch(line);
|
|
993
|
+
if (h && h.level <= 2) skipping = EXCLUDED_SECTION_RE.test(h.title);
|
|
994
|
+
else if (h && skipping) continue;
|
|
995
|
+
if (!skipping) filtered.push(line);
|
|
996
|
+
}
|
|
997
|
+
return parseStepsFromLines(filtered);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Single placeholder step from a plan title when no steps could be parsed. */
|
|
1001
|
+
export function synthesizeFromPlanTitle(md: string): TodoItem | null {
|
|
1002
|
+
const src = String(md ?? "");
|
|
1003
|
+
const m = src.match(/^#\s+Plan[:\s]+(.+)$/im) || src.match(/^#\s+(.+)$/m);
|
|
1004
|
+
if (!m) return null;
|
|
1005
|
+
const text = normalizeStepText(m[1]).slice(0, 120);
|
|
1006
|
+
if (!text) return null;
|
|
1007
|
+
return { step: 1, text, completed: false, label: "1", source: "synthesized" };
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
export function isPlanWritePath(p: string, cwd: string): boolean {
|
|
1011
|
+
const norm = p.replace(/\\/g, "/").toLowerCase();
|
|
1012
|
+
const cwdNorm = cwd.replace(/\\/g, "/").toLowerCase();
|
|
1013
|
+
return norm.includes(".pi/plans/") || norm.startsWith(cwdNorm + "/.pi/plans");
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ── Plan date helpers (UTC) ──────────────────────────────────────
|
|
1017
|
+
|
|
1018
|
+
/** Return YYYY-MM-DD in UTC for the given date (default: now). Uses toISOString slice, deterministic across locales. */
|
|
1019
|
+
export function getUtcDatePrefix(d: Date = new Date()): string {
|
|
1020
|
+
return d.toISOString().slice(0, 10);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
export function normalizePlanPath(
|
|
1024
|
+
p: string,
|
|
1025
|
+
cwd: string,
|
|
1026
|
+
today: string,
|
|
1027
|
+
): { path: string; corrected: boolean; original: string } {
|
|
1028
|
+
const original = p;
|
|
1029
|
+
// Only touch paths that are under .pi/plans
|
|
1030
|
+
if (!isPlanWritePath(p, cwd)) return { path: p, corrected: false, original };
|
|
1031
|
+
// Split dir + basename (handle both / and \ separators)
|
|
1032
|
+
const normalizedSep = p.replace(/\\/g, "/");
|
|
1033
|
+
const lastSlash = normalizedSep.lastIndexOf("/");
|
|
1034
|
+
const dir = lastSlash >= 0 ? p.slice(0, lastSlash + 1) : "";
|
|
1035
|
+
const base = lastSlash >= 0 ? p.slice(lastSlash + 1) : p;
|
|
1036
|
+
if (!base) return { path: p, corrected: false, original };
|
|
1037
|
+
const m = base.match(/^(\d{4}-\d{2}-\d{2})-(.+)$/);
|
|
1038
|
+
if (m) {
|
|
1039
|
+
const currentDate = m[1];
|
|
1040
|
+
const rest = m[2];
|
|
1041
|
+
if (currentDate === today) return { path: p, corrected: false, original };
|
|
1042
|
+
// Validate that today looks like YYYY-MM-DD to avoid corrupting on invalid today
|
|
1043
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(today))
|
|
1044
|
+
return { path: p, corrected: false, original };
|
|
1045
|
+
const correctedBase = `${today}-${rest}`;
|
|
1046
|
+
return { path: `${dir}${correctedBase}`, corrected: true, original };
|
|
1047
|
+
}
|
|
1048
|
+
// No date prefix — prepend today-
|
|
1049
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(today))
|
|
1050
|
+
return { path: p, corrected: false, original };
|
|
1051
|
+
return { path: `${dir}${today}-${base}`, corrected: true, original };
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// ── Rewind checkpoint helpers (pure, no FS/git) ─────────────────────
|
|
1055
|
+
|
|
1056
|
+
export function shortId(id: string): string {
|
|
1057
|
+
return (id || "").slice(0, 6);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
export function formatTimestamp(ts: string | undefined): string {
|
|
1061
|
+
if (!ts) return "";
|
|
1062
|
+
try {
|
|
1063
|
+
const d = new Date(ts);
|
|
1064
|
+
if (Number.isNaN(d.getTime())) return ts;
|
|
1065
|
+
const now = Date.now();
|
|
1066
|
+
const diff = now - d.getTime();
|
|
1067
|
+
if (diff < 0) return ts;
|
|
1068
|
+
const sec = Math.floor(diff / 1000);
|
|
1069
|
+
if (sec < 60) return "just now";
|
|
1070
|
+
const min = Math.floor(sec / 60);
|
|
1071
|
+
if (min < 60) return `${min}m ago`;
|
|
1072
|
+
const hr = Math.floor(min / 60);
|
|
1073
|
+
if (hr < 24) return `${hr}h ago`;
|
|
1074
|
+
const days = Math.floor(hr / 24);
|
|
1075
|
+
if (days < 7) return `${days}d ago`;
|
|
1076
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
1077
|
+
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
1078
|
+
} catch {
|
|
1079
|
+
return ts || "";
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function extractUserText(content: unknown): string {
|
|
1084
|
+
if (typeof content === "string") return content;
|
|
1085
|
+
if (Array.isArray(content)) {
|
|
1086
|
+
return (content as any[])
|
|
1087
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
1088
|
+
.map((b) => b.text as string)
|
|
1089
|
+
.join("\n");
|
|
1090
|
+
}
|
|
1091
|
+
return "";
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function firstNonEmptyLine(text: string): string {
|
|
1095
|
+
const lines = text.split(/\r?\n/);
|
|
1096
|
+
for (const ln of lines) {
|
|
1097
|
+
const t = ln.trim();
|
|
1098
|
+
if (t.length > 0) return t;
|
|
1099
|
+
}
|
|
1100
|
+
return "";
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export function getHeaderText(e: any): string {
|
|
1104
|
+
try {
|
|
1105
|
+
if (!e || typeof e !== "object") return "";
|
|
1106
|
+
// Custom btw entries
|
|
1107
|
+
if (
|
|
1108
|
+
e.type === "custom" &&
|
|
1109
|
+
e.customType === "workflow-btw" &&
|
|
1110
|
+
e.data?.note
|
|
1111
|
+
) {
|
|
1112
|
+
const n = String(e.data.note).trim();
|
|
1113
|
+
return collapseAndTruncate(firstNonEmptyLine(n) || n);
|
|
1114
|
+
}
|
|
1115
|
+
if (
|
|
1116
|
+
e.type === "custom_message" &&
|
|
1117
|
+
e.customType === "workflow-btw" &&
|
|
1118
|
+
typeof e.content === "string"
|
|
1119
|
+
) {
|
|
1120
|
+
const c = String(e.content)
|
|
1121
|
+
.replace(/^\[BTW\]\s*/i, "")
|
|
1122
|
+
.trim();
|
|
1123
|
+
return collapseAndTruncate(firstNonEmptyLine(c) || c);
|
|
1124
|
+
}
|
|
1125
|
+
// Label bookmarks
|
|
1126
|
+
if (e.type === "label" && typeof e.label === "string" && e.label.trim()) {
|
|
1127
|
+
return collapseAndTruncate(firstNonEmptyLine(e.label.trim()));
|
|
1128
|
+
}
|
|
1129
|
+
// User messages
|
|
1130
|
+
if (e.type === "message" && e.message?.role === "user") {
|
|
1131
|
+
const raw = extractUserText(e.message.content);
|
|
1132
|
+
if (!raw.trim()) {
|
|
1133
|
+
// image-only: check for image blocks
|
|
1134
|
+
const hasImage =
|
|
1135
|
+
Array.isArray(e.message.content) &&
|
|
1136
|
+
(e.message.content as any[]).some((b) => b?.type === "image");
|
|
1137
|
+
if (hasImage) return "[image]";
|
|
1138
|
+
return "";
|
|
1139
|
+
}
|
|
1140
|
+
const first = firstNonEmptyLine(raw);
|
|
1141
|
+
return collapseAndTruncate(first || raw.trim());
|
|
1142
|
+
}
|
|
1143
|
+
return "";
|
|
1144
|
+
} catch {
|
|
1145
|
+
return "";
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function collapseAndTruncate(s: string): string {
|
|
1150
|
+
const collapsed = s.replace(/\s+/g, " ").trim();
|
|
1151
|
+
if (collapsed.length <= 60) return collapsed;
|
|
1152
|
+
return collapsed.slice(0, 60).trimEnd() + "…";
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function isWorkflowInjected(e: any): boolean {
|
|
1156
|
+
// Extension-injected continuations have display false or workflow-* customTypes
|
|
1157
|
+
if (e.type === "custom_message" && e.display === false) return true;
|
|
1158
|
+
if (e.customType && typeof e.customType === "string") {
|
|
1159
|
+
if (e.customType.startsWith("workflow-plan-context")) return true;
|
|
1160
|
+
if (e.customType.startsWith("workflow-build-context")) return true;
|
|
1161
|
+
if (e.customType === "workflow" || e.customType === "workflow-complete")
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
// Some injected messages use content containing the marker
|
|
1165
|
+
if (e.type === "message" && e.message?.role === "user") {
|
|
1166
|
+
const raw = extractUserText(e.message.content);
|
|
1167
|
+
if (
|
|
1168
|
+
raw.includes("[PLAN MODE ACTIVE]") ||
|
|
1169
|
+
raw.includes("[BUILD MODE — executing plan")
|
|
1170
|
+
)
|
|
1171
|
+
return true;
|
|
1172
|
+
}
|
|
1173
|
+
if (e.type === "custom" && e.customType === "workflow") return true;
|
|
1174
|
+
if (
|
|
1175
|
+
e.type === "custom" &&
|
|
1176
|
+
e.customType === "workflow-btw" &&
|
|
1177
|
+
e.data?.note === undefined
|
|
1178
|
+
)
|
|
1179
|
+
return true;
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
export function isUserInteractionEntry(e: any): boolean {
|
|
1184
|
+
if (!e || typeof e !== "object") return false;
|
|
1185
|
+
if (isWorkflowInjected(e)) return false;
|
|
1186
|
+
// Direct user message
|
|
1187
|
+
if (e.type === "message" && e.message?.role === "user") return true;
|
|
1188
|
+
// BTW notes as custom user interaction (display true)
|
|
1189
|
+
if (
|
|
1190
|
+
e.type === "custom" &&
|
|
1191
|
+
e.customType === "workflow-btw" &&
|
|
1192
|
+
typeof e.data?.note === "string" &&
|
|
1193
|
+
e.data.note.trim()
|
|
1194
|
+
)
|
|
1195
|
+
return true;
|
|
1196
|
+
if (
|
|
1197
|
+
e.type === "custom_message" &&
|
|
1198
|
+
e.customType === "workflow-btw" &&
|
|
1199
|
+
e.display === true
|
|
1200
|
+
)
|
|
1201
|
+
return true;
|
|
1202
|
+
return false;
|
|
1203
|
+
}
|