@retasc/cli 1.43.0 → 1.45.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,43 @@ 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.45.0 (2026-09-08)
10
+
11
+ - **RTSC-862** — a binding now covers every **git worktree** of the repo it was made in.
12
+
13
+ `findBindingByPath` walks up from the current directory looking for a bound folder and
14
+ stops at the first `.git`, which is what stops `retasc bind` in `~` from silently
15
+ binding every project beneath it. But `.git` is a FILE at the root of every worktree,
16
+ so that stop fired on the first step inside one: every worktree resolved to nothing,
17
+ with no error and no hint, and the tools were simply absent. Repos whose workflow
18
+ creates a worktree per task (this one included) hit it constantly, and the rational
19
+ workaround — paste a raw key into a config that works everywhere — silently costs the
20
+ watchdog, session rows, transcripts and the folder name.
21
+
22
+ "Same repository" is decided by git's common dir, the one directory every worktree
23
+ shares, read from disk rather than by shelling out to git. A sibling repo checked out
24
+ inside a bound directory still resolves to nothing, so the cross-org property RTSC-91
25
+ exists for is untouched. Binding a worktree deliberately still wins over its repo's
26
+ binding.
27
+
28
+ `retasc doctor` says when a folder resolved this way, instead of the old warning that
29
+ the id "was bound at a different folder" — advice which, for a worktree, would have
30
+ minted a second key and a second agent for one repo.
31
+
32
+ ## 1.44.0 (2026-09-08)
33
+
34
+ - **RTSC-861** — the proxy now tells Retasc which CLI version it is. It rides on
35
+ `mint_session_key`, the one call every proxy already makes at startup, as an optional
36
+ `cliVersion` argument, so an older build that sends nothing still mints exactly as
37
+ before.
38
+
39
+ Why it matters: the update notice (RTSC-520) fires from a `postAction` hook on
40
+ `retasc <command>`, and someone whose only contact with the CLI is the proxy their
41
+ harness spawns runs no commands and never sees it. Three live setups were found on
42
+ builds older than 1.41.0 with no way to find out. The server can now say so in the
43
+ tool result, which reaches every harness, and with this version it can name the exact
44
+ gap instead of inferring one from behaviour.
45
+
9
46
  ## 1.43.0 (2026-09-05)
10
47
 
11
48
  - **RTSC-799** - `tidy`, `done` and `claim` now work out which branch is this repo's trunk
@@ -1,6 +1,7 @@
1
1
  import { loadConfig } from "../config.js";
2
2
  import { claudeConfigPath, isNetworkError, readGlobalBinding, readLocalBinding, readShadowedBinding, resolveBinding, sameIdentity, } from "../lib/binding.js";
3
3
  import { getBinding } from "../lib/keystore.js";
4
+ import { gitCommonDir } from "../lib/gitRepo.js";
4
5
  import { runsOk } from "../lib/launcher.js";
5
6
  import { clean } from "../lib/text.js";
6
7
  // RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
@@ -150,9 +151,26 @@ export async function doctorAction() {
150
151
  // may have reused someone else's id — surface it rather than silently use it.
151
152
  const entry = getBinding(local.workspaceId);
152
153
  if (entry?.boundPath && entry.boundPath !== cwd) {
153
- warn(`this workspace id was bound at a different folder:\n` +
154
- ` ${entry.boundPath}\n` +
155
- ` If you didn't move this repo, re-run \`retasc bind\` to mint a key for THIS folder.`);
154
+ // RTSC-862 a WORKTREE of the bound repo is the expected case now, not a
155
+ // problem, so it must not be reported as one. Before this it read "bound at a
156
+ // different folder… re-run bind", which is exactly the wrong advice: re-binding
157
+ // a worktree mints a second key and a second agent for one repo, which is how
158
+ // people ended up with three.
159
+ const repo = gitCommonDir(cwd);
160
+ const sameRepo = !!repo && gitCommonDir(entry.boundPath) === repo;
161
+ if (sameRepo) {
162
+ // Deliberately not "this folder is a worktree of it": the relation is
163
+ // symmetric, so that sentence is false when the cwd is the main checkout and
164
+ // the BOUND folder is the worktree, which is an ordinary way round.
165
+ console.log(` ℹ same repository as the bound folder:\n` +
166
+ ` ${clean(entry.boundPath)}\n` +
167
+ ` Worktrees share one binding, so this is expected. Nothing to do.`);
168
+ }
169
+ else {
170
+ warn(`this workspace id was bound at a different folder:\n` +
171
+ ` ${entry.boundPath}\n` +
172
+ ` If you didn't move this repo, re-run \`retasc bind\` to mint a key for THIS folder.`);
173
+ }
156
174
  }
157
175
  }
