@ramon-sg/issue-flow 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -75,7 +75,7 @@ Qué hace, en orden:
75
75
  - Si el PR se mergeó y no queda trabajo sin pushear, ofrece borrar el worktree y la rama.
76
76
  - Si no, dice cómo retomar.
77
77
 
78
- **Permisos:** si el repo tiene [`claude-repo-guard`](https://github.com/ramon-sg/claude-repo-guard), Claude se abre en modo auto con su contexto (lo mismo que `claude:auto`). Con `--ask-permissions` se abre con tus settings, y con `--free` sin sandbox ni preguntas (como `claude:free`). Sin `claude-repo-guard`, se abre con tus settings. `--plan` devuelve esos mismos argumentos en `claudeArgs`. El atajo es `npm run issue:start:free 42`. Con npm, las opciones van después de `--` (`npm run issue:start -- 42 --free`); con pnpm, bun o yarn no hace falta. La tabla de cada administrador está en [Flujos](docs/flujos.md#administradores). `issue-flow issue start --help` explica cada opción.
78
+ **Permisos:** si el repo tiene [`claude-repo-guard`](https://github.com/ramon-sg/claude-repo-guard), Claude se abre en modo auto con su contexto (lo mismo que `claude:auto`). Con `--ask-permissions` se abre con tus settings, con `--free` sin sandbox ni preguntas (como `claude:free`), y con `--free --no-repo-deny` también sin los `deny` del `.claude/settings.json` del repo, que vuelven al salir ([qué quita cada modo](docs/flujos/terminal.md#opciones)). Sin `claude-repo-guard`, se abre con tus settings. `--plan` devuelve esos mismos argumentos en `claudeArgs`. El atajo es `npm run issue:start:free 42`. Con npm, las opciones van después de `--` (`npm run issue:start -- 42 --free`); con pnpm, bun o yarn no hace falta. La tabla de cada administrador está en [Flujos](docs/flujos.md#administradores). `issue-flow issue start --help` explica cada opción.
79
79
 
80
80
  ### Con la nube y con otras herramientas
81
81
 
@@ -119,7 +119,7 @@ Lo propio de cada repo (instalar, armar la base, levantar Docker) son **pasos**
119
119
  ```
120
120
 
121
121
  - **Cuándo corren:** `issue start` corre el `setup` en cada worktree nuevo, después de copiar `.worktreeinclude`. El hook `session` lo corre al abrir Claude en un worktree. El `teardown` corre antes de borrar un worktree, con `prune` o al limpiar después del merge.
122
- - **De dónde salen:** siempre de la config del checkout principal, nunca de la del worktree. Así una rama no puede sumar comandos a su propio setup. En la nube no hay otro checkout: ahí la config es la de la rama de la sesión.
122
+ - **De dónde salen:** del `.agents/flow.json` de `origin/<base>`, nunca del worktree. Así una rama no puede sumar comandos a su propio setup, y un checkout principal que quedó atrás no deja al worktree sin su `install`. Si los pasos del checkout principal son otros, `issue-flow` lo avisa. Sin `origin/<base>`, o si ahí el archivo no es válido, usa los del checkout principal. Sin pasos, `worktree setup` e `issue start` dicen de qué archivo los leyeron. En la nube no hay otro checkout: ahí la config es la de la rama de la sesión. El teardown sigue usando la config del checkout principal.
123
123
  - **Pueden correr más de una vez:** `issue start` y el hook `session` corren el `setup` del mismo worktree, así que un paso sin `once` tiene que poder repetirse.
124
124
  - **`trusted`:** el paso corre solo si el código del worktree está en `origin/<base>` y no tiene cambios. Es para un install, que corre los scripts de `package.json` de esa rama. En la nube corre siempre, porque la máquina es descartable.
125
125
  - **`once`:** el paso no se repite una vez que funcionó (deja una marca en el git dir del worktree).
@@ -50,6 +50,9 @@ export declare const OPTIONS: {
50
50
  readonly free: {
51
51
  readonly type: "boolean";
52
52
  };
53
+ readonly "no-repo-deny": {
54
+ readonly type: "boolean";
55
+ };
53
56
  readonly plan: {
54
57
  readonly type: "boolean";
55
58
  };
package/dist/cli-flags.js CHANGED
@@ -14,6 +14,7 @@ export const OPTIONS = {
14
14
  "no-launch": { type: "boolean" },
15
15
  "ask-permissions": { type: "boolean" },
16
16
  free: { type: "boolean" },
17
+ "no-repo-deny": { type: "boolean" },
17
18
  plan: { type: "boolean" },
18
19
  cloud: { type: "boolean" },
19
20
  here: { type: "boolean" },
@@ -38,6 +39,7 @@ export const COMMAND_FLAGS = {
38
39
  "no-launch",
39
40
  "ask-permissions",
40
41
  "free",
42
+ "no-repo-deny",
41
43
  "plan",
42
44
  "cloud",
43
45
  "here",
package/dist/cli.js CHANGED
@@ -82,8 +82,10 @@ export function ownFlags(command, flags) {
82
82
  function permissionsOf(flags) {
83
83
  if (flags["ask-permissions"] && flags.free)
84
84
  throw new UsageError("use --ask-permissions or --free, not both.");
85
+ if (flags["no-repo-deny"] && !flags.free)
86
+ throw new UsageError("--no-repo-deny goes with --free.");
85
87
  if (flags.free)
86
- return "free";
88
+ return flags["no-repo-deny"] ? "free-no-repo-deny" : "free";
87
89
  return flags["ask-permissions"] ? "ask" : "default";
88
90
  }
89
91
  /**
@@ -99,18 +101,18 @@ function startArgs(flags, rest) {
99
101
  throw new UsageError("issue start needs one issue number.");
100
102
  if (flags.resume !== undefined && flags.new)
101
103
  throw new UsageError("--resume does not go with --new.");
102
- const conflicting = flags.branch || flags["dry-run"] || flags.yes || flags.resume !== undefined || flags.new;
104
+ const conflicting = flags.branch || flags["dry-run"] || flags.yes || flags.resume !== undefined || flags.new || flags["no-repo-deny"];
103
105
  if (flags.plan && conflicting)
104
- throw new UsageError("--plan does not go with --branch, --dry-run, --yes, --resume nor --new.");
106
+ throw new UsageError("--plan does not go with --branch, --dry-run, --yes, --resume, --new nor --no-repo-deny.");
105
107
  cloudArgs(flags);
106
108
  return Number(text);
107
109
  }
108
110
  /** The flags that do not go together with each way of starting, besides each other. */
109
111
  const EXCLUSIVE = {
110
- cloud: ["branch", "plan", "ask-permissions", "free"],
111
- here: ["branch", "plan", "ask-permissions", "free"],
112
+ cloud: ["branch", "plan", "ask-permissions", "free", "no-repo-deny"],
113
+ here: ["branch", "plan", "ask-permissions", "free", "no-repo-deny"],
112
114
  teleport: ["plan", "new", "no-launch"],
113
- adopt: ["branch", "plan", "resume", "new", "ask-permissions", "free"],
115
+ adopt: ["branch", "plan", "resume", "new", "ask-permissions", "free", "no-repo-deny"],
114
116
  };
115
117
  /**
116
118
  * Checks `--cloud`, `--here`, `--teleport` and `--adopt`: at most one, and none with the
@@ -22,11 +22,11 @@ import { canonicalPath, dockerCleanup } from "../docker.js";
22
22
  import { checkoutRoot, currentBranch, locateRepo } from "../git.js";
23
23
  import { issueOfBranch } from "../issue-text.js";
24
24
  import { inPath } from "../launch.js";
25
- import { UsageError, warn } from "../output.js";
25
+ import { warn } from "../output.js";
26
26
  import { releasePortClaims } from "../ports.js";
27
27
  import { copyWorktreeIncludes } from "../start-place.js";
28
28
  import { StartError } from "../start-state.js";
29
- import { runSetup, teardownWorktree } from "../worktree-steps.js";
29
+ import { checkOnly, runSetup, teardownWorktree } from "../worktree-steps.js";
30
30
  import { issueStart } from "./issue-start.js";
31
31
  import { stepPlace } from "./worktree.js";
32
32
  /** Orca's CLI, which knows which issue a workspace came from; `ISSUE_FLOW_ORCA_CLI` changes it. */
@@ -162,9 +162,7 @@ function linkedPlace(options, out) {
162
162
  * @example await adapterSetup(options, out)
163
163
  */
164
164
  export async function adapterSetup(options, out) {
165
- const names = options.config.worktrees.setup.map((step) => step.name);
166
- if (options.only !== null && !names.includes(options.only))
167
- throw new UsageError(`--only expects a setup step of .agents/flow.json (${names.join(", ") || "it has none"}): ${options.only}`);
165
+ checkOnly(stepPlace(options, out), options.only);
168
166
  const place = linkedPlace(options, out);
169
167
  if (place === null)
170
168
  return 0;
@@ -111,7 +111,7 @@ export async function worktreeCreate(input, config, cwd, out) {
111
111
  throw new StartError(`could not make the worktree ${path}: ${add.err}`);
112
112
  copyWorktreeIncludes(mainRoot, path, stderrOnly);
113
113
  }
114
- await runSetup({ root: path, mainRoot, base, config, out: stderrOnly, remote: false }, null);
114
+ await runSetup({ root: path, mainRoot, base, config, out: stderrOnly, remote: false }, null, true);
115
115
  out.stdout(`${path}\n`);
116
116
  return 0;
117
117
  }
@@ -17,6 +17,7 @@ import { inPath, launchSession, permissionArgs, teleportSession } from "../launc
17
17
  import { emitJson, warn } from "../output.js";
18
18
  import { findOpenPlan, findPlan } from "../plan-doc.js";
19
19
  import { proposeBranches } from "../propose.js";
20
+ import { liftRepoDeny, restoreRepoDeny } from "../repo-deny.js";
20
21
  import { lockWorktree, unlockWorktree } from "../session-lock.js";
21
22
  import { afterSession, choosePlace, copyWorktreeIncludes, prepareBranch, publish } from "../start-place.js";
22
23
  import { chooseAdopted, fetchOrigin, finishHere, launchCloud, placeAdopted, placeCloud, placeHere, prCloudSession, prepareHere, readAdopted, renameAdopted, withoutFresh, } from "../start-modes.js";
@@ -245,6 +246,31 @@ function question(state) {
245
246
  };
246
247
  return { text: "Start the issue?", declined: "did nothing." };
247
248
  }
249
+ /**
250
+ * Before opening Claude: puts back the repo's deny rules a session whose terminal was
251
+ * closed left out, and with `--free --no-repo-deny` takes them out for this one. With
252
+ * another session in the worktree it touches nothing, as it may be using them.
253
+ * @param state - The start.
254
+ * @param lock - The worktree lock.
255
+ * @returns What to put back, or null if nothing was taken out.
256
+ */
257
+ function liftForSession(state, lock) {
258
+ const { out, commonDir, branch } = state;
259
+ const wanted = state.options.permissions === "free-no-repo-deny";
260
+ if (lock.shared) {
261
+ if (wanted)
262
+ warn(out, "another session uses this worktree: the repo's deny rules stay.");
263
+ return null;
264
+ }
265
+ const leftover = restoreRepoDeny(commonDir, branch, state.workDir, null);
266
+ if (leftover !== null)
267
+ warn(out, leftover);
268
+ if (!wanted)
269
+ return null;
270
+ const { message, saved } = liftRepoDeny(state.workDir, commonDir, branch);
271
+ out.stdout(`${message}\n`);
272
+ return saved;
273
+ }
248
274
  /**
249
275
  * Opens Claude in the place, locking a worktree meanwhile, and then offers the cleanup.
250
276
  * Without `claude` or with `--no-launch`, says how to open it.
@@ -264,6 +290,7 @@ async function openSession(state) {
264
290
  process.once("exit", release);
265
291
  forgetTypedAhead();
266
292
  const permissions = permissionArgs(options.permissions, state.workDir, state.mainRoot).args;
293
+ const lifted = liftForSession(state, lock);
267
294
  if (options.teleport) {
268
295
  out.stdout(`Bringing the cloud session${state.cloudSession === null ? "" : ` (${state.cloudSession})`}. To resume it later: claude --resume\n`);
269
296
  teleportSession(state.workDir, permissions, state.cloudSession);
@@ -278,6 +305,11 @@ async function openSession(state) {
278
305
  color: issueColor(state.labels, options.config.colors)?.color ?? null,
279
306
  permissions,
280
307
  }, (text) => warn(out, text));
308
+ if (lifted !== null) {
309
+ const problem = restoreRepoDeny(state.commonDir, state.branch, state.workDir, lifted);
310
+ if (problem !== null)
311
+ warn(out, problem);
312
+ }
281
313
  release();
282
314
  process.off("exit", release);
283
315
  await afterSession(state, lock);
@@ -90,7 +90,7 @@ export async function session(options, out) {
90
90
  if (remote)
91
91
  cloudSession(root, say);
92
92
  if (root !== mainRoot || remote)
93
- await runSetup(place, null);
93
+ await runSetup(place, null, true);
94
94
  const unstarted = !remote && root !== mainRoot ? unstartedIssue(root) : null;
95
95
  if (unstarted !== null)
96
96
  say(`issue #${unstarted} was not started: its branch is not published and has no draft PR. Before working, run the start-issue skill (${unstarted}).`);
@@ -12,11 +12,11 @@ import { resolve } from "node:path";
12
12
  import { canonicalPath, dockerCleanup } from "../docker.js";
13
13
  import { checkoutRoot, git, isClean, locateRepo, safeToDelete, worktrees } from "../git.js";
14
14
  import { forgetSession } from "../launch.js";
15
- import { UsageError, warn } from "../output.js";
15
+ import { warn } from "../output.js";
16
16
  import { claimPortBase, nextPortBase, portBase } from "../ports.js";
17
17
  import { isStale, LOCK_REASON } from "../session-lock.js";
18
18
  import { StartError } from "../start-state.js";
19
- import { runSetup, teardownWorktree } from "../worktree-steps.js";
19
+ import { checkOnly, runSetup, teardownWorktree } from "../worktree-steps.js";
20
20
  /** The reason `claude --worktree` locks the worktrees it makes while its session lives. */
21
21
  const CLAUDE_LOCK_REASON = "claude session";
22
22
  /**
@@ -57,10 +57,9 @@ export function stepPlace(options, out) {
57
57
  * @example await worktreeSetup(options, out)
58
58
  */
59
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;
60
+ const place = stepPlace(options, out);
61
+ checkOnly(place, options.only);
62
+ return (await runSetup(place, options.only)) ? 0 : 1;
64
63
  }
65
64
  /**
66
65
  * `worktree teardown [--path <dir>]`: cleans a worktree before deleting it (its steps, its
package/dist/launch.d.ts CHANGED
@@ -3,8 +3,12 @@ import { type StartMode } from "./issue-text.js";
3
3
  export declare const AUTO_MODE_FILE = ".claude/repo-guard.auto-mode.json";
4
4
  /** Folder, inside the shared `.git`, with one file per branch holding its session id. */
5
5
  export declare const SESSIONS_DIR = "issue-flow-sessions";
6
- /** How Claude asks for permissions: chosen by the flags, or the default. */
7
- export type Permissions = "default" | "ask" | "free";
6
+ /**
7
+ * How Claude asks for permissions: chosen by the flags, or the default. `free-no-repo-deny`
8
+ * is `--free --no-repo-deny`: the same arguments as `free`, and `issue start` also takes the
9
+ * repo's deny rules out while Claude runs (see repo-deny.ts).
10
+ */
11
+ export type Permissions = "default" | "ask" | "free" | "free-no-repo-deny";
8
12
  /** The arguments of the permission mode, and how the summary names it. */
9
13
  export interface PermissionArgs {
10
14
  args: string[];
@@ -53,7 +57,16 @@ export declare function sessionExists(id: string): boolean;
53
57
  */
54
58
  export declare function savedSession(commonDir: string, branch: string): string | null;
55
59
  /**
56
- * Forgets a branch's session id, once its work is gone.
60
+ * The note `--no-repo-deny` keeps while the repo's deny rules are out, next to the
61
+ * branch's session id.
62
+ * @param commonDir - The shared `.git`.
63
+ * @param branch - The branch.
64
+ * @returns The path.
65
+ * @example noteFile("/repo/.git", "feat/42-x") // "/repo/.git/issue-flow-sessions/feat+42-x.repo-deny.json"
66
+ */
67
+ export declare function noteFile(commonDir: string, branch: string): string;
68
+ /**
69
+ * Forgets a branch's session id (and its `--no-repo-deny` note), once its work is gone.
57
70
  * @param commonDir - The shared `.git`.
58
71
  * @param branch - The branch.
59
72
  * @example forgetSession("/repo/.git", "feat/42-x")
package/dist/launch.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * @file Opens Claude for an issue in the same terminal. The permission mode follows
3
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.
4
+ * default, plain `claude` with `--ask-permissions`, no sandbox nor questions with `--free`,
5
+ * and also without the repo's deny rules with `--free --no-repo-deny`. Each branch keeps its session id, so starting the issue again resumes it.
6
6
  * Ported from `launch_session` and `run_claude` in Abastio's `scripts/start-issue.sh`.
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
@@ -32,7 +32,12 @@ export function permissionArgs(permissions, workDir, mainRoot) {
32
32
  if (permissions === "free")
33
33
  return {
34
34
  args: ["--settings", NO_SANDBOX, "--dangerously-skip-permissions"],
35
- label: "none: no sandbox and no questions (--free)",
35
+ label: "none: no sandbox and no questions; deny rules still apply (--free)",
36
+ };
37
+ if (permissions === "free-no-repo-deny")
38
+ return {
39
+ args: ["--settings", NO_SANDBOX, "--dangerously-skip-permissions"],
40
+ label: "none: no sandbox, no questions and no deny rules of the repo; yours still apply (--free --no-repo-deny)",
36
41
  };
37
42
  if (permissions === "ask")
38
43
  return { args: [], label: "your settings (--ask-permissions)" };
@@ -96,13 +101,25 @@ export function savedSession(commonDir, branch) {
96
101
  return id !== "" && sessionExists(id) ? id : null;
97
102
  }
98
103
  /**
99
- * Forgets a branch's session id, once its work is gone.
104
+ * The note `--no-repo-deny` keeps while the repo's deny rules are out, next to the
105
+ * branch's session id.
106
+ * @param commonDir - The shared `.git`.
107
+ * @param branch - The branch.
108
+ * @returns The path.
109
+ * @example noteFile("/repo/.git", "feat/42-x") // "/repo/.git/issue-flow-sessions/feat+42-x.repo-deny.json"
110
+ */
111
+ export function noteFile(commonDir, branch) {
112
+ return `${sessionFile(commonDir, branch)}.repo-deny.json`;
113
+ }
114
+ /**
115
+ * Forgets a branch's session id (and its `--no-repo-deny` note), once its work is gone.
100
116
  * @param commonDir - The shared `.git`.
101
117
  * @param branch - The branch.
102
118
  * @example forgetSession("/repo/.git", "feat/42-x")
103
119
  */
104
120
  export function forgetSession(commonDir, branch) {
105
121
  rmSync(sessionFile(commonDir, branch), { force: true });
122
+ rmSync(noteFile(commonDir, branch), { force: true });
106
123
  }
107
124
  /**
108
125
  * Runs Claude in the terminal; its exit code does not matter, what follows runs anyway.
@@ -0,0 +1,54 @@
1
+ /** The repo's Claude settings, relative to the checkout. */
2
+ export declare const REPO_SETTINGS = ".claude/settings.json";
3
+ /** What it takes to put the rules back. */
4
+ export interface Lifted {
5
+ /** The file as it was. */
6
+ original: string;
7
+ /** The file as issue-flow left it, without the rules. */
8
+ lifted: string;
9
+ }
10
+ /**
11
+ * The settings without `permissions.deny` (and without `permissions` if nothing else is
12
+ * left in it), or null if they have no rules to take out.
13
+ * @param text - The settings file.
14
+ * @returns The new text, or null.
15
+ * @throws SyntaxError if the file is not JSON.
16
+ * @example withoutDeny('{"permissions":{"deny":["Edit(/x)"]}}') // "{}\n"
17
+ */
18
+ export declare function withoutDeny(text: string): string | null;
19
+ /**
20
+ * The settings with the original's deny rules added back (keeping any the text has).
21
+ * @param text - The settings now.
22
+ * @param original - The settings with the rules.
23
+ * @returns The new text.
24
+ * @throws SyntaxError if either is not JSON.
25
+ * @example withDeny("{}", '{"permissions":{"deny":["Edit(/x)"]}}') // '{\n "permissions": {\n "deny": [\n "Edit(/x)"\n ]\n }\n}\n'
26
+ */
27
+ export declare function withDeny(text: string, original: string): string;
28
+ /**
29
+ * Takes the `deny` rules out of the worktree's settings and hides the change from git. A
30
+ * note in the shared `.git` keeps the original in case the process dies before it puts
31
+ * them back; the caller keeps what it returns and puts them back from that.
32
+ * @param workDir - The worktree.
33
+ * @param commonDir - The shared `.git`.
34
+ * @param branch - The branch.
35
+ * @returns What happened, for the person, and what to put back (null if nothing was taken).
36
+ * @example liftRepoDeny(wt, "/repo/.git", "feat/42-x").message
37
+ */
38
+ export declare function liftRepoDeny(workDir: string, commonDir: string, branch: string): {
39
+ message: string;
40
+ saved: Lifted | null;
41
+ };
42
+ /**
43
+ * Puts the rules back in the worktree's settings. From `saved` when the caller kept it (the
44
+ * note is in reach of the session, so it is only trusted after a process that died); else
45
+ * from the branch's note, if there is one. If Claude changed the file meanwhile, the rules
46
+ * go back on top of its change, with a warning to review it.
47
+ * @param commonDir - The shared `.git`.
48
+ * @param branch - The branch.
49
+ * @param workDir - The worktree, never taken from the note.
50
+ * @param saved - What liftRepoDeny returned, or null to read the note.
51
+ * @returns A warning, or null if there was nothing to say.
52
+ * @example restoreRepoDeny("/repo/.git", "feat/42-x", wt, null) // null
53
+ */
54
+ export declare function restoreRepoDeny(commonDir: string, branch: string, workDir: string, saved: Lifted | null): string | null;
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @file `--free --no-repo-deny`: takes the `deny` rules out of the worktree's
3
+ * `.claude/settings.json` while Claude runs, and puts them back when it exits. Claude adds
4
+ * the `deny` of every settings file it loads and `--dangerously-skip-permissions` respects
5
+ * them, so a flag cannot drop them; and `--setting-sources user,local` drops them but also
6
+ * the repo's `CLAUDE.md`, skills, agents and MCP servers. Editing the worktree's own copy
7
+ * keeps all that, and leaves the main checkout and the other worktrees alone.
8
+ *
9
+ * While the rules are out, `git update-index --skip-worktree` hides the change from
10
+ * `git status` and `git add`, so it never lands in a commit. A note in the shared `.git`
11
+ * keeps the original, so a session whose terminal was closed is put back on the next
12
+ * `issue start` of the branch.
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { git } from "./git.js";
17
+ import { noteFile } from "./launch.js";
18
+ /** The repo's Claude settings, relative to the checkout. */
19
+ export const REPO_SETTINGS = ".claude/settings.json";
20
+ /**
21
+ * The settings without `permissions.deny` (and without `permissions` if nothing else is
22
+ * left in it), or null if they have no rules to take out.
23
+ * @param text - The settings file.
24
+ * @returns The new text, or null.
25
+ * @throws SyntaxError if the file is not JSON.
26
+ * @example withoutDeny('{"permissions":{"deny":["Edit(/x)"]}}') // "{}\n"
27
+ */
28
+ export function withoutDeny(text) {
29
+ const settings = JSON.parse(text);
30
+ const deny = settings.permissions?.deny;
31
+ if (!Array.isArray(deny) || deny.length === 0)
32
+ return null;
33
+ const { deny: _dropped, ...permissions } = settings.permissions ?? {};
34
+ const { permissions: _old, ...rest } = settings;
35
+ const result = Object.keys(permissions).length === 0 ? rest : { ...rest, permissions };
36
+ return `${JSON.stringify(result, null, 2)}\n`;
37
+ }
38
+ /**
39
+ * The settings with the original's deny rules added back (keeping any the text has).
40
+ * @param text - The settings now.
41
+ * @param original - The settings with the rules.
42
+ * @returns The new text.
43
+ * @throws SyntaxError if either is not JSON.
44
+ * @example withDeny("{}", '{"permissions":{"deny":["Edit(/x)"]}}') // '{\n "permissions": {\n "deny": [\n "Edit(/x)"\n ]\n }\n}\n'
45
+ */
46
+ export function withDeny(text, original) {
47
+ const settings = JSON.parse(text);
48
+ const deny = JSON.parse(original).permissions?.deny ?? [];
49
+ const now = Array.isArray(settings.permissions?.deny) ? settings.permissions.deny : [];
50
+ const merged = [...deny, ...now.filter((rule) => !deny.includes(rule))];
51
+ return `${JSON.stringify({ ...settings, permissions: { ...settings.permissions, deny: merged } }, null, 2)}\n`;
52
+ }
53
+ /**
54
+ * Takes the `deny` rules out of the worktree's settings and hides the change from git. A
55
+ * note in the shared `.git` keeps the original in case the process dies before it puts
56
+ * them back; the caller keeps what it returns and puts them back from that.
57
+ * @param workDir - The worktree.
58
+ * @param commonDir - The shared `.git`.
59
+ * @param branch - The branch.
60
+ * @returns What happened, for the person, and what to put back (null if nothing was taken).
61
+ * @example liftRepoDeny(wt, "/repo/.git", "feat/42-x").message
62
+ */
63
+ export function liftRepoDeny(workDir, commonDir, branch) {
64
+ const file = join(workDir, REPO_SETTINGS);
65
+ if (!existsSync(file))
66
+ return { message: `the repo has no ${REPO_SETTINGS}: there are no deny rules to take out.`, saved: null };
67
+ const original = readFileSync(file, "utf8");
68
+ let lifted;
69
+ try {
70
+ lifted = withoutDeny(original);
71
+ }
72
+ catch {
73
+ return { message: `${REPO_SETTINGS} is not valid JSON: its deny rules stay.`, saved: null };
74
+ }
75
+ if (lifted === null)
76
+ return { message: `${REPO_SETTINGS} has no deny rules: nothing to take out.`, saved: null };
77
+ const saved = { original, lifted };
78
+ const note = noteFile(commonDir, branch);
79
+ mkdirSync(dirname(note), { recursive: true });
80
+ writeFileSync(note, `${JSON.stringify(saved)}\n`);
81
+ writeFileSync(file, lifted);
82
+ git(workDir, ["update-index", "--skip-worktree", REPO_SETTINGS]);
83
+ return { message: `took out the deny rules of ${REPO_SETTINGS} for this session; they come back when Claude exits.`, saved };
84
+ }
85
+ /**
86
+ * Puts the rules back in the worktree's settings. From `saved` when the caller kept it (the
87
+ * note is in reach of the session, so it is only trusted after a process that died); else
88
+ * from the branch's note, if there is one. If Claude changed the file meanwhile, the rules
89
+ * go back on top of its change, with a warning to review it.
90
+ * @param commonDir - The shared `.git`.
91
+ * @param branch - The branch.
92
+ * @param workDir - The worktree, never taken from the note.
93
+ * @param saved - What liftRepoDeny returned, or null to read the note.
94
+ * @returns A warning, or null if there was nothing to say.
95
+ * @example restoreRepoDeny("/repo/.git", "feat/42-x", wt, null) // null
96
+ */
97
+ export function restoreRepoDeny(commonDir, branch, workDir, saved) {
98
+ const note = noteFile(commonDir, branch);
99
+ let from = saved;
100
+ if (from === null) {
101
+ if (!existsSync(note))
102
+ return null;
103
+ try {
104
+ from = JSON.parse(readFileSync(note, "utf8"));
105
+ }
106
+ catch {
107
+ rmSync(note, { force: true });
108
+ return `could not read ${note}; check the deny rules of ${REPO_SETTINGS} with git diff.`;
109
+ }
110
+ }
111
+ rmSync(note, { force: true });
112
+ if (!existsSync(workDir))
113
+ return null;
114
+ const file = join(workDir, REPO_SETTINGS);
115
+ git(workDir, ["update-index", "--no-skip-worktree", REPO_SETTINGS]);
116
+ const now = existsSync(file) ? readFileSync(file, "utf8") : null;
117
+ if (now === from.lifted || now === null) {
118
+ writeFileSync(file, from.original);
119
+ return null;
120
+ }
121
+ try {
122
+ writeFileSync(file, withDeny(now, from.original));
123
+ }
124
+ catch {
125
+ return `Claude left ${REPO_SETTINGS} as invalid JSON, so its deny rules could not be put back: fix it and add them before committing (git diff ${REPO_SETTINGS}).`;
126
+ }
127
+ return `Claude changed ${REPO_SETTINGS} during the session: its deny rules are back on top of that change; review it with git diff ${REPO_SETTINGS}.`;
128
+ }
package/dist/usage.d.ts CHANGED
@@ -2,9 +2,9 @@
2
2
  * @file The usage text of the CLI, printed with `--help` and after a usage error.
3
3
  */
4
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 apply [--yes] [--dry-run] [--json] [--recipe <name>,...]\n issue-flow doctor [--json]\n issue-flow labels sync [--update] [--dry-run] [--json] [-R owner/repo]\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. issue start --help explains each flag.\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\napply leaves the repo as this version needs it: .agents/flow.json (the first time, with\nthe recipes its files suggest), issue-flow at this version and github-rest and the scripts\nif missing in package.json, and the hooks; it changes no other version, and refuses a newer\nissue-flow or a range. doctor checks them. npx agent-kitchen apply runs apply at the version\nit picks, along with the skills, claude-cloud and claude-repo-guard. init and upgrade still\nrun apply, with a warning, until 0.4.0.\n\nlabels sync makes in GitHub the labels the flow wants (\"labels\" in .agents/flow.json: the\nissue types and P1 to P4 by default) that the repo lacks. Names ignore case and are never\nrenamed nor deleted; --update also fixes the color and description of those that differ.";
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 [--no-repo-deny]] [--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 apply [--yes] [--dry-run] [--json] [--recipe <name>,...]\n issue-flow doctor [--json]\n issue-flow labels sync [--update] [--dry-run] [--json] [-R owner/repo]\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, with no sandbox nor questions with --free, and also without the repo's\ndeny rules with --free --no-repo-deny. --plan prints the branch and the prompt as JSON and\nchanges nothing. issue start --help explains each flag.\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\napply leaves the repo as this version needs it: .agents/flow.json (the first time, with\nthe recipes its files suggest), issue-flow at this version and github-rest and the scripts\nif missing in package.json, and the hooks; it changes no other version, and refuses a newer\nissue-flow or a range. doctor checks them. npx agent-kitchen apply runs apply at the version\nit picks, along with the skills, claude-cloud and claude-repo-guard. init and upgrade still\nrun apply, with a warning, until 0.4.0.\n\nlabels sync makes in GitHub the labels the flow wants (\"labels\" in .agents/flow.json: the\nissue types and P1 to P4 by default) that the repo lacks. Names ignore case and are never\nrenamed nor deleted; --update also fixes the color and description of those that differ.";
6
6
  /** Usage text of `issue start`, printed with `issue start --help`. */
7
- export declare const START_USAGE = "Usage: issue-flow issue start <n> [flags]\n\nStarts issue <n>: the branch (in a worktree, or in this checkout with --branch), the empty\nstart commit, the push, the draft PR and the assignment, after confirming; then it opens\nClaude there.\n\nFlags:\n --dry-run show what it would do and change nothing\n -y, --yes do it without asking\n --branch in this checkout instead of a worktree\n --resume <branch> take that branch of the issue\n --new make another branch, even if the issue has one\n --no-launch leave everything ready and do not open Claude\n --namer claude|title who names the branch when the issue has no slug block\n -R owner/repo another repo than the checkout's\n --plan print the branch and the prompt as JSON, and change nothing\n --cloud open a Claude cloud session that starts the issue there\n --here inside a cloud session, start it in this checkout\n --teleport bring the cloud session noted in the PR to the worktree\n --adopt start it in the worktree Orca or Paseo just made\n -h, --help this help\n\nPermissions of the Claude it opens (one at most):\n (none) auto mode if the repo has claude-repo-guard, else your settings\n --ask-permissions your settings: Claude asks before what they do not allow\n --free no sandbox and no questions, as claude:free\n\nFrom the repo's scripts (issue:start, and issue:start:free, which adds --free):\n npm run issue:start:free 42 npm passes a number on as it is\n npm run issue:start -- 42 --free but keeps the flags for itself, unless after --\n pnpm issue:start 42 --free pnpm, bun and yarn pass everything on\n bun run issue:start 42 --free\n yarn issue:start 42 --free\nA -- does no harm with any of them: issue-flow drops it.\n\nissue:start:free does not go with --ask-permissions, --cloud, --here nor --adopt: use\nissue:start for those.";
7
+ export declare const START_USAGE = "Usage: issue-flow issue start <n> [flags]\n\nStarts issue <n>: the branch (in a worktree, or in this checkout with --branch), the empty\nstart commit, the push, the draft PR and the assignment, after confirming; then it opens\nClaude there.\n\nFlags:\n --dry-run show what it would do and change nothing\n -y, --yes do it without asking\n --branch in this checkout instead of a worktree\n --resume <branch> take that branch of the issue\n --new make another branch, even if the issue has one\n --no-launch leave everything ready and do not open Claude\n --namer claude|title who names the branch when the issue has no slug block\n -R owner/repo another repo than the checkout's\n --plan print the branch and the prompt as JSON, and change nothing\n --cloud open a Claude cloud session that starts the issue there\n --here inside a cloud session, start it in this checkout\n --teleport bring the cloud session noted in the PR to the worktree\n --adopt start it in the worktree Orca or Paseo just made\n -h, --help this help\n\nPermissions of the Claude it opens (one at most):\n (none) auto mode if the repo has claude-repo-guard, else your settings\n --ask-permissions your settings: Claude asks before what they do not allow\n --free no sandbox and no questions, as claude:free\n --no-repo-deny with --free: also without the deny rules of the repo's settings\n\nWhat each one takes away, and what stays:\n (none), --ask-permissions\n take nothing away: the sandbox, the questions your settings ask\n and every deny rule stay.\n --free takes away the sandbox and the questions. Every deny rule stays:\n yours (~/.claude), the repo's (.claude/settings.json) and the\n local ones (.claude/settings.local.json).\n --free --no-repo-deny\n also takes the deny rules out of the worktree's\n .claude/settings.json while Claude runs, and puts them back when it\n exits; git does not see the change meanwhile. Your deny rules and the\n local ones stay, and so do the repo's hooks, attribution, CLAUDE.md,\n skills, agents and MCP servers. If Claude changes that file, the\n rules go back on top of its change, with a warning to review it. If\n the terminal closes first, the next issue start of the issue puts\n them back.\n\nFrom the repo's scripts (issue:start, and issue:start:free, which adds --free):\n npm run issue:start:free 42 npm passes a number on as it is\n npm run issue:start -- 42 --free but keeps the flags for itself, unless after --\n pnpm issue:start 42 --free pnpm, bun and yarn pass everything on\n bun run issue:start 42 --free\n yarn issue:start 42 --free\nA -- does no harm with any of them: issue-flow drops it.\n\nissue:start:free does not go with --ask-permissions, --cloud, --here nor --adopt: use\nissue:start for those. --no-repo-deny goes after it, as any flag:\n npm run issue:start:free -- 42 --no-repo-deny\n pnpm issue:start:free 42 --no-repo-deny";
8
8
  /**
9
9
  * The usage text for a command: its own if it has one, else the whole CLI's.
10
10
  * @param words - The command's words, as given (`["issue", "start", "42"]`).
package/dist/usage.js CHANGED
@@ -6,7 +6,7 @@ export const USAGE = `Usage:
6
6
  issue-flow issue branch <n>... [--namer claude|title] [--json] [-R owner/repo]
7
7
  issue-flow issue branch --missing [--limit <n>] [--namer claude|title] [--dry-run | --yes] [--json] [-R owner/repo]
8
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]
9
+ [--ask-permissions | --free [--no-repo-deny]] [--namer claude|title] [-R owner/repo]
10
10
  issue-flow issue start <n> --plan [--ask-permissions | --free] [--namer claude|title] [-R owner/repo]
11
11
  issue-flow issue start <n> --cloud | --here | --teleport | --adopt [...]
12
12
  issue-flow worktree setup [--only <step>]
@@ -29,8 +29,9 @@ and the branch from the repo's template (an older "Rama sugerida" block is used
29
29
  issue start makes the branch in a worktree (or, with --branch, in this checkout), the
30
30
  start commit, the push, the draft PR and the assignment, after confirming, and opens
31
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. issue start --help explains each flag.
32
+ --ask-permissions, with no sandbox nor questions with --free, and also without the repo's
33
+ deny rules with --free --no-repo-deny. --plan prints the branch and the prompt as JSON and
34
+ changes nothing. issue start --help explains each flag.
34
35
 
35
36
  --cloud confirms here and opens a Claude cloud session that starts the issue there.
36
37
  --here, inside a cloud session, starts it in this checkout and opens nothing. --teleport
@@ -85,6 +86,24 @@ Permissions of the Claude it opens (one at most):
85
86
  (none) auto mode if the repo has claude-repo-guard, else your settings
86
87
  --ask-permissions your settings: Claude asks before what they do not allow
87
88
  --free no sandbox and no questions, as claude:free
89
+ --no-repo-deny with --free: also without the deny rules of the repo's settings
90
+
91
+ What each one takes away, and what stays:
92
+ (none), --ask-permissions
93
+ take nothing away: the sandbox, the questions your settings ask
94
+ and every deny rule stay.
95
+ --free takes away the sandbox and the questions. Every deny rule stays:
96
+ yours (~/.claude), the repo's (.claude/settings.json) and the
97
+ local ones (.claude/settings.local.json).
98
+ --free --no-repo-deny
99
+ also takes the deny rules out of the worktree's
100
+ .claude/settings.json while Claude runs, and puts them back when it
101
+ exits; git does not see the change meanwhile. Your deny rules and the
102
+ local ones stay, and so do the repo's hooks, attribution, CLAUDE.md,
103
+ skills, agents and MCP servers. If Claude changes that file, the
104
+ rules go back on top of its change, with a warning to review it. If
105
+ the terminal closes first, the next issue start of the issue puts
106
+ them back.
88
107
 
89
108
  From the repo's scripts (issue:start, and issue:start:free, which adds --free):
90
109
  npm run issue:start:free 42 npm passes a number on as it is
@@ -95,7 +114,9 @@ From the repo's scripts (issue:start, and issue:start:free, which adds --free):
95
114
  A -- does no harm with any of them: issue-flow drops it.
96
115
 
97
116
  issue:start:free does not go with --ask-permissions, --cloud, --here nor --adopt: use
98
- issue:start for those.`;
117
+ issue:start for those. --no-repo-deny goes after it, as any flag:
118
+ npm run issue:start:free -- 42 --no-repo-deny
119
+ pnpm issue:start:free 42 --no-repo-deny`;
99
120
  /** The usage text of each command that has its own, by its words. */
100
121
  const OWN_USAGE = { "issue start": START_USAGE };
101
122
  /**
@@ -1,5 +1,6 @@
1
- import type { FlowConfig } from "./config.js";
1
+ import { type FlowConfig } from "./config.js";
2
2
  import { type Output } from "./output.js";
3
+ import { type Step } from "./worktree-config.js";
3
4
  /** Folder, in the worktree's git dir, with a mark per `once` step that worked. */
4
5
  export declare const SETUP_MARKS_DIR = "issue-flow-setup";
5
6
  /** Where the steps run, and with what. */
@@ -30,17 +31,43 @@ export declare function trustedCode(root: string, base: string): boolean;
30
31
  * @example stepEnv(place, 21430).ISSUE_FLOW_PORT_BASE // "21430"
31
32
  */
32
33
  export declare function stepEnv(place: StepPlace, port: number | null): Record<string, string>;
34
+ /** The setup steps a worktree runs, and where they were read. */
35
+ export interface SetupSteps {
36
+ steps: Step[];
37
+ source: string;
38
+ warning: string | null;
39
+ }
40
+ /**
41
+ * The setup steps: those of `.agents/flow.json` in `origin/<base>`, which a worktree made
42
+ * from it has; none if the file is not there. In a cloud session, without `origin/<base>`,
43
+ * or with a file there that is not valid, those of the checkout's config. The warning says
44
+ * when the main checkout's steps differ, or why they were used.
45
+ * @param place - Where.
46
+ * @returns The steps, where they come from and a warning, or null.
47
+ * @example setupSteps(place).source // "origin/main:.agents/flow.json"
48
+ */
49
+ export declare function setupSteps(place: StepPlace): SetupSteps;
50
+ /**
51
+ * Checks that `--only` names one of the setup steps.
52
+ * @param place - Where.
53
+ * @param only - The step, or null.
54
+ * @throws UsageError if it names no step.
55
+ * @example checkOnly(place, "deps")
56
+ */
57
+ export declare function checkOnly(place: StepPlace, only: string | null): void;
33
58
  /**
34
59
  * Prepares a worktree with the setup steps: one (`only`) or all, in order. In the main
35
60
  * checkout it does nothing, except in a cloud session, whose checkout is not a worktree. It
36
61
  * fixes the block of ports first, so the steps (and the apps after them) agree on it.
37
- * Never stops on a failed step: it says what to run and goes on.
62
+ * Never stops on a failed step: it says what to run and goes on. Without steps it says so,
63
+ * unless `quiet` (the session hooks, which run at every start).
38
64
  * @param place - Where.
39
65
  * @param only - The step to run, or null for all.
66
+ * @param quiet - True to say nothing when there are no steps.
40
67
  * @returns True if every step that ran worked.
41
68
  * @example await runSetup(place, null)
42
69
  */
43
- export declare function runSetup(place: StepPlace, only: string | null): Promise<boolean>;
70
+ export declare function runSetup(place: StepPlace, only: string | null, quiet?: boolean): Promise<boolean>;
44
71
  /**
45
72
  * Cleans a worktree before it is deleted: its apps and then its teardown steps (if the
46
73
  * folder still exists), so a step that drops a database finds nobody connected; then its
@@ -1,7 +1,9 @@
1
1
  /**
2
2
  * @file Runs the steps of `worktrees.setup` and `worktrees.teardown` in a worktree. The
3
- * steps come from the main checkout's config, not the worktree's, so a branch cannot add
4
- * commands to its own setup. A `trusted` step (an install, which runs the lifecycle scripts
3
+ * setup steps come from `.agents/flow.json` in `origin/<base>`, not from the worktree, so a
4
+ * branch cannot add commands to its own setup, and a main checkout nobody pulled does not
5
+ * leave a new worktree without its install. The teardown steps come from the main
6
+ * checkout's config. A `trusted` step (an install, which runs the lifecycle scripts
5
7
  * of `package.json`) runs only if the worktree's code is in `origin/<base>` and has no
6
8
  * changes; a `once` step leaves a mark when it works and is not repeated. Two setups of one
7
9
  * worktree at a time (the tool's and the session hook's) take turns with a lock. Each step
@@ -12,11 +14,14 @@ import { spawnSync } from "node:child_process";
12
14
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
13
15
  import { basename, join } from "node:path";
14
16
  import { setTimeout as sleep } from "node:timers/promises";
17
+ import { CONFIG_PATH, isObject } from "./config-base.js";
18
+ import { DEFAULT_CONFIG } from "./config.js";
15
19
  import { dockerCleanup, sanitizeLabel } from "./docker.js";
16
20
  import { git, isClean } from "./git.js";
17
- import { warn } from "./output.js";
21
+ import { UsageError, warn } from "./output.js";
18
22
  import { claimPortBase, portBase, releasePortClaims, sha256 } from "./ports.js";
19
23
  import { stopApps } from "./stop-apps.js";
24
+ import { worktreesSection } from "./worktree-config.js";
20
25
  /** Folder, in the worktree's git dir, with a mark per `once` step that worked. */
21
26
  export const SETUP_MARKS_DIR = "issue-flow-setup";
22
27
  /** The lock of the setup, in the worktree's git dir. */
@@ -78,6 +83,58 @@ function runStep(place, step, env) {
78
83
  warn(place.out, `step "${step.name}" failed${result.status === null ? "" : ` (exit ${result.status})`}; run '${step.run}' in ${place.root}.`);
79
84
  return false;
80
85
  }
86
+ /**
87
+ * The setup steps: those of `.agents/flow.json` in `origin/<base>`, which a worktree made
88
+ * from it has; none if the file is not there. In a cloud session, without `origin/<base>`,
89
+ * or with a file there that is not valid, those of the checkout's config. The warning says
90
+ * when the main checkout's steps differ, or why they were used.
91
+ * @param place - Where.
92
+ * @returns The steps, where they come from and a warning, or null.
93
+ * @example setupSteps(place).source // "origin/main:.agents/flow.json"
94
+ */
95
+ export function setupSteps(place) {
96
+ const local = place.config.worktrees.setup;
97
+ const ref = `origin/${place.base}`;
98
+ const fallback = { steps: local, source: `the main checkout's ${CONFIG_PATH}` };
99
+ // NOTE: a cloud session has no other checkout: its steps are those of the session's branch.
100
+ if (place.remote || !git(place.mainRoot, ["rev-parse", "-q", "--verify", `${ref}^{commit}`]).ok)
101
+ return { ...fallback, warning: null };
102
+ const source = `${ref}:${CONFIG_PATH}`;
103
+ const shown = git(place.mainRoot, ["show", source]);
104
+ let steps;
105
+ try {
106
+ if (!shown.ok)
107
+ steps = DEFAULT_CONFIG.worktrees.setup;
108
+ else {
109
+ const raw = JSON.parse(shown.out);
110
+ steps = worktreesSection(isObject(raw) ? raw.worktrees : undefined, DEFAULT_CONFIG.worktrees).setup;
111
+ }
112
+ }
113
+ catch (error) {
114
+ const reason = error instanceof Error ? error.message : String(error);
115
+ return { ...fallback, warning: `${source} is not valid: ${reason}; using the main checkout's steps.` };
116
+ }
117
+ const differ = JSON.stringify(steps) !== JSON.stringify(local);
118
+ return {
119
+ steps,
120
+ source,
121
+ warning: differ ? `the setup steps come from ${source}; the main checkout's differ (is it behind? pull it).` : null,
122
+ };
123
+ }
124
+ /**
125
+ * Checks that `--only` names one of the setup steps.
126
+ * @param place - Where.
127
+ * @param only - The step, or null.
128
+ * @throws UsageError if it names no step.
129
+ * @example checkOnly(place, "deps")
130
+ */
131
+ export function checkOnly(place, only) {
132
+ if (only === null)
133
+ return;
134
+ const names = setupSteps(place).steps.map((step) => step.name);
135
+ if (!names.includes(only))
136
+ throw new UsageError(`--only expects a setup step of ${CONFIG_PATH} (${names.join(", ") || "it has none"}): ${only}`);
137
+ }
81
138
  /**
82
139
  * Takes the setup's lock: a folder with the pid. A lock of a process that is gone is taken
83
140
  * over, and so is one without a pid that is some seconds old (its process died before
@@ -126,17 +183,24 @@ function alive(pid) {
126
183
  * Prepares a worktree with the setup steps: one (`only`) or all, in order. In the main
127
184
  * checkout it does nothing, except in a cloud session, whose checkout is not a worktree. It
128
185
  * fixes the block of ports first, so the steps (and the apps after them) agree on it.
129
- * Never stops on a failed step: it says what to run and goes on.
186
+ * Never stops on a failed step: it says what to run and goes on. Without steps it says so,
187
+ * unless `quiet` (the session hooks, which run at every start).
130
188
  * @param place - Where.
131
189
  * @param only - The step to run, or null for all.
190
+ * @param quiet - True to say nothing when there are no steps.
132
191
  * @returns True if every step that ran worked.
133
192
  * @example await runSetup(place, null)
134
193
  */
135
- export async function runSetup(place, only) {
194
+ export async function runSetup(place, only, quiet = false) {
136
195
  const { root, mainRoot, config, out } = place;
137
196
  if (root === mainRoot && !place.remote)
138
197
  return true;
139
- const chosen = config.worktrees.setup.filter((step) => only === null || step.name === only);
198
+ const found = setupSteps(place);
199
+ if (found.warning !== null)
200
+ warn(out, found.warning);
201
+ if (found.steps.length === 0 && !quiet)
202
+ warn(out, `no setup steps in ${found.source}; nothing ran.`);
203
+ const chosen = found.steps.filter((step) => only === null || step.name === only);
140
204
  const ports = config.worktrees.ports;
141
205
  const port = ports === null ? null : await claimPortBase(ports, root, null);
142
206
  if (ports !== null && port === null)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramon-sg/issue-flow",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "description": "Issue-to-PR workflow for any repo: names each issue's branch, creates its worktree, opens the draft PR and launches Claude Code, locally and in Claude's cloud.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/schema.json CHANGED
@@ -76,7 +76,7 @@
76
76
  "default": ".claude/worktrees"
77
77
  },
78
78
  "setup": {
79
- "description": "Steps that prepare a worktree, in order. They come from the main checkout's config, never the worktree's.",
79
+ "description": "Steps that prepare a worktree, in order. They come from this file in origin/<base> (the main checkout's if origin has none to read), never the worktree's.",
80
80
  "type": "array",
81
81
  "items": {
82
82
  "type": "object",