@retasc/cli 1.36.1 → 1.38.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,53 @@ 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.38.0 (2026-08-28)
10
+
11
+ - **RTSC-780** — `retasc setup` wires Retasc into every MCP harness on the machine, once,
12
+ and `retasc bind` now runs it for you. Until this release the CLI could wire exactly one
13
+ harness: it spawned `claude mcp add`, and if that worked it stopped. Codex, Grok and
14
+ anything else got nothing, which is why an agent bound to the same project as two
15
+ working Claude Code sessions could sit there unable to claim a single issue.
16
+ The entry we write names no project and carries no key. The watchdog proxy works out
17
+ which project it is in from the directory it was spawned in, so one line in a global
18
+ config is correct in every folder, and the folder still decides the org exactly as
19
+ before. Adding a harness is one entry in the registry; Claude Code, Codex and Grok ship
20
+ today, each verified against a real installation rather than a config format from
21
+ memory.
22
+ A folder you have not bound yet is now an ordinary state rather than a broken install,
23
+ so it gets a real answer: the tool call comes back naming the folder and telling you to
24
+ run `retasc bind` in it. Previously a keyless call surfaced as an authentication or
25
+ network failure while the CLI's own login was global and still fine, which reads as
26
+ "the Retasc server is down" and sends you to check a server that is serving everyone
27
+ else. The server cannot name your folder; the proxy runs in it, so it can.
28
+ Which folder counts is bounded by the repository: a subdirectory resolves to its repo,
29
+ and a repo checked out inside a bound directory resolves to nothing. Binding a
30
+ directory must not quietly bind every unrelated project underneath it.
31
+ Existing markers are untouched: one naming a workspace id keeps resolving through that
32
+ id, and keeps priority over the folder.
33
+
34
+ - **RTSC-781** — `--runtime` now says what it does. It sets the agent's label in the
35
+ Dash, and it never chose where config was written, but it is offered on the commands
36
+ that install MCP config and documented with a list of harness names, so
37
+ `key mint --runtime codex --install` named Codex, wired Claude Code, and said nothing.
38
+ The help text says label, and the commands that mint a key and write a key-bearing
39
+ entry now say plainly that they write for this folder only, that a key never belongs
40
+ in a machine-wide config, and that `retasc setup` is what wires the harness you named.
41
+
42
+ ## 1.37.0 (2026-08-26)
43
+
44
+ - **RTSC-749** — `retasc triage` reads and approves work filed from outside your org. Work
45
+ that arrives through a GitHub or GitLab connector is written by whoever can file on that
46
+ repo, and since RTSC-746 no agent can pick it up until a person has read it and approved
47
+ it. This is that decision from the terminal: `retasc triage` lists what is waiting,
48
+ `retasc triage RTSC-42` prints the full body and then asks.
49
+ It is deliberately hard to automate, because the CLI runs where coding agents run: there
50
+ is no `--approve` flag, the command refuses to run without an interactive terminal, and
51
+ confirming means retyping the issue id rather than pressing y. Saying no is as cheap as
52
+ saying yes (type `reject`) — the safe answer must never be the expensive one. The Dash
53
+ stays the recommended surface: an agent that can drive a real PTY on your machine could
54
+ drive this command too, and only logging the CLI out takes that away.
55
+
9
56
  ## 1.36.1 (2026-08-25)
10
57
 
11
58
  - **RTSC-645** — `retasc gate install` no longer throws away your edits. It rewrites the
package/dist/api.js CHANGED
@@ -44,6 +44,14 @@ const fns = {
44
44
  // imported from X before?" once the progress row is gone.
45
45
  latestImport: makeFunctionReference("import:latestImport"),
46
46
  importHistory: makeFunctionReference("import:importHistory"),
47
+ // RTSC-749 — the triage door. These are the ONLY CLI calls that must never be
48
+ // reachable with an agent key: they are `convex/triage.ts`, which authenticates through
49
+ // Convex Auth and rejects API keys outright (RTSC-746). The CLI reaches them over the
50
+ // human's device-flow SESSION, like billing and invites — never over the workspace MCP
51
+ // key the issue commands use.
52
+ listQuarantined: makeFunctionReference("triage:listQuarantined"),
53
+ approveExternal: makeFunctionReference("triage:approveExternal"),
54
+ rejectExternal: makeFunctionReference("triage:rejectExternal"),
47
55
  claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
48
56
  claimGhost: makeFunctionReference("ghosts:claimGhost"),
49
57
  dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
@@ -217,6 +225,10 @@ export const api = {
217
225
  listImportTargets: (args) => withAuth(() => client().action(fns.listImportTargets, args)),
218
226
  listImportStatuses: (args) => withAuth(() => client().action(fns.listImportStatuses, args)),
219
227
  listReviewCandidates: (args) => withAuth(() => client().query(fns.listReviewCandidates, args)),
228
+ // RTSC-749 — see `fns` above for why these ride the user session, not the MCP key.
229
+ listQuarantined: (args) => withAuth(() => client().query(fns.listQuarantined, args)),
230
+ approveExternal: (args) => withAuth(() => client().mutation(fns.approveExternal, args)),
231
+ rejectExternal: (args) => withAuth(() => client().mutation(fns.rejectExternal, args)),
220
232
  runImport: (args) => withAuth(() => client().action(fns.runImport, args)),
221
233
  latestImport: (args) => withAuth(() => client().query(fns.latestImport, args)),
222
234
  importHistory: (args) => withAuth(() => client().query(fns.importHistory, args)),
@@ -3,6 +3,7 @@ import { api, cliError } from "../api.js";
3
3
  import { deviceLogin } from "../auth.js";
4
4
  import { loadConfig, patchConfig } from "../config.js";
5
5
  import { installMarker, printMarkerBlock } from "./mcp.js";
6
+ import { runSetup } from "./setup.js";
6
7
  import { readLocalBinding, resolveBinding, bindingRefused } from "../lib/binding.js";
7
8
  import { getBinding, setBinding, removeBinding, newWorkspaceId } from "../lib/keystore.js";
8
9
  import { resolveLauncher, launcherNote, onDurablePath, selfCommand, versionStamp } from "../lib/launcher.js";
@@ -450,6 +451,11 @@ export async function completeWorkspaceSetup(args) {
450
451
  // Per-folder only (local scope), always watchdog. The marker carries only the
451
452
  // workspace id — no secret — so ./.mcp.json is safe to commit.
452
453
  const marker = installMarker({ workspaceId, scope: "local", launcher, quiet: true });
454
+ // RTSC-780: and the once-per-machine half. Idempotent by construction (each adapter
455
+ // replaces its own entry), so running it on every bind is how a harness installed
456
+ // AFTER the first bind gets picked up without anyone having to know that `retasc
457
+ // setup` exists. Quiet: the receipt below names what it wired, in one row.
458
+ const wired = runSetup({ launcher, quiet: true });
453
459
  // Confirm the binding the same way the agent will see it, and print it as the receipt
454
460
  // (RTSC-673). Resolved rather than echoed from what we just sent: this is the last
455
461
  // chance to notice a folder bound to something other than what was asked for, and a
@@ -470,6 +476,15 @@ export async function completeWorkspaceSetup(args) {
470
476
  { label: "Project", value: bound?.name ? `${pfx} ${DOT} ${bound.name}` : pfx },
471
477
  { label: "Agent key", value: `${minted.key.slice(0, 14)}…`, note: "never leaves ~/.retasc" },
472
478
  { label: "MCP wired", value: marker.where, note: "secret-free, safe to commit" },
479
+ ...(wired.wired.length
480
+ ? [
481
+ {
482
+ label: "Harnesses",
483
+ value: wired.wired.map((w) => w.label).join(", "),
484
+ note: "every folder",
485
+ },
486
+ ]
487
+ : []),
473
488
  ], `Agents launched here can only ever read or write ${pfx}.`));
474
489
  // Only when we could write it nowhere. See printMarkerBlock.
475
490
  if (!marker.wroteFile && marker.where === "")
@@ -568,6 +583,12 @@ export async function setupFromToken(token, opts, deps = {}) {
568
583
  createdAt: Date.now(),
569
584
  });
