@retasc/cli 1.0.0 → 1.1.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/README.md CHANGED
@@ -21,19 +21,25 @@ This installs the `retasc` command. Requires Node.js ≥ 18.
21
21
  ## Quickstart
22
22
 
23
23
  ```sh
24
- # 1. Sign in with GitHub (device flow)
24
+ # 1. Sign in with GitHub (device flow) — once per machine
25
25
  retasc login
26
26
 
27
- # 2. Create an org + project, mint an agent key, and wire it into your agent — one shot
28
- retasc init --org "Acme" --project "Acme" --prefix ACME
27
+ # 2. Bind THIS folder to one org + project (pick or create), and wire the watchdog
28
+ cd ~/acme
29
+ retasc bind
29
30
 
30
- # 3. Your agent (e.g. Claude Code) can now pull work over MCP
31
+ # 3. Your agent (e.g. Claude Code) can now pull work over MCP — scoped to this folder
31
32
  ```
32
33
 
33
- `retasc init` creates the org and project, mints an agent API key, and registers the Retasc
34
- MCP server with your agent (writing `.mcp.json` or registering with Claude Code). From then on
35
- your agent calls `next_issue` / `next_batch` to atomically claim the top unblocked, prioritized
36
- work and several agents can run at once without ever claiming the same issue.
34
+ `retasc bind` is the per-workspace binder: it picks (or creates) an org + project, mints an
35
+ agent API key for that pair, and wires the Retasc MCP server into **this folder only**. The key
36
+ is stored in your home keystore (`~/.retasc/bindings.json`); the folder's `.mcp.json` is a
37
+ **secret-free marker** (safe to commit). Each workspace is bound to exactly one org/project, so
38
+ an agent can never file issues into the wrong one — the server enforces it by the key. From then
39
+ on your agent calls `next_issue` / `next_batch` to atomically claim the top unblocked,
40
+ prioritized work, and several agents run at once without ever claiming the same issue.
41
+
42
+ Run `retasc whoami` (shows this folder's binding) or `retasc doctor` (checks it) any time.
37
43
 
38
44
  ## What the agent gets over MCP
39
45
 
@@ -50,8 +56,10 @@ work — and several agents can run at once without ever claiming the same issue
50
56
  | Command | What it does |
51
57
  |---------|--------------|
52
58
  | `retasc login` / `retasc logout` | Sign in / out (GitHub device flow) |
53
- | `retasc whoami` | Show the signed-in user and their orgs |
54
- | `retasc init …` | Create org + project, mint a key, wire the MCP — one shot |
59
+ | `retasc bind` | Bind THIS folder to one org + project, wire the watchdog (the per-workspace setup) |
60
+ | `retasc whoami` | Show this folder's org/project binding, plus your orgs |
61
+ | `retasc doctor` | Check this folder is correctly + safely bound |
62
+ | `retasc init …` | Create org + project, mint a key, wire the MCP — one shot (non-interactive) |
55
63
  | `retasc org create` / `retasc project create` | Create orgs / projects |
56
64
  | `retasc key mint \| list \| rotate \| revoke` | Manage agent API keys |
57
65
  | `retasc mcp install` | Register the Retasc MCP server with your agent |
package/dist/api.js CHANGED
@@ -6,6 +6,7 @@ import { loadConfig } from "./config.js";
6
6
  // importing the parent's generated api.
7
7
  const fns = {
8
8
  me: makeFunctionReference("manage:me"),
9
+ listProjects: makeFunctionReference("manage:listProjects"),
9
10
  createOrg: makeFunctionReference("manage:createOrg"),
10
11
  createProject: makeFunctionReference("manage:createProject"),
11
12
  renameProjectPrefix: makeFunctionReference("manage:renameProjectPrefix"),
@@ -23,6 +24,7 @@ function client() {
23
24
  }
24
25
  export const api = {
25
26
  me: () => client().query(fns.me, {}),
27
+ listProjects: (args) => client().query(fns.listProjects, args),
26
28
  createOrg: (args) => client().mutation(fns.createOrg, args),
27
29
  createProject: (args) => client().mutation(fns.createProject, args),
28
30
  renameProjectPrefix: (args) => client().mutation(fns.renameProjectPrefix, args),
@@ -0,0 +1,163 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin, stdout } from "node:process";
3
+ import { api } from "../api.js";
4
+ import { loadConfig } from "../config.js";
5
+ import { installMarker } from "./mcp.js";
6
+ import { readLocalBinding, resolveBinding } from "../lib/binding.js";
7
+ import { setBinding, newWorkspaceId } from "../lib/keystore.js";
8
+ function isInteractive() {
9
+ return Boolean(stdin.isTTY && stdout.isTTY);
10
+ }
11
+ async function ask(question) {
12
+ const rl = createInterface({ input: stdin, output: stdout });
13
+ try {
14
+ return (await rl.question(question)).trim();
15
+ }
16
+ finally {
17
+ rl.close();
18
+ }
19
+ }
20
+ async function confirm(question, assumeYes) {
21
+ if (assumeYes)
22
+ return true;
23
+ if (!isInteractive())
24
+ return false;
25
+ const a = (await ask(`${question} [y/N] `)).toLowerCase();
26
+ return a === "y" || a === "yes";
27
+ }
28
+ /** Pick from a list interactively, or return undefined to fall through to create. */
29
+ async function pick(label, items, render) {
30
+ if (!items.length)
31
+ return undefined;
32
+ console.log(`\n${label}:`);
33
+ items.forEach((it, i) => console.log(` ${i + 1}) ${render(it)}`));
34
+ console.log(` ${items.length + 1}) + create new`);
35
+ const a = await ask("Choose a number: ");
36
+ const n = Number(a);
37
+ if (Number.isInteger(n) && n >= 1 && n <= items.length)
38
+ return items[n - 1];
39
+ return undefined; // create-new (or invalid → treated as create)
40
+ }
41
+ export async function bindAction(opts) {
42
+ const cfg = loadConfig();
43
+ const cwd = process.cwd();
44
+ // --- loud on re-bind -------------------------------------------------------
45
+ const existing = readLocalBinding(cwd);
46
+ if (existing) {
47
+ let where = "an existing Retasc binding";
48
+ try {
49
+ const b = await resolveBinding(existing.url || cfg.mcpUrl, existing.key);
50
+ where = `org "${b.org.name}" / project ${b.project.prefix}`;
51
+ }
52
+ catch {
53
+ /* key may be stale/revoked — still warn before replacing */
54
+ }
55
+ console.log(`This folder is already bound to ${where}.`);
56
+ if (!(await confirm("Replace it?", opts.yes))) {
57
+ console.log("Left unchanged.");
58
+ return;
59
+ }
60
+ }
61
+ // --- resolve org (pick / flag / create) ------------------------------------
62
+ let orgId = opts.orgId;
63
+ if (!orgId && opts.orgName) {
64
+ const org = (await api.createOrg({ name: opts.orgName }));
65
+ orgId = org.orgId;
66
+ console.log(`✓ Created org "${opts.orgName}".`);
67
+ }
68
+ if (!orgId) {
69
+ const me = (await api.me());
70
+ const orgs = me.orgs ?? [];
71
+ if (isInteractive()) {
72
+ const chosen = await pick("Select an org", orgs, (o) => `${o.name} (${o.slug})`);
73
+ if (chosen) {
74
+ orgId = chosen.id;
75
+ }
76
+ else {
77
+ const name = await ask("New org name: ");
78
+ if (!name)
79
+ throw new Error("org name required");
80
+ orgId = (await api.createOrg({ name })).orgId;
81
+ console.log(`✓ Created org "${name}".`);
82
+ }
83
+ }
84
+ else if (orgs.length === 1) {
85
+ orgId = orgs[0].id; // unambiguous in a non-interactive run
86
+ }
87
+ else {
88
+ throw new Error("no org selected — pass --org-id <id> or --org-name <name> (or run interactively).");
89
+ }
90
+ }
91
+ // --- resolve project (pick / flag / create) --------------------------------
92
+ let projectId = opts.projectId;
93
+ let prefix;
94
+ if (!projectId && opts.project && opts.prefix) {
95
+ const p = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
96
+ projectId = p.projectId;
97
+ prefix = p.prefix;
98
+ console.log(`✓ Created project ${p.prefix}.`);
99
+ }
100
+ if (!projectId) {
101
+ const { projects } = (await api.listProjects({ orgId: orgId }));
102
+ const list = projects ?? [];
103
+ if (isInteractive()) {
104
+ const chosen = await pick("Select a project", list, (p) => `${p.prefix} — ${p.name}`);
105
+ if (chosen) {
106
+ projectId = chosen.id;
107
+ prefix = chosen.prefix;
108
+ }
109
+ else {
110
+ const name = await ask("New project name: ");
111
+ const pfx = (await ask("Project prefix (e.g. ACME): ")).toUpperCase();
112
+ if (!name || !pfx)
113
+ throw new Error("project name and prefix required");
114
+ const p = (await api.createProject({ orgId: orgId, name, prefix: pfx }));
115
+ projectId = p.projectId;
116
+ prefix = p.prefix;
117
+ console.log(`✓ Created project ${p.prefix}.`);
118
+ }
119
+ }
120
+ else if (list.length === 1) {
121
+ projectId = list[0].id;
122
+ prefix = list[0].prefix;
123
+ }
124
+ else {
125
+ throw new Error("no project selected — pass --project-id <id>, or --project <name> --prefix <PFX>.");
126
+ }
127
+ }
128
+ // --- mint a key for THIS (org, project) and wire the watchdog into THIS folder
129
+ const minted = (await api.mintKey({
130
+ orgId: orgId,
131
+ projectId: projectId,
132
+ agentName: opts.agent,
133
+ runtime: opts.runtime ?? "claude-code",
134
+ keyName: prefix ? `${prefix} key` : undefined,
135
+ }));
136
+ console.log(`✓ Minted key for this workspace (${minted.key.slice(0, 14)}…).\n`);
137
+ // RTSC-92: the secret stays OUT of the repo. Store it in the home keystore
138
+ // keyed by a workspace id; reuse this folder's existing id (so a re-bind, or a
139
+ // teammate's committed marker, keeps the same id) or mint a fresh one.
140
+ const workspaceId = existing?.workspaceId ?? newWorkspaceId();
141
+ setBinding(workspaceId, {
142
+ orgId: orgId,
143
+ projectId: projectId,
144
+ key: minted.key,
145
+ url: cfg.mcpUrl,
146
+ prefix,
147
+ orgName: undefined,
148
+ boundPath: cwd,
149
+ createdAt: Date.now(),
150
+ });
151
+ // Per-folder only (local scope), always watchdog. The marker carries only the
152
+ // workspace id — no secret — so ./.mcp.json is safe to commit.
153
+ installMarker({ workspaceId, scope: "local" });
154
+ // Confirm the binding the same way the agent will see it.
155
+ try {
156
+ const b = await resolveBinding(cfg.mcpUrl, minted.key);
157
+ console.log(`\n✓ This folder is bound to org "${b.org.name}" / project ${b.project.prefix}.\n` +
158
+ ` Agents launched here can only ever read or write ${b.project.prefix}.`);
159
+ }
160
+ catch {
161
+ /* binding written; whoami confirmation is best-effort */
162
+ }
163
+ }
@@ -0,0 +1,78 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "../config.js";
5
+ import { readLocalBinding, resolveBinding } from "../lib/binding.js";
6
+ import { getBinding } from "../lib/keystore.js";
7
+ // RTSC-91 (DESIGN §13): `retasc doctor` — confirm THIS folder is correctly and
8
+ // safely bound. Bound? Key valid (resolves to an org/project)? Watchdog wired?
9
+ // And — the safety check — is there an illegal machine-global Retasc server that
10
+ // could shadow per-folder bindings and leak issues across orgs?
11
+ const ok = (m) => console.log(` ✓ ${m}`);
12
+ const warn = (m) => console.log(` ! ${m}`);
13
+ const bad = (m) => console.log(` ✗ ${m}`);
14
+ /** A user-scope (global) Retasc MCP server lives at the top level of
15
+ * ~/.claude.json; per-folder ones live under projects[path]. A top-level one is
16
+ * illegal under §13 — it would apply to every workspace. Best-effort detection. */
17
+ function hasGlobalClaudeServer() {
18
+ const path = join(homedir(), ".claude.json");
19
+ if (!existsSync(path))
20
+ return false;
21
+ try {
22
+ const doc = JSON.parse(readFileSync(path, "utf8"));
23
+ return Boolean(doc?.mcpServers?.retasc);
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
29
+ export async function doctorAction() {
30
+ const cfg = loadConfig();
31
+ const cwd = process.cwd();
32
+ console.log(`Retasc workspace check — ${cwd}\n`);
33
+ // 1) Is this folder bound?
34
+ const local = readLocalBinding(cwd);
35
+ if (!local) {
36
+ bad("not bound — no Retasc MCP server in ./.mcp.json. Run `retasc bind`.");
37
+ }
38
+ else if (local.markerOnly) {
39
+ // RTSC-92: marker present (e.g. a cloned repo) but no key in this machine's
40
+ // keystore. Safe — the agent gets no tools — but the user must bind.
41
+ warn(`workspace marker present (${local.workspaceId}) but no key in your keystore.`);
42
+ bad("not usable yet on this machine — run `retasc bind` to mint your own key.");
43
+ }
44
+ else {
45
+ if (local.workspaceId) {
46
+ ok(`bound (secret-free marker → home keystore, ${local.workspaceId}).`);
47
+ // The id resolved, but if it was bound at a different folder, this marker
48
+ // may have reused someone else's id — surface it rather than silently use it.
49
+ const entry = getBinding(local.workspaceId);
50
+ if (entry?.boundPath && entry.boundPath !== cwd) {
51
+ warn(`this workspace id was bound at a different folder:\n` +
52
+ ` ${entry.boundPath}\n` +
53
+ ` If you didn't move this repo, re-run \`retasc bind\` to mint a key for THIS folder.`);
54
+ }
55
+ }
56
+ else if (local.legacy) {
57
+ ok("bound (legacy inline-key .mcp.json).");
58
+ warn("the key is stored IN this folder. Run `retasc bind` to move it to the keystore (secret-free marker).");
59
+ }
60
+ // 2) Does the key resolve to an org/project?
61
+ try {
62
+ const b = await resolveBinding(local.url || cfg.mcpUrl, local.key);
63
+ ok(`key valid → org "${b.org.name}" / project ${b.project.prefix} (${b.project.name}).`);
64
+ }
65
+ catch (e) {
66
+ bad(`key not accepted by the server: ${String(e?.message ?? e)}. Re-run \`retasc bind\`.`);
67
+ }
68
+ }
69
+ // 3) The safety check: any illegal global Retasc server?
70
+ if (hasGlobalClaudeServer()) {
71
+ bad("a GLOBAL Retasc MCP server is registered (~/.claude.json top level).\n" +
72
+ " This applies to every workspace and can leak issues across orgs.\n" +
73
+ " Remove it: claude mcp remove -s user retasc");
74
+ }
75
+ else {
76
+ ok("no illegal global Retasc server.");
77
+ }
78
+ }
@@ -2,6 +2,18 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFileSync, writeFileSync, existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  export const SERVER_NAME = "retasc";
5
+ /** Normalize a user-supplied scope string. `user` (global) is refused and
6
+ * downgraded to `local`, loudly — per-folder binding is the only right way. */
7
+ export function normalizeScope(s) {
8
+ if (s === "project")
9
+ return "project";
10
+ if (s === "user") {
11
+ console.error(" ! Global (--scope user) is not allowed for Retasc — each workspace must bind to\n" +
12
+ " exactly one org/project. Using per-folder scope (local) instead.");
13
+ return "local";
14
+ }
15
+ return "local";
16
+ }
5
17
  /** The MCP server entry we wire into an agent's config. */
6
18
  export function mcpServerEntry(url, key) {
7
19
  return {
@@ -31,6 +43,21 @@ export function mcpProxyEntry(url, key) {
31
43
  export function mcpProxyConfigBlock(url, key) {
32
44
  return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpProxyEntry(url, key) } }, null, 2);
33
45
  }
46
+ /**
47
+ * RTSC-92: the SECRET-FREE watchdog marker. No key — only the opaque workspace
48
+ * id, which the proxy resolves to a key through the home keystore. Safe to
49
+ * commit; doubles as the "this repo is a Retasc workspace" marker.
50
+ */
51
+ export function mcpMarkerEntry(workspaceId) {
52
+ return {
53
+ command: "retasc",
54
+ args: ["mcp-proxy"],
55
+ env: { RETASC_WORKSPACE: workspaceId },
56
+ };
57
+ }
58
+ export function mcpMarkerConfigBlock(workspaceId) {
59
+ return JSON.stringify({ mcpServers: { [SERVER_NAME]: mcpMarkerEntry(workspaceId) } }, null, 2);
60
+ }
34
61
  /** Try `claude mcp add`. Returns true on success, false if claude is absent or errored. */
35
62
  function tryClaudeCli(url, key, scope) {
36
63
  const args = [
@@ -54,6 +81,17 @@ function tryClaudeCliWatchdog(url, key, scope) {
54
81
  ];
55
82
  return runClaudeAdd(args);
56
83
  }
84
+ /** Try `claude mcp add` for the secret-free stdio marker (RTSC-92). */
85
+ function tryClaudeCliMarker(workspaceId, scope) {
86
+ const args = [
87
+ "mcp", "add",
88
+ "--transport", "stdio",
89
+ "--scope", scope,
90
+ "--env", `RETASC_WORKSPACE=${workspaceId}`,
91
+ SERVER_NAME, "--", "retasc", "mcp-proxy",
92
+ ];
93
+ return runClaudeAdd(args);
94
+ }
57
95
  function runClaudeAdd(args) {
58
96
  const r = spawnSync("claude", args, { encoding: "utf8" });
59
97
  if (r.error)
@@ -90,7 +128,7 @@ function writeProjectMcpJson(entry) {
90
128
  * can paste it into any other MCP client.
91
129
  */
92
130
  export function installMcp(opts) {
93
- const scope = opts.scope ?? "user";
131
+ const scope = opts.scope ?? "local";
94
132
  // Watchdog mode (RTSC-44): wire the stdio proxy so claims stay alive automatically.
95
133
  if (opts.watchdog) {
96
134
  const viaClaude = tryClaudeCliWatchdog(opts.url, opts.key, scope);
@@ -120,3 +158,23 @@ export function installMcp(opts) {
120
158
  console.log(mcpConfigBlock(opts.url, opts.key));
121
159
  console.log(`\nYour agent can now reach Retasc at ${opts.url}.`);
122
160
  }
161
+ /**
162
+ * RTSC-92: wire the SECRET-FREE watchdog marker into this folder. The key lives
163
+ * in the home keystore under `workspaceId`; this writes only the pointer (env
164
+ * RETASC_WORKSPACE). Prefers the `claude` CLI, falls back to ./.mcp.json.
165
+ */
166
+ export function installMarker(opts) {
167
+ const scope = opts.scope ?? "local";
168
+ const viaClaude = tryClaudeCliMarker(opts.workspaceId, scope);
169
+ if (viaClaude) {
170
+ console.log(`✓ Registered Retasc watchdog (secret-free marker, scope: ${scope}).`);
171
+ }
172
+ else {
173
+ const path = writeProjectMcpJson(mcpMarkerEntry(opts.workspaceId));
174
+ console.log(`✓ Wrote secret-free watchdog marker to ${path}`);
175
+ console.log(" (Claude Code CLI not detected — used .mcp.json instead.)");
176
+ }
177
+ console.log("\nMarker block (any stdio MCP client) — no secret, safe to commit:\n");
178
+ console.log(mcpMarkerConfigBlock(opts.workspaceId));
179
+ console.log("\nThe key lives in your home keystore (~/.retasc/bindings.json), not in the repo.");
180
+ }
@@ -0,0 +1,257 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { dirname, resolve, sep } from "node:path";
3
+ import { resolveMcpConn, readMcpJson, mcpCall, isValidIssueId } from "../lib/claim.js";
4
+ import { issueIdFromBranch, classifyBranch, parseWorktreePorcelain, } from "../lib/tidy.js";
5
+ // `retasc tidy` / `retasc done` (RTSC-93). The CLI already creates the worktree +
6
+ // branch on claim (commands/claim.ts); this closes the loop — it tears them down
7
+ // when the issue is done, reading status read-only from the control plane and
8
+ // doing the git here. The Retasc server never touches git; the CLI is the hands.
9
+ function git(args, cwd) {
10
+ return spawnSync("git", args, { encoding: "utf8", cwd });
11
+ }
12
+ function gitOut(args, cwd) {
13
+ const r = git(args, cwd);
14
+ if (r.status !== 0)
15
+ return null;
16
+ return (r.stdout ?? "").trim();
17
+ }
18
+ function note(msg) {
19
+ process.stderr.write(msg + "\n");
20
+ }
21
+ const BASE = "origin/main";
22
+ /** Resolve the MCP connection + the main checkout, or exit with a clear message. */
23
+ function setup() {
24
+ const conn = resolveMcpConn({ mcpJson: readMcpJson() });
25
+ if (!conn.key) {
26
+ note("✗ No Retasc MCP key found. Run `retasc mcp install`, or set RETASC_MCP_KEY.");
27
+ process.exit(1);
28
+ }
29
+ const commonDir = gitOut(["rev-parse", "--git-common-dir"]);
30
+ if (!commonDir) {
31
+ note("✗ Not inside a git repository.");
32
+ process.exit(1);
33
+ }
34
+ // --git-common-dir always points at the MAIN checkout's .git, even from a linked
35
+ // worktree — so worktree/branch ops always run against the main checkout.
36
+ const mainCheckout = dirname(resolve(commonDir));
37
+ const currentBranch = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]);
38
+ return { conn, mainCheckout, currentBranch };
39
+ }
40
+ /** Build the reconciled branch table: every issue-shaped branch (local ∪ remote)
41
+ * joined with its issue status (read-only) and merged-into-main state. */
42
+ async function scan(ctx, only) {
43
+ const { conn, mainCheckout } = ctx;
44
+ // Refresh so merged-state + the remote list are current. Best-effort.
45
+ git(["fetch", "origin", "--prune"], mainCheckout);
46
+ const worktrees = parseWorktreePorcelain(gitOut(["worktree", "list", "--porcelain"], mainCheckout) ?? "");
47
+ const wtByBranch = new Map();
48
+ for (const w of worktrees)
49
+ if (w.branch)
50
+ wtByBranch.set(w.branch, w.path);
51
+ const refList = (refspec, strip) => (gitOut(["for-each-ref", "--format=%(refname:short)", refspec], mainCheckout) ?? "")
52
+ .split("\n")
53
+ .map((s) => s.trim())
54
+ .filter(Boolean)
55
+ .map((s) => (strip ? s.replace(strip, "") : s));
56
+ const locals = refList("refs/heads/");
57
+ const remotes = refList("refs/remotes/origin/", "origin/").filter((b) => b !== "HEAD");
58
+ const localSet = new Set(locals);
59
+ const remoteSet = new Set(remotes);
60
+ let branches = [...new Set([...locals, ...remotes])]
61
+ .filter((b) => issueIdFromBranch(b) !== null)
62
+ .sort();
63
+ if (only)
64
+ branches = branches.filter((b) => issueIdFromBranch(b) === only);
65
+ if (branches.length === 0)
66
+ return [];
67
+ // Read issue statuses — read-only against the control plane, one per unique id.
68
+ const uniqueIds = [...new Set(branches.map((b) => issueIdFromBranch(b)))];
69
+ const statusById = new Map();
70
+ await Promise.all(uniqueIds.map(async (id) => {
71
+ try {
72
+ const issue = (await mcpCall(conn, "get_issue", { identifier: id }));
73
+ statusById.set(id, typeof issue?.status === "string" ? issue.status : null);
74
+ }
75
+ catch {
76
+ statusById.set(id, null); // not found / error → untracked, never reaped
77
+ }
78
+ }));
79
+ return branches.map((branch) => {
80
+ const issue = issueIdFromBranch(branch);
81
+ const status = issue ? (statusById.get(issue) ?? null) : null;
82
+ const remote = remoteSet.has(branch);
83
+ // Merged check prefers the remote ref (authoritative); falls back to the local.
84
+ const ref = remote ? `origin/${branch}` : branch;
85
+ const merged = git(["merge-base", "--is-ancestor", ref, BASE], mainCheckout).status === 0;
86
+ return {
87
+ branch,
88
+ issue,
89
+ status,
90
+ merged,
91
+ verdict: classifyBranch({ status, merged }),
92
+ local: localSet.has(branch),
93
+ remote,
94
+ worktree: wtByBranch.get(branch) ?? null,
95
+ };
96
+ });
97
+ }
98
+ const VERDICT_LABEL = {
99
+ reap: "reap",
100
+ orphan: "orphan — review",
101
+ active: "active — keep",
102
+ untracked: "untracked",
103
+ };
104
+ function printTable(rows) {
105
+ const w = Math.max(...rows.map((r) => r.branch.length), 6);
106
+ note(` ${"branch".padEnd(w)} issue status merged verdict`);
107
+ for (const r of rows) {
108
+ note(` ${r.branch.padEnd(w)} ${(r.issue ?? "—").padEnd(9)} ${(r.status ?? "—").padEnd(9)} ` +
109
+ `${(r.merged ? "yes" : "no").padEnd(6)} ${VERDICT_LABEL[r.verdict]}`);
110
+ }
111
+ }
112
+ /** Delete one branch's artifacts (worktree → local branch → remote branch),
113
+ * refusing anything unsafe. Returns whether it acted. */
114
+ function reap(row, ctx, force) {
115
+ const { mainCheckout, currentBranch } = ctx;
116
+ if (row.branch === currentBranch) {
117
+ note(` · skip ${row.branch} — it's your current branch (cd elsewhere to remove it)`);
118
+ return "skipped";
119
+ }
120
+ // `row.merged` was computed from the REMOTE ref, but we force-delete the LOCAL
121
+ // branch below. If the local branch carries commits not yet on origin/main
122
+ // (committed-but-unpushed), skip unless --force — so `branch -D` can't silently
123
+ // discard them. (--force is the consented escape hatch.)
124
+ if (row.local && !force) {
125
+ const localOnMain = git(["merge-base", "--is-ancestor", row.branch, BASE], mainCheckout).status === 0;
126
+ if (!localOnMain) {
127
+ note(` · skip ${row.branch} — local branch has commits not on ${BASE} (unpushed?); --force to delete`);
128
+ return "skipped";
129
+ }
130
+ }
131
+ if (row.worktree) {
132
+ const insideTarget = resolve(process.cwd()) === resolve(row.worktree) ||
133
+ resolve(process.cwd()).startsWith(resolve(row.worktree) + sep);
134
+ if (insideTarget) {
135
+ note(` · skip ${row.branch} — you're inside its worktree; cd out and re-run`);
136
+ return "skipped";
137
+ }
138
+ // Distinguish "clean" from "couldn't determine": a null (git error / stale
139
+ // path) must NOT read as clean. `worktree remove` (no --force) is the backstop.
140
+ const st = gitOut(["status", "--porcelain"], row.worktree);
141
+ if (st === null) {
142
+ note(` · skip ${row.branch} — couldn't read its worktree state (${row.worktree})`);
143
+ return "skipped";
144
+ }
145
+ if (st !== "") {
146
+ note(` · skip ${row.branch} — worktree has uncommitted changes (${row.worktree})`);
147
+ return "skipped";
148
+ }
149
+ const rm = git(["worktree", "remove", row.worktree], mainCheckout);
150
+ if (rm.status !== 0) {
151
+ note(` ✗ ${row.branch}: worktree remove failed — ${(rm.stderr || "").trim().split("\n")[0]}`);
152
+ return "failed";
153
+ }
154
+ }
155
+ // -D is safe here: either the local branch is on origin/main (verified above) or
156
+ // the user passed --force. `--` guards against any future caller bypassing the
157
+ // issueIdFromBranch filter (the regex already forbids leading-dash names).
158
+ if (row.local) {
159
+ const d = git(["branch", "-D", "--", row.branch], mainCheckout);
160
+ if (d.status !== 0) {
161
+ note(` ✗ ${row.branch}: local delete failed — ${(d.stderr || "").trim().split("\n")[0]}`);
162
+ return "failed";
163
+ }
164
+ }
165
+ if (row.remote) {
166
+ const p = git(["push", "origin", "--delete", row.branch], mainCheckout);
167
+ if (p.status !== 0) {
168
+ note(` ✗ ${row.branch}: remote delete failed — ${(p.stderr || "").trim().split("\n")[0]}`);
169
+ return "failed";
170
+ }
171
+ }
172
+ note(` ✓ reaped ${row.branch}`);
173
+ return "deleted";
174
+ }
175
+ /**
176
+ * `retasc tidy` — reconcile every `rtsc-NN/*` branch against its issue status and
177
+ * merged-state, then (with --prune) delete the ones whose work is done and on
178
+ * main. Dry-run by default. Orphans (done but unmerged) are only deleted with
179
+ * --force; untracked branches (no matching issue) are never touched.
180
+ */
181
+ export async function tidyAction(opts) {
182
+ const ctx = setup();
183
+ const rows = await scan(ctx, opts.only);
184
+ if (rows.length === 0) {
185
+ note(opts.only ? `· No branch found for ${opts.only}.` : "· No issue branches (rtsc-NN/…) found.");
186
+ return;
187
+ }
188
+ if (opts.json) {
189
+ console.log(JSON.stringify(rows, null, 2));
190
+ }
191
+ else {
192
+ printTable(rows);
193
+ }
194
+ const reapable = rows.filter((r) => r.verdict === "reap");
195
+ const orphans = rows.filter((r) => r.verdict === "orphan");
196
+ const targets = [...reapable, ...(opts.force ? orphans : [])];
197
+ if (!opts.prune) {
198
+ note("");
199
+ const n = reapable.length;
200
+ note(n
201
+ ? `→ ${n} branch${n === 1 ? "" : "es"} reapable. Run \`retasc tidy --prune\` to delete${orphans.length ? `, \`--force\` to also clear ${orphans.length} orphan(s)` : ""}.`
202
+ : orphans.length
203
+ ? `→ no clean reaps; ${orphans.length} orphan(s) need review (done but unmerged) — \`--prune --force\` to delete.`
204
+ : "→ nothing to reap.");
205
+ return;
206
+ }
207
+ if (targets.length === 0) {
208
+ note("");
209
+ note(orphans.length ? "→ only orphans remain; re-run with --force to delete them." : "→ nothing to reap.");
210
+ return;
211
+ }
212
+ note("");
213
+ let deleted = 0;
214
+ let failed = 0;
215
+ for (const row of targets) {
216
+ const r = reap(row, ctx, !!opts.force);
217
+ if (r === "deleted")
218
+ deleted++;
219
+ else if (r === "failed")
220
+ failed++;
221
+ }
222
+ note(`✓ reaped ${deleted} branch${deleted === 1 ? "" : "es"}.${failed ? ` ${failed} failed.` : ""}`);
223
+ if (failed)
224
+ process.exitCode = 1; // let scripts/CI see a partial failure
225
+ }
226
+ /**
227
+ * `retasc done` — the symmetric close to `retasc claim`: mark the current issue
228
+ * (from the `rtsc-NN/` branch, or --id) done, then tear down its worktree+branch.
229
+ * Run it after the PR merges; an unmerged branch is kept (reported as an orphan)
230
+ * unless you pass --force. Run from the main checkout for full teardown — from
231
+ * inside the worktree it can't remove the tree you're standing in.
232
+ */
233
+ export async function doneAction(opts) {
234
+ const conn = resolveMcpConn({ mcpJson: readMcpJson() });
235
+ if (!conn.key) {
236
+ note("✗ No Retasc MCP key found. Run `retasc mcp install`, or set RETASC_MCP_KEY.");
237
+ process.exit(1);
238
+ }
239
+ const currentBranch = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]);
240
+ // Normalize an explicit --id to the canonical uppercase form so `done --id rtsc-93`
241
+ // matches the uppercased branch ids (and is sent canonically to save_issue).
242
+ const issueId = opts.id ? opts.id.toUpperCase() : currentBranch ? issueIdFromBranch(currentBranch) : null;
243
+ if (!issueId || !isValidIssueId(issueId)) {
244
+ note("✗ Not on a rtsc-NN/ branch — pass --id <RTSC-NN>.");
245
+ process.exit(1);
246
+ }
247
+ try {
248
+ await mcpCall(conn, "save_issue", { identifier: issueId, status: "done" });
249
+ }
250
+ catch (e) {
251
+ note(`✗ Couldn't mark ${issueId} done: ${String(e?.message ?? e).split("\n")[0]}`);
252
+ process.exit(1);
253
+ }
254
+ note(`✓ ${issueId} → done`);
255
+ note(" tidying its branch…");
256
+ await tidyAction({ prune: true, force: opts.force, only: issueId });
257
+ }
package/dist/index.js CHANGED
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
4
- import { installMcp } from "./commands/mcp.js";
4
+ import { installMcp, normalizeScope } from "./commands/mcp.js";
5
5
  import { installGate } from "./commands/gate.js";
6
6
  import { claimAction } from "./commands/claim.js";
7
+ import { bindAction } from "./commands/bind.js";
8
+ import { doctorAction } from "./commands/doctor.js";
9
+ import { readLocalBinding, resolveBinding } from "./lib/binding.js";
10
+ import { tidyAction, doneAction } from "./commands/tidy.js";
7
11
  import { runProxy } from "./proxy.js";
8
12
  import { deviceLogin } from "./auth.js";
9
13
  import { api } from "./api.js";
@@ -11,7 +15,7 @@ const program = new Command();
11
15
  program
12
16
  .name("retasc")
13
17
  .description("Retasc — sign in, create projects, mint agent API keys, and wire your agent to the MCP server.")
14
- .version("1.0.0");
18
+ .version("1.1.0");
15
19
  function requireLogin() {
16
20
  if (!isLoggedIn()) {
17
21
  console.error("Not signed in. Run `retasc login` first.");
@@ -51,9 +55,32 @@ program
51
55
  });
52
56
  program
53
57
  .command("whoami")
54
- .description("Show the signed-in user and their orgs.")
58
+ .description("Show THIS folder's org/project binding, plus the signed-in user and their orgs.")
55
59
  .action(async () => {
56
- requireLogin();
60
+ // RTSC-91 (§13): lead with the binding for the folder you're in — the same
61
+ // "you are in org X / project Y" heads-up the agent gets — so a human can
62
+ // confirm scope before any work. Resolved from the local key, server-enforced.
63
+ const local = readLocalBinding(process.cwd());
64
+ if (local?.markerOnly) {
65
+ console.log("This folder → has a Retasc marker but no key on this machine. Run `retasc bind`.\n");
66
+ }
67
+ else if (local) {
68
+ try {
69
+ const b = await resolveBinding(local.url || loadConfig().mcpUrl, local.key);
70
+ console.log(`This folder → org "${b.org.name}" / project ${b.project.prefix} (${b.project.name})`);
71
+ console.log(` as ${b.member.name}${b.member.session ? ` · session ${b.member.session}` : ""}\n`);
72
+ }
73
+ catch (e) {
74
+ console.log(`This folder → bound, but the key did not resolve: ${String(e?.message ?? e)}\n`);
75
+ }
76
+ }
77
+ else {
78
+ console.log("This folder → not bound. Run `retasc bind`.\n");
79
+ }
80
+ if (!isLoggedIn()) {
81
+ console.log("Not signed in (management). Run `retasc login` to manage orgs/projects.");
82
+ return;
83
+ }
57
84
  try {
58
85
  const me = await api.me();
59
86
  console.log(JSON.stringify(me, null, 2));
@@ -72,7 +99,7 @@ program
72
99
  .requiredOption("--prefix <PREFIX>", "Project prefix, e.g. XEN")
73
100
  .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
74
101
  .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
75
- .option("--scope <scope>", "MCP install scope: local | user | project", "user")
102
+ .option("--scope <scope>", "MCP install scope: local | project (per-folder only)", "local")
76
103
  .option("--no-watchdog", "Wire a plain direct connection instead of the liveness watchdog")
77
104
  .action(async (opts) => {
78
105
  requireLogin();
@@ -95,14 +122,41 @@ program
95
122
  keyName: `${project.prefix} key`,
96
123
  }));
97
124
  console.log(`✓ Minted API key (${minted.key.slice(0, 14)}…).`);
98
- const cfg = patchConfig({ defaultOrgId: orgId, defaultProjectPrefix: project.prefix });
125
+ // RTSC-91 (§13, override #2): never persist a global default org — binding
126
+ // is a per-folder act. (The prefix is kept only as a gate convenience.)
127
+ const cfg = patchConfig({ defaultProjectPrefix: project.prefix });
99
128
  console.log("");
100
- installMcp({ url: cfg.mcpUrl, key: minted.key, scope: opts.scope, watchdog: opts.watchdog });
129
+ installMcp({ url: cfg.mcpUrl, key: minted.key, scope: normalizeScope(opts.scope), watchdog: opts.watchdog });
101
130
  }
102
131
  catch (e) {
103
132
  fail(e);
104
133
  }
105
134
  });
135
+ // `retasc bind` — the canonical per-workspace binder (DESIGN §13). Interactive
136
+ // pick/create org → project, mint, wire the watchdog into THIS folder; loud on
137
+ // re-bind. Prefer this over `init` for binding an existing org/project.
138
+ program
139
+ .command("bind")
140
+ .description("Bind THIS workspace folder to one org + project (pick/create), and wire the watchdog.")
141
+ .option("--org-id <id>", "Use an existing org id")
142
+ .option("--org-name <name>", "Create a new org with this name")
143
+ .option("--project-id <id>", "Use an existing project id")
144
+ .option("--project <name>", "Create a new project with this name (with --prefix)")
145
+ .option("--prefix <PREFIX>", "Prefix for a new project, e.g. ACME")
146
+ .option("--agent <name>", "Agent member name (default: auto)")
147
+ .option("--runtime <runtime>", "Agent runtime", "claude-code")
148
+ .option("-y, --yes", "Don't prompt to confirm replacing an existing binding")
149
+ .action(async (opts) => {
150
+ requireLogin();
151
+ await bindAction(opts).catch(fail);
152
+ });
153
+ // `retasc doctor` — verify this folder's binding + flag any illegal global server.
154
+ program
155
+ .command("doctor")
156
+ .description("Check that THIS workspace is correctly and safely bound to one org/project.")
157
+ .action(async () => {
158
+ await doctorAction().catch(fail);
159
+ });
106
160
  // --- org / project ---------------------------------------------------------
107
161
  const org = program.command("org").description("Manage orgs.");
108
162
  org
@@ -163,7 +217,7 @@ key
163
217
  .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
164
218
  .option("--name <label>", "Key label")
165
219
  .option("--install", "Also wire the key into your agent via MCP")
166
- .option("--scope <scope>", "MCP install scope if --install", "user")
220
+ .option("--scope <scope>", "MCP install scope if --install: local | project", "local")
167
221
  .action(async (opts) => {
168
222
  requireLogin();
169
223
  try {
@@ -179,7 +233,7 @@ key
179
233
  if (opts.install) {
180
234
  const cfg = loadConfig();
181
235
  console.log("");
182
- installMcp({ url: cfg.mcpUrl, key: res.key, scope: opts.scope, watchdog: true });
236
+ installMcp({ url: cfg.mcpUrl, key: res.key, scope: normalizeScope(opts.scope), watchdog: true });
183
237
  }
184
238
  }
185
239
  catch (e) {
@@ -232,12 +286,12 @@ mcp
232
286
  .command("install")
233
287
  .description("Register the Retasc MCP server with your agent (Claude Code) or write .mcp.json.")
234
288
  .requiredOption("--key <key>", "A Retasc API key (from `retasc key mint`)")
235
- .option("--scope <scope>", "local | user | project", "user")
289
+ .option("--scope <scope>", "local | project (per-folder only)", "local")
236
290
  .option("--url <url>", "Override the MCP URL")
237
291
  .option("--no-watchdog", "Wire a plain direct connection instead of the liveness watchdog (default: watchdog on)")
238
292
  .action((opts) => {
239
293
  const cfg = loadConfig();
240
- installMcp({ url: opts.url ?? cfg.mcpUrl, key: opts.key, scope: opts.scope, watchdog: opts.watchdog });
294
+ installMcp({ url: opts.url ?? cfg.mcpUrl, key: opts.key, scope: normalizeScope(opts.scope), watchdog: opts.watchdog });
241
295
  });
242
296
  // Internal: the watchdog proxy, spawned by the harness (not for manual use).
243
297
  mcp
@@ -288,6 +342,20 @@ addClaimFlags(program
288
342
  addClaimFlags(program
289
343
  .command("next")
290
344
  .description("Claim the next unblocked issue and drop into a fresh worktree (alias of `claim`).")).action((opts) => claimAction(opts).catch(fail));
345
+ // --- branch hygiene (close the worktree+branch claim opened) ----------------
346
+ program
347
+ .command("tidy")
348
+ .description("Reconcile rtsc-NN/* branches against their issue status; reap the done+merged ones.")
349
+ .option("--prune", "Delete reapable branches + worktrees (default: dry-run, just report)")
350
+ .option("--force", "Also delete orphans (issue done but branch unmerged)")
351
+ .option("--json", "Emit the reconciled branch table as JSON")
352
+ .action((opts) => tidyAction(opts).catch(fail));
353
+ program
354
+ .command("done")
355
+ .description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
356
+ .option("--id <RTSC-NN>", "The issue to close (default: derived from the current branch)")
357
+ .option("--force", "Tear down even if the branch isn't merged into main yet")
358
+ .action((opts) => doneAction(opts).catch(fail));
291
359
  // --- config ----------------------------------------------------------------
292
360
  program
293
361
  .command("config")
@@ -0,0 +1,68 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getBinding } from "./keystore.js";
4
+ /** Read the Retasc MCP entry from a folder's ./.mcp.json, if any. */
5
+ export function readLocalBinding(dir) {
6
+ const path = join(dir, ".mcp.json");
7
+ if (!existsSync(path))
8
+ return undefined;
9
+ let doc;
10
+ try {
11
+ doc = JSON.parse(readFileSync(path, "utf8"));
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ const s = doc?.mcpServers?.retasc;
17
+ if (!s)
18
+ return undefined;
19
+ // Canonical (RTSC-92): secret-free marker → resolve the key from the keystore.
20
+ const workspaceId = s.env?.RETASC_WORKSPACE;
21
+ if (workspaceId) {
22
+ const entry = getBinding(workspaceId);
23
+ if (entry)
24
+ return { key: entry.key, url: entry.url, watchdog: true, workspaceId };
25
+ // Marker present but no keystore entry (e.g. a cloned repo) → needs binding.
26
+ return { key: "", url: "", watchdog: true, workspaceId, markerOnly: true };
27
+ }
28
+ // Legacy inline-key forms (pre-RTSC-92): key in env or the Authorization header.
29
+ if (s.env?.RETASC_MCP_KEY) {
30
+ return { key: String(s.env.RETASC_MCP_KEY), url: String(s.env.RETASC_MCP_URL ?? ""), watchdog: true, legacy: true };
31
+ }
32
+ const auth = s.headers?.Authorization ?? s.headers?.authorization;
33
+ if (auth) {
34
+ const m = String(auth).match(/^Bearer\s+(.+)$/i);
35
+ return { key: m ? m[1].trim() : String(auth).trim(), url: String(s.url ?? ""), watchdog: false, legacy: true };
36
+ }
37
+ return undefined;
38
+ }
39
+ /** Resolve a key's org/project by calling the MCP `whoami` tool (server-enforced
40
+ * scope — the same banner the agent sees). Throws on an invalid/revoked key. */
41
+ export async function resolveBinding(url, key) {
42
+ const res = await fetch(url, {
43
+ method: "POST",
44
+ headers: {
45
+ Authorization: `Bearer ${key}`,
46
+ "Content-Type": "application/json",
47
+ Accept: "application/json",
48
+ },
49
+ body: JSON.stringify({
50
+ jsonrpc: "2.0",
51
+ id: 1,
52
+ method: "tools/call",
53
+ params: { name: "whoami", arguments: {} },
54
+ }),
55
+ });
56
+ if (!res.ok)
57
+ throw new Error(`MCP server returned ${res.status}`);
58
+ const body = await res.json();
59
+ if (body?.error)
60
+ throw new Error(body.error.message ?? "MCP error");
61
+ const result = body?.result;
62
+ if (result?.isError)
63
+ throw new Error(result?.content?.[0]?.text ?? "key not accepted");
64
+ const text = result?.content?.[0]?.text;
65
+ if (!text)
66
+ throw new Error("empty whoami response");
67
+ return JSON.parse(text);
68
+ }
@@ -0,0 +1,52 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
4
+ import { randomUUID } from "node:crypto";
5
+ /** The dir the keystore lives in. RETASC_DIR overrides it (tests, sandboxes). */
6
+ function keystoreDir() {
7
+ return process.env.RETASC_DIR || join(homedir(), ".retasc");
8
+ }
9
+ export function keystorePath() {
10
+ return join(keystoreDir(), "bindings.json");
11
+ }
12
+ export function loadKeystore() {
13
+ const p = keystorePath();
14
+ if (!existsSync(p))
15
+ return { version: 1, bindings: {} };
16
+ try {
17
+ const doc = JSON.parse(readFileSync(p, "utf8"));
18
+ return { version: 1, bindings: doc?.bindings ?? {} };
19
+ }
20
+ catch {
21
+ // Corrupt keystore — don't crash; treat as empty (a re-bind heals it).
22
+ return { version: 1, bindings: {} };
23
+ }
24
+ }
25
+ export function saveKeystore(ks) {
26
+ mkdirSync(keystoreDir(), { recursive: true });
27
+ const p = keystorePath();
28
+ writeFileSync(p, JSON.stringify(ks, null, 2) + "\n", "utf8");
29
+ try {
30
+ chmodSync(p, 0o600); // secrets — user-only
31
+ }
32
+ catch {
33
+ /* best effort (e.g. Windows) */
34
+ }
35
+ }
36
+ export function getBinding(workspaceId) {
37
+ return loadKeystore().bindings[workspaceId];
38
+ }
39
+ export function setBinding(workspaceId, entry) {
40
+ const ks = loadKeystore();
41
+ ks.bindings[workspaceId] = entry;
42
+ saveKeystore(ks);
43
+ }
44
+ export function removeBinding(workspaceId) {
45
+ const ks = loadKeystore();
46
+ delete ks.bindings[workspaceId];
47
+ saveKeystore(ks);
48
+ }
49
+ /** A fresh opaque workspace id for a marker. Not secret, not a path. */
50
+ export function newWorkspaceId() {
51
+ return `ws_${randomUUID()}`;
52
+ }
@@ -0,0 +1,58 @@
1
+ // Pure helpers for `retasc tidy` / `retasc done` (RTSC-93). Side-effect-free
2
+ // (no git, no network) so branch→issue parsing, verdict classification, and the
3
+ // worktree-porcelain parse can be unit-tested. The git + API work lives in
4
+ // ../commands/tidy.ts.
5
+ //
6
+ // The principle: detecting a stale branch is a JOIN of two facts — the issue's
7
+ // status (Retasc / control plane, read-only) and whether the branch is merged
8
+ // (git / execution plane). This file is just the join logic; it owns no state.
9
+ /**
10
+ * The issue id embedded in a `rtsc-NN/<slug>` branch name, normalized to the
11
+ * uppercase id (`rtsc-37/foo` → `RTSC-37`). Returns null for branches that don't
12
+ * follow the convention (`main`, `hotfix/x`, a bare `rtsc-37` with no slash) —
13
+ * those are never matched, so they can never be auto-deleted. Generic over the
14
+ * prefix, so it also matches `xen-12/…`.
15
+ */
16
+ export function issueIdFromBranch(branch) {
17
+ const m = branch.match(/^([a-z][a-z0-9]*)-(\d+)\//);
18
+ return m ? `${m[1].toUpperCase()}-${m[2]}` : null;
19
+ }
20
+ /**
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)
24
+ * - done/canceled + unmerged → orphan (review — work may have landed elsewhere)
25
+ * - todo/doing/blocked → active (keep — work in progress)
26
+ * - no matching issue → untracked (report only — never auto-delete)
27
+ * `status` is null when no issue maps to the branch (off-convention name, or the
28
+ * issue wasn't found).
29
+ */
30
+ export function classifyBranch(opts) {
31
+ const { status, merged } = opts;
32
+ if (status === null)
33
+ return "untracked";
34
+ if (status === "done" || status === "canceled")
35
+ return merged ? "reap" : "orphan";
36
+ return "active";
37
+ }
38
+ /**
39
+ * Parse `git worktree list --porcelain` into {path, branch}. Entries are blank-
40
+ * line separated; each starts with `worktree <path>`, and a `branch refs/heads/x`
41
+ * line names its branch (absent for detached worktrees).
42
+ */
43
+ export function parseWorktreePorcelain(out) {
44
+ return out
45
+ .split(/\n\n+/)
46
+ .map((block) => {
47
+ const lines = block.split("\n");
48
+ const pathLine = lines.find((l) => l.startsWith("worktree "));
49
+ if (!pathLine)
50
+ return null;
51
+ const branchLine = lines.find((l) => l.startsWith("branch "));
52
+ const branch = branchLine
53
+ ? branchLine.slice("branch ".length).trim().replace(/^refs\/heads\//, "")
54
+ : null;
55
+ return { path: pathLine.slice("worktree ".length).trim(), branch };
56
+ })
57
+ .filter((w) => w !== null);
58
+ }
package/dist/proxy.js CHANGED
@@ -8,8 +8,26 @@
8
8
  import { createInterface } from "node:readline";
9
9
  import { hostname } from "node:os";
10
10
  import { applyObservation, heartbeatRequest, isClaimLost, } from "./lib/watchdog.js";
11
- const MCP_URL = process.env.RETASC_MCP_URL || "https://mcp.retasc.com/mcp";
12
- const KEY = process.env.RETASC_MCP_KEY || "";
11
+ import { getBinding } from "./lib/keystore.js";
12
+ // RTSC-92: resolve the workspace key. Canonical path — a secret-free marker sets
13
+ // RETASC_WORKSPACE; we look the key up in the home keystore (never in the repo).
14
+ // Legacy path — RETASC_MCP_KEY inlined in an old ./.mcp.json still works.
15
+ function resolveKeyUrl() {
16
+ const envUrl = process.env.RETASC_MCP_URL || "https://mcp.retasc.com/mcp";
17
+ if (process.env.RETASC_MCP_KEY) {
18
+ return { key: process.env.RETASC_MCP_KEY, url: envUrl };
19
+ }
20
+ const wsId = process.env.RETASC_WORKSPACE;
21
+ if (wsId) {
22
+ const entry = getBinding(wsId);
23
+ if (entry)
24
+ return { key: entry.key, url: entry.url || envUrl };
25
+ }
26
+ return { key: "", url: envUrl };
27
+ }
28
+ const resolved = resolveKeyUrl();
29
+ let MCP_URL = resolved.url;
30
+ const KEY = resolved.key;
13
31
  const HEARTBEAT_MS = Number(process.env.RETASC_HEARTBEAT_MS) || 10 * 60 * 1000;
14
32
  const leases = new Map();
15
33
  let hbSeq = -1; // out-of-band heartbeat ids are negative — never collide with the harness's
@@ -63,6 +81,28 @@ async function adoptSessionKey() {
63
81
  log(`session-key mint failed (${String(e?.message ?? e)}) — using the workspace key`);
64
82
  }
65
83
  }
84
+ /**
85
+ * RTSC-91 (DESIGN §13, D2): announce the binding to the HUMAN on startup, so the
86
+ * org/project this session will write into is visible in the harness's MCP logs
87
+ * before any work. Best-effort — never blocks serving traffic.
88
+ */
89
+ async function announceBinding() {
90
+ try {
91
+ const resp = await postRemote({
92
+ jsonrpc: "2.0",
93
+ id: -1001,
94
+ method: "tools/call",
95
+ params: { name: "whoami", arguments: {} },
96
+ });
97
+ const r = toolResult(resp);
98
+ if (r?.org && r?.project) {
99
+ log(`bound → org "${r.org.name}" / project ${r.project.prefix}`);
100
+ }
101
+ }
102
+ catch {
103
+ /* announcement is best-effort */
104
+ }
105
+ }
66
106
  // The tool result payload — the JSON inside result.content[0].text — or the raw result.
67
107
  function toolResult(resp) {
68
108
  try {
@@ -133,6 +173,7 @@ export async function runProxy() {
133
173
  // Adopt a per-session key BEFORE serving traffic, so even the first claim is
134
174
  // attributed to this session. stdin buffers in the OS pipe meanwhile.
135
175
  await adoptSessionKey();
176
+ await announceBinding();
136
177
  const timer = setInterval(() => void heartbeatAll(), HEARTBEAT_MS);
137
178
  timer.unref?.(); // the timer alone must not keep the process alive
138
179
  const rl = createInterface({ input: process.stdin });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Retasc CLI — sign in with GitHub, 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": {