@retasc/cli 1.42.1 → 1.43.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,50 @@ release commits and the issues they reference.
6
6
 
7
7
  Dates are the npm publish date. Each entry names the RTSC issue behind it.
8
8
 
9
+ ## 1.43.0 (2026-09-05)
10
+
11
+ - **RTSC-799** - `tidy`, `done` and `claim` now work out which branch is this repo's trunk
12
+ instead of assuming it is called `main`. Until this release `tidy` compared every branch
13
+ against `origin/main`, a name written into the source with no flag, no config and no way
14
+ to see it. In a repo whose trunk is `master` that comparison does not fail into view:
15
+ `git merge-base --is-ancestor` exits 128 for an unreadable ref, the code tested only for
16
+ 0, and so a broken lookup and an honest "not merged" produced the same word. Every branch
17
+ read `merged: no`, nothing was ever reapable, and because `claim` creates a worktree per
18
+ issue and only `tidy` removes one, the worktrees accumulated with nothing printed to say
19
+ why. The team who reported it had thirty of them, built up over months, and could not tell
20
+ from the outside whether the tool was wrong or their setup was.
21
+ The trunk now comes from `origin/HEAD`, which is what `git clone` already records and what
22
+ `git fetch` re-records when it goes stale. Override it per repo with
23
+ `git config --local retasc.base origin/develop`, or per run with a new `--base` on `tidy`
24
+ and `done`. Read at `--local` scope on purpose: a plain `git config --get` also sees
25
+ `~/.gitconfig`, and one machine-wide value would have quietly redefined the trunk in every
26
+ repo on the box and outranked each repo's own `origin/HEAD`. When the trunk cannot be
27
+ worked out at all, that is an error naming the ref and every way to set one, because a
28
+ command that cannot tell what merged means must not go on to report that nothing did.
29
+ Ancestry now has three answers rather than two. "Git could not tell" is carried through as
30
+ its own state, prints as `?`, and is never deleted, not even under `--force`: that flag
31
+ means "unmerged, but I know the work landed elsewhere", which is a judgement about a branch
32
+ git actually reported on. Every run also prints the trunk it measured against, which is the
33
+ line that would have turned this into a one-minute diagnosis, and a shallow clone warns,
34
+ since missing history produces the same uniform "no".
35
+ Care taken with the sharp edges, because this turns a command that deleted nothing in those
36
+ repos into one that deletes:
37
+ - A stale `origin/HEAD`, which outlives the branch it names when a remote renames its trunk
38
+ and which git before 2.45 never repairs, falls through to the next candidate with a note
39
+ rather than refusing `claim`, `tidy` and `done` in a repo that is otherwise healthy.
40
+ - `done` still closes the issue when the trunk cannot be resolved. The control-plane close
41
+ is the durable half and a CI checkout legitimately has no `origin/HEAD`; the teardown is
42
+ what needs a trunk, so that is what reports.
43
+ - The first `--prune` after upgrading asks once before deleting five or more branches, since
44
+ it may be clearing months of work a team believed this command never touched. Scripts and
45
+ the MCP auto-reap never see the question, and `--yes` skips it.
46
+ - `claim` fetches before it proves the base exists, so a just-pushed base still works, and
47
+ resolves before taking the lease, so a claim is never held for a worktree that cannot be
48
+ created. It also prints the trunk it branched from.
49
+ - A bare repository is refused rather than operated on (the old path resolved to its parent
50
+ directory), a detached HEAD no longer defeats the "that's your current branch" guard, and
51
+ the watchdog now logs a non-zero exit from an auto-reap instead of swallowing it.
52
+
9
53
  ## 1.42.1 (2026-09-05)
10
54
 
11
55
  - **RTSC-832** — the model now actually appears. 1.42.0 read it from the SessionStart
@@ -4,6 +4,7 @@ import { basename, dirname, resolve, sep } from "node:path";
4
4
  import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, normalizeIssueId, resolveIssueRef, } from "../lib/claim.js";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { clean } from "../lib/text.js";
7
+ import { resolveBase, baseHelp } from "../lib/base.js";
7
8
  function git(args, cwd) {
8
9
  return spawnSync("git", args, { encoding: "utf8", cwd });
9
10
  }