570
585
  (deps.marker ?? installMarker)({ workspaceId, scope: "local", launcher });
586
+ // RTSC-780 — same once-per-machine wiring on the agent door. This path has no TTY and
587
+ // no receipt card, so it prints its own line rather than staying silent.
588
+ const wired = (deps.setup ?? runSetup)({ launcher, quiet: true });
589
+ if (wired.wired.length) {
590
+ console.log(`✓ Retasc wired into ${wired.wired.map((w) => w.label).join(", ")}.`);
591
+ }
571
592
  // RTSC-532 — name the PATH, not "this folder". A binding is folder → project, and
572
593
  // until this line said which folder, a wrong one was undetectable: the agent still
573
594
  // calls in, so the Dash shows success while the folder she actually works in has no
@@ -161,6 +161,34 @@ export function writeProjectMcpJson(entry) {
161
161
  }
162
162
  return path;
163
163
  }
164
+ /**
165
+ * Say plainly that `--runtime` labels an agent and does not choose a harness (RTSC-781).
166
+ *
167
+ * The flag reads as a promise. It is offered on the same commands that install MCP
168
+ * config, it is documented with a list of harness names, and before RTSC-780 the only
169
+ * thing any of those commands could write was Claude Code. So
170
+ * `key mint --runtime codex --install` named Codex, wired Claude Code, and said nothing
171
+ * — and the resulting failure surfaced as "signed in, but the server is unreachable",
172
+ * which sends you to check a server that is fine and serving other clients.
173
+ *
174
+ * The commands that call this mint a key and write a KEY-BEARING entry, which is
175
+ * deliberately per-folder and Claude-Code-only: a key must never be written into a
176
+ * machine-global config (RTSC-91). So the honest answer is not to wire the named harness
177
+ * here, it is to say that this command cannot and name the one that can.
178
+ *
179
+ * A note rather than a refusal. By the time this prints, the key is minted and the
180
+ * Claude Code wiring is real work someone asked for; exiting would throw that away to
181
+ * make a point. Silence was the bug, not the wiring.
182
+ */
183
+ export function noteRuntimeIsALabel(runtime) {
184
+ const r = (runtime ?? "").trim();
185
+ if (!r || r === "claude-code")
186
+ return;
187
+ console.log(`Note: --runtime ${r} sets this agent's LABEL in the Dash. It does not choose where\n` +
188
+ `config is written. This command writes a key-bearing entry for THIS folder, and a\n` +
189
+ `key only ever belongs in Claude Code's own config, never in a machine-wide one.\n` +
190
+ `To wire ${r}, run \`retasc setup\` — secret-free, every harness, every folder.\n`);
191
+ }
164
192
  /**
165
193
  * Wire the Retasc MCP server into the user's agent. Prefers the `claude` CLI;
166
194
  * falls back to writing ./.mcp.json. Always prints the manual block so the user
@@ -0,0 +1,86 @@
1
+ // RTSC-780: `retasc setup` — wire every MCP harness on this machine, once.
2
+ //
3
+ // The shape this exists to deliver:
4
+ // once per machine `retasc setup`, and every harness that is installed gets an entry
5
+ // once per project `retasc bind`, and every one of them sees it
6
+ //
7
+ // Nothing harness-specific after that, ever again. The entry written here names no
8
+ // workspace (keystore.AUTO_WORKSPACE), so it is correct in every folder: the proxy
9
+ // resolves the directory it was spawned in against the home keystore at startup. The
10
+ // folder still decides the org, which is the whole routing model (RTSC-91) and the thing
11
+ // a machine-global entry would otherwise break.
12
+ //
13
+ // NOT an npm postinstall hook. Most people reach this CLI through `npx`, which installs
14
+ // nothing (the reason `resolveLauncher` exists, RTSC-493), so a postinstall would never
15
+ // run for them; and writing into somebody's global agent configs from a package install
16
+ // is a thing to do in front of a human, not behind one.
17
+ import { AUTO_WORKSPACE } from "../lib/keystore.js";
18
+ import { HARNESSES, detectHarnesses, tildePath } from "../lib/harness.js";
19
+ import { resolveLauncher, launcherNote } from "../lib/launcher.js";
20
+ import { VERSION } from "../version.js";
21
+ import { card } from "../lib/card.js";
22
+ /** The one entry every harness gets: the watchdog proxy, resolving its own folder. */
23
+ export function autoMarkerEntry(launcher) {
24
+ return {
25
+ command: launcher.command,
26
+ args: [...launcher.args, "mcp-proxy"],
27
+ env: { RETASC_WORKSPACE: AUTO_WORKSPACE },
28
+ };
29
+ }
30
+ /**
31
+ * Wire the auto marker into every detected harness.
32
+ *
33
+ * `quiet` is for the call at the end of `bind`, where the bind receipt does the talking.
34
+ */
35
+ export function runSetup(opts) {
36
+ const all = opts.harnesses;
37
+ const resolved = opts.launcher ?? resolveLauncher({ version: VERSION, install: opts.install });
38
+ if (!opts.launcher) {
39
+ const note = launcherNote(resolved);
40
+ if (note)
41
+ console.log(note);
42
+ }
43
+ const entry = autoMarkerEntry(resolved.launcher);
44
+ const known = all ?? HARNESSES;
45
+ const present = detectHarnesses(known);
46
+ const presentIds = new Set(present.map((h) => h.id));
47
+ const result = {
48
+ wired: [],
49
+ failed: [],
50
+ absent: known.filter((h) => !presentIds.has(h.id)).map((h) => h.label),
51
+ };
52
+ for (const h of present) {
53
+ const outcome = h.install(entry);
54
+ if (outcome.ok) {
55
+ result.wired.push({ label: h.label, where: tildePath(outcome.path), replaced: outcome.replaced });
56
+ }
57
+ else {
58
+ result.failed.push({ label: h.label, reason: outcome.reason });
59
+ }
60
+ }
61
+ if (!opts.quiet)
62
+ printSetup(result);
63
+ return result;
64
+ }
65
+ /** The receipt. One row per harness, so "what did this just touch" needs no guessing. */
66
+ export function printSetup(r) {
67
+ if (!r.wired.length && !r.failed.length) {
68
+ console.log("No MCP harness found on this machine.");
69
+ console.log("Install Claude Code, Codex or Grok and run `retasc setup` again, or bind a folder\n" +
70
+ "with `retasc bind` and paste the config block it prints into your own client.");
71
+ return;
72
+ }
73
+ console.log("\n" +
74
+ card(`✓ Retasc wired into ${r.wired.length} ${r.wired.length === 1 ? "harness" : "harnesses"}`, r.wired.map((w) => ({
75
+ label: w.label,
76
+ value: w.where,
77
+ note: w.replaced ? "updated" : "new",
78
+ }))));
79
+ for (const f of r.failed)
80
+ console.log(` ! ${f.label}: ${f.reason}`);
81
+ if (r.absent.length)
82
+ console.log(` Not on this machine: ${r.absent.join(", ")}.`);
83
+ console.log("\nThese entries carry no key and name no project, so they are correct in every folder.\n" +
84
+ "Run `retasc bind` in a project to say which org it belongs to, then start your agent\n" +
85
+ "there (or restart it if it's already open).");
86
+ }
@@ -0,0 +1,171 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { api } from "../api.js";
3
+ // ---------------------------------------------------------------------------
4
+ // RTSC-749 — `retasc triage`. Epic RTSC-751.
5
+ //
6
+ // The second human signing surface for the quarantine gate (RTSC-746): external intake is
7
+ // undispatchable until a member reads it and signs it, and plenty of people live in a
8
+ // terminal rather than in the Dash.
9
+ //
10
+ // THE HARD PART, and why this file is careful. The Dash is a browser a human is sitting
11
+ // in. The CLI is a binary on a machine where a CODING AGENT CAN SHELL OUT. If approval
12
+ // were scriptable, an agent that just read a hostile body could approve the next one, and
13
+ // the gate it is standing in front of would protect nothing.
14
+ //
15
+ // The server-side invariant does most of the work: `convex/triage.ts` authenticates
16
+ // through Convex Auth and rejects agent API keys outright, so an agent holding only a
17
+ // workspace MCP key cannot sign, full stop. What remains is an agent shelling out on a
18
+ // machine where a human is already logged into the CLI. These are the client-side
19
+ // defences against that, and they RAISE THE BAR rather than close the hole:
20
+ //
21
+ // • Refuse a non-interactive stdin/stdout. A harnessed `exec` has no PTY.
22
+ // • No approve flag. There is no `--approve`, no `--yes`, no `--force` — so there is no
23
+ // one-liner to smuggle into a command, and no "just add --yes" for anyone to suggest.
24
+ // • Render the body, then require the identifier RETYPED. Not a y/N: a typed value an
25
+ // agent would have to have read the screen to produce.
26
+ // • Echo the content hash of the body just printed, so the server refuses if the text
27
+ // moved under the reader.
28
+ //
29
+ // RESIDUAL RISK, stated rather than hidden: an agent puppeting a real PTY on a machine
30
+ // with a logged-in CLI defeats the TTY check, and the server cannot tell that apart from
31
+ // a human. The mitigation is revocation (`retasc logout`), and the Dash remains the
32
+ // recommended surface. This is said in the command's own help text too — a defence
33
+ // somebody believes is stronger than it is has already done its damage.
34
+ // ---------------------------------------------------------------------------
35
+ /**
36
+ * Is a real person at this terminal?
37
+ *
38
+ * BOTH streams, deliberately. `stdin` alone is the usual check and it is not enough here:
39
+ * a harness that pipes output while leaving stdin attached would still be driving a
40
+ * command whose entire safety story is "a human is reading the screen".
41
+ */
42
+ function interactive() {
43
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
44
+ }
45
+ /** The SHA-256 the server binds a signature to. Must match `externalContentHash` in
46
+ * convex/lib/quarantine.ts EXACTLY, including the length prefixes — a drift here means
47
+ * every terminal approval fails with CONTENT_CHANGED on text nobody edited. */
48
+ async function contentHash(title, body) {
49
+ const canonical = `${title.length}:${title}${body.length}:${body}`;
50
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
51
+ return Array.from(new Uint8Array(digest))
52
+ .map((b) => b.toString(16).padStart(2, "0"))
53
+ .join("");
54
+ }
55
+ /** Resolve the org the same way `retasc billing` does: explicit, or the only one. */
56
+ async function resolveOrg(orgId) {
57
+ const me = await api.me();
58
+ if (!orgId) {
59
+ if (me.orgs.length === 0)
60
+ throw new Error("You're not a member of any org yet.");
61
+ if (me.orgs.length > 1) {
62
+ const list = me.orgs.map((o) => ` ${o.id} ${o.name}${o.slug ? ` (${o.slug})` : ""}`);
63
+ throw new Error(`Several orgs — pass --org-id <id>:\n${list.join("\n")}`);
64
+ }
65
+ orgId = me.orgs[0].id;
66
+ }
67
+ const org = me.orgs.find((o) => o.id === orgId);
68
+ return { orgId: orgId, label: org ? `${org.name}${org.slug ? ` (${org.slug})` : ""}` : String(orgId) };
69
+ }
70
+ /** `retasc triage` — what is waiting. A LIST ONLY: nothing here approves. */
71
+ export async function triageListAction(opts) {
72
+ const { orgId, label } = await resolveOrg(opts.orgId);
73
+ const res = await api.listQuarantined({ orgId });
74
+ const items = res.items ?? [];
75
+ if (opts.json) {
76
+ console.log(JSON.stringify({ orgId, items, hasMore: res.hasMore }, null, 2));
77
+ return;
78
+ }
79
+ if (items.length === 0) {
80
+ console.log(`Nothing waiting in ${label}.`);
81
+ return;
82
+ }
83
+ console.log(`${items.length} ${items.length === 1 ? "issue" : "issues"} filed from outside ${label}, ` +
84
+ `waiting for you to read and approve ${items.length === 1 ? "it" : "them"}:\n`);
85
+ for (const i of items) {
86
+ const who = i.externalAuthor ?? "an unidentified account";
87
+ console.log(` ${i.identifier} ${i.title}`);
88
+ console.log(` by ${who}${i.sourceUrl ? ` · ${i.sourceUrl}` : ""}`);
89
+ }
90
+ if (res.hasMore)
91
+ console.log(`\n …and more. Clear some and run this again.`);
92
+ // The list deliberately does NOT print bodies. Reading happens one at a time, next to
93
+ // the decision — a wall of concatenated external text is exactly the thing people skim.
94
+ console.log(`\nRead one in full, then approve or reject: retasc triage <ISSUE-ID>`);
95
+ }
96
+ /**
97
+ * `retasc triage RTSC-42` — render the full body, then ask.
98
+ *
99
+ * The whole command is the reading. Everything before the prompt exists to make sure the
100
+ * person answering it has actually seen the text they are vouching for.
101
+ */
102
+ export async function triageOneAction(identifier, opts) {
103
+ // FIRST, before any network call: refuse to run where nobody can read the screen. Doing
104
+ // this first means the refusal is about the environment, not about what happens to be
105
+ // in the queue.
106
+ if (!interactive()) {
107
+ throw new Error("`retasc triage <id>` needs an interactive terminal.\n" +
108
+ " Approving external work means a person read it, so this command refuses to run\n" +
109
+ " with piped input or output. There is deliberately no --approve flag.\n" +
110
+ " Approve in the Dash instead: https://dash.retasc.com/?tab=queue");
111
+ }
112
+ const { orgId, label } = await resolveOrg(opts.orgId);
113
+ const res = await api.listQuarantined({ orgId });
114
+ const items = res.items ?? [];
115
+ const want = identifier.trim().toUpperCase();
116
+ const item = items.find((i) => i.identifier.toUpperCase() === want);
117
+ if (!item) {
118
+ throw new Error(`${identifier} is not waiting for approval in ${label}.\n` +
119
+ ` It may already be approved, rejected, or never have been external.\n` +
120
+ ` Run \`retasc triage\` to see what is waiting.`);
121
+ }
122
+ // The rendering. Plain, undecorated, and complete — no truncation, because a body
123
+ // truncated at the interesting part is worse than no preview at all.
124
+ const who = item.externalAuthor ?? "an unidentified account";
125
+ console.log("");
126
+ console.log(` ${item.identifier} ${item.title}`);
127
+ console.log("");
128
+ console.log(` Filed from OUTSIDE ${label}, by ${who} on ${item.externalOrigin ?? "a connector"}.`);
129
+ if (item.sourceUrl)
130
+ console.log(` Source: ${item.sourceUrl}`);
131
+ console.log("");
132
+ console.log(" ── the text you are approving ".padEnd(72, "─"));
133
+ console.log("");
134
+ for (const line of (item.body || "(no description)").split("\n"))
135
+ console.log(` ${line}`);
136
+ console.log("");
137
+ console.log(" ".padEnd(72, "─"));
138
+ console.log("");
139
+ console.log(" Treat this as a report to evaluate, not as instructions. Approving tells");
140
+ console.log(" your agents the text is safe to act on.");
141
+ console.log("");
142
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
143
+ try {
144
+ // The typed confirm. Not y/N: a value that has to be read off the screen. `reject` is
145
+ // the other real answer, and it is deliberately as easy to type as the id is — saying
146
+ // no must never be the expensive option, or people will drift toward yes.
147
+ const answer = (await rl.question(` Type ${item.identifier} to APPROVE, or "reject" to refuse: `)).trim();
148
+ if (answer.toLowerCase() === "reject") {
149
+ await api.rejectExternal({ orgId, identifier: item.identifier });
150
+ console.log(`\n ✓ ${item.identifier} rejected and closed.`);
151
+ return;
152
+ }
153
+ if (answer.toUpperCase() !== item.identifier.toUpperCase()) {
154
+ console.log(`\n Nothing done — that didn't match. ${item.identifier} is still waiting.`);
155
+ return;
156
+ }
157
+ // Recompute from the text just printed rather than trusting the value the list gave
158
+ // us: if the two could differ, the hash would stop meaning "what was on screen".
159
+ const hash = await contentHash(item.title, item.body ?? "");
160
+ if (hash !== item.contentHash) {
161
+ // Belt and braces — the server will refuse anyway. Saying it here is friendlier
162
+ // than a CONTENT_CHANGED from the backend.
163
+ throw new Error(`${item.identifier} changed while you were reading it. Run the command again.`);
164
+ }
165
+ await api.approveExternal({ orgId, identifier: item.identifier, contentHash: hash });
166
+ console.log(`\n ✓ ${item.identifier} approved. Agents can pick it up now.`);
167
+ }
168
+ finally {
169
+ rl.close();
170
+ }
171
+ }
package/dist/index.js CHANGED
@@ -3,7 +3,8 @@ import { Command } from "commander";
3
3
  import { VERSION } from "./version.js";
