@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
package/dist/launch.js ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @file Opens Claude for an issue in the same terminal. The permission mode follows
3
+ * `claude-repo-guard` when the repo has it: auto mode with its classifier context by
4
+ * default, plain `claude` with `--ask-permissions`, and no sandbox nor questions with
5
+ * `--free`. Each branch keeps its session id, so starting the issue again resumes it.
6
+ * Ported from `launch_session` and `run_claude` in Abastio's `scripts/start-issue.sh`.
7
+ */
8
+ import { randomUUID } from "node:crypto";
9
+ import { spawnSync } from "node:child_process";
10
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { delimiter, dirname, join } from "node:path";
13
+ import { dirName, sessionName } from "./issue-text.js";
14
+ /** The file `claude-repo-guard` writes with the auto mode's classifier context. */
15
+ export const AUTO_MODE_FILE = ".claude/repo-guard.auto-mode.json";
16
+ /** Folder, inside the shared `.git`, with one file per branch holding its session id. */
17
+ export const SESSIONS_DIR = "issue-flow-sessions";
18
+ /** Settings that turn the sandbox off for one session, as `claude:free` does. */
19
+ const NO_SANDBOX = '{"sandbox":{"enabled":false}}';
20
+ /**
21
+ * The arguments for the permission mode. By default, auto mode with the classifier context
22
+ * of `claude-repo-guard` if the repo has it (relative, from the folder where Claude runs,
23
+ * if it is there; or else by its full path in the main checkout), and plain `claude` if it
24
+ * does not.
25
+ * @param permissions - What the flags asked for.
26
+ * @param workDir - Where Claude runs, or null if that folder does not exist yet.
27
+ * @param mainRoot - The main checkout.
28
+ * @returns The arguments and their label.
29
+ * @example permissionArgs("default", wt, root).args // ["--settings", ".claude/repo-guard.auto-mode.json", "--permission-mode", "auto"]
30
+ */
31
+ export function permissionArgs(permissions, workDir, mainRoot) {
32
+ if (permissions === "free")
33
+ return {
34
+ args: ["--settings", NO_SANDBOX, "--dangerously-skip-permissions"],
35
+ label: "none: no sandbox and no questions (--free)",
36
+ };
37
+ if (permissions === "ask")
38
+ return { args: [], label: "your settings (--ask-permissions)" };
39
+ const file = workDir !== null && existsSync(join(workDir, AUTO_MODE_FILE))
40
+ ? AUTO_MODE_FILE
41
+ : existsSync(join(mainRoot, AUTO_MODE_FILE))
42
+ ? join(mainRoot, AUTO_MODE_FILE)
43
+ : null;
44
+ if (file === null)
45
+ return { args: [], label: "your settings (claude-repo-guard is not installed)" };
46
+ return {
47
+ args: ["--settings", file, "--permission-mode", "auto"],
48
+ label: "auto mode, with claude-repo-guard's context",
49
+ };
50
+ }
51
+ /**
52
+ * Whether a program is in the PATH, without running it.
53
+ * @param name - The program.
54
+ * @returns True if it is.
55
+ * @example inPath("claude")
56
+ */
57
+ export function inPath(name) {
58
+ return (process.env.PATH ?? "")
59
+ .split(delimiter)
60
+ .some((dir) => dir !== "" && existsSync(join(dir, name)));
61
+ }
62
+ /**
63
+ * The file that holds a branch's session id.
64
+ * @param commonDir - The shared `.git`.
65
+ * @param branch - The branch.
66
+ * @returns The path.
67
+ * @example sessionFile("/repo/.git", "feat/42-x") // "/repo/.git/issue-flow-sessions/feat+42-x"
68
+ */
69
+ export function sessionFile(commonDir, branch) {
70
+ return join(commonDir, SESSIONS_DIR, dirName(branch));
71
+ }
72
+ /**
73
+ * Whether Claude Code has a saved session with that id.
74
+ * @param id - The id.
75
+ * @returns True if it does.
76
+ * @example sessionExists("11111111-2222-3333-4444-555555555555")
77
+ */
78
+ export function sessionExists(id) {
79
+ const projects = join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects");
80
+ if (!existsSync(projects))
81
+ return false;
82
+ return readdirSync(projects).some((project) => existsSync(join(projects, project, `${id}.jsonl`)));
83
+ }
84
+ /**
85
+ * The saved session of a branch, if Claude still has it.
86
+ * @param commonDir - The shared `.git`.
87
+ * @param branch - The branch.
88
+ * @returns The id, or null.
89
+ * @example savedSession("/repo/.git", "feat/42-x")
90
+ */
91
+ export function savedSession(commonDir, branch) {
92
+ const file = sessionFile(commonDir, branch);
93
+ if (!existsSync(file))
94
+ return null;
95
+ const id = readFileSync(file, "utf8").trim();
96
+ return id !== "" && sessionExists(id) ? id : null;
97
+ }
98
+ /**
99
+ * Forgets a branch's session id, once its work is gone.
100
+ * @param commonDir - The shared `.git`.
101
+ * @param branch - The branch.
102
+ * @example forgetSession("/repo/.git", "feat/42-x")
103
+ */
104
+ export function forgetSession(commonDir, branch) {
105
+ rmSync(sessionFile(commonDir, branch), { force: true });
106
+ }
107
+ /**
108
+ * Runs Claude in the terminal; its exit code does not matter, what follows runs anyway.
109
+ * @param cwd - Where.
110
+ * @param args - The arguments.
111
+ */
112
+ function runClaude(cwd, args) {
113
+ spawnSync("claude", args, { cwd, stdio: "inherit" });
114
+ }
115
+ /**
116
+ * Opens Claude: resumes the branch's saved session or creates one with its name, its color
117
+ * and the prompt. The color is set first with a `claude -p "/color …"` on the new session,
118
+ * because a session only takes it from a command.
119
+ * @param plan - Where, which branch and with what.
120
+ * @param warn - Where a warning goes.
121
+ * @example launchSession(plan, (text) => console.error(text))
122
+ */
123
+ export function launchSession(plan, warn) {
124
+ const saved = savedSession(plan.commonDir, plan.branch);
125
+ if (saved !== null) {
126
+ runClaude(plan.workDir, [...plan.permissions, "--resume", saved, plan.prompt]);
127
+ return;
128
+ }
129
+ const id = randomUUID();
130
+ const file = sessionFile(plan.commonDir, plan.branch);
131
+ mkdirSync(dirname(file), { recursive: true });
132
+ writeFileSync(file, `${id}\n`);
133
+ const name = sessionName(plan.branch, plan.mode);
134
+ if (plan.color !== null) {
135
+ const colored = spawnSync("claude", ["-p", "--session-id", id, "-n", name, `/color ${plan.color}`], {
136
+ cwd: plan.workDir,
137
+ stdio: "ignore",
138
+ });
139
+ if (colored.status !== 0)
140
+ warn("could not color the session; it opens without a color.");
141
+ }
142
+ if (sessionExists(id))
143
+ runClaude(plan.workDir, [...plan.permissions, "--resume", id, plan.prompt]);
144
+ else
145
+ runClaude(plan.workDir, [...plan.permissions, "--session-id", id, "-n", name, plan.prompt]);
146
+ }
147
+ /**
148
+ * Brings a cloud session to the folder with `claude --teleport`: the one noted in the PR,
149
+ * or the list of sessions to choose from if there is none. It keeps no session id: once
150
+ * brought, `claude --resume` finds it.
151
+ * @param workDir - Where.
152
+ * @param permissions - The arguments of the permission mode.
153
+ * @param id - The cloud session, or null for the list.
154
+ * @example teleportSession(wt, [], "session_01A")
155
+ */
156
+ export function teleportSession(workDir, permissions, id) {
157
+ runClaude(workDir, [...permissions, "--teleport", ...(id === null ? [] : [id])]);
158
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @file Where commands write, and the usage error. Commands get an `Output` instead of
3
+ * writing to `process` directly, so the entry point decides about the terminal.
4
+ */
5
+ /** Usage error: exits 2 and prints the usage text. */
6
+ export declare class UsageError extends Error {
7
+ }
8
+ /** Where a command writes, and whether someone can answer a question. */
9
+ export interface Output {
10
+ stdout: (text: string) => void;
11
+ stderr: (text: string) => void;
12
+ interactive: boolean;
13
+ }
14
+ /**
15
+ * The process's output; interactive when both stdin and stdout are a terminal.
16
+ * @returns The output.
17
+ * @example processOutput().interactive
18
+ */
19
+ export declare function processOutput(): Output;
20
+ /**
21
+ * Prints a warning on stderr with the program's name.
22
+ * @param out - Where to write.
23
+ * @param text - The warning.
24
+ * @example warn(out, "nothing written")
25
+ */
26
+ export declare function warn(out: Output, text: string): void;
27
+ /**
28
+ * Prints a value as one line of JSON on stdout.
29
+ * @param out - Where to write.
30
+ * @param value - The value.
31
+ * @example emitJson(out, { ok: true })
32
+ */
33
+ export declare function emitJson(out: Output, value: unknown): void;
package/dist/output.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file Where commands write, and the usage error. Commands get an `Output` instead of
3
+ * writing to `process` directly, so the entry point decides about the terminal.
4
+ */
5
+ /** Usage error: exits 2 and prints the usage text. */
6
+ export class UsageError extends Error {
7
+ }
8
+ /**
9
+ * The process's output; interactive when both stdin and stdout are a terminal.
10
+ * @returns The output.
11
+ * @example processOutput().interactive
12
+ */
13
+ export function processOutput() {
14
+ return {
15
+ stdout: (text) => process.stdout.write(text),
16
+ stderr: (text) => process.stderr.write(text),
17
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
18
+ };
19
+ }
20
+ /**
21
+ * Prints a warning on stderr with the program's name.
22
+ * @param out - Where to write.
23
+ * @param text - The warning.
24
+ * @example warn(out, "nothing written")
25
+ */
26
+ export function warn(out, text) {
27
+ out.stderr(`issue-flow: ${text}\n`);
28
+ }
29
+ /**
30
+ * Prints a value as one line of JSON on stdout.
31
+ * @param out - Where to write.
32
+ * @param value - The value.
33
+ * @example emitJson(out, { ok: true })
34
+ */
35
+ export function emitJson(out, value) {
36
+ out.stdout(`${JSON.stringify(value)}\n`);
37
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * @file The repo's `package.json` as the flow needs it: `issue-flow`, `github-rest` and
3
+ * `skills-manager` as exact dev dependencies (the skills call them with `npx`, which then
4
+ * uses the repo's copy), and the scripts to start an issue, prune worktrees and review a PR.
5
+ * A dependency or script the repo already has with another value stays as it is.
6
+ */
7
+ import type { OwnVersions } from "./setup-files.js";
8
+ /**
9
+ * The dependencies the flow pins, with their versions.
10
+ * @param versions - This package's versions.
11
+ * @returns Package to version.
12
+ * @example pinnedPackages(versions)["@ramon-sg/issue-flow"] // "0.1.0"
13
+ */
14
+ export declare function pinnedPackages(versions: OwnVersions): Record<string, string>;
15
+ /**
16
+ * The version of a package in the repo, as a dependency or a dev dependency.
17
+ * @param pkg - The package.json.
18
+ * @param name - The package.
19
+ * @returns Its version range, or null.
20
+ * @example dependencyVersion(pkg, "@ramon-sg/github-rest") // "0.2.1"
21
+ */
22
+ export declare function dependencyVersion(pkg: Record<string, unknown> | null, name: string): string | null;
23
+ /**
24
+ * Adds the flow's dev dependencies and scripts that are missing. With `move`, also moves the
25
+ * ones already there to these versions (what `upgrade` does).
26
+ * @param pkg - The package.json, or null if there is none.
27
+ * @param versions - The versions to pin.
28
+ * @param move - Whether to move the dependencies already there.
29
+ * @returns The new package.json and what changed or was kept.
30
+ * @example withFlowPackages(null, versions, false).changes // ["devDependencies: @ramon-sg/issue-flow 0.1.0", …]
31
+ */
32
+ export declare function withFlowPackages(pkg: Record<string, unknown> | null, versions: OwnVersions, move: boolean): {
33
+ pkg: Record<string, unknown>;
34
+ changes: string[];
35
+ };
@@ -0,0 +1,78 @@
1
+ /** The scripts `init` adds. */
2
+ const FLOW_SCRIPTS = {
3
+ "issue:start": "issue-flow issue start",
4
+ "worktree:prune": "issue-flow worktree prune",
5
+ "pr:worktree": "issue-flow pr worktree",
6
+ };
7
+ /**
8
+ * The dependencies the flow pins, with their versions.
9
+ * @param versions - This package's versions.
10
+ * @returns Package to version.
11
+ * @example pinnedPackages(versions)["@ramon-sg/issue-flow"] // "0.1.0"
12
+ */
13
+ export function pinnedPackages(versions) {
14
+ return {
15
+ "@ramon-sg/issue-flow": versions.issueFlow,
16
+ "@ramon-sg/github-rest": versions.githubRest,
17
+ "@ramon-sg/skills-manager": versions.skillsManager,
18
+ };
19
+ }
20
+ /**
21
+ * Where a package is in the repo's package.json, and its version.
22
+ * @param pkg - The package.json.
23
+ * @param name - The package.
24
+ * @returns The field and the version range, or null if it is in neither.
25
+ */
26
+ function dependencyOf(pkg, name) {
27
+ for (const field of ["dependencies", "devDependencies"]) {
28
+ const deps = pkg?.[field];
29
+ const version = typeof deps === "object" && deps !== null ? deps[name] : undefined;
30
+ if (typeof version === "string")
31
+ return { field, version };
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * The version of a package in the repo, as a dependency or a dev dependency.
37
+ * @param pkg - The package.json.
38
+ * @param name - The package.
39
+ * @returns Its version range, or null.
40
+ * @example dependencyVersion(pkg, "@ramon-sg/github-rest") // "0.2.1"
41
+ */
42
+ export function dependencyVersion(pkg, name) {
43
+ return dependencyOf(pkg, name)?.version ?? null;
44
+ }
45
+ /**
46
+ * Adds the flow's dev dependencies and scripts that are missing. With `move`, also moves the
47
+ * ones already there to these versions (what `upgrade` does).
48
+ * @param pkg - The package.json, or null if there is none.
49
+ * @param versions - The versions to pin.
50
+ * @param move - Whether to move the dependencies already there.
51
+ * @returns The new package.json and what changed or was kept.
52
+ * @example withFlowPackages(null, versions, false).changes // ["devDependencies: @ramon-sg/issue-flow 0.1.0", …]
53
+ */
54
+ export function withFlowPackages(pkg, versions, move) {
55
+ const next = pkg === null ? { private: true } : { ...pkg };
56
+ const scripts = { ...(next.scripts ?? {}) };
57
+ const changes = [];
58
+ for (const [name, version] of Object.entries(pinnedPackages(versions))) {
59
+ const current = dependencyOf(next, name);
60
+ if (current?.version === version)
61
+ continue;
62
+ if (current !== null && !move) {
63
+ changes.push(`kept ${name} ${current.version} (issue-flow ${versions.issueFlow} was tested with ${version})`);
64
+ continue;
65
+ }
66
+ const field = current?.field ?? "devDependencies";
67
+ next[field] = { ...(next[field] ?? {}), [name]: version };
68
+ changes.push(`${field}: ${name} ${current === null ? "" : `${current.version} → `}${version}`);
69
+ }
70
+ for (const [name, command] of Object.entries(FLOW_SCRIPTS)) {
71
+ if (scripts[name] !== undefined)
72
+ continue;
73
+ scripts[name] = command;
74
+ changes.push(`scripts: ${name}`);
75
+ }
76
+ next.scripts = scripts;
77
+ return { pkg: next, changes };
78
+ }
@@ -0,0 +1,26 @@
1
+ /** Where to look and what for. */
2
+ export interface PlanQuery {
3
+ cwd: string;
4
+ base: string;
5
+ repo: string;
6
+ dirs: string[];
7
+ }
8
+ /**
9
+ * The plan of a branch being resumed: one its own commits touched or, if none, one in the
10
+ * base branch that names its PR.
11
+ * @param query - Where to look.
12
+ * @param branch - The branch.
13
+ * @param pr - Its PR, or null.
14
+ * @returns The plan's path, or null.
15
+ * @example findPlan(query, "feat/42-x", 7) // "docs/plans/2026-10-24-x.md"
16
+ */
17
+ export declare function findPlan(query: PlanQuery, branch: string, pr: number | null): string | null;
18
+ /**
19
+ * The plan an earlier round left open: a document of the base branch that names one of
20
+ * the merged PRs and is not in `applied/` nor `abandoned/`.
21
+ * @param query - Where to look.
22
+ * @param merged - The issue's merged PRs.
23
+ * @returns The plan's path, or null.
24
+ * @example findOpenPlan(query, [7]) // "docs/plans/2026-10-24-x.md"
25
+ */
26
+ export declare function findOpenPlan(query: PlanQuery, merged: number[]): string | null;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * @file Finds the plan of an issue, so a resumed session picks up from it: a plan the
3
+ * branch's own commits touched, or a plan in the base branch whose `prs:` names one of the
4
+ * issue's PRs. Ported from `find_plan` and `find_open_plan` in Abastio's
5
+ * `scripts/start-issue.sh`.
6
+ */
7
+ import { gitLines, hasLocal, onOrigin } from "./git.js";
8
+ /** Folders of finished and discarded plans, which never count as open. */
9
+ const CLOSED_FOLDERS = /\/(applied|abandoned)\//;
10
+ /** A PR in a `prs:` list: `12`, `#12` or `owner/repo#12`, with or without quotes. */
11
+ const PR_REFERENCE = /(?:([\w.-]+\/[\w.-]+))?#?(\d+)/g;
12
+ /**
13
+ * The first file in the order of the configured folders (plans before specs by default).
14
+ * @param files - The files found.
15
+ * @param dirs - The folders, in order of preference.
16
+ * @returns The file, or null.
17
+ */
18
+ function preferred(files, dirs) {
19
+ for (const dir of dirs) {
20
+ const found = files.find((file) => file.startsWith(`${dir}/`));
21
+ if (found !== undefined)
22
+ return found;
23
+ }
24
+ return null;
25
+ }
26
+ /**
27
+ * Whether a `prs:` line names that PR of this repo. A reference to another repo does not
28
+ * count.
29
+ * @param line - The line.
30
+ * @param repo - `owner/repo`.
31
+ * @param number - The PR.
32
+ * @returns True if it names it.
33
+ */
34
+ function namesPr(line, repo, number) {
35
+ for (const match of line.matchAll(PR_REFERENCE)) {
36
+ const [, other, digits] = match;
37
+ if (Number(digits) !== number)
38
+ continue;
39
+ if (other === undefined || other.toLowerCase() === repo.toLowerCase())
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ /**
45
+ * The documents of the base branch whose `prs:` names one of the PRs.
46
+ * @param query - Where to look.
47
+ * @param prs - The PRs.
48
+ * @returns The paths, relative to the repo.
49
+ */
50
+ function documentsOf(query, prs) {
51
+ if (prs.length === 0)
52
+ return [];
53
+ const ref = `origin/${query.base}`;
54
+ const hits = gitLines(query.cwd, ["grep", "-E", "^prs:", ref, "--", ...query.dirs]);
55
+ const files = [];
56
+ for (const hit of hits) {
57
+ const [, path = "", line = ""] = /^[^:]+:([^:]+):(.*)$/.exec(hit) ?? [];
58
+ if (prs.some((pr) => namesPr(line.slice("prs:".length), query.repo, pr)))
59
+ files.push(path);
60
+ }
61
+ return [...new Set(files)];
62
+ }
63
+ /**
64
+ * The plan of a branch being resumed: one its own commits touched or, if none, one in the
65
+ * base branch that names its PR.
66
+ * @param query - Where to look.
67
+ * @param branch - The branch.
68
+ * @param pr - Its PR, or null.
69
+ * @returns The plan's path, or null.
70
+ * @example findPlan(query, "feat/42-x", 7) // "docs/plans/2026-10-24-x.md"
71
+ */
72
+ export function findPlan(query, branch, pr) {
73
+ const ref = hasLocal(query.cwd, branch) ? branch : onOrigin(query.cwd, branch) ? `origin/${branch}` : null;
74
+ const touched = ref === null
75
+ ? []
76
+ : gitLines(query.cwd, ["log", "--format=", "--name-only", `origin/${query.base}..${ref}`, "--", ...query.dirs]);
77
+ const own = preferred([...new Set(touched)].sort(), query.dirs);
78
+ if (own !== null)
79
+ return own;
80
+ return preferred(documentsOf(query, pr === null ? [] : [pr]), query.dirs);
81
+ }
82
+ /**
83
+ * The plan an earlier round left open: a document of the base branch that names one of
84
+ * the merged PRs and is not in `applied/` nor `abandoned/`.
85
+ * @param query - Where to look.
86
+ * @param merged - The issue's merged PRs.
87
+ * @returns The plan's path, or null.
88
+ * @example findOpenPlan(query, [7]) // "docs/plans/2026-10-24-x.md"
89
+ */
90
+ export function findOpenPlan(query, merged) {
91
+ const open = documentsOf(query, merged).filter((file) => !CLOSED_FOLDERS.test(`/${file}`));
92
+ return preferred(open.sort(), query.dirs);
93
+ }
@@ -0,0 +1,90 @@
1
+ import type { PortsConfig } from "./worktree-config.js";
2
+ /** File, in the worktree's own git dir, with the first port of its block. */
3
+ export declare const PORT_BASE_FILE = "issue-flow-port-base";
4
+ /**
5
+ * The sha256 of a text, in hex.
6
+ * @param value - The text.
7
+ * @returns The hash.
8
+ * @example sha256("a").slice(0, 6) // "ca9781"
9
+ */
10
+ export declare function sha256(value: string): string;
11
+ /**
12
+ * Where `issue-flow` keeps what belongs to the whole machine (which worktree took each
13
+ * block): `ISSUE_FLOW_STATE_DIR`, or `~/.issue-flow`.
14
+ * @returns The folder.
15
+ * @example stateDir() // "/Users/me/.issue-flow"
16
+ */
17
+ export declare function stateDir(): string;
18
+ /**
19
+ * A stable name of the machine, without its domain. On macOS the local host name, not
20
+ * `hostname()`: that one changes with the network, and every block would move.
21
+ * @returns The name.
22
+ * @example machineName() // "ramon-mbp"
23
+ */
24
+ export declare function machineName(): string;
25
+ /**
26
+ * The first port of the block a folder starts from on a machine. It is only the start:
27
+ * `claimPortBase` moves on if it is taken.
28
+ * @param ports - The range.
29
+ * @param machine - The machine's name.
30
+ * @param root - The worktree.
31
+ * @param system - The system (`os.platform()`).
32
+ * @returns The port.
33
+ * @example defaultPortBase(DEFAULT_PORTS, "mbp", "/repo/.claude/worktrees/feat+42-x", "darwin") // 20000 to 24990
34
+ */
35
+ export declare function defaultPortBase(ports: PortsConfig, machine: string, root: string, system: string): number;
36
+ /**
37
+ * The block after one, without leaving its half: after the half's last it goes back to
38
+ * the half's first.
39
+ * @param ports - The range.
40
+ * @param base - The first port of a block.
41
+ * @returns The next block's first port.
42
+ * @example nextPortBase(DEFAULT_PORTS, 24990) // 20000
43
+ */
44
+ export declare function nextPortBase(ports: PortsConfig, base: number): number;
45
+ /**
46
+ * Whether a value is the first port of a block of the range.
47
+ * @param ports - The range.
48
+ * @param value - The value.
49
+ * @returns True if it is.
50
+ * @example isPortBase(DEFAULT_PORTS, 20010) // true
51
+ */
52
+ export declare function isPortBase(ports: PortsConfig, value: number): boolean;
53
+ /**
54
+ * The first port of a worktree's block: the saved one, or the one it starts from. It only
55
+ * reads: `claimPortBase` writes it once, before the apps start.
56
+ * @param ports - The range.
57
+ * @param root - The worktree.
58
+ * @returns The port.
59
+ * @example portBase(DEFAULT_PORTS, wt) // 21430
60
+ */
61
+ export declare function portBase(ports: PortsConfig, root: string): number;
62
+ /**
63
+ * Whether a folder is still the root of a checkout, comparing resolved paths. An empty
64
+ * folder left by a deleted worktree inside the main checkout finds the main repo, so being
65
+ * inside git is not enough.
66
+ * @param path - The folder.
67
+ * @returns True if it is a root.
68
+ * @example isCheckoutRoot("/repo/.claude/worktrees/feat+42-x")
69
+ */
70
+ export declare function isCheckoutRoot(path: string): boolean;
71
+ /**
72
+ * Deletes the worktree's registrations, but the one of the block it keeps. Run when the
73
+ * worktree moves to another block or goes away.
74
+ * @param root - The worktree.
75
+ * @param keep - The block it keeps, or null for none.
76
+ * @example releasePortClaims(wt, null)
77
+ */
78
+ export declare function releasePortClaims(root: string, keep: number | null): void;
79
+ /**
80
+ * Fixes the worktree's block and returns it. A valid saved block stays. Otherwise, or with
81
+ * `start` (to move it), it looks from `start` (or the block it starts from) for the first
82
+ * free one of its half: nobody registered it and nothing listens in it. It registers that
83
+ * block, saves it and drops the ones it had.
84
+ * @param ports - The range.
85
+ * @param root - The worktree.
86
+ * @param start - Where to start looking, or null to keep the saved block.
87
+ * @returns The first port, or null if the whole half is taken.
88
+ * @example await claimPortBase(DEFAULT_PORTS, wt, null) // 21430
89
+ */
90
+ export declare function claimPortBase(ports: PortsConfig, root: string, start: number | null): Promise<number | null>;