@dieulc/workflow 0.9.2 → 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/README.md +104 -20
- package/commit-policy.ts +74 -10
- package/edit-policy.ts +111 -44
- package/folder-policy.ts +1159 -0
- package/index.ts +2868 -496
- package/package.json +16 -6
- package/provider-retarget.ts +258 -0
- package/read-policy-redact.ts +319 -0
- package/read-policy-tools.ts +310 -0
- package/read-policy.ts +654 -0
- package/review-handoff.ts +126 -0
- package/review-recovery.ts +67 -0
- package/review-runtime.ts +454 -94
- package/review-tools.ts +472 -7
- package/review.ts +451 -33
- package/shell-paths.ts +1045 -0
- package/todo-panel.ts +334 -0
- package/utils.ts +68 -0
- package/workflow-status.ts +22 -8
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
|
package/workflow-status.ts
CHANGED
|
@@ -21,8 +21,11 @@ export const WORKFLOW_STATUS_KEYS = {
|
|
|
21
21
|
|
|
22
22
|
export interface WorkflowStatusInput {
|
|
23
23
|
mode: "build" | "plan" | null;
|
|
24
|
-
/**
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`;
|