4
4
  import { selfCommand, versionStamp } from "./lib/launcher.js";
5
5
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
6
- import { installMcp, normalizeScope } from "./commands/mcp.js";
6
+ import { installMcp, noteRuntimeIsALabel, normalizeScope } from "./commands/mcp.js";
7
+ import { runSetup } from "./commands/setup.js";
7
8
  import { installGate, resolveGatePrefix } from "./commands/gate.js";
8
9
  import { claimAction, releaseAction } from "./commands/claim.js";
9
10
  import { bindAction, setupFromToken } from "./commands/bind.js";
@@ -14,6 +15,7 @@ import { identityAction } from "./commands/identity.js";
14
15
  import { importAction } from "./commands/import.js";
15
16
  import { doctorAction } from "./commands/doctor.js";
16
17
  import { billingAction } from "./commands/billing.js";
18
+ import { triageListAction, triageOneAction } from "./commands/triage.js";
17
19
  import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
18
20
  import { whoamiView, orgCreatedView, projectCreatedView, keyListView, inviteListView, } from "./lib/format.js";
19
21
  import { tidyAction, doneAction } from "./commands/tidy.js";
@@ -143,7 +145,7 @@ program
143
145
  .requiredOption("--project <name>", "Project name")
144
146
  .requiredOption("--prefix <PREFIX>", "Project prefix, e.g. XEN")
