@retasc/cli 1.42.0 → 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,64 @@ 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
+
53
+ ## 1.42.1 (2026-09-05)
54
+
55
+ - **RTSC-832** — the model now actually appears. 1.42.0 read it from the SessionStart
56
+ hook's `model` field, and Claude Code 2.1.261 does not send one: a captured payload is
57
+ `session_id`, `transcript_path`, `cwd`, `hook_event_name`, `source` and nothing else.
58
+ The row therefore filled in only after a `/model` switch, which is backwards.
59
+ Claude Code does write the model on every assistant message in the transcript, so the
60
+ proxy reads it from there instead, and every session gets one.
61
+ - The read is bounded to the last 64KB: transcripts reach megabytes and this runs on a
62
+ throttle for the whole session. Reading backwards from the end also means a `/model`
63
+ switch is picked up by the same read, with or without the hook.
64
+ - Only the model STRING is ever sent. The transcript path stays on the machine that
65
+ wrote it, and nothing else from the file leaves it.
66
+
9
67
  ## 1.42.0 (2026-09-05)
10
68
 
11
69
  - **RTSC-821** — a session now reports WHICH MODEL ran it. The MCP handshake carries the
@@ -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
@@ -625,6 +625,8 @@ hook
625
625
  ...(parsed.model ? { model: parsed.model } : {}),
626
626
  ...(parsed.agentType ? { agentType: parsed.agentType } : {}),
627
627
  ...(parsed.agentId ? { agentId: parsed.agentId } : {}),
628
+ // RTSC-832 — the proxy reads the model out of this. Stays on this machine.
629
+ ...(parsed.transcriptPath ? { transcriptPath: parsed.transcriptPath } : {}),
628
630
  });
629
631
  }
630
632
  }
@@ -701,7 +703,7 @@ function addClaimFlags(cmd) {
701
703
  return cmd
702
704
  .option("--id <RTSC-NN>", "Claim one specific issue instead of the next unblocked one")
703
705
  .option("--all-lanes", "Pull from every lane, including other humans' work (default: only your lane + unassigned)")
704
- .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)")
705
707
  .option("--dir <path>", "Parent dir for the worktree (default: beside the main checkout)")
706
708
  .option("--no-fetch", "Skip refreshing the base ref from origin first")
707
709
  .option("--no-worktree", "Just claim — don't create a worktree")
@@ -729,6 +731,8 @@ program
729
731
  .option("--prune", "Delete reapable branches + worktrees (default: dry-run, just report)")
730
732
  .option("--force", "Also delete orphans (issue done but branch unmerged)")
731
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")
732
736
  .option("--only <RTSC-NN>", "Scope the sweep to a single issue's branch (used by the MCP auto-reap)")
733
737
  .action((opts) =>
734
738
  // Normalize --only to the canonical uppercase id so it matches the branch ids
@@ -745,7 +749,8 @@ program
745
749
  .command("done")
746
750
  .description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
747
751
  .option("--id <RTSC-NN>", "The issue to close (default: derived from the current branch)")
748
- .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)")
749
754
  // RTSC-709: a read before the write. `done` is one word that closes an issue, and it
750
755
  // closed one whose PR was still open. --dry-run shows the readback and the teardown plan
751
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
  }
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync, unlinkSync, writeFileSync } from "node:fs";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { homedir } from "node:os";
@@ -105,8 +105,96 @@ export function readHookRecord(cwd, opts = {}) {
105
105
  ...(ident(rec.model, 60) ? { model: ident(rec.model, 60) } : {}),
106
106
  ...(ident(rec.agentType, 60) ? { agentType: ident(rec.agentType, 60) } : {}),
107
107
  ...(ident(rec.agentId, 100) ? { agentId: ident(rec.agentId, 100) } : {}),
108
+ ...(typeof rec.transcriptPath === "string" && rec.transcriptPath ? { transcriptPath: rec.transcriptPath } : {}),
108
109
  };
109
110
  }
