gflows 0.1.18 → 1.0.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.
Files changed (48) hide show
  1. package/AGENTS.md +78 -0
  2. package/CHANGELOG.md +40 -0
  3. package/README.md +279 -505
  4. package/llms.txt +22 -0
  5. package/package.json +29 -23
  6. package/src/cli.ts +21 -367
  7. package/src/commands/abort.ts +39 -0
  8. package/src/commands/bump.ts +10 -10
  9. package/src/commands/completion.ts +14 -4
  10. package/src/commands/config.ts +123 -0
  11. package/src/commands/continue.ts +40 -0
  12. package/src/commands/delete.ts +4 -4
  13. package/src/commands/doctor.ts +143 -0
  14. package/src/commands/finish.ts +363 -137
  15. package/src/commands/help.ts +29 -21
  16. package/src/commands/init.ts +99 -18
  17. package/src/commands/list.ts +6 -1
  18. package/src/commands/mcp.ts +238 -0
  19. package/src/commands/pr.ts +120 -0
  20. package/src/commands/schema.ts +98 -0
  21. package/src/commands/start.ts +33 -31
  22. package/src/commands/status.ts +70 -51
  23. package/src/commands/switch.ts +14 -19
  24. package/src/commands/sync.ts +160 -0
  25. package/src/commands/undo.ts +78 -0
  26. package/src/commands/version.ts +3 -15
  27. package/src/commands/viz.ts +18 -0
  28. package/src/dispatch.ts +21 -0
  29. package/src/errors.ts +55 -12
  30. package/src/flow.ts +157 -0
  31. package/src/git.ts +135 -8
  32. package/src/index.ts +24 -1
  33. package/src/interactive.ts +209 -0
  34. package/src/out.ts +11 -4
  35. package/src/package-scripts.ts +73 -0
  36. package/src/parse.ts +430 -0
  37. package/src/prompts.ts +89 -0
  38. package/src/recommend.ts +132 -0
  39. package/src/run-state.ts +165 -0
  40. package/src/tui/HubHome.tsx +343 -0
  41. package/src/tui/HubShell.tsx +186 -0
  42. package/src/tui/flows.tsx +140 -0
  43. package/src/tui/hub.ts +89 -0
  44. package/src/tui/prompts.tsx +207 -0
  45. package/src/types.ts +62 -3
  46. package/src/ui.ts +132 -0
  47. package/src/version.ts +24 -0
  48. package/src/viz.ts +294 -0
