@ramon-sg/issue-flow 0.0.0-stage → 0.2.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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +297 -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 +120 -0
  14. package/dist/cli.js +344 -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 +11 -0
  20. package/dist/commands/doctor.js +90 -0
  21. package/dist/commands/init.d.ts +24 -0
  22. package/dist/commands/init.js +136 -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 +85 -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/issue-text.d.ts +128 -0
  44. package/dist/issue-text.js +210 -0
  45. package/dist/launch.d.ts +90 -0
  46. package/dist/launch.js +158 -0
  47. package/dist/output.d.ts +33 -0
  48. package/dist/output.js +37 -0
  49. package/dist/package-edits.d.ts +35 -0
  50. package/dist/package-edits.js +78 -0
  51. package/dist/plan-doc.d.ts +26 -0
  52. package/dist/plan-doc.js +93 -0
  53. package/dist/ports.d.ts +90 -0
  54. package/dist/ports.js +257 -0
  55. package/dist/propose.d.ts +42 -0
  56. package/dist/propose.js +111 -0
  57. package/dist/recipes.d.ts +43 -0
  58. package/dist/recipes.js +123 -0
  59. package/dist/session-lock.d.ts +32 -0
  60. package/dist/session-lock.js +69 -0
  61. package/dist/settings-hooks.d.ts +31 -0
  62. package/dist/settings-hooks.js +133 -0
  63. package/dist/setup-files.d.ts +48 -0
  64. package/dist/setup-files.js +91 -0
  65. package/dist/start-modes.d.ts +162 -0
  66. package/dist/start-modes.js +277 -0
  67. package/dist/start-place.d.ts +61 -0
  68. package/dist/start-place.js +301 -0
  69. package/dist/start-state.d.ts +72 -0
  70. package/dist/start-state.js +13 -0
  71. package/dist/start-summary.d.ts +42 -0
  72. package/dist/start-summary.js +217 -0
  73. package/dist/stop-apps.d.ts +14 -0
  74. package/dist/stop-apps.js +127 -0
  75. package/dist/usage.d.ts +5 -0
  76. package/dist/usage.js +54 -0
  77. package/dist/work-state.d.ts +51 -0
  78. package/dist/work-state.js +58 -0
  79. package/dist/worktree-config.d.ts +35 -0
  80. package/dist/worktree-config.js +139 -0
  81. package/dist/worktree-steps.d.ts +53 -0
  82. package/dist/worktree-steps.js +210 -0
  83. package/package.json +20 -4
  84. package/recipes/bun.json +5 -0
  85. package/recipes/direnv.json +17 -0
  86. package/recipes/docker-compose.json +30 -0
  87. package/recipes/npm.json +5 -0
  88. package/recipes/pnpm.json +5 -0
  89. package/recipes/staging-main.json +5 -0
  90. package/schema.json +281 -0
