@dieulc/workflow 0.10.0 → 0.11.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/todo-panel.ts ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Workflow todo panel — pure presentation and state machine.
3
+ *
4
+ * The panel is a fixed widget in the host dock (`ctx.ui.setWidget`), so it can
5
+ * never scroll with the transcript. Its job is therefore to be as small as
6
+ * possible once the plan is finished:
7
+ *
8
+ * - expanded: full anchored rows while work is open;
9
+ * - collapsed: one summary line the moment every item is completed/cancelled;
10
+ * - hidden: no widget at all after the next user prompt (or by explicit user
11
+ * choice).
12
+ *
13
+ * Everything in this module is pure (except `truncateToWidth`) so the state
14
+ * matrix and the rendered rows are unit-testable without the extension host.
15
+ */
16
+
17
+ import { truncateToWidth } from "@earendil-works/pi-tui";
18
+ import { isDone, itemStatus, todoKey, todoRef, type TodoItem } from "./utils.ts";
19
+
20
+ export type TodoPanelState = "expanded" | "collapsed" | "hidden";
21
+ export type TodoPanelOverride = TodoPanelState | null;
22
+ export type TodoPanelMode = "plan" | "build" | null;
23
+
24
+ /** Row cap for the expanded panel (component factories bypass the host's 10-line limit). */
25
+ export const TODO_PANEL_MAX_ROWS = 15;
26
+
27
+ /** Minimal theme surface used by the renderers (keeps tests host-free). */
28
+ export interface TodoPanelTheme {
29
+ fg(color: string, text: string): string;
30
+ strikethrough(text: string): string;
31
+ }
32
+
33
+ export interface TodoPanelInput {
34
+ items: TodoItem[];
35
+ /** Explicit user choice; ignored when `overrideKey` no longer matches the list. */
36
+ override?: TodoPanelOverride;
37
+ /** Identity of the list the override belongs to (see `todoListKey`). */
38
+ overrideKey?: string | null;
39
+ /** A fresh user prompt arrived after the list settled. */
40
+ promptAfterSettle?: boolean;
41
+ /** Drives the ⚠ marker in the summary; the footer owns the actual warning. */
42
+ stale?: boolean;
43
+ }
44
+
45
+ export interface TodoPanelView {
46
+ state: TodoPanelState;
47
+ settled: boolean;
48
+ listKey: string;
49
+ done: number;
50
+ total: number;
51
+ cancelled: number;
52
+ next?: TodoItem;
53
+ summary: string;
54
+ }
55
+
56
+ /** Shape persisted in the `workflow` custom entry. */
57
+ export interface PersistedTodoPanel {
58
+ override: TodoPanelOverride;
59
+ overrideKey: string | null;
60
+ promptAfterSettle: boolean;
61
+ completedSignature: string | null;
62
+ }
63
+
64
+ export const EMPTY_PERSISTED_TODO_PANEL: PersistedTodoPanel = {
65
+ override: null,
66
+ overrideKey: null,
67
+ promptAfterSettle: false,
68
+ completedSignature: null,
69
+ };
70
+
71
+ /** FNV-1a (32-bit) — short, stable, dependency-free. */
72
+ function shortHash(input: string): string {
73
+ let h = 0x811c9dc5;
74
+ for (let i = 0; i < input.length; i++) {
75
+ h ^= input.charCodeAt(i);
76
+ h = Math.imul(h, 0x01000193);
77
+ }
78
+ return (h >>> 0).toString(16).padStart(8, "0");
79
+ }
80
+
81
+ /** Display ref for a todo row (stable canonical GLOBAL step). */
82
+ function todoLabel(it: TodoItem, index: number): string {
83
+ return todoRef(it) || String(it.step || index + 1);
84
+ }
85
+
86
+ function firstOpenItem(items: TodoItem[]): TodoItem | undefined {
87
+ return items.find((it) => !isDone(it) && itemStatus(it) !== "cancelled");
88
+ }
89
+
90
+ function countDone(items: TodoItem[]): number {
91
+ return items.filter((it) => isDone(it)).length;
92
+ }
93
+
94
+ function countCancelled(items: TodoItem[]): number {
95
+ return items.filter((it) => itemStatus(it) === "cancelled").length;
96
+ }
97
+
98
+ /**
99
+ * Identity of a todo list for override scoping. Deliberately excludes status:
100
+ * marking a step done must not reset the user's collapse/hide choice. Adding,
101
+ * removing, renaming or re-grouping steps changes the key and expires it.
102
+ */
103
+ export function todoListKey(items: TodoItem[]): string {
104
+ if (items.length === 0) return "0:empty";
105
+ const basis = items
106
+ .map(
107
+ (it) =>
108
+ `${todoRef(it)}\u0001${todoKey(it)}\u0001${it.group ?? ""}`,
109
+ )
110
+ .join("\u0002");
111
+ return `${items.length}:${shortHash(basis)}`;
112
+ }
113
+
114
+ /**
115
+ * Identity of one *completion event*: the list plus every item's status. Used
116
+ * to send the Build-complete card once per revision instead of once per
117
+ * `agent_end`.
118
+ */
119
+ export function todoCompletionSignature(items: TodoItem[]): string {
120
+ const statuses = items
121
+ .map((it) => `${todoRef(it)}:${itemStatus(it)}`)
122
+ .join(",");
123
+ return shortHash(`${todoListKey(items)}|${statuses}`);
124
+ }
125
+
126
+ /** True when every item is completed or cancelled and the list is non-empty. */
127
+ export function isTodoListSettled(items: TodoItem[]): boolean {
128
+ return (
129
+ items.length > 0 &&
130
+ items.every((it) => isDone(it) || itemStatus(it) === "cancelled")
131
+ );
132
+ }
133
+
134
+ /**
135
+ * Derive the panel state. Mode is intentionally not an input: it only affects
136
+ * row styling, not whether the panel is expanded, collapsed or hidden.
137
+ *
138
+ * Precedence:
139
+ * 1. empty list → hidden;
140
+ * 2. explicit hidden → hidden, for any list state;
141
+ * 3. settled + explicit expanded/collapsed → that state (survives the prompt);
142
+ * 4. settled + a user prompt happened → hidden;
143
+ * 5. settled → collapsed (the “all done” moment);
144
+ * 6. open + explicit collapsed → collapsed;
145
+ * 7. open → expanded.
146
+ */
147
+ export function deriveTodoPanel(input: TodoPanelInput): TodoPanelView {
148
+ const items = input.items;
149
+ const listKey = todoListKey(items);
150
+ const override =
151
+ input.overrideKey != null && input.overrideKey === listKey
152
+ ? (input.override ?? null)
153
+ : null;
154
+ const settled = isTodoListSettled(items);
155
+ let state: TodoPanelState;
156
+ if (items.length === 0) {
157
+ state = "hidden";
158
+ } else if (override === "hidden") {
159
+ state = "hidden";
160
+ } else if (settled) {
161
+ if (override === "expanded" || override === "collapsed") {
162
+ state = override;
163
+ } else if (input.promptAfterSettle === true) {
164
+ state = "hidden";
165
+ } else {
166
+ state = "collapsed";
167
+ }
168
+ } else {
169
+ state = override === "collapsed" ? "collapsed" : "expanded";
170
+ }
171
+
172
+ return {
173
+ state,
174
+ settled,
175
+ listKey,
176
+ done: countDone(items),
177
+ total: items.length,
178
+ cancelled: countCancelled(items),
179
+ next: firstOpenItem(items),
180
+ summary: todoPanelSummary(items, { stale: input.stale }),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * One-line summary. Theme-free so tests and notifications can assert exact
186
+ * text; `renderTodoSummary` applies colors.
187
+ */
188
+ export function todoPanelSummary(
189
+ items: TodoItem[],
190
+ opts: { stale?: boolean } = {},
191
+ ): string {
192
+ if (items.length === 0) return "";
193
+ const done = countDone(items);
194
+ const total = items.length;
195
+ const cancelled = countCancelled(items);
196
+ if (isTodoListSettled(items)) {
197
+ const parts = [`☑ Todos ${done}/${total}`];
198
+ if (cancelled > 0) parts.push(`${cancelled} cancelled`);
199
+ parts.push("all done", "/todos expand");
200
+ return parts.join(" · ");
201
+ }
202
+ const stale = opts.stale ? " ⚠" : "";
203
+ const head = `☐ Todos ${done}/${total}${stale}`;
204
+ const next = firstOpenItem(items);
205
+ return next
206
+ ? `${head} · next ${todoRef(next)}. ${next.text} · /todos expand`
207
+ : `${head} · /todos expand`;
208
+ }
209
+
210
+ /** Render the collapsed one-liner for the widget. */
211
+ export function renderTodoSummary(
212
+ theme: TodoPanelTheme,
213
+ width: number,
214
+ view: TodoPanelView,
215
+ ): string {
216
+ const tone = view.settled
217
+ ? "success"
218
+ : view.state === "collapsed"
219
+ ? "accent"
220
+ : "muted";
221
+ return truncateToWidth(
222
+ theme.fg(tone, view.summary),
223
+ Math.max(1, width - 2),
224
+ "…",
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Rows for the expanded widget: group headers + labelled items, anchored on
230
+ * the first incomplete item (including its group header), capped for the
231
+ * terminal. Output is identical to the pre-refactor `buildTodoRows`.
232
+ */
233
+ export function buildTodoRows(
234
+ theme: TodoPanelTheme,
235
+ width: number,
236
+ items: TodoItem[],
237
+ mode: TodoPanelMode,
238
+ ): string[] {
239
+ if (items.length === 0) return [];
240
+ const contentWidth = Math.max(1, width - 2);
241
+ type Row = { group?: string; item?: TodoItem; index: number };
242
+ const rows: Row[] = [];
243
+ let lastGroup: string | undefined;
244
+ items.forEach((item, index) => {
245
+ if (item.group && item.group !== lastGroup) {
246
+ rows.push({ group: item.group, index });
247
+ lastGroup = item.group;
248
+ }
249
+ rows.push({ item, index });
250
+ });
251
+ const firstUndone = items.findIndex(
252
+ (t) => !isDone(t) && itemStatus(t) !== "cancelled",
253
+ );
254
+ const anchorIndex = firstUndone < 0 ? 0 : firstUndone;
255
+ let startRow = rows.findIndex((r) => r.item && r.index === anchorIndex);
256
+ if (startRow < 0) startRow = 0;
257
+ if (startRow > 0 && rows[startRow - 1]?.group) startRow -= 1;
258
+ const visible = rows.slice(startRow, startRow + TODO_PANEL_MAX_ROWS);
259
+ const rendered = visible.map((row) => {
260
+ if (row.group) {
261
+ return truncateToWidth(
262
+ theme.fg("muted", `── ${row.group}`),
263
+ contentWidth,
264
+ "…",
265
+ );
266
+ }
267
+ const item = row.item as TodoItem;
268
+ const ref = todoLabel(item, row.index);
269
+ const status = itemStatus(item);
270
+ const isCurrent = row.index === anchorIndex && !isDone(item);
271
+ const prefix =
272
+ isCurrent || status === "in_progress"
273
+ ? theme.fg("accent", "▶ ")
274
+ : status === "completed"
275
+ ? theme.fg("success", "☑ ")
276
+ : status === "cancelled"
277
+ ? theme.fg("dim", "✖ ")
278
+ : theme.fg("dim", "☐ ");
279
+ const labelPart =
280
+ item.label !== undefined && String(item.label) !== ref
281
+ ? theme.fg("muted", ` (${item.label})`)
282
+ : "";
283
+ const body = `${ref}. ${item.text}${labelPart}`;
284
+ let styled: string;
285
+ if (isDone(item)) {
286
+ styled = theme.fg("muted", theme.strikethrough(body));
287
+ } else if (isCurrent && mode === "build") {
288
+ styled = theme.fg("accent", body);
289
+ } else if (mode === "plan" || mode === null) {
290
+ styled = theme.fg("muted", body);
291
+ } else {
292
+ styled = body;
293
+ }
294
+ return truncateToWidth(`${prefix}${styled}`, contentWidth, "…");
295
+ });
296
+ const remaining = rows.length - (startRow + visible.length);
297
+ if (remaining > 0) {
298
+ rendered.push(theme.fg("muted", `… +${remaining} more`));
299
+ }
300
+ return rendered;
301
+ }
302
+
303
+ /**
304
+ * Defensive reader for the persisted `todoPanel` object. Accepts either the
305
+ * `workflow` entry data or the panel object itself. Unknown/missing fields fall
306
+ * back to automatic behaviour; a mismatched `overrideKey` is restored verbatim
307
+ * so `deriveTodoPanel` can expire it naturally.
308
+ */
309
+ export function readPersistedTodoPanel(data: unknown): PersistedTodoPanel {
310
+ const container =
311
+ data && typeof data === "object"
312
+ ? (data as { todoPanel?: unknown })
313
+ : undefined;
314
+ const raw =
315
+ container && "todoPanel" in container && container.todoPanel
316
+ ? container.todoPanel
317
+ : data;
318
+ const obj = raw && typeof raw === "object" ? (raw as any) : {};
319
+ const override =
320
+ obj.override === "expanded" ||
321
+ obj.override === "collapsed" ||
322
+ obj.override === "hidden"
323
+ ? obj.override
324
+ : null;
325
+ return {
326
+ override,
327
+ overrideKey: typeof obj.overrideKey === "string" ? obj.overrideKey : null,
328
+ promptAfterSettle: obj.promptAfterSettle === true,
329
+ completedSignature:
330
+ typeof obj.completedSignature === "string"
331
+ ? obj.completedSignature
332
+ : null,
333
+ };
334
+ }
package/utils.ts CHANGED
@@ -4,8 +4,76 @@
4
4
  * imports) so it stays loadable in plain Node tests.
5
5
  */
6
6
 
7
+ import * as fs from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
7
10
  import { isCommandAllowedForRole } from "./permissions.ts";
8
11
 
12
+ /**
13
+ * Translate shell foreign path syntax to native syntax without changing case.
14
+ *
15
+ * On Windows, Git-Bash/MSYS (`/c/...`), Cygwin (`/cygdrive/c/...`) and WSL
16
+ * (`/mnt/c/...`) spellings otherwise become `C:\c\...` under `path.resolve`.
17
+ * On POSIX, a Windows drive path is mapped to `/mnt/<drive>/...` only when the
18
+ * mount exists; otherwise the caller treats it as uncertain. UNC/WSL UNC
19
+ * paths intentionally stay network paths.
20
+ *
21
+ * This is the case-preserving plumbing used for real filesystem/settings
22
+ * paths. `normalizePolicyPath` applies this and then lowercases only for the
23
+ * comparison form.
24
+ */
25
+ export function translateForeignPath(value: unknown): {
26
+ path: string;
27
+ uncertain?: boolean;
28
+ } {
29
+ if (typeof value !== "string") return { path: "", uncertain: true };
30
+ let raw = value.trim();
31
+ if (!raw) return { path: "" };
32
+
33
+ if (raw.startsWith("file:")) {
34
+ try {
35
+ raw = fileURLToPath(raw);
36
+ } catch {
37
+ try {
38
+ raw = decodeURIComponent(raw.replace(/^file:\/\//i, ""));
39
+ } catch {
40
+ return { path: raw, uncertain: true };
41
+ }
42
+ }
43
+ }
44
+
45
+ if (process.platform === "win32") {
46
+ const drivePath = (drive: string, rest: string | undefined): string =>
47
+ `${drive.toUpperCase()}:/${rest ?? ""}`;
48
+ const cygdrive = /^\/cygdrive\/([A-Za-z])(?:\/(.*))?$/i.exec(raw);
49
+ if (cygdrive) return { path: drivePath(cygdrive[1], cygdrive[2]) };
50
+ const wsl = /^\/mnt\/([A-Za-z])(?:\/(.*))?$/i.exec(raw);
51
+ if (wsl) return { path: drivePath(wsl[1], wsl[2]) };
52
+ const msys = /^\/([A-Za-z])(?:\/(.*))?$/.exec(raw);
53
+ if (msys) return { path: drivePath(msys[1], msys[2]) };
54
+
55
+ // A drive-relative path (`C:foo`) or a device path is not a normal
56
+ // absolute path; do not guess what it means.
57
+ if (/^[A-Za-z]:[^\\/]/.test(raw) || /^\\\\.\\/.test(raw)) {
58
+ return { path: raw, uncertain: true };
59
+ }
60
+ // Extended-length prefixes are not meaningful to the policy layer.
61
+ raw = raw
62
+ .replace(/^\\\\\?\\UNC\\/i, "\\\\")
63
+ .replace(/^\\\\\?\\/i, "");
64
+ return { path: raw };
65
+ }
66
+
67
+ // POSIX host: map a Windows drive path only when its WSL mount exists.
68
+ const win = /^([A-Za-z]):[\\/](.*)$/.exec(raw);
69
+ if (win) {
70
+ const mount = `/mnt/${win[1].toLowerCase()}`;
71
+ if (fs.existsSync(mount)) return { path: `${mount}/${win[2]}` };
72
+ return { path: raw, uncertain: true };
73
+ }
74
+ return { path: raw };
75
+ }
76
+
9
77
  /**
10
78
  * Backward-compatible alias for Plan Mode's command policy (read-only plus
11
79
  * test/lint/typecheck). New code should call
@@ -21,8 +21,11 @@ export const WORKFLOW_STATUS_KEYS = {
21
21
 
22
22
  export interface WorkflowStatusInput {
23
23
  mode: "build" | "plan" | null;
24
- /** Plannotator's Plan sub-state, shown inside the mode segment. */
25
- plannotatorActive?: boolean;
24
+ /**
25
+ * The exclusive Review turn. `running|paused|handoff` replaces the author
26
+ * mode in the segment; `transition` is internal and renders as running.
27
+ */
28
+ reviewPhase?: "idle" | "running" | "paused" | "handoff" | "transition" | null;
26
29
  done: number;
27
30
  total: number;
28
31
  stale?: boolean;
@@ -31,6 +34,8 @@ export interface WorkflowStatusInput {
31
34
  commitMode: string;
32
35
  /** Only Default mode has a write policy; null/undefined omits it. */
33
36
  writeMode?: string | null;
37
+ /** Shared read/write/commit folder scope; null/undefined omits it. */
38
+ folderMode?: string | null;
34
39
  }
35
40
 
36
41
  export interface WorkflowStatusSegments {
@@ -51,16 +56,25 @@ export function buildWorkflowStatusSegments(
51
56
  .join(" | ");
52
57
  const policy =
53
58
  `commits:${input.commitMode}` +
54
- (input.writeMode ? ` · writes:${input.writeMode}` : "");
59
+ (input.writeMode ? ` · writes:${input.writeMode}` : "") +
60
+ (input.folderMode ? ` · folders:${input.folderMode}` : "");
55
61
 
56
62
  let mode: string;
57
- if (input.mode === "build") {
63
+ const phase = input.reviewPhase ?? "idle";
64
+ if (phase !== "idle") {
65
+ // The mode segment is deliberately free of `n/m` and pass details: the
66
+ // Desktop's progress parser must not read review progress as plan steps.
67
+ const base =
68
+ phase === "paused"
69
+ ? "🔍 review — paused"
70
+ : phase === "handoff"
71
+ ? "🔍 review — awaiting decision"
72
+ : "🔍 review";
73
+ mode = base;
74
+ } else if (input.mode === "build") {
58
75
  mode = total > 0 ? `▶ build ${done}/${total}${stale}` : `▶ build${stale}`;
59
76
  } else if (input.mode === "plan") {
60
- const base = input.plannotatorActive
61
- ? "⏸ plan — reviewing in browser"
62
- : "⏸ plan";
63
- mode = total > 0 ? `${base} ${done}/${total}${stale}` : `${base}${stale}`;
77
+ mode = total > 0 ? `⏸ plan ${done}/${total}${stale}` : `⏸ plan${stale}`;
64
78
  } else if (total > 0) {
65
79
  // The default mode must stay parseable by the Desktop badge decoder.
66
80
  mode = `◦ ${done}/${total}${stale}`;