package/src/parse.ts ADDED
@@ -0,0 +1,430 @@
1
+ /**
2
+ * Argv parser for gflows. Resolves -C/path, command, type, name, and flags.
3
+ * @module parse
4
+ */
5
+
6
+ import { existsSync, statSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { parseArgs } from "node:util";
9
+ import { EXIT_USER } from "./constants.js";
10
+ import type { BranchType, Command, ConfigAction, ParsedArgs } from "./types.js";
11
+ import { ALL_COMMANDS } from "./types.js";
12
+
13
+ const BRANCH_TYPES: BranchType[] = ["feature", "bugfix", "chore", "release", "hotfix", "spike"];
14
+
15
+ function buildParseArgsOptions(argv: string[]) {
16
+ return {
17
+ args: argv,
18
+ strict: false,
19
+ allowPositionals: true,
20
+ options: {
21
+ path: { type: "string" as const, short: "C" },
22
+ // Command shorts (-L → deleteCommand so --delete can mean finish delete-branch)
23
+ init: { type: "boolean" as const, short: "I" },
24
+ start: { type: "boolean" as const, short: "S" },
25
+ finish: { type: "boolean" as const, short: "F" },
26
+ switch: { type: "boolean" as const, short: "W" },
27
+ deleteCommand: { type: "boolean" as const, short: "L" },
28
+ list: { type: "boolean" as const, short: "l" },
29
+ status: { type: "boolean" as const, short: "t" },
30
+ bumpCommand: { type: "boolean" as const, short: "U" },
31
+ help: { type: "boolean" as const, short: "h" },
32
+ version: { type: "boolean" as const, short: "V" },
33
+ feature: { type: "boolean" as const, short: "f" },
34
+ bugfix: { type: "boolean" as const, short: "b" },
35
+ chore: { type: "boolean" as const, short: "c" },
36
+ release: { type: "boolean" as const, short: "r" },
37
+ hotfix: { type: "boolean" as const, short: "x" },
38
+ spike: { type: "boolean" as const, short: "e" },
39
+ push: { type: "boolean" as const, short: "p" },
40
+ noPush: { type: "boolean" as const, short: "P" },
41
+ "no-push": { type: "boolean" as const },
42
+ main: { type: "string" as const },
43
+ dev: { type: "string" as const },
44
+ remote: { type: "string" as const, short: "R" },
45
+ from: { type: "string" as const, short: "o" },
46
+ branch: { type: "string" as const, short: "B" },
47
+ yes: { type: "boolean" as const, short: "y" },
48
+ dryRun: { type: "boolean" as const, short: "d" },
49
+ "dry-run": { type: "boolean" as const },
50
+ verbose: { type: "boolean" as const, short: "v" },
51
+ quiet: { type: "boolean" as const, short: "q" },
52
+ force: { type: "boolean" as const },
53
+ noFf: { type: "boolean" as const },
54
+ "no-ff": { type: "boolean" as const },
55
+ deleteBranch: { type: "boolean" as const, short: "D" },
56
+ delete: { type: "boolean" as const },
57
+ noDelete: { type: "boolean" as const, short: "N" },
58
+ "no-delete": { type: "boolean" as const },
59
+ sign: { type: "boolean" as const, short: "s" },
60
+ noTag: { type: "boolean" as const, short: "T" },
61
+ "no-tag": { type: "boolean" as const },
62
+ tagMessage: { type: "string" as const, short: "M" },
63
+ "tag-message": { type: "string" as const },
64
+ message: { type: "string" as const, short: "m" },
65
+ includeRemote: { type: "boolean" as const },
66
+ "include-remote": { type: "boolean" as const },
67
+ restore: { type: "boolean" as const },
68
+ clean: { type: "boolean" as const },
69
+ cancel: { type: "boolean" as const },
70
+ move: { type: "boolean" as const },
71
+ destroy: { type: "boolean" as const },
72
+ squash: { type: "boolean" as const },
73
+ preview: { type: "boolean" as const },
74
+ bump: { type: "boolean" as const },
75
+ json: { type: "boolean" as const },
76
+ rebase: { type: "boolean" as const },
77
+ "script-alias": { type: "string" as const },
78
+ scriptAlias: { type: "string" as const },
79
+ "no-script-alias": { type: "boolean" as const },
80
+ noScriptAlias: { type: "boolean" as const },
81
+ },
82
+ };
83
+ }
84
+
85
+ function resolveCwd(pathFlag: string | undefined): string {
86
+ if (!pathFlag || pathFlag.trim() === "") {
87
+ return process.cwd();
88
+ }
89
+ const absolute = resolve(process.cwd(), pathFlag.trim());
90
+ if (!existsSync(absolute)) {
91
+ console.error(`gflows: path does not exist: ${absolute}`);
92
+ process.exit(EXIT_USER);
93
+ }
94
+ const stat = statSync(absolute, { throwIfNoEntry: false });
95
+ if (!stat?.isDirectory()) {
96
+ console.error(`gflows: path is not a directory: ${absolute}`);
97
+ process.exit(EXIT_USER);
98
+ }
99
+ return absolute;
100
+ }
101
+
102
+ function closestCommand(input: string): Command | undefined {
103
+ if (!input || input.length < 2) return undefined;
104
+ const target = input.toLowerCase();
105
+ let best: Command | undefined;
106
+ let bestDistance = 3;
107
+ for (const cmd of ALL_COMMANDS) {
108
+ const d = editDistance(target, cmd);
109
+ if (d < bestDistance) {
110
+ bestDistance = d;
111
+ best = cmd;
112
+ }
113
+ }
114
+ return best;
115
+ }
116
+
117
+ function editDistance(a: string, b: string): number {
118
+ const m = a.length;
119
+ const n = b.length;
120
+ const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
121
+ for (let i = 0; i <= m; i++) {
122
+ const row = dp[i];
123
+ if (row) row[0] = i;
124
+ }
125
+ for (let j = 0; j <= n; j++) {
126
+ const row = dp[0];
127
+ if (row) row[j] = j;
128
+ }
129
+ for (let i = 1; i <= m; i++) {
130
+ for (let j = 1; j <= n; j++) {
131
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
132
+ const v1 = dp[i - 1]?.[j] ?? 0;
133
+ const v2 = dp[i]?.[j - 1] ?? 0;
134
+ const v3 = dp[i - 1]?.[j - 1] ?? 0;
135
+ const rowI = dp[i];
136
+ if (rowI) rowI[j] = Math.min(v1 + 1, v2 + 1, v3 + cost);
137
+ }
138
+ }
139
+ return dp[m]?.[n] ?? 0;
140
+ }
141
+
142
+ function flagTrue(
143
+ v: Record<string, string | boolean | undefined>,
144
+ camel: string,
145
+ kebab?: string,
146
+ ): boolean {
147
+ if (v[camel] === true) return true;
148
+ if (kebab && v[kebab] === true) return true;
149
+ return false;
150
+ }
151
+
152
+ function flagString(
153
+ v: Record<string, string | boolean | undefined>,
154
+ camel: string,
155
+ kebab?: string,
156
+ ): string | undefined {
157
+ const a = v[camel];
158
+ if (typeof a === "string" && a.trim() !== "") return a.trim();
159
+ if (kebab) {
160
+ const b = v[kebab];
161
+ if (typeof b === "string" && b.trim() !== "") return b.trim();
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ function resolveCommandFromFlags(
167
+ v: Record<string, string | boolean | undefined>,
168
+ positionals: string[],
169
+ ): Command | undefined {
170
+ if (v.init === true) return "init";
171
+ if (v.start === true) return "start";
172
+ if (v.finish === true) return "finish";
173
+ if (v.switch === true) return "switch";
174
+ if (v.deleteCommand === true) return "delete";
175
+ if (v.list === true) return "list";
176
+ if (v.status === true) return "status";
177
+ if (v.bumpCommand === true) return "bump";
178
+ if (v.help === true) return "help";
179
+ if (v.version === true) return "version";
180
+ const first = positionals[0];
181
+ if (first && ALL_COMMANDS.includes(first as Command)) {
182
+ return first as Command;
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ function resolveType(
188
+ command: Command,
189
+ positionals: string[],
190
+ values: Record<string, string | boolean | undefined>,
191
+ ): BranchType | undefined {
192
+ if (command !== "start" && command !== "finish" && command !== "list") {
193
+ return undefined;
194
+ }
195
+ if (command === "list") {
196
+ if (values.feature === true) return "feature";
197
+ if (values.bugfix === true) return "bugfix";
198
+ if (values.chore === true) return "chore";
199
+ if (values.hotfix === true) return "hotfix";
200
+ if (values.spike === true) return "spike";
201
+ } else {
202
+ if (values.release === true) return "release";
203
+ if (values.feature === true) return "feature";
204
+ if (values.bugfix === true) return "bugfix";
205
+ if (values.chore === true) return "chore";
206
+ if (values.hotfix === true) return "hotfix";
207
+ if (values.spike === true) return "spike";
208
+ }
209
+ const idx = positionals[0] && ALL_COMMANDS.includes(positionals[0] as Command) ? 1 : 0;
210
+ const pos = positionals[idx];
211
+ if (pos && BRANCH_TYPES.includes(pos as BranchType)) {
212
+ return pos as BranchType;
213
+ }
214
+ return undefined;
215
+ }
216
+
217
+ function resolveName(
218
+ command: Command,
219
+ positionals: string[],
220
+ values: Record<string, string | boolean | undefined>,
221
+ ): string | undefined {
222
+ const branch = values.branch;
223
+ if (typeof branch === "string" && branch.trim() !== "") {
224
+ return branch.trim();
225
+ }
226
+ const skip = positionals[0] && ALL_COMMANDS.includes(positionals[0] as Command) ? 1 : 0;
227
+ if (command === "start") {
228
+ const typeFromPos = positionals[skip] && BRANCH_TYPES.includes(positionals[skip] as BranchType);
229
+ if (typeFromPos) return positionals[skip + 1];
230
+ return positionals[skip];
231
+ }
232
+ if (command === "completion") {
233
+ const shell = positionals[skip];
234
+ if (shell === "bash" || shell === "zsh" || shell === "fish") return shell;
235
+ return undefined;
236
+ }
237
+ if (command === "bump") {
238
+ const dir = positionals[skip];
239
+ if (dir === "up" || dir === "down") return dir;
240
+ return undefined;
241
+ }
242
+ if (command === "switch" || command === "pr" || command === "sync") {
243
+ return positionals[skip];
244
+ }
245
+ return undefined;
246
+ }
247
+
248
+ function resolveBump(positionals: string[]): {
249
+ direction?: "up" | "down";
250
+ type?: "patch" | "minor" | "major";
251
+ } {
252
+ // Skip leading "bump" when present; with -U, positionals start at up/down
253
+ const skip = positionals[0] === "bump" ? 1 : 0;
254
+ const a = positionals[skip];
255
+ const b = positionals[skip + 1];
256
+ const direction = a === "up" || a === "down" ? a : undefined;
257
+ const type = b === "patch" || b === "minor" || b === "major" ? b : undefined;
258
+ return { direction, type };
259
+ }
260
+
261
+ function resolveConfigArgs(positionals: string[]): {
262
+ action?: ConfigAction;
263
+ key?: string;
264
+ value?: string;
265
+ } {
266
+ const skip = positionals[0] === "config" ? 1 : 0;
267
+ const action = positionals[skip];
268
+ if (action !== "get" && action !== "set") return {};
269
+ return { action, key: positionals[skip + 1], value: positionals[skip + 2] };
270
+ }
271
+
272
+ function emptyArgs(cwd: string, pathStr: string | undefined): ParsedArgs {
273
+ return {
274
+ command: "help",
275
+ cwd,
276
+ push: false,
277
+ noPush: false,
278
+ main: undefined,
279
+ dev: undefined,
280
+ remote: undefined,
281
+ branch: undefined,
282
+ yes: false,
283
+ dryRun: false,
284
+ verbose: false,
285
+ quiet: false,
286
+ force: false,
287
+ path: pathStr,
288
+ from: undefined,
289
+ fromMain: false,
290
+ noFf: false,
291
+ deleteAfterFinish: false,
292
+ noDeleteAfterFinish: false,
293
+ signTag: false,
294
+ noTag: false,
295
+ tagMessage: undefined,
296
+ message: undefined,
297
+ squash: false,
298
+ preview: false,
299
+ bumpOnFinish: false,
300
+ includeRemote: false,
301
+ json: false,
302
+ rebase: false,
303
+ scriptAlias: undefined,
304
+ noScriptAlias: false,
305
+ };
306
+ }
307
+
308
+ /**
309
+ * Parse raw argv into ParsedArgs.
310
+ * @param options.allowMissingCommand - When true, missing command returns help stub instead of exiting.
311
+ */
312
+ export function parse(
313
+ argv: string[] = Bun.argv.slice(2),
314
+ options?: { allowMissingCommand?: boolean },
315
+ ): ParsedArgs {
316
+ const { values, positionals } = parseArgs(buildParseArgsOptions(argv));
317
+ const v = values as Record<string, string | boolean | undefined>;
318
+
319
+ const pathStr = typeof v.path === "string" ? v.path : undefined;
320
+ const cwd = resolveCwd(pathStr);
321
+
322
+ const command = resolveCommandFromFlags(v, positionals);
323
+ if (!command) {
324
+ if (options?.allowMissingCommand) {
325
+ return emptyArgs(cwd, pathStr);
326
+ }
327
+ const first = positionals[0];
328
+ const suggestion = typeof first === "string" ? closestCommand(first) : undefined;
329
+ if (suggestion) {
330
+ console.error(`gflows: unknown command '${first}'. Did you mean '${suggestion}'?`);
331
+ } else {
332
+ console.error("gflows: missing command. Use 'gflows help' for usage.");
333
+ }
334
+ process.exit(EXIT_USER);
335
+ }
336
+
337
+ const type = resolveType(command, positionals, v);
338
+ const name = resolveName(command, positionals, v);
339
+ const { direction: bumpDirection, type: bumpType } =
340
+ command === "bump" ? resolveBump(positionals) : { direction: undefined, type: undefined };
341
+ const configArgs = command === "config" ? resolveConfigArgs(positionals) : {};
342
+
343
+ const branchNames =
344
+ command === "delete"
345
+ ? positionals[0] === "delete"
346
+ ? positionals.slice(1)
347
+ : v.deleteCommand === true
348
+ ? positionals
349
+ : positionals[0] && ALL_COMMANDS.includes(positionals[0] as Command)
350
+ ? positionals.slice(1)
351
+ : positionals
352
+ : undefined;
353
+
354
+ const includeRemote =
355
+ command === "list"
356
+ ? flagTrue(v, "includeRemote", "include-remote") || v.release === true
357
+ : false;
358
+
359
+ let completionShell: "bash" | "zsh" | "fish" | undefined;
360
+ if (command === "completion" && name === "bash") completionShell = "bash";
361
+ else if (command === "completion" && name === "zsh") completionShell = "zsh";
362
+ else if (command === "completion" && name === "fish") completionShell = "fish";
363
+
364
+ let switchMode: "restore" | "clean" | "cancel" | "move" | "destroy" | undefined;
365
+ if (command === "switch") {
366
+ const modes = [
367
+ v.restore === true && ("restore" as const),
368
+ v.clean === true && ("clean" as const),
369
+ v.cancel === true && ("cancel" as const),
370
+ v.move === true && ("move" as const),
371
+ v.destroy === true && ("destroy" as const),
372
+ ].filter(Boolean) as Array<"restore" | "clean" | "cancel" | "move" | "destroy">;
373
+ if (modes.length > 1) {
374
+ console.error(
375
+ "gflows switch: only one of --restore, --clean, --cancel, --move, or --destroy may be used at a time.",
376
+ );
377
+ process.exit(EXIT_USER);
378
+ }
379
+ switchMode = modes[0];
380
+ }
381
+
382
+ const fromRaw = typeof v.from === "string" && v.from.trim() !== "" ? v.from.trim() : undefined;
383
+ const mainOverride =
384
+ typeof v.main === "string" && v.main.trim() !== "" ? v.main.trim() : undefined;
385
+ const fromMain = fromRaw === "main" || (!!mainOverride && fromRaw === mainOverride);
386
+
387
+ return {
388
+ command,
389
+ cwd,
390
+ type,
391
+ name,
392
+ completionShell,
393
+ branchNames,
394
+ bumpDirection,
395
+ bumpType,
396
+ configAction: configArgs.action,
397
+ configKey: configArgs.key,
398
+ configValue: configArgs.value,
399
+ push: v.push === true,
400
+ noPush: flagTrue(v, "noPush", "no-push"),
401
+ main: mainOverride,
402
+ dev: typeof v.dev === "string" && v.dev.trim() !== "" ? v.dev.trim() : undefined,
403
+ remote: typeof v.remote === "string" ? v.remote : undefined,
404
+ branch: typeof v.branch === "string" ? v.branch : undefined,
405
+ yes: v.yes === true,
406
+ dryRun: flagTrue(v, "dryRun", "dry-run"),
407
+ verbose: v.verbose === true,
408
+ quiet: v.quiet === true,
409
+ force: v.force === true,
410
+ path: pathStr,
411
+ from: fromRaw,
412
+ fromMain,
413
+ noFf: flagTrue(v, "noFf", "no-ff"),
414
+ deleteAfterFinish: flagTrue(v, "deleteBranch") || flagTrue(v, "delete"),
415
+ noDeleteAfterFinish: flagTrue(v, "noDelete", "no-delete"),
416
+ signTag: v.sign === true,
417
+ noTag: flagTrue(v, "noTag", "no-tag"),
418
+ tagMessage: flagString(v, "tagMessage", "tag-message"),
419
+ message: typeof v.message === "string" ? v.message : undefined,
420
+ squash: v.squash === true,
421
+ preview: v.preview === true,
422
+ bumpOnFinish: v.bump === true,
423
+ includeRemote,
424
+ json: v.json === true,
425
+ rebase: v.rebase === true,
426
+ switchMode,
427
+ scriptAlias: flagString(v, "scriptAlias", "script-alias"),
428
+ noScriptAlias: flagTrue(v, "noScriptAlias", "no-script-alias"),
429
+ };
430
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Modern interactive prompts via @clack/prompts (replaces Inquirer).
3
+ * @module prompts
4
+ */
5
+
6
+ import * as clack from "@clack/prompts";
7
+
8
+ /**
9
+ * Exits the process when the user cancels a prompt (Ctrl+C / Escape).
10
+ */
11
+ function exitIfCancel<T>(value: T | symbol): asserts value is T {
12
+ if (clack.isCancel(value)) {
13
+ clack.cancel("Cancelled.");
14
+ process.exit(0);
15
+ }
16
+ }
17
+
18
+ /** Select option for {@link selectPrompt}. */
19
+ export interface SelectOption<T extends string> {
20
+ value: T;
21
+ label: string;
22
+ hint?: string;
23
+ }
24
+
25
+ /**
26
+ * Single-select prompt.
27
+ */
28
+ export async function selectPrompt<T extends string>(opts: {
29
+ message: string;
30
+ options: SelectOption<T>[];
31
+ initialValue?: T;
32
+ }): Promise<T> {
33
+ // Clack's Option<T> conditional types don't accept generic T extends string cleanly.
34
+ const value = await clack.select({
35
+ message: opts.message,
36
+ options: opts.options as never,
37
+ initialValue: opts.initialValue,
38
+ });
39
+ exitIfCancel(value);
40
+ return value as T;
41
+ }
42
+
43
+ /**
44
+ * Yes/no confirm prompt.
45
+ */
46
+ export async function confirmPrompt(opts: {
47
+ message: string;
48
+ initialValue?: boolean;
49
+ }): Promise<boolean> {
50
+ const value = await clack.confirm({
51
+ message: opts.message,
52
+ initialValue: opts.initialValue ?? true,
53
+ });
54
+ exitIfCancel(value);
55
+ return value;
56
+ }
57
+
58
+ /**
59
+ * Free-text input prompt.
60
+ */
61
+ export async function inputPrompt(opts: {
62
+ message: string;
63
+ defaultValue?: string;
64
+ placeholder?: string;
65
+ }): Promise<string> {
66
+ const value = await clack.text({
67
+ message: opts.message,
68
+ defaultValue: opts.defaultValue,
69
+ placeholder: opts.placeholder ?? opts.defaultValue,
70
+ });
71
+ exitIfCancel(value);
72
+ return value;
73
+ }
74
+
75
+ /**
76
+ * Multi-select (checkbox) prompt.
77
+ */
78
+ export async function multiSelectPrompt<T extends string>(opts: {
79
+ message: string;
80
+ options: SelectOption<T>[];
81
+ }): Promise<T[]> {
82
+ const value = await clack.multiselect({
83
+ message: opts.message,
84
+ options: opts.options as never,
85
+ required: false,
86
+ });
87
+ exitIfCancel(value);
88
+ return value as T[];
89
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Contextual next-step recommendations for the hub and viz panel.
3
+ * @module recommend
4
+ */
5
+
6
+ import type { VizSnapshot } from "./viz.js";
7
+
8
+ /** Hub / CLI action ids used for recommendations. */
9
+ export type RecommendAction =
10
+ | "init"
11
+ | "continue"
12
+ | "sync"
13
+ | "finish"
14
+ | "pr"
15
+ | "start"
16
+ | "commit"
17
+ | "doctor"
18
+ | "help";
19
+
20
+ /** A single recommended next step. */
21
+ export interface Recommendation {
22
+ action: RecommendAction;
23
+ /** Short label for the status panel. */
24
+ label: string;
25
+ /** One-line explanation. */
26
+ detail: string;
27
+ /** Menu choice label (human). */
28
+ menuLabel: string;
29
+ }
30
+
31
+ /**
32
+ * Picks the best next action from a viz snapshot (Claude/Codex-style “what next”).
33
+ */
34
+ export function recommend(snap: VizSnapshot): Recommendation {
35
+ if (snap.needsInit) {
36
+ return {
37
+ action: "init",
38
+ label: "Initialize this repo",
39
+ detail: "Create main + dev and write .gflows.json",
40
+ menuLabel: "Initialize repo (gflows init)",
41
+ };
42
+ }
43
+ if (snap.suspended) {
44
+ return {
45
+ action: "continue",
46
+ label: "Continue suspended operation",
47
+ detail: snap.suspended,
48
+ menuLabel: "Continue suspended operation",
49
+ };
50
+ }
51
+ if (!snap.current) {
52
+ return {
53
+ action: "doctor",
54
+ label: "Check repo health",
55
+ detail: "HEAD is detached",
56
+ menuLabel: "Doctor (check setup)",
57
+ };
58
+ }
59
+
60
+ const row = snap.rows.find((r) => r.name === snap.current);
61
+ if (!row) {
62
+ return {
63
+ action: "start",
64
+ label: "Start new work",
65
+ detail: "Current branch is outside gflows prefixes",
66
+ menuLabel: "Start new work",
67
+ };
68
+ }
69
+
70
+ if (row.type === "main") {
71
+ return {
72
+ action: "start",
73
+ label: "Start a hotfix (or switch to dev)",
74
+ detail: "Production line — prefer hotfix / release from here",
75
+ menuLabel: "Start new work",
76
+ };
77
+ }
78
+ if (row.type === "dev") {
79
+ return {
80
+ action: "start",
81
+ label: "Start a feature (or chore / bugfix)",
82
+ detail: "Integration line — short-lived branches start here",
83
+ menuLabel: "Start new work",
84
+ };
85
+ }
86
+ if (row.type === "unknown") {
87
+ return {
88
+ action: "doctor",
89
+ label: "Run doctor",
90
+ detail: "Branch type unknown — check prefixes / config",
91
+ menuLabel: "Doctor (check setup)",
92
+ };
93
+ }
94
+
95
+ // Workflow branch
96
+ if (row.behind > 0) {
97
+ return {
98
+ action: "sync",
99
+ label: "Sync with base (base moved)",
100
+ detail: `+${row.ahead}/-${row.behind} vs ${row.base}`,
101
+ menuLabel: "Sync this branch with base",
102
+ };
103
+ }
104
+ if (row.ahead === 0) {
105
+ return {
106
+ action: "commit",
107
+ label: "Commit work, then sync or finish",
108
+ detail: "Nothing ahead of base yet",
109
+ menuLabel: "Start new work",
110
+ };
111
+ }
112
+ return {
113
+ action: "finish",
114
+ label: "Finish or open a pull request",
115
+ detail: `+${row.ahead} ahead of ${row.base} → merge ${row.mergeTargetDisplay}`,
116
+ menuLabel: "Finish / merge this branch",
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Lifecycle step ids in order (for viz highlighting).
122
+ */
123
+ export const LIFECYCLE_ORDER = ["init", "start", "commit", "sync", "pr", "finish"] as const;
124
+
125
+ /**
126
+ * Whether an action is a lifecycle step that can be highlighted.
127
+ */
128
+ export function isLifecycleStep(
129
+ action: RecommendAction,
130
+ ): action is (typeof LIFECYCLE_ORDER)[number] {
131
+ return (LIFECYCLE_ORDER as readonly string[]).includes(action);
132
+ }