145
147
  .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
146
- .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
148
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
147
149
  .option("--scope <scope>", "MCP install scope: local | project (per-folder only)", "local")
148
150
  .option("--no-watchdog", "Wire a plain direct connection instead of the liveness watchdog")
149
151
  .action(async (opts) => {
@@ -171,6 +173,7 @@ program
171
173
  // is a per-folder act. (The prefix is kept only as a gate convenience.)
172
174
  const cfg = patchConfig({ defaultProjectPrefix: project.prefix });
173
175
  console.log("");
176
+ noteRuntimeIsALabel(opts.runtime);
174
177
  installMcp({ url: cfg.mcpUrl, key: minted.key, scope: normalizeScope(opts.scope), watchdog: opts.watchdog });
175
178
  }
176
179
  catch (e) {
@@ -189,7 +192,7 @@ program
189
192
  .option("--project <name>", "Create a new project with this name (with --prefix)")
190
193
  .option("--prefix <PREFIX>", "Prefix for a new project, e.g. ACME")
191
194
  .option("--agent <name>", "Agent member name (default: auto)")
192
- .option("--runtime <runtime>", "Agent runtime", "claude-code")
195
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
193
196
  .option("-y, --yes", "Don't prompt to confirm replacing an existing binding")
194
197
  // RTSC-523 — decline the global install without a TTY. `bind`/`join` ask when a human
195
198
  // is present; this is how a scripted run, or a developer whose own agent runs setup,
@@ -253,6 +256,27 @@ program
253
256
  requireLogin();
254
257
  await billingAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
255
258
  });