@@ -72,6 +73,7 @@ export async function claimAction(opts) {
72
73
  let parentDir = opts.dir;
73
74
  let repoName = "repo";
74
75
  let inLinkedWorktree = false;
76
+ let base = "";
75
77
  if (makeWorktree) {
76
78
  const commonDir = gitOut(["rev-parse", "--git-common-dir"]);
77
79
  const gitDir = gitOut(["rev-parse", "--git-dir"]);
@@ -86,6 +88,38 @@ export async function claimAction(opts) {
86
88
  const mainCheckout = dirname(resolve(commonDir)); // <main>/.git → <main>
87
89
  repoName = basename(mainCheckout);
88
90
  parentDir = parentDir ?? dirname(mainCheckout);
91
+ // RTSC-799: the branch base is discovered from the repo, not assumed to be
92
+ // `origin/main`. Resolved HERE, before the claim: a lease taken for a worktree
93
+ // that then can't be created leaves the issue held by an agent with nowhere to
94
+ // work, and the caller has to hand it back to free it.
95
+ //
96
+ // The fetch comes FIRST, and has to. Resolving proves the base resolves to a
97
+ // commit, and a base that is only on the remote so far (a just-pushed branch, a
98
+ // --single-branch clone) does not resolve until it has been fetched. Proving
99
+ // before fetching would refuse claims that worked before this change.
100
+ if (opts.fetch !== false) {
101
+ const named = opts.base?.startsWith("origin/") ? opts.base.slice("origin/".length) : null;
102
+ const f = named ? git(["fetch", "origin", named]) : git(["fetch", "origin"]);
103
+ if (f.status !== 0)
104
+ note(`⚠ git fetch failed (${(f.stderr || "").trim().split("\n")[0]}); using local refs.`);
105
+ }
106
+ // Same cwd as `tidy` uses (the main checkout), so `claim` and the `tidy` that
107
+ // later judges the branch can never read a different trunk for the same repo.
108
+ const gitRun = (args) => {
109
+ const r = git(args, mainCheckout);
110
+ return { status: r.status, stdout: r.stdout ?? "" };
111
+ };
112
+ const resolved = resolveBase(gitRun, opts.base);
113
+ if (!resolved.ok) {
114
+ for (const line of baseHelp(resolved))
115
+ note(line);
116
+ note(" Nothing was claimed. Or pass --no-worktree to claim without one.");
117
+ process.exit(1);
118
+ }
119
+ if (resolved.staleOriginHead) {
120
+ note(`⚠ origin/HEAD still points at ${resolved.staleOriginHead}, which is gone. Branching from ${resolved.ref}.`);
121
+ }
122
+ base = resolved.ref;
89
123
  }
90
124
  // --- claim over MCP ------------------------------------------------------
91
125
  let claim;
@@ -170,14 +204,6 @@ export async function claimAction(opts) {
170
204
  finish(plan.path, plan.branch, issueId, title, claim.claimToken, opts);
171
205
  return;
172
206
  }
173
- // Best-effort refresh of the base so the worktree starts from current origin.
174
- const base = opts.base ?? "origin/main";
175
- if (opts.fetch !== false) {
176
- const remoteBranch = base.startsWith("origin/") ? base.slice("origin/".length) : null;
177
- const f = remoteBranch ? git(["fetch", "origin", remoteBranch]) : git(["fetch", "origin"]);
178
- if (f.status !== 0)
179
- note(`⚠ git fetch failed (${(f.stderr || "").trim().split("\n")[0]}); using local ${base}.`);
180
- }
181
207
  const add = git(["worktree", "add", plan.path, "-b", plan.branch, base]);
182
208
  if (add.status !== 0) {
183
209
  const err = (add.stderr || add.stdout || "").trim().split("\n")[0];
@@ -199,6 +225,9 @@ export async function claimAction(opts) {
199
225
  note(`✓ Claimed ${issueId}${title ? ` — ${title}` : ""}`);
200
226
  note(` worktree: ${plan.path}`);
201
227
  note(` branch: ${plan.branch}`);
228
+ // Name the trunk here too: `tidy` prints it, and claim is the other command that
229
+ // acts on it. An unexpected trunk should be visible before the work starts.
230
+ note(` from: ${base}`);
202
231
  // RTSC-446: a fresh worktree checks out tracked files only — deps/artifacts are absent.
203
232
  note(` Fresh worktree: only tracked files. Run the repo's usual setup (what docs/CI`);
204
233
  note(` run on a fresh checkout) before your first build or test.`);
@@ -4,6 +4,7 @@ import { resolveMcpConn, readMcpJson, mcpCall, isValidIssueId } from "../lib/cla
4
4
  import { issueIdFromBranch, classifyBranch, parseWorktreePorcelain, } from "../lib/tidy.js";
5
5
  import { confirm, isInteractive } from "../lib/prompt.js";
6
6
  import { doneReadback } from "../lib/format.js";
7
+ import { resolveBase, baseHelp, mergedInto, isShallow } from "../lib/base.js";
7
8
  // `retasc tidy` / `retasc done` (RTSC-93). The CLI already creates the worktree +
8
9
  // branch on claim (commands/claim.ts); this closes the loop — it tears them down
9
10
  // when the issue is done, reading status read-only from the control plane and
@@ -20,9 +21,11 @@ function gitOut(args, cwd) {
20
21
  function note(msg) {
21
22
  process.stderr.write(msg + "\n");
22
23
  }
23
- const BASE = "origin/main";
24
- /** Resolve the MCP connection + the main checkout, or exit with a clear message. */
25
- function setup() {
24
+ /** Resolve the MCP connection, the main checkout, and the trunk — or exit with a
25
+ * clear message. Every exit here happens BEFORE `done` writes a terminal status:
26
+ * closing an issue and only then discovering the teardown can't run is the worse
27
+ * of the two failures. */
28
+ function setup(baseOpt) {
26
29
  const conn = resolveMcpConn({ mcpJson: readMcpJson() });
27
30
  if (!conn.key) {
28
31
  note("✗ No Retasc MCP key found. Run `retasc mcp install`, or set RETASC_MCP_KEY.");
@@ -33,11 +36,49 @@ function setup() {
33
36
  note("✗ Not inside a git repository.");
34
37
  process.exit(1);
35
38
  }
39
+ // In a BARE repo --git-common-dir is `.`, so this would resolve to the bare repo's
40
+ // PARENT — and every branch delete, worktree remove and `push --delete` below would
41
+ // then run against whatever repository happens to live there. Refuse instead.
42
+ if (gitOut(["rev-parse", "--is-bare-repository"]) === "true") {
43
+ note("✗ This is a bare repository — there are no worktrees or checkouts here to tidy.");
44
+ process.exit(1);
45
+ }
36
46
  // --git-common-dir always points at the MAIN checkout's .git, even from a linked
37
47
  // worktree — so worktree/branch ops always run against the main checkout.
38
48
  const mainCheckout = dirname(resolve(commonDir));
39
- const currentBranch = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]);
40
- return { conn, mainCheckout, currentBranch };
49
+ // symbolic-ref, not `rev-parse --abbrev-ref`: the latter returns the literal string
50
+ // "HEAD" when detached, which would then match no branch and silently disarm the
51
+ // "that's your current branch" guard below.
52
+ const currentBranch = gitOut(["symbolic-ref", "--quiet", "--short", "HEAD"]);
53
+ // RTSC-799: the trunk is discovered, not assumed. An unresolvable one is a hard
54
+ // stop — the alternative it replaces was reporting every branch as unmerged and
55
+ // reaping nothing, for months, with nothing printed to say why.
56
+ const run = (args) => {
57
+ const r = git(args, mainCheckout);
58
+ return { status: r.status, stdout: r.stdout ?? "" };
59
+ };
60
+ // Refresh BEFORE resolving. The prune is what deletes `origin/master` after a remote
61
+ // rename, so resolving first would bless a ref this very command is about to remove,
62
+ // and every row would then read "?" against a trunk that no longer exists.
63
+ git(["fetch", "origin", "--prune"], mainCheckout);
64
+ const base = resolveBase(run, baseOpt);
65
+ if (!base.ok) {
66
+ return { conn, mainCheckout, currentBranch, run, base: null, baseFailure: base };
67
+ }
68
+ if (base.staleOriginHead) {
69
+ note(`⚠ origin/HEAD still points at ${base.staleOriginHead}, which is gone. Using ${base.ref}.`);
70
+ note(` \`git remote set-head origin -a\` re-records it.`);
71
+ }
72
+ // A shallow clone can be missing the very commits ancestry is read from, which
73
+ // produces the same uniform "not merged" this command exists to stop believing.
74
+ // `null` (git couldn't say) warns too: a suppressed warning is the same silence.
75
+ const shallow = isShallow(run);
76
+ if (shallow !== false) {
77
+ note(shallow === true
78
+ ? `⚠ Shallow clone — merge state against ${base.ref} may be wrong. \`git fetch --unshallow\` for a true answer.`
79
+ : `⚠ Couldn't tell whether this is a shallow clone; merge state against ${base.ref} may be wrong.`);
80
+ }
81
+ return { conn, mainCheckout, currentBranch, run, base: base.ref, baseFailure: null };
41
82
  }
42
83
  /** Short ref names under a refspec (`origin/` stripped when asked). */
43
84
  function refList(mainCheckout, refspec, strip) {
@@ -51,8 +92,6 @@ function refList(mainCheckout, refspec, strip) {
51
92
  * joined with its issue status (read-only) and merged-into-main state. */
52
93
  async function scan(ctx, only) {
53
94
  const { conn, mainCheckout } = ctx;
54
- // Refresh so merged-state + the remote list are current. Best-effort.
55
- git(["fetch", "origin", "--prune"], mainCheckout);
56
95
  const worktrees = parseWorktreePorcelain(gitOut(["worktree", "list", "--porcelain"], mainCheckout) ?? "");
57
96
  const wtByBranch = new Map();
58
97
  for (const w of worktrees)
@@ -87,7 +126,11 @@ async function scan(ctx, only) {
87
126
  const remote = remoteSet.has(branch);
88
127
  // Merged check prefers the remote ref (authoritative); falls back to the local.
89
128
  const ref = remote ? `origin/${branch}` : branch;
90
- const merged = git(["merge-base", "--is-ancestor", ref, BASE], mainCheckout).status === 0;
129
+ const merged = mergedInto(ctx.run, ref, ctx.base);
130
+ // The base already resolved, so an unknown here is the ref itself — say so
131
+ // rather than letting a "no" in the table stand in for a failed check.
132
+ if (merged === null)
133
+ note(`⚠ ${branch}: git couldn't compare ${ref} against ${ctx.base} — treating it as unmerged.`);
91
134
  return {
92
135
  branch,
93
136
  issue,
@@ -100,6 +143,8 @@ async function scan(ctx, only) {
100
143
  };
101
144
  });
102
145
  }
146
+ /** Ask once before a prune this large. See the call site for why. */
147
+ const BULK_CONFIRM_AT = 5;
103
148
  const VERDICT_LABEL = {
104
149
  reap: "reap",
105
150
  orphan: "orphan — review",
@@ -111,7 +156,7 @@ function printTable(rows) {
111
156
  note(` ${"branch".padEnd(w)} issue status merged verdict`);
112
157
  for (const r of rows) {
113
158
  note(` ${r.branch.padEnd(w)} ${(r.issue ?? "—").padEnd(9)} ${(r.status ?? "—").padEnd(9)} ` +
114
- `${(r.merged ? "yes" : "no").padEnd(6)} ${VERDICT_LABEL[r.verdict]}`);
159
+ `${(r.merged === null ? "?" : r.merged ? "yes" : "no").padEnd(6)} ${VERDICT_LABEL[r.verdict]}`);
115
160
  }
116
161
  }
117
162
  /** Delete one branch's artifacts (worktree → local branch → remote branch),
@@ -123,13 +168,16 @@ function reap(row, ctx, force) {
123
168
  return "skipped";
124
169
  }
125
170
  // `row.merged` was computed from the REMOTE ref, but we force-delete the LOCAL
126
- // branch below. If the local branch carries commits not yet on origin/main
171
+ // branch below. If the local branch carries commits not yet on the trunk
127
172
  // (committed-but-unpushed), skip unless --force — so `branch -D` can't silently
128
- // discard them. (--force is the consented escape hatch.)
173
+ // discard them. (--force is the consented escape hatch.) An unknown answer skips
174
+ // for the same reason a "no" does: only a proven-landed branch gets deleted.
129
175
  if (row.local && !force) {
130
- const localOnMain = git(["merge-base", "--is-ancestor", row.branch, BASE], mainCheckout).status === 0;
131
- if (!localOnMain) {
132
- note(` · skip ${row.branch} — local branch has commits not on ${BASE} (unpushed?); --force to delete`);
176
+ const localOnBase = mergedInto(ctx.run, row.branch, ctx.base);
177
+ if (localOnBase !== true) {
178
+ note(localOnBase === null
179
+ ? ` · skip ${row.branch} — couldn't check its local branch against ${ctx.base}`
180
+ : ` · skip ${row.branch} — local branch has commits not on ${ctx.base} (unpushed?); --force to delete`);
133
181
  return "skipped";
134
182
  }
135
183
  }
@@ -157,7 +205,7 @@ function reap(row, ctx, force) {
157
205
  return "failed";
158
206
  }
159
207
  }
160
- // -D is safe here: either the local branch is on origin/main (verified above) or
208
+ // -D is safe here: either the local branch is on the trunk (verified above) or
161
209
  // the user passed --force. `--` guards against any future caller bypassing the
162
210
  // issueIdFromBranch filter (the regex already forbids leading-dash names).
163
211
  if (row.local) {
@@ -184,12 +232,23 @@ function reap(row, ctx, force) {
184
232
  * --force; untracked branches (no matching issue) are never touched.
185
233
  */
186
234
  export async function tidyAction(opts) {
187
- const ctx = setup();
188
- const rows = await scan(ctx, opts.only);
235
+ const ctx = setup(opts.base);
236
+ // `tidy`'s entire job is merge state, so it has nothing to say without a trunk.
237
+ // (`done` treats the same failure as non-fatal: its write is the durable half.)
238
+ if (ctx.base === null) {
239
+ for (const line of baseHelp(ctx.baseFailure))
240
+ note(line);
241
+ process.exit(1);
242
+ }
243
+ const resolved = ctx;
244
+ const rows = await scan(resolved, opts.only);
189
245
  if (rows.length === 0) {
190
246
  note(opts.only ? `· No branch found for ${opts.only}.` : "· No issue branches (rtsc-NN/…) found.");
191
247
  return;
192
248
  }
249
+ // Name the trunk every run. Without it "merged: no" is unfalsifiable from the
250
+ // outside, which is precisely how RTSC-799 survived for months.
251
+ note(`· merged is measured against ${resolved.base}`);
193
252
  if (opts.json) {
194
253
  console.log(JSON.stringify(rows, null, 2));
195
254
  }
@@ -198,7 +257,13 @@ export async function tidyAction(opts) {
198
257
  }
199
258
  const reapable = rows.filter((r) => r.verdict === "reap");
200
259
  const orphans = rows.filter((r) => r.verdict === "orphan");
201
- const targets = [...reapable, ...(opts.force ? orphans : [])];
260
+ // --force means "unmerged, but I know the work landed elsewhere" — a judgement about
261
+ // a branch whose state git DID report. A row git couldn't judge at all is not that,
262
+ // and deleting it would make this file's own rule ("an unknown can never authorize a
263
+ // delete") false. Those need the ref fixed, not a bigger hammer.
264
+ const unknown = orphans.filter((r) => r.merged === null);
265
+ const forceable = orphans.filter((r) => r.merged !== null);
266
+ const targets = [...reapable, ...(opts.force ? forceable : [])];
202
267
  if (!opts.prune) {
203
268
  note("");
204
269
  const n = reapable.length;
@@ -211,14 +276,30 @@ export async function tidyAction(opts) {
211
276
  }
212
277
  if (targets.length === 0) {
213
278
  note("");
214
- note(orphans.length ? "→ only orphans remain; re-run with --force to delete them." : "→ nothing to reap.");
279
+ note(forceable.length ? "→ only orphans remain; re-run with --force to delete them." : "→ nothing to reap.");
280
+ if (unknown.length)
281
+ note(`→ ${unknown.length} branch(es) git couldn't judge are left alone, even with --force.`);
215
282
  return;
216
283
  }
284
+ // The first --prune after upgrading can be enormous: this command was a silent no-op
285
+ // in every repo whose trunk wasn't `main`, so a team can be sitting on months of
286
+ // branches it never reaped. Deleting all of them on one keystroke, against a mental
287
+ // model that says --prune does nothing, is not a fix. Scripts never see this (no TTY),
288
+ // and the proxy's auto-reap passes --only, so it is always a single branch.
289
+ if (!opts.only && !opts.yes && isInteractive() && targets.length >= BULK_CONFIRM_AT) {
290
+ note("");
291
+ const remotes = targets.filter((t) => t.remote).length;
292
+ note(`About to delete ${targets.length} branches (${remotes} also on origin) and their worktrees.`);
293
+ if (!(await confirm("Delete them?"))) {
294
+ note("· Nothing deleted.");
295
+ return;
296
+ }
297
+ }
217
298
  note("");
218
299
  let deleted = 0;
219
300
  let failed = 0;
220
301
  for (const row of targets) {
221
- const r = reap(row, ctx, !!opts.force);
302
+ const r = reap(row, resolved, !!opts.force);
222
303
  if (r === "deleted")
223
304
  deleted++;
224
305
  else if (r === "failed")
@@ -229,10 +310,11 @@ export async function tidyAction(opts) {
229
310
  process.exitCode = 1; // let scripts/CI see a partial failure
230
311
  }
231
312
  /**
232
- * Is every branch for this issue already on origin/main? null when it has no branch here.
313
+ * Is every branch for this issue already on the trunk? null when it has no branch here.
233
314
  *
234
- * Pessimistic on purpose: if ANY branch for the issue is unmerged, this says unmerged. The
235
- * readback exists to stop a close over open work, so the cautious answer is the true one.
315
+ * Pessimistic on purpose: if ANY branch for the issue is unmerged or can't be checked —
316
+ * this says unmerged. The readback exists to stop a close over open work, so the cautious
317
+ * answer is the true one.
236
318
  */
237
319
  function issueBranchMerged(ctx, issueId) {
238
320
  const locals = refList(ctx.mainCheckout, "refs/heads/");
@@ -241,8 +323,7 @@ function issueBranchMerged(ctx, issueId) {
241
323
  const branches = [...new Set([...locals, ...remotes])].filter((b) => issueIdFromBranch(b) === issueId);
242
324
  if (branches.length === 0)
243
325
  return null;
244
- return branches.every((b) => git(["merge-base", "--is-ancestor", remoteSet.has(b) ? `origin/${b}` : b, BASE], ctx.mainCheckout)
245
- .status === 0);
326
+ return branches.every((b) => mergedInto(ctx.run, remoteSet.has(b) ? `origin/${b}` : b, ctx.base) === true);
246
327
  }
247
328
  /**
248
329
  * `retasc done` — the symmetric close to `retasc claim`: mark the current issue
@@ -265,7 +346,7 @@ export async function doneAction(opts) {
265
346
  // setup() resolves the MCP key AND the main checkout. Both are needed either way, and
266
347
  // failing on a missing repo BEFORE the write beats closing the issue and then finding
267
348
  // the teardown can't run.
268
- const ctx = setup();
349
+ const ctx = setup(opts.base);
269
350
  // Normalize an explicit --id to the canonical uppercase form so `done --id rtsc-93`
270
351
  // matches the uppercased branch ids (and is sent canonically to save_issue).
271
352
  const issueId = opts.id
@@ -277,9 +358,8 @@ export async function doneAction(opts) {
277
358
  note("✗ Not on a rtsc-NN/ branch — pass --id <RTSC-NN>.");
278
359
  process.exit(1);
279
360
  }
280
- // Refresh before judging merged-state: a stale origin/main reports merged work as
281
- // unmerged, which is the warning that teaches people to ignore the warning.
282
- git(["fetch", "origin", "--prune"], ctx.mainCheckout);
361
+ // (setup() already fetched: a stale trunk reports merged work as unmerged, which is
362
+ // the warning that teaches people to ignore the warning.)
283
363
  let issue = null;
284
364
  try {
285
365
  issue = await mcpCall(ctx.conn, "get_issue", { identifier: issueId });
@@ -289,12 +369,22 @@ export async function doneAction(opts) {
289
369
  // below carry the decision with what little is known.
290
370
  note(`⚠ Couldn't read ${issueId}: ${String(e?.message ?? e).split("\n")[0]}`);
291
371
  }
292
- note(doneReadback({ ...(issue ?? {}), id: issueId }, issueBranchMerged(ctx, issueId)));
372
+ // An unresolvable trunk is NOT fatal here, deliberately. The control-plane close is
373
+ // the durable half of `done`; the git teardown is local best-effort. Refusing to close
374
+ // an issue because a checkout has no origin/HEAD — which is exactly what a CI clone
375
+ // looks like — would break the scripts this command promises to keep working. So say
376
+ // what couldn't be checked, and let the human decide with that in front of them.
377
+ if (ctx.base === null) {
378
+ note(`⚠ Can't tell whether the branch merged: ${ctx.baseFailure.reason}.`);
379
+ note(` Closing is still fine; the branch teardown below is what needs a trunk.`);
380
+ }
381
+ const resolved = ctx.base === null ? null : ctx;
382
+ note(doneReadback({ ...(issue ?? {}), id: issueId }, resolved ? issueBranchMerged(resolved, issueId) : null, ctx.base ?? "the trunk"));
293
383
  if (opts.dryRun) {
294
384
  note("");
295
385
  note(`· Dry run: ${issueId} was NOT closed. Below is what the teardown would do.`);
296
386
  note("");
297
- await tidyAction({ force: opts.force, only: issueId });
387
+ await tidyAction({ force: opts.force, base: ctx.base ?? undefined, only: issueId });
298
388
  return;
299
389
  }
300
390
  // Already done: go straight to the teardown. Re-sending the write would cost a metered
@@ -321,5 +411,5 @@ export async function doneAction(opts) {
321
411
  note(`✓ ${issueId} → done`);
322
412
  }
323
413
  note(" tidying its branch…");
324
- await tidyAction({ prune: true, force: opts.force, only: issueId });
414
+ await tidyAction({ prune: true, force: opts.force, base: ctx.base ?? undefined, only: issueId });
325
415
  }
package/dist/index.js CHANGED
@@ -703,7 +703,7 @@ function addClaimFlags(cmd) {
703
703
  return cmd
704
704
  .option("--id <RTSC-NN>", "Claim one specific issue instead of the next unblocked one")
705
705
  .option("--all-lanes", "Pull from every lane, including other humans' work (default: only your lane + unassigned)")
706
- .option("--base <ref>", "Base ref for the new branch", "origin/main")
706
+ .option("--base <ref>", "Base ref for the new branch (default: this repo's trunk, from origin/HEAD)")
707
707
  .option("--dir <path>", "Parent dir for the worktree (default: beside the main checkout)")
708
708
  .option("--no-fetch", "Skip refreshing the base ref from origin first")
709
709
  .option("--no-worktree", "Just claim — don't create a worktree")
@@ -731,6 +731,8 @@ program
731
731
  .option("--prune", "Delete reapable branches + worktrees (default: dry-run, just report)")
732
732
  .option("--force", "Also delete orphans (issue done but branch unmerged)")
733
733
  .option("--json", "Emit the reconciled branch table as JSON")
734
+ .option("--base <ref>", "Trunk to measure merge state against (default: this repo's trunk, from origin/HEAD)")
735
+ .option("-y, --yes", "Skip the confirmation before a large prune")
734
736
  .option("--only <RTSC-NN>", "Scope the sweep to a single issue's branch (used by the MCP auto-reap)")
735
737
  .action((opts) =>
736
738
  // Normalize --only to the canonical uppercase id so it matches the branch ids
@@ -747,7 +749,8 @@ program
747
749
  .command("done")
748
750
  .description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
749
751
  .option("--id <RTSC-NN>", "The issue to close (default: derived from the current branch)")
750
- .option("--force", "Tear down even if the branch isn't merged into main yet")
752
+ .option("--force", "Tear down even if the branch isn't merged into the trunk yet")
753
+ .option("--base <ref>", "Trunk to measure merge state against (default: this repo's trunk, from origin/HEAD)")
751
754
  // RTSC-709: a read before the write. `done` is one word that closes an issue, and it
752
755
  // closed one whose PR was still open. --dry-run shows the readback and the teardown plan
753
756
  // without touching anything; -y is for the human who already knows (scripts never prompt).
@@ -0,0 +1,222 @@
1
+ // Which ref is this repo's trunk? (RTSC-799)
2
+ //
3
+ // `tidy` and `done` decide whether a branch's work has landed by asking git
4
+ // whether it's an ancestor of the trunk, and `claim` branches from the trunk.
5
+ // All three used to spell that trunk `origin/main`, written into the source with
6
+ // no way to change it. In a repo whose default branch is `master` the comparison
7
+ // doesn't error into view — `merge-base --is-ancestor` exits 128, the caller only
8
+ // tested for 0, and every branch came back "not merged". Nothing was ever
9
+ // reapable, so `claim` kept minting worktrees that nothing ever removed. One
10
+ // customer accumulated thirty of them over months with no error printed once.
11
+ //
12
+ // So two rules live here, and they're the whole point of the file:
13
+ // 1. The trunk is DISCOVERED (origin/HEAD, the same thing `git clone` records),
14
+ // overridable per-repo, and never assumed.
15
+ // 2. A git failure is never an answer. `merge-base --is-ancestor` has three
16
+ // outcomes and this module returns three, so "couldn't tell" can't wear the
17
+ // same face as "no".
18
+ //
19
+ // The decision (`pickBase`) is pure and takes the repo's facts as data, so the
20
+ // precedence order is testable without a git fixture; `resolveBase` is the thin
21
+ // layer that gathers those facts.
22
+ /**
23
+ * Per-repo override: `git config retasc.base origin/develop`.
24
+ *
25
+ * Read at `--local` scope ONLY, and deliberately. A plain `git config --get` also
26
+ * sees ~/.gitconfig, so one global value would redefine the trunk in every repo on
27
+ * the machine and outrank each repo's own origin/HEAD — and the trunk is the only
28
+ * evidence behind `git branch -D` / `git push origin --delete`, which the proxy runs
29
+ * unattended on every terminal close. A machine-wide answer to a per-repo question
30
+ * is not a default, it is a footgun.
31
+ */
32
+ export const BASE_CONFIG_KEY = "retasc.base";
33
+ /**
34
+ * Choose the trunk from what the repo already knows, most explicit first:
35
+ * `--base` → `git config retasc.base` → `origin/HEAD` → the one of
36
+ * `origin/{main,master}` that exists → for a repo with no remote, the one of
37
+ * `main`/`master` that exists.
38
+ *
39
+ * Ambiguity is a failure, never a guess. A repo carrying BOTH `origin/main` and
40
+ * `origin/master` with no `origin/HEAD` to break the tie gets an error naming the
41
+ * two, because picking the wrong one here doesn't misreport one row — it silently
42
+ * reclassifies every branch in the repo.
43
+ */
44
+ export function pickBase(i) {
45
+ // Only ABSENT skips. An empty `--base "$UNSET_VAR"` must not quietly become
46
+ // "discover one for me" and then measure deletions against a trunk the caller
47
+ // never named — the sibling rule claim.ts already spells out for issue ids.
48
+ if (i.explicit != null && i.explicit.trim() === "") {
49
+ return { ok: false, reason: "--base was given an empty value" };
50
+ }
51
+ if (i.configured != null && i.configured.trim() === "") {
52
+ return { ok: false, reason: `git config ${BASE_CONFIG_KEY} is set to an empty value` };
53
+ }
54
+ if (i.explicit)
55
+ return { ok: true, ref: i.explicit, source: "flag" };
56
+ if (i.configured)
57
+ return { ok: true, ref: i.configured, source: "config" };
58
+ if (i.originHead)
59
+ return { ok: true, ref: i.originHead, source: "origin-head" };
60
+ if (i.hasOrigin) {
61
+ if (i.remoteCandidates.length === 1) {
62
+ return { ok: true, ref: i.remoteCandidates[0], source: "remote-default" };
63
+ }
64
+ if (i.remoteCandidates.length > 1) {
65
+ return {
66
+ ok: false,
67
+ reason: `origin has both ${i.remoteCandidates.join(" and ")}, and no origin/HEAD to say which is the trunk`,
68
+ };
69
+ }
70
+ return {
71
+ ok: false,
72
+ reason: "origin has no recorded default branch (no origin/HEAD), and neither origin/main nor origin/master exists",
73
+ };
74
+ }
75
+ if (i.localCandidates.length === 1) {
76
+ return { ok: true, ref: i.localCandidates[0], source: "local-default" };
77
+ }
78
+ if (i.localCandidates.length > 1) {
79
+ return { ok: false, reason: `this repo has no origin remote, and both ${i.localCandidates.join(" and ")} exist locally` };
80
+ }
81
+ return { ok: false, reason: "this repo has no origin remote, and neither main nor master exists locally" };
82
+ }
83
+ /**
84
+ * Read the repo and pick its trunk, then PROVE the pick resolves to a commit.
85
+ *
86
+ * The proof matters as much as the pick: a `--base` for a ref that isn't there would
87
+ * otherwise fail later as "nothing is merged", the exact silence this exists to end.
88
+ * `tried` carries the ref that didn't resolve so the caller can name it.
89
+ */
90
+ export function resolveBase(run, explicit) {
91
+ const out = (args) => {
92
+ const r = run(args);
93
+ return r.status === 0 ? (r.stdout ?? "").trim() : null;
94
+ };
95
+ const resolves = (ref) => run(["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]).status === 0;
96
+ /** What the repo's own refs say, or null when `git remote` couldn't be read. */
97
+ const discover = () => {
98
+ // One for-each-ref for all four candidates. Full refnames, not short ones: a
99
+ // local branch literally named `origin/main` would otherwise read as the remote.
100
+ const refs = (out([
101
+ "for-each-ref",
102
+ "--format=%(refname)",
103
+ "refs/remotes/origin/main",
104
+ "refs/remotes/origin/master",
105
+ "refs/heads/main",
106
+ "refs/heads/master",
107
+ ]) ?? "")
108
+ .split("\n")
109
+ .map((r) => r.trim())
110
+ .filter(Boolean);
111
+ // A FAILED `git remote` must not read as "no origin". That would route the pick to
112
+ // the local candidates and hand back a local `main`/`master` as the trunk that
113
+ // REMOTE branch deletion is then measured against. No remotes at all is exit 0 with
114
+ // empty output, so the two are distinguishable — keep them distinguished.
115
+ const remotes = out(["remote"]);
116
+ if (remotes === null)
117
+ return null;
118
+ return {
119
+ hasOrigin: remotes.split("\n").some((r) => r.trim() === "origin"),
120
+ remoteCandidates: ["main", "master"]
121
+ .filter((b) => refs.includes(`refs/remotes/origin/${b}`))
122
+ .map((b) => `origin/${b}`),
123
+ localCandidates: ["main", "master"].filter((b) => refs.includes(`refs/heads/${b}`)),
124
+ };
125
+ };
126
+ // --get-all, not --get: with two values `--get` silently returns the last one. The
127
+ // trunk is the evidence behind a delete, so an ambiguous answer is no answer.
128
+ const configured = explicit ? null : out(["config", "--local", "--get-all", BASE_CONFIG_KEY]);
129
+ if (configured !== null && configured.split("\n").filter((l) => l.trim()).length > 1) {
130
+ return { ok: false, reason: `git config ${BASE_CONFIG_KEY} has more than one value`, tried: null };
131
+ }
132
+ const originHead = explicit || configured ? null : out(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
133
+ // Only inspect the repo's refs when nothing more explicit answered. Beyond being
134
+ // cheaper, it means a repo whose `git remote` is unreadable still works when the
135
+ // caller named the trunk outright.
136
+ let inputs;
137
+ if (explicit != null || configured != null || originHead) {
138
+ inputs = { explicit, configured, originHead, hasOrigin: false, remoteCandidates: [], localCandidates: [] };
139
+ }
140
+ else {
141
+ const found = discover();
142
+ if (!found)
143
+ return { ok: false, reason: "git couldn't list this repo's remotes", tried: null };
144
+ inputs = found;
145
+ }
146
+ const pick = pickBase(inputs);
147
+ if (!pick.ok)
148
+ return { ok: false, reason: pick.reason, tried: null };
149
+ if (resolves(pick.ref))
150
+ return { ok: true, ref: pick.ref, source: pick.source };
151
+ // An INFERRED pick that doesn't resolve falls through; an explicit one does not.
152
+ // `origin/HEAD` outlives the branch it names: rename the trunk on the remote and the
153
+ // symref still reports the deleted `origin/master`, and git older than 2.45 never
154
+ // repairs it on fetch. Hard-failing there would refuse `claim`, `tidy` AND `done` in a
155
+ // repo that is otherwise perfectly healthy, which is a worse outage than the silence
156
+ // this file exists to end. A ref the CALLER named is different: they asked for that one,
157
+ // so a typo gets reported rather than quietly worked around.
158
+ if (pick.source === "origin-head") {
159
+ const found = discover();
160
+ const second = found ? pickBase(found) : { ok: false, reason: "" };
161
+ if (second.ok && resolves(second.ref)) {
162
+ return { ok: true, ref: second.ref, source: second.source, staleOriginHead: pick.ref };
163
+ }
164
+ }
165
+ return { ok: false, reason: `${pick.ref} (${sourceLabel(pick.source)}) doesn't resolve to a commit`, tried: pick.ref };
166
+ }
167
+ function sourceLabel(s) {
168
+ return s === "flag"
169
+ ? "from --base"
170
+ : s === "config"
171
+ ? `from git config ${BASE_CONFIG_KEY}`
172
+ : s === "origin-head"
173
+ ? "from origin/HEAD"
174
+ : s === "remote-default"
175
+ ? "the only default-looking branch on origin"
176
+ : "the only default-looking local branch";
177
+ }
178
+ /**
179
+ * The lines to print when the trunk can't be resolved. Every one of them is a
180
+ * command the reader can run, because the alternative this replaces was a table
181
+ * of confident falsehoods and no way to tell it was lying.
182
+ */
183
+ export function baseHelp(result) {
184
+ return [
185
+ `✗ Can't tell which branch is this repo's trunk: ${result.reason}.`,
186
+ ` Merge state is measured against it, so without one every branch would read as unmerged.`,
187
+ ` Fix it with any of:`,
188
+ ` retasc <command> --base origin/<trunk>`,
189
+ ` git config --local ${BASE_CONFIG_KEY} origin/<trunk> (per-repo, remembered)`,
190
+ ` git remote set-head origin -a (records origin/HEAD from the remote)`,
191
+ ];
192
+ }
193
+ /**
194
+ * Is `ref` an ancestor of `base`? THREE outcomes, on purpose:
195
+ * true / false / null for "git couldn't answer".
196
+ *
197
+ * `merge-base --is-ancestor` exits 0 for yes, 1 for no, and 128 for an error
198
+ * (missing ref, unreadable object, a shallow clone that's missing the history).
199
+ * Collapsing 128 into `false` is the RTSC-799 bug in one character, so the
200
+ * unknown is carried out of here as its own value and can never be reaped.
201
+ */
202
+ export function mergedInto(run, ref, base) {
203
+ const r = run(["merge-base", "--is-ancestor", ref, base]);
204
+ if (r.status === 0)
205
+ return true;
206
+ if (r.status === 1)
207
+ return false;
208
+ return null;
209
+ }
210
+ /**
211
+ * Is the repo a shallow clone, where ancestry answers can't be trusted?
212
+ * `null` when git couldn't say — same rule as `mergedInto`, and for the same reason:
213
+ * this module's whole job is to stop a failed check from reading as a clean answer,
214
+ * and a suppressed warning is exactly the silence RTSC-799 was made of.
215
+ */
216
+ export function isShallow(run) {
217
+ const r = run(["rev-parse", "--is-shallow-repository"]);
218
+ if (r.status !== 0)
219
+ return null;
220
+ const out = (r.stdout ?? "").trim();
221
+ return out === "true" ? true : out === "false" ? false : null;
222
+ }
@@ -308,14 +308,16 @@ function time(ms) {
308
308
  * front of it: it closed RTSC-706 while that PR was open and unreviewed, printed a tick,
309
309
  * and moved on to offering branch deletion. This is the sentence that would have caught it.
310
310
  */
311
- export function doneReadback(i, merged) {
311
+ export function doneReadback(i, merged, base) {
312
312
  const rows = [
313
313
  ["Issue", `${clean(i.id ?? "—")} ${clean(i.title ?? "")}`.trimEnd()],
314
314
  ["Status", `${clean(i.status ?? "—")} → done`],
315
315
  ];
316
316
  if (i.assignee)
317
317
  rows.push(["Assignee", memberRef(i.assignee)]);
318
+ // `base` is the trunk `done` actually measured against (RTSC-799) — naming it is
319
+ // the difference between a reader trusting this line and being able to check it.
318
320
  if (merged !== null)
319
- rows.push(["Branch", merged ? "merged into origin/main" : "NOT merged into origin/main"]);
321
+ rows.push(["Branch", merged ? `merged into ${base}` : `NOT merged into ${base}`]);
320
322
  return labelled(rows);
321
323
  }
package/dist/lib/tidy.js CHANGED
@@ -19,20 +19,24 @@ export function issueIdFromBranch(branch) {
19
19
  }
20
20
  /**
21
21
  * Decide what to do with a branch from the join of issue status (control plane)
22
- * and merged-into-main (execution plane):
23
- * - done/canceled + merged → reap (safe to delete; work is on main)
22
+ * and merged-into-the-trunk (execution plane):
23
+ * - done/canceled + merged → reap (safe to delete; work is on the trunk)
24
24
  * - done/canceled + unmerged → orphan (review — work may have landed elsewhere)
25
25
  * - todo/doing/blocked → active (keep — work in progress)
26
26
  * - no matching issue → untracked (report only — never auto-delete)
27
27
  * `status` is null when no issue maps to the branch (off-convention name, or the
28
28
  * issue wasn't found).
29
+ *
30
+ * `merged` is null when git couldn't answer (RTSC-799). Only an explicit `true`
31
+ * reaps: "couldn't tell" must never authorize a delete, and it lands in the same
32
+ * needs-a-human bucket as a genuinely unmerged branch.
29
33
  */
30
34
  export function classifyBranch(opts) {
31
35
  const { status, merged } = opts;
32
36
  if (status === null)
33
37
  return "untracked";
34
38
  if (status === "done" || status === "canceled")
35
- return merged ? "reap" : "orphan";
39
+ return merged === true ? "reap" : "orphan";
36
40
  return "active";
37
41
  }
38
42
  /**
package/dist/proxy.js CHANGED
@@ -160,7 +160,14 @@ function runReap(issueId) {
160
160
  log(`reap of ${issueId} couldn't start: ${String(e?.message ?? e)}`);
161
161
  finish();
162
162
  });
163
- child.on("exit", finish);
163
+ // Log a non-zero exit. `tidy` refuses to guess when it can't work out the repo's
164
+ // trunk (RTSC-799), and a reap that fails every time must not look exactly like a
165
+ // reap that worked — the whole bug was a teardown failing in silence.
166
+ child.on("exit", (code) => {
167
+ if (code)
168
+ log(`reap of ${issueId} exited ${code} — see its output above`);
169
+ finish();
170
+ });
164
171
  child.unref?.(); // a pending reap must not keep the proxy alive
165
172
  });
166
173
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.42.1",
3
+ "version": "1.43.0",
4
4
  "description": "Retasc CLI — the issue tracker AI agents pull work from. Sign in with GitHub or Google, create projects, mint agent API keys, and wire your agent to the Retasc MCP server in one command.",
5
5
  "type": "module",
6
6
  "bin": {