@ramon-sg/issue-flow 0.0.0-stage → 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +304 -2
  3. package/dist/ask.d.ts +28 -0
  4. package/dist/ask.js +73 -0
  5. package/dist/branch-name.d.ts +113 -0
  6. package/dist/branch-name.js +226 -0
  7. package/dist/claude-namer.d.ts +51 -0
  8. package/dist/claude-namer.js +140 -0
  9. package/dist/cli-setup.d.ts +22 -0
  10. package/dist/cli-setup.js +26 -0
  11. package/dist/cli-worktree.d.ts +74 -0
  12. package/dist/cli-worktree.js +123 -0
  13. package/dist/cli.d.ts +132 -0
  14. package/dist/cli.js +348 -0
  15. package/dist/commands/adapter.d.ts +49 -0
  16. package/dist/commands/adapter.js +231 -0
  17. package/dist/commands/claude-hooks.d.ts +37 -0
  18. package/dist/commands/claude-hooks.js +162 -0
  19. package/dist/commands/doctor.d.ts +10 -0
  20. package/dist/commands/doctor.js +124 -0
  21. package/dist/commands/init.d.ts +27 -0
  22. package/dist/commands/init.js +213 -0
  23. package/dist/commands/issue-branch.d.ts +36 -0
  24. package/dist/commands/issue-branch.js +182 -0
  25. package/dist/commands/issue-start.d.ts +11 -0
  26. package/dist/commands/issue-start.js +361 -0
  27. package/dist/commands/pr-worktree.d.ts +31 -0
  28. package/dist/commands/pr-worktree.js +122 -0
  29. package/dist/commands/session.d.ts +15 -0
  30. package/dist/commands/session.js +102 -0
  31. package/dist/commands/upgrade.d.ts +16 -0
  32. package/dist/commands/upgrade.js +115 -0
  33. package/dist/commands/worktree.d.ts +82 -0
  34. package/dist/commands/worktree.js +244 -0
  35. package/dist/config-base.d.ts +32 -0
  36. package/dist/config-base.js +47 -0
  37. package/dist/config.d.ts +63 -0
  38. package/dist/config.js +230 -0
  39. package/dist/docker.d.ts +53 -0
  40. package/dist/docker.js +153 -0
  41. package/dist/git.d.ts +130 -0
  42. package/dist/git.js +192 -0
  43. package/dist/guard-config.d.ts +16 -0
  44. package/dist/guard-config.js +38 -0
  45. package/dist/issue-text.d.ts +128 -0
  46. package/dist/issue-text.js +210 -0
  47. package/dist/launch.d.ts +90 -0
  48. package/dist/launch.js +158 -0
  49. package/dist/output.d.ts +33 -0
  50. package/dist/output.js +37 -0
  51. package/dist/package-edits.d.ts +35 -0
  52. package/dist/package-edits.js +78 -0
  53. package/dist/plan-doc.d.ts +26 -0
  54. package/dist/plan-doc.js +93 -0
  55. package/dist/ports.d.ts +90 -0
  56. package/dist/ports.js +257 -0
  57. package/dist/propose.d.ts +42 -0
  58. package/dist/propose.js +111 -0
  59. package/dist/recipes.d.ts +43 -0
  60. package/dist/recipes.js +123 -0
  61. package/dist/session-lock.d.ts +32 -0
  62. package/dist/session-lock.js +69 -0
  63. package/dist/settings-hooks.d.ts +31 -0
  64. package/dist/settings-hooks.js +133 -0
  65. package/dist/setup-files.d.ts +89 -0
  66. package/dist/setup-files.js +138 -0
  67. package/dist/start-modes.d.ts +162 -0
  68. package/dist/start-modes.js +277 -0
  69. package/dist/start-place.d.ts +61 -0
  70. package/dist/start-place.js +301 -0
  71. package/dist/start-state.d.ts +72 -0
  72. package/dist/start-state.js +13 -0
  73. package/dist/start-summary.d.ts +42 -0
  74. package/dist/start-summary.js +217 -0
  75. package/dist/stop-apps.d.ts +14 -0
  76. package/dist/stop-apps.js +127 -0
  77. package/dist/usage.d.ts +5 -0
  78. package/dist/usage.js +54 -0
  79. package/dist/work-state.d.ts +51 -0
  80. package/dist/work-state.js +58 -0
  81. package/dist/worktree-config.d.ts +35 -0
  82. package/dist/worktree-config.js +139 -0
  83. package/dist/worktree-steps.d.ts +53 -0
  84. package/dist/worktree-steps.js +210 -0
  85. package/package.json +20 -4
  86. package/recipes/bun.json +5 -0
  87. package/recipes/direnv.json +17 -0
  88. package/recipes/docker-compose.json +30 -0
  89. package/recipes/npm.json +5 -0
  90. package/recipes/pnpm.json +5 -0
  91. package/recipes/staging-main.json +5 -0
  92. package/schema.json +281 -0
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file What `issue start` knows about the issue once it chose the branch and the place:
3
+ * shared by the summary, the preparation and the publishing, so none of them asks git or
4
+ * GitHub twice.
5
+ */
6
+ import type { IssueView } from "@ramon-sg/github-rest";
7
+ import type { FlowConfig, Namer } from "./config.js";
8
+ import type { PrKind, StartMode } from "./issue-text.js";
9
+ import type { Permissions } from "./launch.js";
10
+ import type { Output } from "./output.js";
11
+ import type { WorkState } from "./work-state.js";
12
+ /** Options of `issue start`. */
13
+ export interface StartOptions {
14
+ repo: string;
15
+ config: FlowConfig;
16
+ cwd: string;
17
+ number: number;
18
+ namer?: Namer;
19
+ dryRun: boolean;
20
+ yes: boolean;
21
+ resume: string | null;
22
+ forceNew: boolean;
23
+ branchMode: boolean;
24
+ launch: boolean;
25
+ permissions: Permissions;
26
+ plan: boolean;
27
+ cloud: boolean;
28
+ here: boolean;
29
+ teleport: boolean;
30
+ adopt: boolean;
31
+ remoteSession: string | null;
32
+ }
33
+ /** The branch of the worktree `--adopt` runs in, and whether another tool just made it. */
34
+ export interface Adopted {
35
+ current: string;
36
+ fresh: boolean;
37
+ }
38
+ /** Error that stops `issue start`: exits 1 with the message. */
39
+ export declare class StartError extends Error {
40
+ }
41
+ /** The issue, its work and where it goes. */
42
+ export interface StartState {
43
+ options: StartOptions;
44
+ out: Output;
45
+ commonDir: string;
46
+ mainRoot: string;
47
+ startRoot: string;
48
+ base: string;
49
+ issue: IssueView;
50
+ labels: string[];
51
+ work: WorkState;
52
+ found: string | null;
53
+ branch: string;
54
+ pr: {
55
+ number: number;
56
+ state: "OPEN" | "CLOSED";
57
+ } | null;
58
+ kind: PrKind;
59
+ mode: StartMode;
60
+ workDir: string;
61
+ plan: string | null;
62
+ adopted: Adopted | null;
63
+ cloudSession: string | null;
64
+ }
65
+ /**
66
+ * The command that starts or resumes this issue, for the messages.
67
+ * @param number - The issue.
68
+ * @param extra - Flags to add.
69
+ * @returns The command.
70
+ * @example again(42) // "issue-flow issue start 42"
71
+ */
72
+ export declare function again(number: number, extra?: string): string;
@@ -0,0 +1,13 @@
1
+ /** Error that stops `issue start`: exits 1 with the message. */
2
+ export class StartError extends Error {
3
+ }
4
+ /**
5
+ * The command that starts or resumes this issue, for the messages.
6
+ * @param number - The issue.
7
+ * @param extra - Flags to add.
8
+ * @returns The command.
9
+ * @example again(42) // "issue-flow issue start 42"
10
+ */
11
+ export function again(number, extra = "") {
12
+ return `issue-flow issue start ${number}${extra === "" ? "" : ` ${extra}`}`;
13
+ }
@@ -0,0 +1,42 @@
1
+ import { type PermissionArgs } from "./launch.js";
2
+ import { type StartState } from "./start-state.js";
3
+ import type { StepPlace } from "./worktree-steps.js";
4
+ /**
5
+ * Whether the start commit is needed: no PR and the branch has no commits of its own, so
6
+ * GitHub would not let a PR open.
7
+ * @param state - The start.
8
+ * @returns True if it is.
9
+ * @example needsStartCommit(state)
10
+ */
11
+ export declare function needsStartCommit(state: StartState): boolean;
12
+ /**
13
+ * One branch on one line: where it is, its own commits and, if asked, its PR.
14
+ * @param state - The start.
15
+ * @param branch - The branch.
16
+ * @param withPr - Whether to add its PR.
17
+ * @returns The text.
18
+ * @example describeBranch(state, "feat/42-x", true) // "feat/42-x (only in origin, 0 own commits), PR #7, OPEN"
19
+ */
20
+ export declare function describeBranch(state: StartState, branch: string, withPr: boolean): string;
21
+ /**
22
+ * The actions it will take, joined with " + ".
23
+ * @param state - The start.
24
+ * @returns The text.
25
+ * @example plannedActions(state) // "create the branch from origin/main + empty commit + push -u + …"
26
+ */
27
+ export declare function plannedActions(state: StartState): string;
28
+ /**
29
+ * The summary that is confirmed.
30
+ * @param state - The start.
31
+ * @param permissions - How Claude will open.
32
+ * @returns The text.
33
+ * @example summary(state, permissions)
34
+ */
35
+ export declare function summary(state: StartState, permissions: PermissionArgs): string;
36
+ /**
37
+ * Where the worktree's steps run, for its setup and its teardown.
38
+ * @param state - The start.
39
+ * @returns The place.
40
+ * @example stepPlaceOf(state).root
41
+ */
42
+ export declare function stepPlaceOf(state: StartState): StepPlace;
@@ -0,0 +1,217 @@
1
+ /**
2
+ * @file The summary `issue start` shows before asking: the issue, what it already has,
3
+ * the branch, the place, the session and the actions it will take. Ported from
4
+ * `plan_actions`, `describe_branch` and `print_summary` in Abastio's
5
+ * `scripts/start-issue.sh`.
6
+ */
7
+ import { existsSync } from "node:fs";
8
+ import { relative } from "node:path";
9
+ import { currentBranch, git, hasLocal, onOrigin, ownCommits } from "./git.js";
10
+ import { cloudSessionUrl, issueColor, issueHeader, prBody, sameCloudSession, sessionName } from "./issue-text.js";
11
+ import { savedSession } from "./launch.js";
12
+ import { again } from "./start-state.js";
13
+ import { branchPr, openPrsElsewhere } from "./work-state.js";
14
+ /** Width of the summary's labels. */
15
+ const LABEL_WIDTH = 14;
16
+ /**
17
+ * A summary line: the label padded, then the value; later lines of the value are indented.
18
+ * @param label - The label, with its colon.
19
+ * @param value - The value, maybe several lines.
20
+ * @returns The line.
21
+ */
22
+ function line(label, value) {
23
+ const pad = " ".repeat(LABEL_WIDTH);
24
+ return `${label.padEnd(LABEL_WIDTH)}${value.split("\n").join(`\n${pad}`)}\n`;
25
+ }
26
+ /**
27
+ * Whether the start commit is needed: no PR and the branch has no commits of its own, so
28
+ * GitHub would not let a PR open.
29
+ * @param state - The start.
30
+ * @returns True if it is.
31
+ * @example needsStartCommit(state)
32
+ */
33
+ export function needsStartCommit(state) {
34
+ return state.pr === null && ownCommits(state.mainRoot, state.branch, state.base) === 0;
35
+ }
36
+ /**
37
+ * One branch on one line: where it is, its own commits and, if asked, its PR.
38
+ * @param state - The start.
39
+ * @param branch - The branch.
40
+ * @param withPr - Whether to add its PR.
41
+ * @returns The text.
42
+ * @example describeBranch(state, "feat/42-x", true) // "feat/42-x (only in origin, 0 own commits), PR #7, OPEN"
43
+ */
44
+ export function describeBranch(state, branch, withPr) {
45
+ const local = hasLocal(state.mainRoot, branch);
46
+ const remote = onOrigin(state.mainRoot, branch);
47
+ const where = local ? (remote ? "local and in origin" : "only local") : remote ? "only in origin" : "deleted";
48
+ const text = `${branch} (${where}, ${ownCommits(state.mainRoot, branch, state.base)} own commits)`;
49
+ const pr = withPr ? branchPr(state.work.prs, branch) : null;
50
+ return pr === null ? text : `${text}, PR #${pr.number}, ${pr.state}`;
51
+ }
52
+ /**
53
+ * The actions it will take, joined with " + ".
54
+ * @param state - The start.
55
+ * @returns The text.
56
+ * @example plannedActions(state) // "create the branch from origin/main + empty commit + push -u + …"
57
+ */
58
+ export function plannedActions(state) {
59
+ if (state.mode === "cloud")
60
+ return "open the cloud session, which makes the branch, the commit, the PR and the assignment";
61
+ const actions = placeActions(state);
62
+ const { mainRoot, branch } = state;
63
+ const { number, remoteSession } = state.options;
64
+ const commit = needsStartCommit(state);
65
+ if (commit)
66
+ actions.push("empty commit");
67
+ if (commit || !onOrigin(mainRoot, branch))
68
+ actions.push("push -u");
69
+ if (state.pr === null) {
70
+ const first = prBody(state.options.config.start.prBody, number, state.kind).split("\n")[0];
71
+ actions.push(`draft PR (${first})`);
72
+ const others = openPrsElsewhere(state.work.prs, branch);
73
+ if (state.kind === "part" && others.length > 0)
74
+ actions.push(`Part of #${number} in the open PRs (${others.map((pr) => `#${pr}`).join(", ")})`);
75
+ }
76
+ if (state.pr?.state === "CLOSED")
77
+ actions.push(`reopen PR #${state.pr.number}`);
78
+ if (state.issue.assignees.length === 0)
79
+ actions.push(`assign #${number} to @me`);
80
+ if (state.mode === "here" && remoteSession !== null && !sameCloudSession(state.cloudSession, remoteSession))
81
+ actions.push("note the session in the PR");
82
+ if (actions.length > 0)
83
+ return actions.join(" + ");
84
+ return state.adopted === null ? "none: only open the session" : "none: everything was already done";
85
+ }
86
+ /**
87
+ * The first action, about the branch: rename the adopted one, create it, make the
88
+ * worktree on it or check it out.
89
+ * @param state - The start.
90
+ * @returns None or one action.
91
+ */
92
+ function placeActions(state) {
93
+ const { adopted, branch } = state;
94
+ if (adopted !== null)
95
+ return adopted.current === branch ? [] : [`rename the branch ${adopted.current} to ${branch}`];
96
+ if (state.found === null)
97
+ return [`create the branch from origin/${state.base}`];
98
+ if (state.mode === "worktree" && !existsSync(state.workDir))
99
+ return ["create the worktree on the branch"];
100
+ if (state.mode !== "worktree" && currentBranch(state.workDir) !== branch)
101
+ return ["check out the branch"];
102
+ return [];
103
+ }
104
+ /**
105
+ * Merged branches that got commits after their merge: a new round starts from the base
106
+ * and does not bring them. The heads of their merged PRs count as merged too, because a
107
+ * squash or rebase merge leaves the branch's commits out of the base.
108
+ * @param state - The start.
109
+ * @returns One warning line per branch.
110
+ */
111
+ function mergedLeftovers(state) {
112
+ const lines = [];
113
+ for (const merged of state.work.mergedBranches) {
114
+ const ref = hasLocal(state.mainRoot, merged) ? merged : `origin/${merged}`;
115
+ const heads = state.work.prs
116
+ .filter((pr) => pr.branch === merged && pr.state === "MERGED" && pr.head !== "")
117
+ .map((pr) => pr.head)
118
+ .filter((head) => git(state.mainRoot, ["cat-file", "-e", `${head}^{commit}`]).ok);
119
+ const count = git(state.mainRoot, ["rev-list", "--count", ref, "--not", `origin/${state.base}`, ...heads]);
120
+ if (count.ok && count.out !== "0")
121
+ lines.push(line("Note:", `${merged} has ${count.out} commits that are not in ${state.base}`));
122
+ }
123
+ return lines;
124
+ }
125
+ /**
126
+ * The lines about the branch: what was found, the live branches and the new one, or the
127
+ * round and the new branch.
128
+ * @param state - The start.
129
+ * @returns The lines.
130
+ */
131
+ function branchLines(state) {
132
+ const { work } = state;
133
+ if (state.found !== null) {
134
+ const found = [`branch ${describeBranch(state, state.branch, false)}`];
135
+ if (state.pr !== null)
136
+ found.push(`PR #${state.pr.number}, ${state.pr.state}`);
137
+ if (state.mode === "worktree" && existsSync(state.workDir))
138
+ found.push(`worktree at ${relative(state.mainRoot, state.workDir)}`);
139
+ return [line("Found:", found.join("\n")), line("Planned:", state.plan ?? "no")];
140
+ }
141
+ if (work.candidates.length > 0)
142
+ return [
143
+ line("Branches:", work.candidates.map((branch) => describeBranch(state, branch, true)).join("\n")),
144
+ line("New branch:", state.branch),
145
+ ];
146
+ if (work.mergedPrs.length === 0)
147
+ return [line("Branch:", state.branch)];
148
+ return [
149
+ line("Round:", `${work.mergedPrs.length + 1} (already merged: PR ${work.mergedPrs.map((pr) => `#${pr}`).join(", ")})`),
150
+ line("Planned:", state.plan ?? "no"),
151
+ ...mergedLeftovers(state),
152
+ line("Branch:", state.branch),
153
+ ];
154
+ }
155
+ /**
156
+ * The summary that is confirmed.
157
+ * @param state - The start.
158
+ * @param permissions - How Claude will open.
159
+ * @returns The text.
160
+ * @example summary(state, permissions)
161
+ */
162
+ export function summary(state, permissions) {
163
+ const { issue, options } = state;
164
+ const header = issueHeader(issue.body);
165
+ const color = issueColor(state.labels, options.config.colors);
166
+ const opensHere = state.adopted === null && (state.mode === "worktree" || state.mode === "branch");
167
+ return [
168
+ line("Issue:", `#${issue.number} — ${issue.title}`),
169
+ ...(header.length > 0 ? [line("Summary:", header.join("\n"))] : []),
170
+ line("Labels:", state.labels.join(" · ")),
171
+ line("Color:", color === null ? "none" : `${color.color} (${color.label})`),
172
+ ...branchLines(state),
173
+ line("Mode:", modeText(state)),
174
+ ...(opensHere ? [...sessionLines(state), line("Permissions:", permissions.label)] : []),
175
+ line("Actions:", plannedActions(state)),
176
+ ].join("");
177
+ }
178
+ /**
179
+ * Where the work goes, for the summary.
180
+ * @param state - The start.
181
+ * @returns The text.
182
+ */
183
+ function modeText(state) {
184
+ if (state.adopted !== null)
185
+ return `adopted worktree at ${state.workDir}`;
186
+ if (state.mode === "here")
187
+ return "this session (--here)";
188
+ if (state.mode === "cloud")
189
+ return "cloud (the session makes the branch and the PR)";
190
+ return state.mode === "worktree" ? `worktree at ${relative(state.mainRoot, state.workDir)}` : "branch in the main checkout";
191
+ }
192
+ /**
193
+ * The session lines: the branch's local session or, with `--teleport`, the cloud one it
194
+ * brings. Without `--teleport`, a cloud session noted in the PR adds how to bring it.
195
+ * @param state - The start.
196
+ * @returns The lines.
197
+ */
198
+ function sessionLines(state) {
199
+ const { cloudSession, options } = state;
200
+ if (options.teleport)
201
+ return [line("Session:", cloudSession === null ? "cloud (chosen from teleport's list)" : `cloud ${cloudSession} (brought with teleport)`)];
202
+ const saved = savedSession(state.commonDir, state.branch) !== null;
203
+ const lines = [line("Session:", `${sessionName(state.branch, state.mode)}${saved ? " (exists, resumed)" : ""}`)];
204
+ if (cloudSession !== null)
205
+ lines.push(line("Cloud:", `the issue has a cloud session (${cloudSessionUrl(cloudSession)}). To bring it: ${again(options.number, "--teleport")}`));
206
+ return lines;
207
+ }
208
+ /**
209
+ * Where the worktree's steps run, for its setup and its teardown.
210
+ * @param state - The start.
211
+ * @returns The place.
212
+ * @example stepPlaceOf(state).root
213
+ */
214
+ export function stepPlaceOf(state) {
215
+ const { workDir, mainRoot, base, out, options } = state;
216
+ return { root: workDir, mainRoot, base, config: options.config, out, remote: false };
217
+ }
@@ -0,0 +1,14 @@
1
+ import { type Output } from "./output.js";
2
+ import type { PortsConfig } from "./worktree-config.js";
3
+ /**
4
+ * Stops the apps of a worktree: TERM to each one that listens on a port of its block (to its
5
+ * process group, if the group's leader runs in the worktree too), and KILL to those still
6
+ * there after `GRACE_SECONDS`.
7
+ * @param ports - The range of ports.
8
+ * @param base - The block's first port.
9
+ * @param root - The worktree, with its symlinks resolved.
10
+ * @param out - Where to say it.
11
+ * @returns How many processes listened.
12
+ * @example await stopApps(ports, 21430, "/repo/.claude/worktrees/feat+42-x", out)
13
+ */
14
+ export declare function stopApps(ports: PortsConfig, base: number, root: string, out: Output): Promise<number>;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @file Stops the apps of a worktree before it goes: the processes that listen on a port of
3
+ * its block and run inside its folder. A dev server (turbo, `bun run dev`) starts each app
4
+ * in its own process group, which would outlive the worktree's terminals with the ports
5
+ * taken; the group gets TERM and, if the app is still there after some seconds, KILL. The
6
+ * group is signalled only if its leader also runs in the worktree: an app started without
7
+ * its own group shares that of whoever launched it (a terminal, Claude, Orca), and then
8
+ * only the app gets the signal. A process outside the worktree is never touched, even on
9
+ * one of its ports, and neither is the group `issue-flow` runs in. Needs `lsof`; without it nothing is stopped. Ported from
10
+ * `stop_apps` in Abastio's `scripts/orca-hooks.sh`.
11
+ */
12
+ import { spawnSync } from "node:child_process";
13
+ import { realpathSync } from "node:fs";
14
+ import { setTimeout as sleep } from "node:timers/promises";
15
+ import { inPath } from "./launch.js";
16
+ import { warn } from "./output.js";
17
+ /** Seconds the apps get to end on their own before they are killed. */
18
+ const GRACE_SECONDS = 5;
19
+ /**
20
+ * Runs a command and gives its output, or null if it failed.
21
+ * @param command - The program.
22
+ * @param args - Its arguments.
23
+ * @returns The output.
24
+ */
25
+ function output(command, args) {
26
+ const result = spawnSync(command, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
27
+ return result.status === 0 ? result.stdout : null;
28
+ }
29
+ /**
30
+ * The processes that listen on a TCP port of a range, with one `lsof`.
31
+ * @param first - The first port.
32
+ * @param last - The last port.
33
+ * @returns Their pids.
34
+ */
35
+ function listeners(first, last) {
36
+ const text = output("lsof", ["-nP", `-iTCP:${first}-${last}`, "-sTCP:LISTEN", "-t"]) ?? "";
37
+ return text.split("\n").filter((line) => /^\d+$/.test(line)).map(Number);
38
+ }
39
+ /**
40
+ * The folder a process runs in, with its symlinks resolved.
41
+ * @param pid - The process.
42
+ * @returns The folder, or null if it cannot be read.
43
+ */
44
+ function processDir(pid) {
45
+ const line = (output("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]) ?? "").split("\n").find((item) => item.startsWith("n/"));
46
+ if (line === undefined)
47
+ return null;
48
+ try {
49
+ return realpathSync(line.slice(1));
50
+ }
51
+ catch {
52
+ return line.slice(1);
53
+ }
54
+ }
55
+ /**
56
+ * The process group of a process.
57
+ * @param pid - The process.
58
+ * @returns The group, or null if the process is gone.
59
+ */
60
+ function processGroup(pid) {
61
+ const text = (output("ps", ["-o", "pgid=", "-p", String(pid)]) ?? "").trim();
62
+ return /^\d+$/.test(text) ? Number(text) : null;
63
+ }
64
+ /**
65
+ * The processes of the worktree that listen on a port of its block.
66
+ * @param ports - The range of ports.
67
+ * @param base - The block's first port.
68
+ * @param root - The worktree, with its symlinks resolved.
69
+ * @returns Their pids.
70
+ */
71
+ function appPids(ports, base, root) {
72
+ return [...new Set(listeners(base, base + ports.blockSize - 1))].filter((pid) => inside(pid, root));
73
+ }
74
+ /**
75
+ * Whether a process runs in the worktree.
76
+ * @param pid - The process.
77
+ * @param root - The worktree, with its symlinks resolved.
78
+ * @returns True if its folder is the worktree or inside it.
79
+ */
80
+ function inside(pid, root) {
81
+ const dir = processDir(pid);
82
+ return dir !== null && (dir === root || dir.startsWith(`${root}/`));
83
+ }
84
+ /**
85
+ * Sends a signal to each target: a negative number is a process group.
86
+ * @param targets - The groups and processes.
87
+ * @param signal - The signal.
88
+ */
89
+ function signal(targets, signal) {
90
+ for (const target of targets)
91
+ try {
92
+ process.kill(target, signal);
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ }
98
+ /**
99
+ * Stops the apps of a worktree: TERM to each one that listens on a port of its block (to its
100
+ * process group, if the group's leader runs in the worktree too), and KILL to those still
101
+ * there after `GRACE_SECONDS`.
102
+ * @param ports - The range of ports.
103
+ * @param base - The block's first port.
104
+ * @param root - The worktree, with its symlinks resolved.
105
+ * @param out - Where to say it.
106
+ * @returns How many processes listened.
107
+ * @example await stopApps(ports, 21430, "/repo/.claude/worktrees/feat+42-x", out)
108
+ */
109
+ export async function stopApps(ports, base, root, out) {
110
+ if (!inPath("lsof"))
111
+ return 0;
112
+ const pids = appPids(ports, base, root);
113
+ if (pids.length === 0)
114
+ return 0;
115
+ const own = processGroup(process.pid);
116
+ const targets = [...new Set(pids.map((pid) => {
117
+ const group = processGroup(pid);
118
+ return group === null || group === own || !inside(group, root) ? pid : -group;
119
+ }))];
120
+ warn(out, `stopping the apps that run in ${root}.`);
121
+ signal(targets, "SIGTERM");
122
+ for (let waited = 0; waited < GRACE_SECONDS && appPids(ports, base, root).length > 0; waited += 1)
123
+ await sleep(1000);
124
+ if (appPids(ports, base, root).length > 0)
125
+ signal(targets, "SIGKILL");
126
+ return pids.length;
127
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @file The usage text of the CLI, printed with `--help` and after a usage error.
3
+ */
4
+ /** Usage text. */
5
+ export declare const USAGE = "Usage:\n issue-flow issue branch <n>... [--namer claude|title] [--json] [-R owner/repo]\n issue-flow issue branch --missing [--limit <n>] [--namer claude|title] [--dry-run | --yes] [--json] [-R owner/repo]\n issue-flow issue start <n> [--branch] [--dry-run | --yes] [--resume <branch> | --new] [--no-launch]\n [--ask-permissions | --free] [--namer claude|title] [-R owner/repo]\n issue-flow issue start <n> --plan [--ask-permissions | --free] [--namer claude|title] [-R owner/repo]\n issue-flow issue start <n> --cloud | --here | --teleport | --adopt [...]\n issue-flow worktree setup [--only <step>]\n issue-flow worktree teardown [--path <dir>]\n issue-flow worktree ports [--next]\n issue-flow worktree cleanup [--path <dir>]\n issue-flow worktree prune [--only <dir>] [--yes] [-R owner/repo]\n issue-flow pr worktree <n> [--no-setup] [-R owner/repo] [-- [<command>...]]\n issue-flow adapter setup [--only <step>] | issue [<url>] | archive [-R owner/repo]\n issue-flow session\n issue-flow hook worktree-create | worktree-remove\n issue-flow init [--yes] [--recipe <name>,...] [--no-cloud] [--no-guard] [--no-skills]\n issue-flow doctor\n issue-flow upgrade [--to <version>] [--dry-run]\n\nThe slug comes from the issue's \"Slug de rama\" block, then from Claude, then from the title,\nand the branch from the repo's template (an older \"Rama sugerida\" block is used if it fits).\n--missing adds the slug block to every open issue without a block, after confirming.\n\nissue start makes the branch in a worktree (or, with --branch, in this checkout), the\nstart commit, the push, the draft PR and the assignment, after confirming, and opens\nClaude: in auto mode if the repo has claude-repo-guard, with your settings with\n--ask-permissions, and with no sandbox nor questions with --free. --plan prints the branch\nand the prompt as JSON and changes nothing.\n\n--cloud confirms here and opens a Claude cloud session that starts the issue there.\n--here, inside a cloud session, starts it in this checkout and opens nothing. --teleport\nbrings the cloud session noted in the PR to the worktree. --adopt starts it in the\nworktree another tool (Orca, Paseo) just made, without asking nor opening Claude.\n\nworktree runs the steps of \"worktrees\" in .agents/flow.json: setup prepares this worktree,\nteardown cleans it before it goes (steps, ports, Docker), ports prints or moves its block\nof ports, cleanup deletes the Docker of deleted worktrees, and prune deletes the worktrees\nwhose PR was merged or closed. session is the SessionStart hook of Claude Code.\n\npr worktree makes (or reuses) a worktree on a PR's own branch, to review and fix it; with\n--, it runs the command there (Claude if none). adapter is what Orca and Paseo run: setup\nwhen they make a worktree (its steps and, if it came from an issue, issue start --adopt),\nissue to start one by hand, and archive before they delete it. hook implements Claude\nCode's WorktreeCreate and WorktreeRemove for claude --worktree.\n\ninit sets the repo up: .agents/flow.json (with the recipes its files suggest), the dev\ndependencies and scripts, and the hooks; then, after asking, claude-cloud init,\nclaude-repo-guard and the skills menu. doctor checks it is set up at this version, and\nupgrade moves the CLI, the hooks and the flow's skills to the latest version (or --to).";
package/dist/usage.js ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @file The usage text of the CLI, printed with `--help` and after a usage error.
3
+ */
4
+ /** Usage text. */
5
+ export const USAGE = `Usage:
6
+ issue-flow issue branch <n>... [--namer claude|title] [--json] [-R owner/repo]
7
+ issue-flow issue branch --missing [--limit <n>] [--namer claude|title] [--dry-run | --yes] [--json] [-R owner/repo]
8
+ issue-flow issue start <n> [--branch] [--dry-run | --yes] [--resume <branch> | --new] [--no-launch]
9
+ [--ask-permissions | --free] [--namer claude|title] [-R owner/repo]
10
+ issue-flow issue start <n> --plan [--ask-permissions | --free] [--namer claude|title] [-R owner/repo]
11
+ issue-flow issue start <n> --cloud | --here | --teleport | --adopt [...]
12
+ issue-flow worktree setup [--only <step>]
13
+ issue-flow worktree teardown [--path <dir>]
14
+ issue-flow worktree ports [--next]
15
+ issue-flow worktree cleanup [--path <dir>]
16
+ issue-flow worktree prune [--only <dir>] [--yes] [-R owner/repo]
17
+ issue-flow pr worktree <n> [--no-setup] [-R owner/repo] [-- [<command>...]]
18
+ issue-flow adapter setup [--only <step>] | issue [<url>] | archive [-R owner/repo]
19
+ issue-flow session
20
+ issue-flow hook worktree-create | worktree-remove
21
+ issue-flow init [--yes] [--recipe <name>,...] [--no-cloud] [--no-guard] [--no-skills]
22
+ issue-flow doctor
23
+ issue-flow upgrade [--to <version>] [--dry-run]
24
+
25
+ The slug comes from the issue's "Slug de rama" block, then from Claude, then from the title,
26
+ and the branch from the repo's template (an older "Rama sugerida" block is used if it fits).
27
+ --missing adds the slug block to every open issue without a block, after confirming.
28
+
29
+ issue start makes the branch in a worktree (or, with --branch, in this checkout), the
30
+ start commit, the push, the draft PR and the assignment, after confirming, and opens
31
+ Claude: in auto mode if the repo has claude-repo-guard, with your settings with
32
+ --ask-permissions, and with no sandbox nor questions with --free. --plan prints the branch
33
+ and the prompt as JSON and changes nothing.
34
+
35
+ --cloud confirms here and opens a Claude cloud session that starts the issue there.
36
+ --here, inside a cloud session, starts it in this checkout and opens nothing. --teleport
37
+ brings the cloud session noted in the PR to the worktree. --adopt starts it in the
38
+ worktree another tool (Orca, Paseo) just made, without asking nor opening Claude.
39
+
40
+ worktree runs the steps of "worktrees" in .agents/flow.json: setup prepares this worktree,
41
+ teardown cleans it before it goes (steps, ports, Docker), ports prints or moves its block
42
+ of ports, cleanup deletes the Docker of deleted worktrees, and prune deletes the worktrees
43
+ whose PR was merged or closed. session is the SessionStart hook of Claude Code.
44
+
45
+ pr worktree makes (or reuses) a worktree on a PR's own branch, to review and fix it; with
46
+ --, it runs the command there (Claude if none). adapter is what Orca and Paseo run: setup
47
+ when they make a worktree (its steps and, if it came from an issue, issue start --adopt),
48
+ issue to start one by hand, and archive before they delete it. hook implements Claude
49
+ Code's WorktreeCreate and WorktreeRemove for claude --worktree.
50
+
51
+ init sets the repo up: .agents/flow.json (with the recipes its files suggest), the dev
52
+ dependencies and scripts, and the hooks; then, after asking, claude-cloud init,
53
+ claude-repo-guard and the skills menu. doctor checks it is set up at this version, and
54
+ upgrade moves the CLI, the hooks and the flow's skills to the latest version (or --to).`;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file What an issue already has, from github-rest's `readWork`: its PRs, the branches it
3
+ * used and the live ones that can be resumed. Ported from `detect_existing` and
4
+ * `branch_pr` in Abastio's `scripts/start-issue.sh`.
5
+ */
6
+ import type { IssueEntry, PrState } from "@ramon-sg/github-rest";
7
+ /** A PR of the issue from this repo (not from a fork). */
8
+ export interface IssuePr {
9
+ number: number;
10
+ branch: string;
11
+ state: PrState;
12
+ head: string;
13
+ }
14
+ /** The issue's work, ready to choose a branch. */
15
+ export interface WorkState {
16
+ prs: IssuePr[];
17
+ forks: number[];
18
+ mergedPrs: number[];
19
+ mergedBranches: string[];
20
+ known: string[];
21
+ candidates: string[];
22
+ }
23
+ /**
24
+ * The issue's work. Branches of merged PRs do not count as live; a branch counts if it is
25
+ * in origin, local, or has an open PR. Without GitHub (offline) only local branches are
26
+ * seen.
27
+ * @param entry - The issue in `readWork`'s output.
28
+ * @returns The work.
29
+ * @example workState(output.issues[0]).candidates // ["feat/42-x"]
30
+ */
31
+ export declare function workState(entry: IssueEntry): WorkState;
32
+ /**
33
+ * The PR of a branch being resumed: the open one or, if there is none, the closed one
34
+ * that was never merged (it gets reopened).
35
+ * @param prs - The issue's PRs.
36
+ * @param branch - The branch.
37
+ * @returns Its number and state, or null if it has none.
38
+ * @example branchPr(prs, "feat/42-x") // { number: 7, state: "OPEN" }
39
+ */
40
+ export declare function branchPr(prs: IssuePr[], branch: string): {
41
+ number: number;
42
+ state: "OPEN" | "CLOSED";
43
+ } | null;
44
+ /**
45
+ * The open PRs of the issue on other branches.
46
+ * @param prs - The issue's PRs.
47
+ * @param branch - The branch being worked on.
48
+ * @returns Their numbers.
49
+ * @example openPrsElsewhere(prs, "feat/42-x") // [150]
50
+ */
51
+ export declare function openPrsElsewhere(prs: IssuePr[], branch: string): number[];
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Sorted and without repeats, like jq's `unique`.
3
+ * @param names - The names.
4
+ * @returns The names.
5
+ */
6
+ const unique = (names) => [...new Set(names)].sort();
7
+ /**
8
+ * The issue's work. Branches of merged PRs do not count as live; a branch counts if it is
9
+ * in origin, local, or has an open PR. Without GitHub (offline) only local branches are
10
+ * seen.
11
+ * @param entry - The issue in `readWork`'s output.
12
+ * @returns The work.
13
+ * @example workState(output.issues[0]).candidates // ["feat/42-x"]
14
+ */
15
+ export function workState(entry) {
16
+ const work = "work" in entry ? entry.work : { prs: [], branches: [] };
17
+ const local = "local" in entry ? entry.local : { branches: [], worktrees: [] };
18
+ const own = work.prs.filter((pr) => !pr.fork);
19
+ const merged = work.branches.filter((branch) => branch.merged).map((branch) => branch.name);
20
+ return {
21
+ prs: own.map(({ number, branch, state, head }) => ({ number, branch, state, head })),
22
+ forks: work.prs.filter((pr) => pr.fork).map((pr) => pr.number),
23
+ mergedPrs: own.filter((pr) => pr.state === "MERGED").map((pr) => pr.number),
24
+ mergedBranches: merged,
25
+ known: unique([...work.branches.map((branch) => branch.name), ...local.branches]),
26
+ candidates: unique([
27
+ ...work.branches.filter((branch) => !branch.merged && branch.remote).map((branch) => branch.name),
28
+ ...local.branches.filter((branch) => !merged.includes(branch)),
29
+ ...own.filter((pr) => pr.state === "OPEN").map((pr) => pr.branch),
30
+ ]),
31
+ };
32
+ }
33
+ /**
34
+ * The PR of a branch being resumed: the open one or, if there is none, the closed one
35
+ * that was never merged (it gets reopened).
36
+ * @param prs - The issue's PRs.
37
+ * @param branch - The branch.
38
+ * @returns Its number and state, or null if it has none.
39
+ * @example branchPr(prs, "feat/42-x") // { number: 7, state: "OPEN" }
40
+ */
41
+ export function branchPr(prs, branch) {
42
+ const mine = prs.filter((pr) => pr.branch === branch);
43
+ const open = mine.find((pr) => pr.state === "OPEN");
44
+ if (open !== undefined)
45
+ return { number: open.number, state: "OPEN" };
46
+ const closed = mine.find((pr) => pr.state === "CLOSED");
47
+ return closed === undefined ? null : { number: closed.number, state: "CLOSED" };
48
+ }
49
+ /**
50
+ * The open PRs of the issue on other branches.
51
+ * @param prs - The issue's PRs.
52
+ * @param branch - The branch being worked on.
53
+ * @returns Their numbers.
54
+ * @example openPrsElsewhere(prs, "feat/42-x") // [150]
55
+ */
56
+ export function openPrsElsewhere(prs, branch) {
57
+ return prs.filter((pr) => pr.state === "OPEN" && pr.branch !== branch).map((pr) => pr.number);
58
+ }