259
+ // RTSC-749 — the terminal half of the quarantine gate (RTSC-746). Sits beside `billing`
260
+ // rather than under `issue`: the `issue` commands go over the workspace MCP key, and this
261
+ // one must go over the HUMAN's session — an agent key cannot sign, by design.
262
+ program
263
+ .command("triage")
264
+ .argument("[issue]", "The issue to read and decide on. Omit to list what is waiting.")
265
+ .description("Read and approve work filed from OUTSIDE your org. Agents can't pick these up until " +
266
+ "a person approves them, and a person means you: this needs an interactive terminal " +
267
+ "and there is deliberately no --approve flag. The Dash is the recommended surface " +
268
+ "(an agent that can drive a real PTY on this machine could drive this command too).")
269
+ .option("--org-id <id>", "Which org (defaults to your only one).")
270
+ .option("--json", "List as raw JSON. Listing only — deciding is never scriptable.")
271
+ .action(async (issue, opts) => {
272
+ requireLogin();
273
+ if (issue === undefined) {
274
+ await triageListAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
275
+ }
276
+ else {
277
+ await triageOneAction(issue, { orgId: opts.orgId }).catch(fail);
278
+ }
279
+ });
256
280
  // --- org / project ---------------------------------------------------------
257
281
  const org = program.command("org").description("Manage orgs.");
258
282
  org
@@ -316,7 +340,7 @@ key
316
340
  .requiredOption("--org-id <id>")
317
341
  .requiredOption("--project-id <id>")
318
342
  .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
319
- .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
343
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
320
344
  .option("--name <label>", "Key label")
321
345
  .option("--install", "Also wire the key into your agent via MCP")
322
346
  .option("--scope <scope>", "MCP install scope if --install: local | project", "local")
@@ -335,6 +359,7 @@ key
335
359
  if (opts.install) {
336
360
  const cfg = loadConfig();
337
361
  console.log("");
362
+ noteRuntimeIsALabel(opts.runtime);
338
363
  installMcp({ url: cfg.mcpUrl, key: res.key, scope: normalizeScope(opts.scope), watchdog: true });
339
364
  }
340
365
  }
@@ -471,7 +496,7 @@ program
471
496
  .option("--no-bind", "Redeem only — don't set this folder up")
472
497
  .option("--project-id <id>", "Which project to bind to (skips the picker)")
473
498
  .option("--agent <name>", "Agent member name (default: auto)")
474
- .option("--runtime <runtime>", "Agent runtime", "claude-code")
499
+ .option("--runtime <runtime>", "Label for this agent in the Dash (claude-code | codex | grok | …). Does NOT choose where MCP config is written — `retasc setup` wires every harness on the machine.", "claude-code")
475
500
  // RTSC-477 — name the way back. `--yes` skips the identity question and must never answer
476
501
  // it, so the flag that causes the gap is the right place to say how to close it.
477
502
  .option("-y, --yes", "Don't prompt to replace an existing binding, and skip the identity question (ask it later with `retasc identity`)")
@@ -519,6 +544,17 @@ program
519
544
  await importAction({ orgId: opts.orgId, source: opts.source, yes: opts.yes }).catch(fail);
520
545
  });
521
546
  // --- mcp wiring ------------------------------------------------------------
547
+ // RTSC-780 — the once-per-machine half of setup. `bind` runs this itself the first
548
+ // time, so most people never type it; it exists on its own for the second harness
549
+ // installed after a bind, and for re-running after an upgrade moved the launcher.
550
+ program
551
+ .command("setup")
552
+ .description("Detect the MCP harnesses on this machine and wire Retasc into each, once.")
553
+ .option("--no-install", "Don't install `retasc` on this machine; wire the pinned npx launcher instead")
554
+ .allowExcessArguments(false)
555
+ .action((opts) => {
556
+ runSetup({ install: opts.install });
557
+ });
522
558
  const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
523
559
  mcp
524
560
  .command("install")