111
+ /**
112
+ * The model the harness is CURRENTLY running, read out of the transcript it writes
113
+ * (RTSC-832).
114
+ *
115
+ * Claude Code never puts `model` in the SessionStart payload, so this is the only source
116
+ * that works for a session nobody has run `/model` in — which is most of them. Each
117
+ * assistant message in the `.jsonl` carries `message.model`, and the LAST one is the
118
+ * model in force now, so a `/model` switch is picked up by the same read.
119
+ *
120
+ * Reads the TAIL only. Transcripts reach 24MB in this repo, and this is called on a
121
+ * throttle for the life of a session; pulling the whole file each time would be a
122
+ * megabyte-scale read on the proxy's response path. The last chunk is where the newest
123
+ * message is, and a line straddling the chunk boundary is simply skipped as unparseable.
124
+ *
125
+ * Never throws: a missing, empty, truncated or half-written file just means no model
126
+ * this time, exactly as a harness that reports none.
127
+ */
128
+ const TRANSCRIPT_TAIL_BYTES = 64 * 1024;
129
+ /** The one retry, for a session whose newest message is bigger than the window. */
130
+ const TRANSCRIPT_WIDE_BYTES = 2 * 1024 * 1024;
131
+ export function modelFromTranscript(path, tailBytes = TRANSCRIPT_TAIL_BYTES) {
132
+ // A single assistant message can exceed the window: 563 lines over 64KB exist on this
133
+ // machine, the largest 1.18MB. The window then holds only that line's tail fragment,
134
+ // which is not parseable JSON, and the read would report nothing at all. So a scan
135
+ // that finds nothing widens once before giving up.
136
+ return scanTail(path, tailBytes) ?? (tailBytes < TRANSCRIPT_WIDE_BYTES ? scanTail(path, TRANSCRIPT_WIDE_BYTES) : undefined);
137
+ }
138
+ function scanTail(path, tailBytes) {
139
+ let fd;
140
+ try {
141
+ const st = statSync(path);
142
+ // isFile FIRST, and it is load-bearing: `openSync` on a FIFO with no writer blocks
143
+ // forever, and this runs on the proxy's synchronous response path. The size check
144
+ // happened to prevent that before; relying on that ordering was an accident.
145
+ if (!st.isFile() || !st.size)
146
+ return undefined;
147
+ const start = Math.max(0, st.size - tailBytes);
148
+ const len = st.size - start;
149
+ const buf = Buffer.alloc(len);
150
+ fd = openSync(path, "r");
151
+ // The harness is APPENDING to this file. A short read, or a truncation between the
152
+ // stat and the read, leaves the rest of the zero-filled buffer glued to the newest
153
+ // line, which then fails to parse and silently yields an older model. Only the bytes
154
+ // actually read are text.
155
+ const n = readSync(fd, buf, 0, len, start);
156
+ if (!n)
157
+ return undefined;
158
+ const lines = buf.subarray(0, n).toString("utf8").split("\n");
159
+ // Backwards: the newest assistant message wins, so a mid-session switch lands.
160
+ for (let i = lines.length - 1; i >= 0; i--) {
161
+ const line = lines[i].trim();
162
+ if (!line || line[0] !== "{")
163
+ continue;
164
+ try {
165
+ const j = JSON.parse(line);
166
+ // ONLY a top-level assistant turn. Measured across 40 real transcripts, MCP
167
+ // sampling records (`attributionMcpServer` / `attributionMcpTool`, 91-271 per
168
+ // file) also carry `message.model`, and so do sidechain entries — a subagent on
169
+ // a different model would otherwise be reported as the session's model, and each
170
+ // flip would spend one of the 40 lookups plus a server round trip.
171
+ // `type: "assistant"` alone is NOT enough: a sampling record wears it too.
172
+ if (j?.type !== "assistant")
173
+ continue;
174
+ if (j?.isSidechain || j?.attributionMcpServer || j?.attributionMcpTool)
175
+ continue;
176
+ const m = ident(j?.message?.model, 60);
177
+ if (m)
178
+ return m;
179
+ }
180
+ catch {
181
+ /* a truncated line, or the one straddling the chunk edge */
182
+ }
183
+ }
184
+ return undefined;
185
+ }
186
+ catch {
187
+ return undefined;
188
+ }
189
+ finally {
190
+ if (fd !== undefined) {
191
+ try {
192
+ closeSync(fd);
193
+ }
194
+ catch { /* nothing to do */ }
195
+ }
196
+ }
197
+ }
110
198
  /**
111
199
  * Rewrite the model on the record for this folder, in place (RTSC-821).
112
200
  *
@@ -164,8 +252,13 @@ function ident(raw, max) {
164
252
  /**
165
253
  * Parse what Claude Code hands a SessionStart hook on stdin. Verified against the hooks
166
254
  * reference (2026-09-04): `session_id`, `transcript_path`, `cwd`, `hook_event_name`,
167
- * `source`, and `model` / `agent_id` / `agent_type` when it has them. The transcript
168
- * path is NOT kept: it is derivable and would bake in a username (RTSC-791).
255
+ * `source`, and `model` / `agent_id` / `agent_type` when it has them.
256
+ *
257
+ * The transcript path IS kept (RTSC-832), reversing RTSC-820. That was dropped on
258
+ * RTSC-791's rule that a path bakes in a username, and the rule still holds: it governs
259
+ * what we SEND, and the path never leaves this machine. It is kept because 2.1.261 puts
260
+ * no `model` in this payload at all, so the transcript is the only place the model
261
+ * exists. See `HookRecord.transcriptPath`.
169
262
  *
170
263
  * `model` is absent from some payloads by design, so a parse that finds an id and no
171
264
  * model is a SUCCESS carrying less, never a failure.
@@ -188,6 +281,8 @@ export function parseClaudeHookInput(stdin) {
188
281
  ...(ident(j?.model, 60) ? { model: ident(j.model, 60) } : {}),
189
282
  ...(ident(j?.agent_type, 60) ? { agentType: ident(j.agent_type, 60) } : {}),
190
283
  ...(ident(j?.agent_id, 100) ? { agentId: ident(j.agent_id, 100) } : {}),
284
+ // Kept for the proxy to READ, never to send. See `HookRecord.transcriptPath`.
285
+ ...(typeof j?.transcript_path === "string" && j.transcript_path ? { transcriptPath: j.transcript_path } : {}),
191
286
  };
192
287
  }
193
288
  /**
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
@@ -14,7 +14,7 @@ import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, should
14
14
  import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
15
15
  import { toolResult as parseTool } from "./lib/toolresult.js";
16
16
  import { mintSessionKey, appendFallbackNotice, recordSession, nameWorkspace, RPC_ID } from "./lib/session.js";
17
- import { readHookRecord, clearHookRecord } from "./lib/sessionHook.js";
17
+ import { readHookRecord, clearHookRecord, modelFromTranscript } from "./lib/sessionHook.js";
18
18
  import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
19
19
  import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
20
20
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
@@ -62,6 +62,7 @@ let transcriptRecorded = false;
62
62
  // longer the end of this proxy's interest in the record.
63
63
  let reportedModel;
64
64
  let lastModelCheckAt = 0;
65
+ let pendingModel;
65
66
  const MODEL_CHECK_INTERVAL_MS = 3_000;
66
67
  let transcriptLookups = 0;
67
68
  let transcriptInFlight = null;
@@ -159,7 +160,14 @@ function runReap(issueId) {
159
160
  log(`reap of ${issueId} couldn't start: ${String(e?.message ?? e)}`);
160
161
  finish();
161
162
  });
162
- 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
+ });
163
171
  child.unref?.(); // a pending reap must not keep the proxy alive
164
172
  });
165
173
  }
@@ -233,6 +241,24 @@ function ownRecordModel() {
233
241
  const rec = readHookRecord(process.cwd(), { maxAgeMs: Number.POSITIVE_INFINITY });
234
242
  if (!rec || rec.sessionId !== transcript.id)
235
243
  return undefined;
244
+ // RTSC-832 — the TRANSCRIPT is the authority, and the only source that works at all
245
+ // for a session nobody ran `/model` in: Claude Code 2.1.261 puts no `model` in the
246
+ // SessionStart payload (captured 2026-09-05), so `rec.model` is set only by the
247
+ // PostModelSwitch hook. The file has one on every assistant message, newest last,
248
+ // so reading it covers the plain case AND a switch. `rec.model` stays as the fallback
249
+ // for a harness that reports a model but writes no transcript we can read.
250
+ // The switch hook is FRESHER than the file: right after `/model`, `rec.model` already
251
+ // holds the new one while the newest assistant line still holds the old. Preferring
252
+ // the file here delayed a switch to the next turn and lost it entirely if the session
253
+ // ended first, which 1.42.0 got right. Once reported, this falls through and the file
254
+ // is authoritative again (it also catches a switch that produced no hook at all).
255
+ if (rec.model && rec.model !== reportedModel)
256
+ return rec.model;
257
+ if (rec.transcriptPath) {
258
+ const fromFile = modelFromTranscript(rec.transcriptPath);
259
+ if (fromFile)
260
+ return fromFile;
261
+ }
236
262
  return rec.model;
237
263
  }
238
264
  /**
@@ -252,8 +278,10 @@ function modelMayHaveChanged() {
252
278
  if (now - lastModelCheckAt < MODEL_CHECK_INTERVAL_MS)
253
279
  return false;
254
280
  lastModelCheckAt = now;
255
- const model = ownRecordModel();
256
- return !!model && model !== reportedModel;
281
+ // Cached for the reporter that runs next: this whole path is synchronous on the
282
+ // response path, and scanning the transcript twice per change tick is pure waste.
283
+ pendingModel = ownRecordModel();
284
+ return !!pendingModel && pendingModel !== reportedModel;
257
285
  }
258
286
  async function reportTranscriptOnce() {
259
287
  // One budget for looking AND for asking: a server that keeps refusing is not asked
@@ -263,7 +291,8 @@ async function reportTranscriptOnce() {
263
291
  transcriptLookups += 1;
264
292
  if (transcriptRecorded) {
265
293
  // Recorded already, so we are here only for a model change — and only for OURS.
266
- const model = ownRecordModel();
294
+ // `modelMayHaveChanged` just did this scan; reuse it rather than paying it twice.
295
+ const model = pendingModel ?? ownRecordModel();
267
296
  if (!model || model === reportedModel || !transcript)
268
297
  return;
269
298
  const switched = await recordSession({ url: MCP_URL, key: activeKey, transcriptId: transcript.id, harness: transcript.harness, model, warn: log });
@@ -293,7 +322,11 @@ async function reportTranscriptOnce() {
293
322
  // harness, not our record (a Codex pane opened in a folder a Claude hook wrote for).
294
323
  if (rec.harness === "claude-code" && clientName && !/claude/i.test(clientName))
295
324
  return;
296
- transcript = { id: rec.sessionId, harness: rec.harness, model: rec.model, agentType: rec.agentType, agentId: rec.agentId };
325
+ // RTSC-832 the transcript usually has no assistant message yet at this point, so
326
+ // this is normally undefined and the model arrives on a later re-report. It matters
327
+ // when the proxy comes up mid-session (a restart), where the file already has one.
328
+ const startModel = (rec.transcriptPath ? modelFromTranscript(rec.transcriptPath) : undefined) ?? rec.model;
329
+ transcript = { id: rec.sessionId, harness: rec.harness, model: startModel, agentType: rec.agentType, agentId: rec.agentId };
297
330
  }
298
331
  const ok = await recordSession({
299
332
  url: MCP_URL,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.42.0",
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": {