@@ -0,0 +1,85 @@
1
+ /**
2
+ * @file `issue-flow upgrade [--dry-run]`: moves the repo's flow files to the version that
3
+ * runs: the dev dependencies to the ones it pins, and the hooks and the config's schema to
4
+ * it. `@ramon-sg/agent-kit` runs it as `npx @ramon-sg/issue-flow@<version> upgrade` and then
5
+ * moves the flow's skills to the same tag, because the skills call this CLI.
6
+ */
7
+ import { CONFIG_PATH } from "../config-base.js";
8
+ import { checkoutRoot, locateRepo } from "../git.js";
9
+ import { dependencyVersion, withFlowPackages } from "../package-edits.js";
10
+ import { hookVersions, registerHooks } from "../settings-hooks.js";
11
+ import { FLOW_REPO, ownVersions, PACKAGE, packageManager, readJson, SETTINGS_FILE, SetupError, writeJson } from "../setup-files.js";
12
+ import { schemaUrl } from "./init.js";
13
+ /** The start of the schema URL `init` writes, at any version. */
14
+ const SCHEMA_PREFIX = `https://raw.githubusercontent.com/${FLOW_REPO}/`;
15
+ /** A release version, after an optional range sign. */
16
+ const RELEASE = /^[\^~=]?(\d+)\.(\d+)\.(\d+)$/;
17
+ /**
18
+ * Whether a version is newer than another; false if either is not a release.
19
+ * @param version - The version, maybe with a range sign.
20
+ * @param than - The other.
21
+ * @returns True if it is newer.
22
+ */
23
+ function isNewer(version, than) {
24
+ const [left, right] = [RELEASE.exec(version), RELEASE.exec(than)];
25
+ if (left === null || right === null)
26
+ return false;
27
+ for (let index = 1; index <= 3; index += 1) {
28
+ const diff = Number(left[index]) - Number(right[index]);
29
+ if (diff !== 0)
30
+ return diff > 0;
31
+ }
32
+ return false;
33
+ }
34
+ /**
35
+ * The newest version of the flow the repo already has, in package.json or a hook.
36
+ * @param root - The repo.
37
+ * @returns The version, or null if it has none.
38
+ */
39
+ function repoVersion(root) {
40
+ const found = [dependencyVersion(readJson(root, "package.json"), PACKAGE), ...Object.values(hookVersions(readJson(root, SETTINGS_FILE)))];
41
+ return found.filter((version) => version !== null).reduce((newest, version) => (newest === null || isNewer(version, newest) ? version : newest), null);
42
+ }
43
+ /**
44
+ * Moves the repo's flow files to this version; see the file's header.
45
+ * @param options - The options.
46
+ * @param out - Where to write.
47
+ * @returns 0.
48
+ * @throws SetupError outside the main checkout, if a file is broken, or if the repo is
49
+ * already at a newer version (a stale local copy must not move it back).
50
+ * @example upgrade({ cwd, dryRun: false }, out)
51
+ */
52
+ export function upgrade(options, out) {
53
+ const located = locateRepo(options.cwd);
54
+ if (located === null)
55
+ throw new SetupError("run upgrade inside a checkout of the repo.");
56
+ const root = located.mainRoot;
57
+ if (checkoutRoot(options.cwd) !== root)
58
+ throw new SetupError(`run upgrade in the main checkout, ${root}, not in a worktree.`);
59
+ const versions = ownVersions();
60
+ const current = repoVersion(root);
61
+ if (current !== null && isNewer(current, versions.issueFlow))
62
+ throw new SetupError(`the repo is at issue-flow ${current}, newer than this ${versions.issueFlow}; run 'npx ${PACKAGE}@${current.replace(/^[\^~=]/, "")} upgrade' or 'npx @ramon-sg/agent-kit'.`);
63
+ const writes = [];
64
+ const lines = [];
65
+ const pkg = withFlowPackages(readJson(root, "package.json"), versions, true);
66
+ if (pkg.changes.length > 0)
67
+ writes.push({ path: "package.json", value: pkg.pkg });
68
+ lines.push(...pkg.changes.map((change) => `package.json: ${change}`));
69
+ const hooks = registerHooks(readJson(root, SETTINGS_FILE), versions.issueFlow);
70
+ if (hooks.changes.length > 0)
71
+ writes.push({ path: SETTINGS_FILE, value: hooks.settings });
72
+ lines.push(...hooks.changes.map((change) => `${SETTINGS_FILE}: ${change}`));
73
+ const config = readJson(root, CONFIG_PATH);
74
+ if (config !== null && typeof config.$schema === "string" && config.$schema.startsWith(SCHEMA_PREFIX) && config.$schema !== schemaUrl(versions.issueFlow)) {
75
+ writes.push({ path: CONFIG_PATH, value: { ...config, $schema: schemaUrl(versions.issueFlow) } });
76
+ lines.push(`${CONFIG_PATH}: $schema at v${versions.issueFlow}`);
77
+ }
78
+ out.stdout(lines.length === 0 ? `Already at issue-flow ${versions.issueFlow}.\n` : `To issue-flow ${versions.issueFlow}:\n${lines.map((line) => ` ${line}\n`).join("")}`);
79
+ if (options.dryRun || writes.length === 0)
80
+ return 0;
81
+ for (const write of writes)
82
+ writeJson(root, write.path, write.value);
83
+ out.stdout(`Next: run '${packageManager(root, readJson(root, "package.json"))} install', review the diff and commit it.\n`);
84
+ return 0;
85
+ }
@@ -0,0 +1,82 @@
1
+ import type { FlowConfig } from "../config.js";
2
+ import { type Output } from "../output.js";
3
+ import { type StepPlace } from "../worktree-steps.js";
4
+ /** Options of `issue-flow worktree`. */
5
+ export interface WorktreeOptions {
6
+ repo: string | null;
7
+ config: FlowConfig;
8
+ cwd: string;
9
+ yes: boolean;
10
+ only: string | null;
11
+ path: string | null;
12
+ next: boolean;
13
+ }
14
+ /**
15
+ * The base branch: the config's, or origin's default as git knows it, or `main`.
16
+ * @param config - The config.
17
+ * @param mainRoot - The main checkout.
18
+ * @returns The branch.
19
+ * @example baseBranch(config, "/repo") // "main"
20
+ */
21
+ export declare function baseBranch(config: FlowConfig, mainRoot: string): string;
22
+ /**
23
+ * Where the steps of the checkout `cwd` is in run.
24
+ * @param options - The options.
25
+ * @param out - Where to write.
26
+ * @returns The place.
27
+ * @throws StartError outside git.
28
+ * @example placeOf(options, out).root
29
+ */
30
+ export declare function stepPlace(options: Pick<WorktreeOptions, "config" | "cwd">, out: Output): StepPlace;
31
+ /**
32
+ * `worktree setup [--only <step>]`: prepares this worktree.
33
+ * @param options - The options.
34
+ * @param out - Where to write.
35
+ * @returns 0, or 1 if a step failed.
36
+ * @throws UsageError if `--only` names no step.
37
+ * @example await worktreeSetup(options, out)
38
+ */
39
+ export declare function worktreeSetup(options: WorktreeOptions, out: Output): Promise<number>;
40
+ /**
41
+ * `worktree teardown [--path <dir>]`: cleans a worktree before deleting it (its steps, its
42
+ * ports and its Docker). With `--path` the folder may be gone already; if it is there, it
43
+ * must be one of the repo's worktrees. Never the main checkout, by any path to it.
44
+ * @param options - The options.
45
+ * @param out - Where to write.
46
+ * @returns 0, or 1 if something could not be cleaned.
47
+ * @throws StartError for the main checkout or a folder that is not a worktree.
48
+ * @example await worktreeTeardown(options, out)
49
+ */
50
+ export declare function worktreeTeardown(options: WorktreeOptions, out: Output): Promise<number>;
51
+ /**
52
+ * `worktree ports [--next]`: prints the first port of this worktree's block, fixing it if
53
+ * it was not yet; with `--next`, moves it to the next free block.
54
+ * @param options - The options.
55
+ * @param out - Where to write.
56
+ * @returns The exit code.
57
+ * @throws StartError without ports in the config, in the main checkout, or with no free block.
58
+ * @example await worktreePorts(options, out)
59
+ */
60
+ export declare function worktreePorts(options: WorktreeOptions, out: Output): Promise<number>;
61
+ /**
62
+ * `worktree cleanup [--path <dir>]`: deletes the Docker of worktrees that are gone, or of
63
+ * one folder.
64
+ * @param options - The options.
65
+ * @param out - Where to write.
66
+ * @returns 0, or 1 if a project could not be deleted.
67
+ * @example worktreeCleanup(options, out)
68
+ */
69
+ export declare function worktreeCleanup(options: WorktreeOptions, out: Output): number;
70
+ /**
71
+ * `worktree prune [--only <dir>] [--yes]`: deletes the worktrees whose PR was merged or
72
+ * closed, after asking: their teardown, the folder, the local branch and the session id.
73
+ * It never touches one with uncommitted changes, unpushed commits, or a lock (a live
74
+ * session of `claude --worktree` or `issue start`, or by hand), and checks each again just
75
+ * before deleting it.
76
+ * @param options - The options.
77
+ * @param out - Where to write.
78
+ * @returns The exit code.
79
+ * @throws StartError outside git.
80
+ * @example await worktreePrune(options, out)
81
+ */
82
+ export declare function worktreePrune(options: WorktreeOptions, out: Output): Promise<number>;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * @file `issue-flow worktree …`: the life of a worktree after `issue start` made it.
3
+ * `setup` and `teardown` run the steps of the config, `ports` shows or moves its block of
4
+ * ports, `cleanup` deletes the Docker of worktrees that are gone, and `prune` deletes the
5
+ * worktrees whose PR was merged or closed. Ported from Abastio's `scripts/worktree.sh`,
6
+ * `worktree-prune.sh` and `worktree-cleanup.sh`.
7
+ */
8
+ import { findPr } from "@ramon-sg/github-rest";
9
+ import { confirm } from "../ask.js";
10
+ import { existsSync } from "node:fs";
11
+ import { resolve } from "node:path";
12
+ import { canonicalPath, dockerCleanup } from "../docker.js";
13
+ import { checkoutRoot, git, isClean, locateRepo, safeToDelete, worktrees } from "../git.js";
14
+ import { forgetSession } from "../launch.js";
15
+ import { UsageError, warn } from "../output.js";
16
+ import { claimPortBase, nextPortBase, portBase } from "../ports.js";
17
+ import { isStale, LOCK_REASON } from "../session-lock.js";
18
+ import { StartError } from "../start-state.js";
19
+ import { runSetup, teardownWorktree } from "../worktree-steps.js";
20
+ /** The reason `claude --worktree` locks the worktrees it makes while its session lives. */
21
+ const CLAUDE_LOCK_REASON = "claude session";
22
+ /**
23
+ * The base branch: the config's, or origin's default as git knows it, or `main`.
24
+ * @param config - The config.
25
+ * @param mainRoot - The main checkout.
26
+ * @returns The branch.
27
+ * @example baseBranch(config, "/repo") // "main"
28
+ */
29
+ export function baseBranch(config, mainRoot) {
30
+ if (config.baseBranch !== null)
31
+ return config.baseBranch;
32
+ const head = git(mainRoot, ["symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD"]);
33
+ return head.ok && head.out.startsWith("origin/") ? head.out.slice("origin/".length) : "main";
34
+ }
35
+ /**
36
+ * Where the steps of the checkout `cwd` is in run.
37
+ * @param options - The options.
38
+ * @param out - Where to write.
39
+ * @returns The place.
40
+ * @throws StartError outside git.
41
+ * @example placeOf(options, out).root
42
+ */
43
+ export function stepPlace(options, out) {
44
+ const located = locateRepo(options.cwd);
45
+ const root = checkoutRoot(options.cwd);
46
+ if (located === null || root === null)
47
+ throw new StartError("run this inside a checkout of the repo.");
48
+ const { mainRoot } = located;
49
+ return { root, mainRoot, base: baseBranch(options.config, mainRoot), config: options.config, out, remote: process.env.CLAUDE_CODE_REMOTE === "true" };
50
+ }
51
+ /**
52
+ * `worktree setup [--only <step>]`: prepares this worktree.
53
+ * @param options - The options.
54
+ * @param out - Where to write.
55
+ * @returns 0, or 1 if a step failed.
56
+ * @throws UsageError if `--only` names no step.
57
+ * @example await worktreeSetup(options, out)
58
+ */
59
+ export async function worktreeSetup(options, out) {
60
+ const names = options.config.worktrees.setup.map((step) => step.name);
61
+ if (options.only !== null && !names.includes(options.only))
62
+ throw new UsageError(`--only expects a setup step of .agents/flow.json (${names.join(", ") || "it has none"}): ${options.only}`);
63
+ return (await runSetup(stepPlace(options, out), options.only)) ? 0 : 1;
64
+ }
65
+ /**
66
+ * `worktree teardown [--path <dir>]`: cleans a worktree before deleting it (its steps, its
67
+ * ports and its Docker). With `--path` the folder may be gone already; if it is there, it
68
+ * must be one of the repo's worktrees. Never the main checkout, by any path to it.
69
+ * @param options - The options.
70
+ * @param out - Where to write.
71
+ * @returns 0, or 1 if something could not be cleaned.
72
+ * @throws StartError for the main checkout or a folder that is not a worktree.
73
+ * @example await worktreeTeardown(options, out)
74
+ */
75
+ export async function worktreeTeardown(options, out) {
76
+ const place = stepPlace(options, out);
77
+ const root = options.path === null ? place.root : canonicalPath(resolve(options.cwd, options.path));
78
+ if (root === place.mainRoot)
79
+ throw new StartError("teardown is for worktrees, not the main checkout.");
80
+ if (existsSync(root) && !worktrees(place.mainRoot).some((entry) => entry.path === root))
81
+ throw new StartError(`${root} is not a worktree of this repo.`);
82
+ return (await teardownWorktree({ ...place, root })) ? 0 : 1;
83
+ }
84
+ /**
85
+ * `worktree ports [--next]`: prints the first port of this worktree's block, fixing it if
86
+ * it was not yet; with `--next`, moves it to the next free block.
87
+ * @param options - The options.
88
+ * @param out - Where to write.
89
+ * @returns The exit code.
90
+ * @throws StartError without ports in the config, in the main checkout, or with no free block.
91
+ * @example await worktreePorts(options, out)
92
+ */
93
+ export async function worktreePorts(options, out) {
94
+ const ports = options.config.worktrees.ports;
95
+ if (ports === null)
96
+ throw new StartError('this repo has no ports for worktrees: add "worktrees.ports" to .agents/flow.json.');
97
+ const place = stepPlace(options, out);
98
+ if (place.root === place.mainRoot)
99
+ throw new StartError("ports are for worktrees, not the main checkout.");
100
+ const start = options.next ? nextPortBase(ports, portBase(ports, place.root)) : null;
101
+ const base = await claimPortBase(ports, place.root, start);
102
+ if (base === null)
103
+ throw new StartError("no block of ports is free on this machine; delete worktrees you no longer use.");
104
+ if (options.next)
105
+ warn(out, `the worktree moves to block ${base}: restart what runs in it.`);
106
+ out.stdout(`${base}\n`);
107
+ return 0;
108
+ }
109
+ /**
110
+ * `worktree cleanup [--path <dir>]`: deletes the Docker of worktrees that are gone, or of
111
+ * one folder.
112
+ * @param options - The options.
113
+ * @param out - Where to write.
114
+ * @returns 0, or 1 if a project could not be deleted.
115
+ * @example worktreeCleanup(options, out)
116
+ */
117
+ export function worktreeCleanup(options, out) {
118
+ const result = dockerCleanup(options.config.worktrees.dockerProjects, options.path);
119
+ if (!result.dockerRunning) {
120
+ warn(out, "Docker is not running; nothing was cleaned. Start it and run 'issue-flow worktree cleanup'.");
121
+ return 0;
122
+ }
123
+ for (const project of result.removed)
124
+ out.stdout(`deleted: ${project}\n`);
125
+ for (const project of result.failed)
126
+ warn(out, `could not delete ${project}; see what holds it with 'docker ps -a --filter label=com.docker.compose.project=${project}'.`);
127
+ return result.failed.length === 0 ? 0 : 1;
128
+ }
129
+ /**
130
+ * Why `prune` leaves a locked worktree alone, or null if it is not locked.
131
+ * @param entry - The worktree.
132
+ * @returns The message, or null.
133
+ */
134
+ function lockedReason(entry) {
135
+ const { lock, path } = entry;
136
+ if (lock === null)
137
+ return null;
138
+ if (lock.startsWith(`${LOCK_REASON} `))
139
+ return isStale(lock)
140
+ ? `locked by an issue-flow session that is gone, not touched: ${path} (free it with: git worktree unlock ${path})`
141
+ : `in use by a Claude session, not touched: ${path}`;
142
+ if (lock.startsWith(CLAUDE_LOCK_REASON))
143
+ return `in use by a Claude session, not touched: ${path}`;
144
+ return `locked${lock === "" ? "" : ` (${lock})`}, not touched: ${path}`;
145
+ }
146
+ /**
147
+ * Whether a worktree is as it was when checked: same tip, clean, not locked. Someone may
148
+ * have gone on working in it while the question was open.
149
+ * @param mainRoot - The main checkout.
150
+ * @param doomed - The worktree.
151
+ * @returns True if it did not change.
152
+ */
153
+ function unchanged(mainRoot, doomed) {
154
+ const tip = git(mainRoot, ["rev-parse", "-q", "--verify", `refs/heads/${doomed.branch}`]).out;
155
+ const entry = worktrees(mainRoot).find((item) => item.path === doomed.path);
156
+ return tip === doomed.tip && isClean(doomed.path) && entry !== undefined && entry.lock === null;
157
+ }
158
+ /**
159
+ * The worktrees `prune` may delete: linked, on a branch, not locked, with the last PR of
160
+ * the branch merged or closed, clean and with nothing unpushed. It says why it skips the
161
+ * others.
162
+ * @param options - The options.
163
+ * @param mainRoot - The main checkout.
164
+ * @param out - Where to write.
165
+ * @returns The worktrees.
166
+ */
167
+ async function pruneCandidates(options, mainRoot, out) {
168
+ const only = options.only === null ? null : checkoutRoot(options.only);
169
+ if (options.only !== null && only === null)
170
+ throw new StartError(`${options.only} is not a worktree.`);
171
+ const found = [];
172
+ const unread = [];
173
+ for (const entry of worktrees(mainRoot).slice(1)) {
174
+ const { path, branch } = entry;
175
+ if (branch === null || (only !== null && path !== only))
176
+ continue;
177
+ const locked = lockedReason(entry);
178
+ if (locked !== null) {
179
+ out.stdout(`${locked}\n`);
180
+ continue;
181
+ }
182
+ const pr = options.repo === null ? null : await findPr(options.repo, branch).catch(() => {
183
+ unread.push(branch);
184
+ return null;
185
+ });
186
+ if (pr === null || (pr.state !== "MERGED" && pr.state !== "CLOSED"))
187
+ continue;
188
+ if (!isClean(path)) {
189
+ out.stdout(`PR ${pr.state}, but with uncommitted changes, not deleted: ${path}\n`);
190
+ continue;
191
+ }
192
+ if (!safeToDelete(mainRoot, branch, pr.head)) {
193
+ out.stdout(`PR ${pr.state}, but with commits that are not in origin, not deleted: ${path}\n`);
194
+ continue;
195
+ }
196
+ out.stdout(`PR ${pr.state}: ${path} (${branch})\n`);
197
+ found.push({ path, branch, tip: git(mainRoot, ["rev-parse", `refs/heads/${branch}`]).out });
198
+ }
199
+ if (unread.length > 0)
200
+ warn(out, `could not read the PRs of ${unread.join(", ")} from GitHub; they were left alone.`);
201
+ return found;
202
+ }
203
+ /**
204
+ * `worktree prune [--only <dir>] [--yes]`: deletes the worktrees whose PR was merged or
205
+ * closed, after asking: their teardown, the folder, the local branch and the session id.
206
+ * It never touches one with uncommitted changes, unpushed commits, or a lock (a live
207
+ * session of `claude --worktree` or `issue start`, or by hand), and checks each again just
208
+ * before deleting it.
209
+ * @param options - The options.
210
+ * @param out - Where to write.
211
+ * @returns The exit code.
212
+ * @throws StartError outside git.
213
+ * @example await worktreePrune(options, out)
214
+ */
215
+ export async function worktreePrune(options, out) {
216
+ const place = stepPlace(options, out);
217
+ const { mainRoot } = place;
218
+ const doomed = await pruneCandidates(options, mainRoot, out);
219
+ if (doomed.length === 0) {
220
+ out.stdout("No worktrees to delete.\n");
221
+ return 0;
222
+ }
223
+ if (!options.yes && !(await confirm(out, "Delete the worktrees above with their local branch and their Docker?"))) {
224
+ out.stdout("Deleted nothing.\n");
225
+ return 0;
226
+ }
227
+ const { commonDir } = locateRepo(mainRoot) ?? { commonDir: "" };
228
+ for (const item of doomed) {
229
+ if (!unchanged(mainRoot, item)) {
230
+ out.stdout(`changed since the check, not deleted: ${item.path}\n`);
231
+ continue;
232
+ }
233
+ await teardownWorktree({ ...place, root: item.path });
234
+ if (!git(mainRoot, ["worktree", "remove", item.path]).ok) {
235
+ warn(out, `could not delete ${item.path} (its teardown already ran: its Docker and ports are gone); see 'git -C ${item.path} status'.`);
236
+ continue;
237
+ }
238
+ if (!git(mainRoot, ["branch", "-q", "-D", item.branch]).ok)
239
+ warn(out, `could not delete the local branch ${item.branch}.`);
240
+ forgetSession(commonDir, item.branch);
241
+ out.stdout(`deleted: ${item.path} (${item.branch})\n`);
242
+ }
243
+ return 0;
244
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * @file What every part of the config's validation shares: where the file lives, its error
3
+ * and the checks of objects, keys and strings.
4
+ */
5
+ /** Where the config lives, relative to the repo root. */
6
+ export declare const CONFIG_PATH = ".agents/flow.json";
7
+ /** Error in the config: exits 2 with the message. */
8
+ export declare class ConfigError extends Error {
9
+ }
10
+ /**
11
+ * Whether a value is a plain object (not null nor an array).
12
+ * @param value - The value.
13
+ * @returns True if it is an object.
14
+ */
15
+ export declare function isObject(value: unknown): value is Record<string, unknown>;
16
+ /**
17
+ * Checks that an object only has known keys.
18
+ * @param value - The object.
19
+ * @param known - The keys it may have.
20
+ * @param where - Its path in the config, for the error (`branch`).
21
+ * @throws ConfigError if a key is unknown.
22
+ */
23
+ export declare function onlyKeys(value: Record<string, unknown>, known: string[], where: string): void;
24
+ /**
25
+ * An optional string of the config.
26
+ * @param value - The value, or undefined.
27
+ * @param where - Its path, for the error.
28
+ * @param fallback - The default.
29
+ * @returns The value or the default.
30
+ * @throws ConfigError if it is not a non-empty string.
31
+ */
32
+ export declare function text<T extends string | null>(value: unknown, where: string, fallback: T): string | T;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * @file What every part of the config's validation shares: where the file lives, its error
3
+ * and the checks of objects, keys and strings.
4
+ */
5
+ /** Where the config lives, relative to the repo root. */
6
+ export const CONFIG_PATH = ".agents/flow.json";
7
+ /** Error in the config: exits 2 with the message. */
8
+ export class ConfigError extends Error {
9
+ }
10
+ /**
11
+ * Whether a value is a plain object (not null nor an array).
12
+ * @param value - The value.
13
+ * @returns True if it is an object.
14
+ */
15
+ export function isObject(value) {
16
+ return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ }
18
+ /**
19
+ * Checks that an object only has known keys.
20
+ * @param value - The object.
21
+ * @param known - The keys it may have.
22
+ * @param where - Its path in the config, for the error (`branch`).
23
+ * @throws ConfigError if a key is unknown.
24
+ */
25
+ export function onlyKeys(value, known, where) {
26
+ for (const key of Object.keys(value)) {
27
+ if (key === "$schema" && where === "")
28
+ continue;
29
+ if (!known.includes(key))
30
+ throw new ConfigError(`${CONFIG_PATH}: unknown key "${where === "" ? key : `${where}.${key}`}". Known: ${known.join(", ")}.`);
31
+ }
32
+ }
33
+ /**
34
+ * An optional string of the config.
35
+ * @param value - The value, or undefined.
36
+ * @param where - Its path, for the error.
37
+ * @param fallback - The default.
38
+ * @returns The value or the default.
39
+ * @throws ConfigError if it is not a non-empty string.
40
+ */
41
+ export function text(value, where, fallback) {
42
+ if (value === undefined)
43
+ return fallback;
44
+ if (typeof value !== "string" || value.trim() === "")
45
+ throw new ConfigError(`${CONFIG_PATH}: "${where}" must be a non-empty string.`);
46
+ return value;
47
+ }
@@ -0,0 +1,63 @@
1
+ import { type WorktreesConfig } from "./worktree-config.js";
2
+ /** Who proposes a branch name when the issue has none: Claude, or the title's slug. */
3
+ export type Namer = "claude" | "title";
4
+ /** How issues map to the branch type: by label first, then by title prefix. */
5
+ export interface TypeRules {
6
+ labels: Record<string, string>;
7
+ titlePrefixes: Record<string, string>;
8
+ }
9
+ /** The whole config, with every default applied. */
10
+ export interface FlowConfig {
11
+ repo: string | null;
12
+ baseBranch: string | null;
13
+ branch: {
14
+ template: string;
15
+ namer: Namer;
16
+ claudeModel: string;
17
+ types: TypeRules;
18
+ };
19
+ worktrees: WorktreesConfig;
20
+ start: {
21
+ commitMessage: string;
22
+ prBody: string;
23
+ prompt: string;
24
+ resumePrompt: string;
25
+ summaryPrompt: string;
26
+ summaryPlanPrompt: string;
27
+ cloudPrompt: string;
28
+ };
29
+ plans: {
30
+ dirs: string[];
31
+ };
32
+ colors: Record<string, string>;
33
+ }
34
+ /** The defaults, the same rules Abastio uses. */
35
+ export declare const DEFAULT_CONFIG: FlowConfig;
36
+ /** The colors a Claude session takes with `/color`. */
37
+ export declare const SESSION_COLORS: string[];
38
+ /**
39
+ * Validates a parsed config and fills in the defaults.
40
+ * @param raw - The parsed JSON.
41
+ * @returns The config.
42
+ * @throws ConfigError if something is invalid.
43
+ * @example parseConfig({ branch: { namer: "title" } }).branch.template // "{type}/{number}-{slug}"
44
+ */
45
+ export declare function parseConfig(raw: unknown): FlowConfig;
46
+ /**
47
+ * The main checkout of the repo that holds a directory, or null outside git. The config
48
+ * is always the main checkout's, also from a worktree: a branch cannot change the steps
49
+ * its own worktree runs.
50
+ * @param cwd - The directory.
51
+ * @returns The root.
52
+ * @example repoRoot("/repo/.claude/worktrees/feat+42-x") // "/repo"
53
+ */
54
+ export declare function repoRoot(cwd: string): string | null;
55
+ /**
56
+ * Reads the config of the main checkout of the repo that holds `cwd`. Outside git, or
57
+ * without the file, every default applies.
58
+ * @param cwd - A directory inside the checkout.
59
+ * @returns The config.
60
+ * @throws ConfigError if the file is not valid JSON or something in it is invalid.
61
+ * @example loadConfig(process.cwd()).branch.namer // "claude"
62
+ */
63
+ export declare function loadConfig(cwd: string): FlowConfig;