158
176
  else if (local.legacy) {
@@ -0,0 +1,141 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ /**
4
+ * Which repository is this directory part of? (RTSC-862.)
5
+ *
6
+ * The answer is git's COMMON DIR — the one directory every worktree of a repository
7
+ * shares. It is the only thing that makes "the same repo" a decidable question:
8
+ *
9
+ * • normal clone `~/retasc` → `~/retasc/.git`
10
+ * • its worktree `~/retasc-rtsc-862` → `~/retasc/.git` (same repo)
11
+ * • bare layout `~/retasc/.bare` with
12
+ * worktrees `main`, `rtsc-854` → `~/retasc/.bare` (same repo)
13
+ * • an unrelated clone nested inside → its own `.git` (different repo)
14
+ *
15
+ * WHY NOT SHELL OUT. `git rev-parse --git-common-dir` answers this directly, and this
16
+ * function deliberately does not call it. It runs at the start of every proxy session,
17
+ * which is every MCP server startup in every harness, and spawning a process there
18
+ * buys a fork+exec on a path whose whole job is to resolve a key in milliseconds. The
19
+ * on-disk format this reads is stable and documented (gitrepository-layout).
20
+ *
21
+ * TRUST MODEL, since this decides which ORG a folder acts in — so a `.git` file is now a
22
+ * routing input, not merely a stop marker, and it is content that can arrive in an
23
+ * archive. git refuses to track a path named `.git`, so this cannot come from a clone,
24
+ * but a tarball, a vendored bundle or a scaffold generator can carry one.
25
+ *
26
+ * So a worktree pointer is only believed when it is RECIPROCAL, the way git's own
27
+ * worktree bookkeeping is: `<common>/worktrees/<name>/gitdir` must point back at the
28
+ * `.git` file we started from. A hand-written `gitdir:` line at someone else's repository
29
+ * fails that check and resolves to nothing, where before this it would have claimed that
30
+ * repository's binding. Verified both ways.
31
+ *
32
+ * The check is git's own invariant rather than an invention, so it costs nothing on a
33
+ * real worktree and cannot drift from what git considers linked. What is deliberately NOT
34
+ * attempted is out-guessing git in general: if someone can write `.git` into a directory
35
+ * you work in, they already own your hooks and config there, which is a larger problem
36
+ * than a binding. The narrow thing we owe is never to resolve ACROSS repositories git
37
+ * considers distinct — which is also why submodules, whose gitdirs carry no `commondir`,
38
+ * resolve to themselves.
39
+ *
40
+ * NEVER THROWS. A malformed `.git` file, a dangling gitdir, a permissions error: all
41
+ * return undefined. Resolution failing means "no binding found", which the caller
42
+ * already handles; a startup crash in the proxy takes the agent's tools away entirely,
43
+ * which is far worse than a folder that needs `retasc bind` run again.
44
+ */
45
+ export function gitCommonDir(dir) {
46
+ try {
47
+ const dotGit = join(dir, ".git");
48
+ if (!existsSync(dotGit))
49
+ return undefined;
50
+ // A normal clone: `.git` is a directory and IS the common dir. A worktree's private
51
+ // gitdir is also a directory and looks identical from here, which is what the
52
+ // `commondir` probe below distinguishes — git writes that file only in the private
53
+ // one. `GIT_DIR` pointed at a worktree gitdir lands here too.
54
+ if (statSync(dotGit).isDirectory())
55
+ return followCommonDir(dotGit);
56
+ // A worktree: `.git` is a FILE holding `gitdir: <path to the private gitdir>`.
57
+ // FIRST line only, as real git reads it — an `m` flag here would accept the pointer
58
+ // anywhere in the file, widening what an archive can smuggle in.
59
+ const first = readFileSync(dotGit, "utf8").split(/\r?\n/, 1)[0];
60
+ const m = /^gitdir:\s*(.+?)\s*$/.exec(first);
61
+ if (!m)
62
+ return undefined;
63
+ const gitdir = isAbsolute(m[1]) ? m[1] : resolve(dir, m[1]);
64
+ if (!existsSync(gitdir))
65
+ return undefined; // dangling: the worktree was pruned
66
+ if (!pointsBackAt(gitdir, dotGit))
67
+ return undefined;
68
+ return followCommonDir(gitdir);
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
74
+ /**
75
+ * Does this private gitdir agree that it belongs to this worktree?
76
+ *
77
+ * git keeps the link in both directions: the worktree's `.git` file names the private
78
+ * gitdir, and `<gitdir>/gitdir` names the worktree's `.git` file back. Only the first
79
+ * direction is writable by whoever plants a folder, so checking the second is what turns
80
+ * "a file claims to belong to your repo" into "your repo agrees".
81
+ *
82
+ * A gitdir with no `gitdir` file is not a worktree's — a submodule's private dir, or the
83
+ * bare/main directory reached some other way — so this returns false and the caller
84
+ * resolves nothing, which is the safe direction.
85
+ */
86
+ function pointsBackAt(gitdir, dotGitFile) {
87
+ try {
88
+ const back = join(gitdir, "gitdir");
89
+ if (!existsSync(back))
90
+ return false;
91
+ const claimed = readFileSync(back, "utf8").trim();
92
+ if (!claimed)
93
+ return false;
94
+ return realish(claimed) === realish(dotGitFile);
95
+ }
96
+ catch {
97
+ return false;
98
+ }
99
+ }
100
+ /**
101
+ * Resolve a gitdir to the repository's common dir.
102
+ *
103
+ * A worktree's private gitdir carries a `commondir` file (usually `../..`) pointing at
104
+ * the shared one. A main `.git` has no such file and is already the common dir. Reading
105
+ * the file rather than assuming `../..` is what makes the bare layout work, where the
106
+ * shared directory is `.bare` rather than `.git`.
107
+ */
108
+ function followCommonDir(gitdir) {
109
+ const marker = join(gitdir, "commondir");
110
+ if (!existsSync(marker))
111
+ return realish(gitdir);
112
+ const rel = readFileSync(marker, "utf8").trim();
113
+ if (!rel)
114
+ return realish(gitdir);
115
+ return realish(isAbsolute(rel) ? rel : resolve(gitdir, rel));
116
+ }
117
+ /**
118
+ * Resolve a path the same way `gitCommonDir` resolves the ones it returns.
119
+ *
120
+ * Exported because comparing an unresolved path against a resolved one is a silent
121
+ * mismatch on macOS, where `/var` is a symlink to `/private/var` and a temp dir spells
122
+ * itself both ways. Anything compared against this module's output comes through here.
123
+ */
124
+ export function realPath(p) {
125
+ return realish(p);
126
+ }
127
+ /**
128
+ * Resolve for comparison. `realpathSync` matters on macOS, where `/tmp` is a symlink to
129
+ * `/private/tmp` and two spellings of one directory would otherwise never match — the
130
+ * same reason `findBindingByPath` resolves both sides before comparing.
131
+ */
132
+ function realish(p) {
133
+ try {
134
+ return realpathSync(resolve(p));
135
+ }
136
+ catch {
137
+ // A path that does not exist cannot be realpath'd. Comparing the lexical form is
138
+ // still better than giving up, and the callers only ever compare two of these.
139
+ return resolve(p);
140
+ }
141
+ }
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
4
4
  import { randomUUID } from "node:crypto";
5
+ import { gitCommonDir, realPath } from "./gitRepo.js";
5
6
  /** The dir the keystore lives in. RETASC_DIR overrides it (tests, sandboxes). */
6
7
  function keystoreDir() {
7
8
  return process.env.RETASC_DIR || join(homedir(), ".retasc");
@@ -119,12 +120,74 @@ export function findBindingByPath(dir) {
119
120
  // Checked AFTER the lookup so the repo root itself, the folder `bind` actually
120
121
  // writes, is always eligible.
121
122
  if (existsSync(join(cur, ".git")))
122
- return undefined;
123
+ break;
123
124
  const up = dirname(cur);
124
125
  if (up === cur)
125
126
  return undefined;
126
127
  cur = up;
127
128
  }
129
+ // RTSC-862 — the walk stopped at a repository root that is not itself bound. Before
130
+ // giving up, ask the question a person actually means: is this a WORKTREE of a repo
131
+ // they already bound?
132
+ //
133
+ // The stop above is correct and stays. An unbounded walk would let `retasc bind` in
134
+ // `~` silently bind every project beneath it, which is the cross-org leak RTSC-91
135
+ // exists to prevent. But `.git` is a FILE at the root of every git worktree, so that
136
+ // same stop fires on the first step inside one — and this repo's own workflow mandates
137
+ // a worktree per claimed issue, created as a SIBLING of the checkout. Every one of them
138
+ // resolved to nothing, with no error and no hint: the tools were simply absent. The
139
+ // rational response is to paste a raw key into a config that works everywhere, and that
140
+ // silently costs the watchdog, session rows, transcripts and the folder name.
141
+ //
142
+ // "Same repository" is decided by git's COMMON DIR, the one directory every worktree of
143
+ // a repo shares, so a sibling repo checked out inside a bound directory still resolves
144
+ // to nothing — different common dir. RTSC-91's property is untouched.
145
+ //
146
+ // A path match always wins, because this runs only after the walk found none: a
147
+ // worktree that is ITSELF bound keeps its own binding.
148
+ const here = gitCommonDir(cur);
149
+ if (!here)
150
+ return undefined;
151
+ // Memoized because this probes OTHER bindings' folders, not just our own, and it runs
152
+ // at every proxy start in every folder a harness opens. One `boundPath` on a stale
153
+ // network mount would otherwise be stat'd repeatedly while it blocks on the mount
154
+ // timeout. Two bindings in one repo is the common case, so the cache earns its keep.
155
+ const repoOf = new Map();
156
+ const commonDirOf = (p) => {
157
+ if (!repoOf.has(p))
158
+ repoOf.set(p, gitCommonDir(p));
159
+ return repoOf.get(p);
160
+ };
161
+ let best;
162
+ for (const hit of byPath.values()) {
163
+ // Cheap rejection first: a binding that cannot beat the current best on recency
164
+ // never needs its folder touched at all.
165
+ if (best && (hit.entry.createdAt ?? 0) <= (best.entry.createdAt ?? 0))
166
+ continue;
167
+ // Realpath'd, because `here` is: comparing a resolved path against an unresolved
168
+ // one silently never matches on macOS (`/var` vs `/private/var`).
169
+ const bound = realPath(hit.entry.boundPath);
170
+ // Same repository, the ordinary case: the bound folder is a worktree (or the main
171
+ // checkout) of this one.
172
+ let match = commonDirOf(bound) === here;
173
+ // Or the bound folder is the one that DIRECTLY CONTAINS this repository's git dir.
174
+ // That is the bare layout — `~/proj/.bare` beside `~/proj/main` — where the folder a
175
+ // person opens and binds, `~/proj`, is not itself a git repo and so has no common
176
+ // dir of its own. Without this, binding the folder they actually look at does
177
+ // nothing and they get the same silent no-tools symptom this issue is about.
178
+ //
179
+ // DIRECT parent, never an ancestor. `dirname("~/proj/.bare") === "~/proj"` matches,
180
+ // while binding `~` and hoping to catch `~/proj/.git` does not — which is the whole
181
+ // point, because that is RTSC-91's cross-org leak.
182
+ if (!match)
183
+ match = dirname(here) === bound;
184
+ if (!match)
185
+ continue;
186
+ // Newest wins, the tie-break `byPath` already uses: a folder bound, unbound and
187
+ // bound again leaves an older entry behind, and the fresher one is live.
188
+ best = hit;
189
+ }
190
+ return best;
128
191
  }
129
192
  /**
130
193
  * RTSC-98: the ONE place that resolves a workspace's key + MCP url. Shared by the
@@ -46,7 +46,13 @@ export async function mintSessionKey(opts) {
46
46
  jsonrpc: "2.0",
47
47
  id: MINT_RPC_ID,
48
48
  method: "tools/call",
49
- params: { name: "mint_session_key", arguments: { label: opts.label } },
49
+ params: {
50
+ name: "mint_session_key",
51
+ arguments: {
52
+ label: opts.label,
53
+ ...(opts.cliVersion ? { cliVersion: opts.cliVersion } : {}),
54
+ },
55
+ },
50
56
  }),
51
57
  signal: AbortSignal.timeout(MINT_TIMEOUT_MS),
52
58
  });
package/dist/proxy.js CHANGED
@@ -15,6 +15,7 @@ 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
17
  import { readHookRecord, clearHookRecord, modelFromTranscript } from "./lib/sessionHook.js";
18
+ import { VERSION } from "./version.js";
18
19
  import { attachRoot, isLocalAttachCall, mergeAttachTool, readAttachFile, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
19
20
  import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetchCall, mergeFetchTool, resolveDownloadTarget, writeDownloadedFile, } from "./lib/fetchFile.js";
20
21
  // RTSC-92/98: resolve the workspace key via the SHARED resolver, so the proxy and
@@ -195,7 +196,16 @@ async function adoptSessionKey() {
195
196
  if (!KEY)
196
197
  return;
197
198
  const label = process.env.RETASC_SESSION_LABEL || `${hostname()}#${process.pid}`;
198
- const outcome = await mintSessionKey({ url: MCP_URL, key: KEY, label, warn: log });
199
+ // RTSC-861 report our own version at the one call every proxy makes at startup,
200
+ // so the server never has to infer whether this build is current. A proxy too old to
201
+ // send it is exactly the case the server-side inference exists for.
202
+ const outcome = await mintSessionKey({
203
+ url: MCP_URL,
204
+ key: KEY,
205
+ label,
206
+ cliVersion: VERSION,
207
+ warn: log,
208
+ });
199
209
  if (outcome.ok) {
200
210
  activeKey = outcome.key; // switch to the session key
201
211
  log(`adopted session key "${outcome.session ?? label}"`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.43.0",
3
+ "version": "1.45.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": {