@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/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
+ }