@@ -0,0 +1,236 @@
1
+ // RTSC-780: the harness registry. One entry per MCP client we know how to wire.
2
+ //
3
+ // Before this file the CLI could wire exactly one harness: `installMcp`/`installMarker`
4
+ // spawned `claude mcp add` and, if that failed, wrote ./.mcp.json. Everything else got
5
+ // nothing, which is why Codex had never worked in any project on any machine. The only
6
+ // non-Claude binding that ever existed here was hand-written into ~/.grok/config.toml by
7
+ // an agent, machine-global with an inline API key — the exact cross-org leak `normalizeScope`
8
+ // refuses to allow for Claude Code.
9
+ //
10
+ // The entry we write is the `auto` marker (keystore.AUTO_WORKSPACE): no key, no workspace
11
+ // id, nothing folder-specific. That is what makes ONE write per harness enough for every
12
+ // project, and it is only safe because the harnesses verified below spawn stdio servers
13
+ // with cwd set to the project directory, so the proxy can resolve the folder itself.
14
+ //
15
+ // Adding a harness is one entry in HARNESSES. Deliberately NOT populated from theory:
16
+ // each entry here was checked against a real installation. Cursor, Windsurf, Gemini CLI,
17
+ // OpenCode and VS Code all have a known config shape and belong here, but writing an
18
+ // unverified serializer into somebody's global agent config is not a thing to ship on a
19
+ // guess — they go in as each is confirmed.
20
+ import { spawnSync } from "node:child_process";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { dirname, join } from "node:path";
24
+ /**
25
+ * The home directory harness configs are resolved under.
26
+ *
27
+ * `RETASC_HOME` overrides it, the same escape hatch `keystore.ts` gives with
28
+ * `RETASC_DIR` and for the same reason: without one, anything that exercises this
29
+ * module writes into the developer's REAL global agent configs. That is not
30
+ * hypothetical — it happened while building this, and the tell was `retasc setup`
31
+ * reporting "replaced" on a Codex config that had never held a retasc entry.
32
+ * A test that can silently reconfigure the machine it runs on is a worse bug than
33
+ * whatever it was testing.
34
+ */
35
+ function home() {
36
+ return process.env.RETASC_HOME || homedir();
37
+ }
38
+ /** `/Users/me/.codex/config.toml` → `~/.codex/config.toml`, for receipts. The card's
39
+ * value column is 20 columns wide, and an absolute path ellipses away to nothing
40
+ * useful exactly where the reader is checking WHICH file we touched. */
41
+ export function tildePath(p) {
42
+ const h = home();
43
+ return p.startsWith(h + "/") ? `~${p.slice(h.length)}` : p;
44
+ }
45
+ export const SERVER_NAME = "retasc";
46
+ // --- shared helpers ---------------------------------------------------------
47
+ /**
48
+ * True when `bin` resolves on PATH.
49
+ *
50
+ * This DOES run a shell (`command -v` is a shell builtin, not an executable), so the
51
+ * argument is concatenated into a command string and a name carrying `;` or a space
52
+ * would be a command injection. Every caller passes a literal from HARNESSES below,
53
+ * but "the only caller today is safe" is how that stops being true, so the shape of
54
+ * the name is checked here rather than trusted.
55
+ */
56
+ export function onPath(bin) {
57
+ if (!/^[a-zA-Z0-9._-]+$/.test(bin))
58
+ return false;
59
+ const r = spawnSync("command", ["-v", bin], { encoding: "utf8", shell: "/bin/sh" });
60
+ return r.status === 0 && Boolean((r.stdout || "").trim());
61
+ }
62
+ function readIfExists(path) {
63
+ try {
64
+ return existsSync(path) ? readFileSync(path, "utf8") : "";
65
+ }
66
+ catch {
67
+ return "";
68
+ }
69
+ }
70
+ /** Write, creating the parent directory. Config files can hold nothing secret by
71
+ * design (the marker is secret-free), so no 0600 dance is needed here — but we
72
+ * also never widen an existing file's mode, so an existing 0600 config stays 0600. */
73
+ function writeConfig(path, text) {
74
+ mkdirSync(dirname(path), { recursive: true });
75
+ writeFileSync(path, text, "utf8");
76
+ }
77
+ // --- TOML (Codex, Grok) -----------------------------------------------------
78
+ /**
79
+ * Splice one table (and its sub-tables) into a TOML document, in place.
80
+ *
81
+ * Deliberately textual rather than parse-and-reserialize. These are the user's own
82
+ * global config files: they hold comments, hand-chosen ordering, and settings that
83
+ * have nothing to do with us. A round-trip through a TOML parser would silently
84
+ * rewrite all of that, and the CLI ships with two dependencies (commander, convex)
85
+ * precisely so it stays cheap to install — adding a TOML library to reformat
86
+ * somebody's config is the wrong trade twice over.
87
+ *
88
+ * Replaces every existing `[prefix]` / `[prefix.*]` table, then appends `block`.
89
+ * A table ends at the next line that starts a different table at column 0.
90
+ */
91
+ export function spliceTomlTable(text, prefix, block) {
92
+ const lines = text.split("\n");
93
+ const out = [];
94
+ let skipping = false;
95
+ const opens = (line) => {
96
+ const m = line.match(/^\s*\[\[?([^\]]+)\]\]?\s*$/);
97
+ return m ? m[1].trim() : null;
98
+ };
99
+ for (const line of lines) {
100
+ const table = opens(line);
101
+ if (table !== null) {
102
+ // A new table header always ends whatever we were skipping.
103
+ skipping = table === prefix || table.startsWith(`${prefix}.`);
104
+ }
105
+ if (!skipping)
106
+ out.push(line);
107
+ }
108
+ // Collapse the blank-line run the removal may have left at the end, so repeated
109
+ // installs don't grow the file by one newline every time.
110
+ while (out.length && out[out.length - 1].trim() === "")
111
+ out.pop();
112
+ const body = out.join("\n");
113
+ return `${body}\n\n${block.trim()}\n`;
114
+ }
115
+ /** The `[mcp_servers.retasc]` block, in the shape Codex and Grok both accept. */
116
+ export function tomlBlock(entry) {
117
+ const arr = entry.args.map((a) => JSON.stringify(a)).join(", ");
118
+ const env = Object.entries(entry.env)
119
+ .map(([k, v]) => `${k} = ${JSON.stringify(v)}`)
120
+ .join("\n");
121
+ return [
122
+ `[mcp_servers.${SERVER_NAME}]`,
123
+ `command = ${JSON.stringify(entry.command)}`,
124
+ `args = [${arr}]`,
125
+ `enabled = true`,
126
+ ``,
127
+ `[mcp_servers.${SERVER_NAME}.env]`,
128
+ env,
129
+ ].join("\n");
130
+ }
131
+ function tomlHarness(id, label, path, bin) {
132
+ return {
133
+ id,
134
+ label,
135
+ configPath: path,
136
+ detect: () => existsSync(path()) || onPath(bin),
137
+ install(entry) {
138
+ const p = path();
139
+ const before = readIfExists(p);
140
+ try {
141
+ writeConfig(p, spliceTomlTable(before, `mcp_servers.${SERVER_NAME}`, tomlBlock(entry)));
142
+ }
143
+ catch (e) {
144
+ return { ok: false, reason: String(e?.message ?? e) };
145
+ }
146
+ return { ok: true, path: p, replaced: /^\s*\[\[?mcp_servers\.retasc\b/m.test(before) };
147
+ },
148
+ };
149
+ }
150
+ // --- JSON (./.mcp.json in the folder) ---------------------------------------
151
+ /** Merge the retasc entry into a `{ mcpServers: {…} }` document, leaving every
152
+ * other server and unknown key byte-for-byte where it was. */
153
+ export function mergeMcpServers(before, entry) {
154
+ let doc = {};
155
+ if (before.trim()) {
156
+ try {
157
+ doc = JSON.parse(before);
158
+ }
159
+ catch {
160
+ doc = {};
161
+ }
162
+ }
163
+ doc.mcpServers = doc.mcpServers ?? {};
164
+ doc.mcpServers[SERVER_NAME] = entry;
165
+ return JSON.stringify(doc, null, 2) + "\n";
166
+ }
167
+ // --- Claude Code ------------------------------------------------------------
168
+ /**
169
+ * Claude Code is the one harness with a real CLI for this, and `claude mcp add` is
170
+ * the ONLY sanctioned way to edit `~/.claude.json`: a running Claude Code rewrites
171
+ * that file constantly, so a read-modify-write from here would race it and could
172
+ * drop unrelated servers.
173
+ *
174
+ * Scope `user` here, which `normalizeScope` refuses everywhere else (RTSC-91). The
175
+ * refusal is about a global entry that NAMES an org — an id or a key in a
176
+ * machine-wide file routes every folder to one project. The `auto` marker names
177
+ * nothing, so the objection does not apply to it, and the whole point of RTSC-780
178
+ * is that this entry is written once and serves every folder.
179
+ */
180
+ const claudeCode = {
181
+ id: "claude-code",
182
+ label: "Claude Code",
183
+ configPath: () => join(home(), ".claude.json"),
184
+ // A test home means "do not touch this machine's real config". `claude mcp add`
185
+ // writes ~/.claude.json wherever HOME points, so the override has to gate DETECTION,
186
+ // not just the path: an undetected harness is never installed into.
187
+ detect: () => !process.env.RETASC_HOME && onPath("claude"),
188
+ install(entry) {
189
+ const args = [
190
+ "mcp", "add",
191
+ SERVER_NAME,
192
+ "--transport", "stdio",
193
+ "--scope", "user",
194
+ ...Object.entries(entry.env).flatMap(([k, v]) => ["--env", `${k}=${v}`]),
195
+ "--", entry.command, ...entry.args,
196
+ ];
197
+ // An existing user-scope entry makes `add` fail rather than replace, so clear it
198
+ // first. Best effort: a missing entry exits non-zero and that is fine.
199
+ //
200
+ // This does open a remove-then-fail window: if `add` fails after a successful
201
+ // remove, the user is left with no retasc entry rather than the one they had. The
202
+ // blast radius is bounded to OUR entry (the name is fixed, and `-s user` scopes it),
203
+ // the outcome is reported rather than swallowed, and `retasc setup` re-runs cleanly.
204
+ // Restoring the previous entry would mean reading ~/.claude.json ourselves, which is
205
+ // the one thing that file must never have done to it: a running Claude Code rewrites
206
+ // it constantly and the read-modify-write would race it.
207
+ const had = spawnSync("claude", ["mcp", "remove", SERVER_NAME, "-s", "user"], {
208
+ encoding: "utf8",
209
+ });
210
+ const r = spawnSync("claude", args, { encoding: "utf8" });
211
+ if (r.error)
212
+ return { ok: false, reason: r.error.message };
213
+ if (r.status !== 0) {
214
+ const msg = (r.stderr || r.stdout || "").trim().split("\n")[0] || `exited ${r.status}`;
215
+ return { ok: false, reason: msg };
216
+ }
217
+ return { ok: true, path: "Claude Code (user scope)", replaced: had.status === 0 };
218
+ },
219
+ };
220
+ // --- the registry -----------------------------------------------------------
221
+ export const HARNESSES = [
222
+ claudeCode,
223
+ tomlHarness("codex", "Codex", () => join(home(), ".codex", "config.toml"), "codex"),
224
+ tomlHarness("grok", "Grok", () => join(home(), ".grok", "config.toml"), "grok"),
225
+ ];
226
+ /** Every harness actually present on this machine. */
227
+ export function detectHarnesses(list = HARNESSES) {
228
+ return list.filter((h) => {
229
+ try {
230
+ return h.detect();
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ });
236
+ }
@@ -1,5 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- import { join } from "node:path";
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
5
  /** The dir the keystore lives in. RETASC_DIR overrides it (tests, sandboxes). */
@@ -52,12 +52,87 @@ export function removeBinding(workspaceId) {
52
52
  export function newWorkspaceId() {
53
53
  return `ws_${randomUUID()}`;
54
54
  }
55
+ /**
56
+ * RTSC-780: the sentinel that means "work out the workspace from where you were
57
+ * spawned", rather than naming one.
58
+ *
59
+ * A marker carrying a real `ws_…` id names ONE workspace, so an entry holding it
60
+ * is only ever correct in the folder it was written for. That is why there is no
61
+ * global scope today (RTSC-91): a machine-wide entry with an id in it would route
62
+ * every folder on the disk to one org. `auto` carries no identity at all, so an
63
+ * entry holding it IS safe to write once, machine-wide, into every harness — the
64
+ * proxy resolves the folder it was spawned in against the keystore at startup and
65
+ * the org still comes from the folder, exactly as before.
66
+ *
67
+ * Not a possible collision: ids are `ws_`-prefixed uuids.
68
+ */
69
+ export const AUTO_WORKSPACE = "auto";
70
+ /**
71
+ * Which binding covers this directory? (RTSC-780)
72
+ *
73
+ * Walks UP from `dir`, matching `boundPath` at each level, so an agent spawned in
74
+ * a subdirectory of a bound project resolves to that project instead of to
75
+ * nothing. The walk is what makes `auto` usable in practice: harnesses spawn a
76
+ * stdio server in the project root, but not every one of them, and a human running
77
+ * `retasc claim` from `src/` should not be told the folder is unbound.
78
+ *
79
+ * Paths are compared resolved, so `/tmp` vs `/private/tmp` and a trailing slash
80
+ * don't produce a false miss.
81
+ *
82
+ * Newest wins when two ids claim the same path. A re-bind normally REUSES the
83
+ * folder's existing id (bind.ts), but a folder bound, unbound and bound again
84
+ * leaves an older entry behind, and the fresher one is the live binding.
85
+ *
86
+ * The walk STOPS at the repository root, and that bound is load-bearing rather than
87
+ * tidiness. An unbounded walk means binding a directory binds everything beneath it:
88
+ * run `retasc bind` once in `~/Development` (or `~`) and every project under it
89
+ * silently starts acting in that org, with no marker in those folders saying so and
90
+ * nothing on screen to notice. That is the cross-org leak this whole design exists to
91
+ * avoid (RTSC-91), reintroduced through the back door. A repository is the unit a
92
+ * person means by "project", so it is the unit the walk is allowed to cross: `src/lib`
93
+ * resolves to its repo, and a sibling repo checked out inside a bound directory
94
+ * resolves to nothing.
95
+ *
96
+ * Detected by the presence of `.git`, which is a FILE in a worktree and a directory in
97
+ * a normal clone, so `existsSync` is the right test for both.
98
+ */
99
+ export function findBindingByPath(dir) {
100
+ const bindings = loadKeystore().bindings;
101
+ // Index by resolved boundPath once, keeping the newest per path.
102
+ const byPath = new Map();
103
+ for (const [workspaceId, entry] of Object.entries(bindings)) {
104
+ if (!entry?.boundPath)
105
+ continue;
106
+ const key = resolve(entry.boundPath);
107
+ const prev = byPath.get(key);
108
+ if (!prev || (entry.createdAt ?? 0) > (prev.entry.createdAt ?? 0)) {
109
+ byPath.set(key, { workspaceId, entry });
110
+ }
111
+ }
112
+ let cur = resolve(dir);
113
+ // Bounded twice: by the repository root (below), and by the filesystem root, since
114
+ // `dirname("/")` is `"/"` and ends the loop for a directory outside any repo.
115
+ for (;;) {
116
+ const hit = byPath.get(cur);
117
+ if (hit)
118
+ return hit;
119
+ // Checked AFTER the lookup so the repo root itself, the folder `bind` actually
120
+ // writes, is always eligible.
121
+ if (existsSync(join(cur, ".git")))
122
+ return undefined;
123
+ const up = dirname(cur);
124
+ if (up === cur)
125
+ return undefined;
126
+ cur = up;
127
+ }
128
+ }
55
129
  /**
56
130
  * RTSC-98: the ONE place that resolves a workspace's key + MCP url. Shared by the
57
131
  * proxy (env-based) and the direct commands `claim`/`tidy`/`done` (.mcp.json-based)
58
132
  * so the two paths can never drift again. Precedence: explicit `RETASC_MCP_KEY`
59
133
  * env → legacy inline key in the marker (env or Authorization header) → canonical
60
- * secret-free marker (`RETASC_WORKSPACE` → home keystore). `mcpEntry` is a
134
+ * secret-free marker (`RETASC_WORKSPACE` → home keystore, or `auto` this
135
+ * process's cwd → home keystore). `mcpEntry` is a
61
136
  * workspace `.mcp.json`'s `mcpServers.retasc` entry, or undefined when only the
62
137
  * environment carries the binding (the spawned proxy).
63
138
  *
@@ -97,15 +172,23 @@ export function resolveConn(opts) {
97
172
  }
98
173
  // Canonical (RTSC-92): secret-free marker → workspace id → home keystore.
99
174
  // The entry's url is deliberately NOT consulted on this branch.
175
+ //
176
+ // RTSC-780 adds one more step in front of the id: `auto` names no workspace, so
177
+ // the folder we were spawned in picks it. Same trust rule either way — the key
178
+ // came from the keystore, so it travels only to the keystore's url. That is what
179
+ // lets `auto` be written into a GLOBAL harness config: the entry is identical on
180
+ // every machine and in every folder, and carries nothing worth stealing.
100
181
  if (!key) {
101
182
  const wsId = env.RETASC_WORKSPACE || entry?.env?.RETASC_WORKSPACE;
102
- if (wsId) {
103
- const b = getBinding(String(wsId));
104
- if (b) {
105
- key = b.key;
106
- if (!url)
107
- url = b.url;
108
- }
183
+ const b = wsId === AUTO_WORKSPACE
184
+ ? findBindingByPath(opts.cwd ?? process.cwd())?.entry
185
+ : wsId
186
+ ? getBinding(String(wsId))
187
+ : undefined;
188
+ if (b) {
189
+ key = b.key;
190
+ if (!url)
191
+ url = b.url;
109
192
  }
110
193
  }
111
194
  return { key, url: url || opts.defaultUrl || "" };
package/dist/proxy.js CHANGED
@@ -12,7 +12,7 @@ import { spawn, spawnSync } from "node:child_process";
12
12
  import { dirname, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { applyObservation, heartbeatRequest, isClaimLost, isUnauthorized, shouldReapOnClose, untrackedLeaseWarning, } from "./lib/watchdog.js";
15
- import { resolveConn } from "./lib/keystore.js";
15
+ import { AUTO_WORKSPACE, resolveConn } from "./lib/keystore.js";
16
16
  import { toolResult as parseTool } from "./lib/toolresult.js";
17
17
  import { mintSessionKey, appendFallbackNotice } from "./lib/session.js";
18
18
  import { attachRoot, isLocalAttachCall, mergeAttachTool, resolveAttachPath, uploadFailureMessage, uploadUrlWith, } from "./lib/attachFile.js";
@@ -23,6 +23,21 @@ import { MAX_FETCH_BYTES, downloadFailureMessage, existingDownload, isLocalFetch
23
23
  const resolved = resolveConn({ env: process.env, defaultUrl: "https://mcp.retasc.com/mcp" });
24
24
  let MCP_URL = resolved.url;
25
25
  const KEY = resolved.key;
26
+ // RTSC-780: with the `auto` marker the SAME entry is wired into every harness, once, and
27
+ // is therefore present in folders that were never bound. That is no longer a broken
28
+ // install, it is the ordinary state of a folder you haven't run `retasc bind` in yet, so
29
+ // it needs an answer rather than a 401.
30
+ //
31
+ // Getting this wrong is what RTSC-781 is about. Forwarding keyless requests produces an
32
+ // auth failure, and the CLI's own login is global and still fine, so the agent reports
33
+ // "signed in, but the server is unreachable" and a human goes and checks a server that
34
+ // was serving other clients the whole time. The folder is the thing that is missing, so
35
+ // the folder is what the error has to name.
36
+ const UNBOUND = !KEY && (process.env.RETASC_WORKSPACE ?? "") === AUTO_WORKSPACE ? process.cwd() : null;
37
+ const UNBOUND_MESSAGE = `This folder (${UNBOUND ?? process.cwd()}) is not bound to a Retasc project, so there is ` +
38
+ `nothing to pull work from. Nothing is wrong with your sign-in or with the Retasc server. ` +
39
+ `Run \`retasc bind\` in this folder to say which org and project it belongs to, then restart ` +
40
+ `this agent so it picks the binding up.`;
26
41
  const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
27
42
  const leases = new Map();
28
43
  let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
@@ -408,6 +423,18 @@ async function handleLine(line) {
408
423
  // context), so every call to the tool is served here rather than forwarded.
409
424
  if (isLocalFetchCall(msg))
410
425
  return await handleLocalFetch(msg);
426
+ // RTSC-780: an unbound folder answers every tool call itself. `initialize` and
427
+ // `tools/list` still go remote, so the agent starts cleanly and can SEE the tools —
428
+ // the failure has to arrive at the moment one is used, worded as the folder problem
429
+ // it is, rather than as a startup crash the harness reports as "server unreachable".
430
+ if (UNBOUND && msg.method === "tools/call" && msg.id !== undefined) {
431
+ process.stdout.write(JSON.stringify({
432
+ jsonrpc: "2.0",
433
+ id: msg.id,
434
+ result: { content: [{ type: "text", text: UNBOUND_MESSAGE }], isError: true },
435
+ }) + "\n");
436
+ return;
437
+ }
411
438
  let resp;
412
439
  try {
413
440
  resp = await postRemote(msg);
@@ -505,7 +532,9 @@ async function heartbeatAll() {
505
532
  }
506
533
  /** Run the stdio proxy. Started by the harness; lives as long as the session. */
507
534
  export async function runProxy() {
508
- if (!KEY)
535
+ if (UNBOUND)
536
+ log(UNBOUND_MESSAGE);
537
+ else if (!KEY)
509
538
  log("warning: RETASC_MCP_KEY is empty — forwarded requests will be unauthorized");
510
539
  // Adopt a per-session key BEFORE serving traffic, so even the first claim is
511
540
  // attributed to this session. stdin buffers in the OS pipe meanwhile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.36.1",
3
+ "version": "1.38.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": {
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "build": "tsc",
42
42
  "dev": "tsc --watch",
43
- "test": "npm run build && node --test test/*.test.mjs",
43
+ "test": "npm run build && node --test --import ./test/_test-home.mjs test/*.test.mjs",
44
44
  "prepublishOnly": "npm run build"
45
45
  },
46
46
  "dependencies": {