@retasc/cli 1.0.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/dist/config.js ADDED
@@ -0,0 +1,56 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
4
+ // Production defaults. Overridable via env for dev/testing.
5
+ // RETASC_DEPLOYMENT_URL — Convex deployment (.cloud) for management calls
6
+ // RETASC_MCP_URL — the MCP endpoint agents connect to
7
+ export const DEFAULTS = {
8
+ deploymentUrl: process.env.RETASC_DEPLOYMENT_URL ?? "https://unique-lyrebird-934.convex.cloud",
9
+ mcpUrl: process.env.RETASC_MCP_URL ?? "https://mcp.retasc.com/mcp",
10
+ };
11
+ const DIR = join(homedir(), ".retasc");
12
+ const FILE = join(DIR, "config.json");
13
+ export function configPath() {
14
+ return FILE;
15
+ }
16
+ export function loadConfig() {
17
+ let stored = {};
18
+ if (existsSync(FILE)) {
19
+ try {
20
+ stored = JSON.parse(readFileSync(FILE, "utf8"));
21
+ }
22
+ catch {
23
+ // Corrupt config — start fresh rather than crash.
24
+ stored = {};
25
+ }
26
+ }
27
+ // Defaults fill in; a saved value always wins.
28
+ return {
29
+ deploymentUrl: stored.deploymentUrl ?? DEFAULTS.deploymentUrl,
30
+ mcpUrl: stored.mcpUrl ?? DEFAULTS.mcpUrl,
31
+ token: stored.token,
32
+ refreshToken: stored.refreshToken,
33
+ user: stored.user,
34
+ defaultOrgId: stored.defaultOrgId,
35
+ defaultProjectPrefix: stored.defaultProjectPrefix,
36
+ };
37
+ }
38
+ export function saveConfig(cfg) {
39
+ mkdirSync(DIR, { recursive: true });
40
+ writeFileSync(FILE, JSON.stringify(cfg, null, 2) + "\n", "utf8");
41
+ // Tokens live here — keep it user-only readable.
42
+ try {
43
+ chmodSync(FILE, 0o600);
44
+ }
45
+ catch {
46
+ /* best effort (e.g. Windows) */
47
+ }
48
+ }
49
+ export function patchConfig(patch) {
50
+ const next = { ...loadConfig(), ...patch };
51
+ saveConfig(next);
52
+ return next;
53
+ }
54
+ export function isLoggedIn(cfg = loadConfig()) {
55
+ return Boolean(cfg.token);
56
+ }
package/dist/index.js ADDED
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { loadConfig, patchConfig, saveConfig, configPath, isLoggedIn } from "./config.js";
4
+ import { installMcp } from "./commands/mcp.js";
5
+ import { installGate } from "./commands/gate.js";
6
+ import { claimAction } from "./commands/claim.js";
7
+ import { runProxy } from "./proxy.js";
8
+ import { deviceLogin } from "./auth.js";
9
+ import { api } from "./api.js";
10
+ const program = new Command();
11
+ program
12
+ .name("retasc")
13
+ .description("Retasc — sign in, create projects, mint agent API keys, and wire your agent to the MCP server.")
14
+ .version("1.0.0");
15
+ function requireLogin() {
16
+ if (!isLoggedIn()) {
17
+ console.error("Not signed in. Run `retasc login` first.");
18
+ process.exit(1);
19
+ }
20
+ }
21
+ function fail(e) {
22
+ const msg = String(e?.message ?? e).split("\n")[0].replace(/^.*?Uncaught Error:\s*/, "");
23
+ console.error(`✗ ${msg}`);
24
+ process.exit(1);
25
+ }
26
+ // --- auth ------------------------------------------------------------------
27
+ program
28
+ .command("login")
29
+ .description("Sign in with GitHub (device flow).")
30
+ .action(async () => {
31
+ try {
32
+ await deviceLogin();
33
+ const me = (await api.me());
34
+ const u = me?.user ?? {};
35
+ console.log(`\n✓ Signed in${u.name ? ` as ${u.name}` : ""}${u.email ? ` <${u.email}>` : ""}.`);
36
+ }
37
+ catch (e) {
38
+ fail(e);
39
+ }
40
+ });
41
+ program
42
+ .command("logout")
43
+ .description("Forget the local session.")
44
+ .action(() => {
45
+ const cfg = loadConfig();
46
+ delete cfg.token;
47
+ delete cfg.refreshToken;
48
+ delete cfg.user;
49
+ saveConfig(cfg);
50
+ console.log("✓ Signed out.");
51
+ });
52
+ program
53
+ .command("whoami")
54
+ .description("Show the signed-in user and their orgs.")
55
+ .action(async () => {
56
+ requireLogin();
57
+ try {
58
+ const me = await api.me();
59
+ console.log(JSON.stringify(me, null, 2));
60
+ }
61
+ catch (e) {
62
+ fail(e);
63
+ }
64
+ });
65
+ // --- onboarding ------------------------------------------------------------
66
+ program
67
+ .command("init")
68
+ .description("Create an org + project, mint an agent key, and wire it into your agent — one shot.")
69
+ .option("--org <name>", "Org name (creates a new org)")
70
+ .option("--org-id <id>", "Use an existing org id instead of creating one")
71
+ .requiredOption("--project <name>", "Project name")
72
+ .requiredOption("--prefix <PREFIX>", "Project prefix, e.g. XEN")
73
+ .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
74
+ .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
75
+ .option("--scope <scope>", "MCP install scope: local | user | project", "user")
76
+ .option("--no-watchdog", "Wire a plain direct connection instead of the liveness watchdog")
77
+ .action(async (opts) => {
78
+ requireLogin();
79
+ try {
80
+ let orgId = opts.orgId;
81
+ if (!orgId) {
82
+ if (!opts.org)
83
+ return fail("Provide --org <name> to create an org, or --org-id <id>.");
84
+ const org = await api.createOrg({ name: opts.org });
85
+ orgId = org.orgId;
86
+ console.log(`✓ Created org (${org.slug}).`);
87
+ }
88
+ const project = (await api.createProject({ orgId: orgId, name: opts.project, prefix: opts.prefix }));
89
+ console.log(`✓ Created project ${project.prefix}.`);
90
+ const minted = (await api.mintKey({
91
+ orgId: orgId,
92
+ projectId: project.projectId,
93
+ agentName: opts.agent, // undefined → backend auto-names "{you}'s {runtime}"
94
+ runtime: opts.runtime,
95
+ keyName: `${project.prefix} key`,
96
+ }));
97
+ console.log(`✓ Minted API key (${minted.key.slice(0, 14)}…).`);
98
+ const cfg = patchConfig({ defaultOrgId: orgId, defaultProjectPrefix: project.prefix });
99
+ console.log("");
100
+ installMcp({ url: cfg.mcpUrl, key: minted.key, scope: opts.scope, watchdog: opts.watchdog });
101
+ }
102
+ catch (e) {
103
+ fail(e);
104
+ }
105
+ });
106
+ // --- org / project ---------------------------------------------------------
107
+ const org = program.command("org").description("Manage orgs.");
108
+ org
109
+ .command("create")
110
+ .requiredOption("--name <name>")
111
+ .option("--slug <slug>")
112
+ .action(async (opts) => {
113
+ requireLogin();
114
+ try {
115
+ const res = await api.createOrg({ name: opts.name, slug: opts.slug });
116
+ console.log(JSON.stringify(res, null, 2));
117
+ }
118
+ catch (e) {
119
+ fail(e);
120
+ }
121
+ });
122
+ const project = program.command("project").description("Manage projects.");
123
+ project
124
+ .command("create")
125
+ .requiredOption("--org-id <id>")
126
+ .requiredOption("--name <name>")
127
+ .requiredOption("--prefix <PREFIX>")
128
+ .action(async (opts) => {
129
+ requireLogin();
130
+ try {
131
+ const res = await api.createProject({ orgId: opts.orgId, name: opts.name, prefix: opts.prefix });
132
+ console.log(JSON.stringify(res, null, 2));
133
+ }
134
+ catch (e) {
135
+ fail(e);
136
+ }
137
+ });
138
+ project
139
+ .command("rename-prefix")
140
+ .description("Rename a project's issue prefix (rewrites every issue id + reference).")
141
+ .requiredOption("--project-id <id>")
142
+ .requiredOption("--prefix <PREFIX>", "New prefix, e.g. RTSC")
143
+ .action(async (opts) => {
144
+ requireLogin();
145
+ try {
146
+ const res = (await api.renameProjectPrefix({
147
+ projectId: opts.projectId,
148
+ newPrefix: opts.prefix,
149
+ }));
150
+ console.log(`✓ Renamed ${res.oldPrefix} → ${res.newPrefix} (${res.issues} issue id${res.issues === 1 ? "" : "s"} rewritten).`);
151
+ }
152
+ catch (e) {
153
+ fail(e);
154
+ }
155
+ });
156
+ // --- keys ------------------------------------------------------------------
157
+ const key = program.command("key").description("Mint/rotate/revoke/list agent API keys.");
158
+ key
159
+ .command("mint")
160
+ .requiredOption("--org-id <id>")
161
+ .requiredOption("--project-id <id>")
162
+ .option("--agent <name>", "Agent member name (default: auto, \"{you}'s {runtime}\")")
163
+ .option("--runtime <runtime>", "Agent runtime: claude-code | codex | opencode | …", "claude-code")
164
+ .option("--name <label>", "Key label")
165
+ .option("--install", "Also wire the key into your agent via MCP")
166
+ .option("--scope <scope>", "MCP install scope if --install", "user")
167
+ .action(async (opts) => {
168
+ requireLogin();
169
+ try {
170
+ const res = (await api.mintKey({
171
+ orgId: opts.orgId,
172
+ projectId: opts.projectId,
173
+ agentName: opts.agent,
174
+ runtime: opts.runtime,
175
+ keyName: opts.name,
176
+ }));
177
+ console.log(`✓ Minted key: ${res.key}`);
178
+ console.log(" (Shown once — store it now.)");
179
+ if (opts.install) {
180
+ const cfg = loadConfig();
181
+ console.log("");
182
+ installMcp({ url: cfg.mcpUrl, key: res.key, scope: opts.scope, watchdog: true });
183
+ }
184
+ }
185
+ catch (e) {
186
+ fail(e);
187
+ }
188
+ });
189
+ key
190
+ .command("list")
191
+ .requiredOption("--org-id <id>")
192
+ .action(async (opts) => {
193
+ requireLogin();
194
+ try {
195
+ const res = await api.listKeys({ orgId: opts.orgId });
196
+ console.log(JSON.stringify(res, null, 2));
197
+ }
198
+ catch (e) {
199
+ fail(e);
200
+ }
201
+ });
202
+ key
203
+ .command("rotate")
204
+ .requiredOption("--key-id <id>")
205
+ .action(async (opts) => {
206
+ requireLogin();
207
+ try {
208
+ const res = (await api.rotateKey({ keyId: opts.keyId }));
209
+ console.log(`✓ Rotated. New key: ${res.key}`);
210
+ console.log(" (Shown once — the old key is revoked.)");
211
+ }
212
+ catch (e) {
213
+ fail(e);
214
+ }
215
+ });
216
+ key
217
+ .command("revoke")
218
+ .requiredOption("--key-id <id>")
219
+ .action(async (opts) => {
220
+ requireLogin();
221
+ try {
222
+ await api.revokeKey({ keyId: opts.keyId });
223
+ console.log("✓ Revoked.");
224
+ }
225
+ catch (e) {
226
+ fail(e);
227
+ }
228
+ });
229
+ // --- mcp wiring ------------------------------------------------------------
230
+ const mcp = program.command("mcp").description("Wire the Retasc MCP server into your agent.");
231
+ mcp
232
+ .command("install")
233
+ .description("Register the Retasc MCP server with your agent (Claude Code) or write .mcp.json.")
234
+ .requiredOption("--key <key>", "A Retasc API key (from `retasc key mint`)")
235
+ .option("--scope <scope>", "local | user | project", "user")
236
+ .option("--url <url>", "Override the MCP URL")
237
+ .option("--no-watchdog", "Wire a plain direct connection instead of the liveness watchdog (default: watchdog on)")
238
+ .action((opts) => {
239
+ const cfg = loadConfig();
240
+ installMcp({ url: opts.url ?? cfg.mcpUrl, key: opts.key, scope: opts.scope, watchdog: opts.watchdog });
241
+ });
242
+ // Internal: the watchdog proxy, spawned by the harness (not for manual use).
243
+ mcp
244
+ .command("proxy", { hidden: true })
245
+ .description("Run the liveness watchdog proxy (spawned by your harness; wired by mcp install).")
246
+ .action(() => runProxy());
247
+ // Top-level alias so harness config can spawn `retasc mcp-proxy`.
248
+ program
249
+ .command("mcp-proxy", { hidden: true })
250
+ .description("Run the liveness watchdog proxy (spawned by your harness).")
251
+ .action(() => runProxy());
252
+ // --- gate (commit↔issue traceability) --------------------------------------
253
+ const gate = program.command("gate").description("Wire the commit↔issue traceability gate into a repo.");
254
+ gate
255
+ .command("install")
256
+ .description("Install a prefix-correct commit-msg hook + check-commit-message Action into this repo.")
257
+ .option("--prefix <PREFIX>", "Project prefix to enforce (default: your configured project)")
258
+ .option("--no-hook", "Skip the local commit-msg hook (CI Action only)")
259
+ .option("--no-action", "Skip the GitHub Action (local hook only)")
260
+ .action((opts) => {
261
+ try {
262
+ const prefix = (opts.prefix ?? loadConfig().defaultProjectPrefix)?.toUpperCase();
263
+ if (!prefix) {
264
+ return fail("no project prefix — pass --prefix <PREFIX>, or run `retasc init`/`login` so it's resolved from your project.");
265
+ }
266
+ installGate({ prefix, layers: { hook: opts.hook, action: opts.action } });
267
+ }
268
+ catch (e) {
269
+ fail(e);
270
+ }
271
+ });
272
+ // --- work (claim → worktree) -----------------------------------------------
273
+ function addClaimFlags(cmd) {
274
+ return cmd
275
+ .option("--id <RTSC-NN>", "Claim one specific issue instead of the next unblocked one")
276
+ .option("--mine", "Pull only issues assigned to your principal (or unassigned)")
277
+ .option("--base <ref>", "Base ref for the new branch", "origin/main")
278
+ .option("--dir <path>", "Parent dir for the worktree (default: beside the main checkout)")
279
+ .option("--no-fetch", "Skip refreshing the base ref from origin first")
280
+ .option("--no-worktree", "Just claim — don't create a worktree")
281
+ .option("--shell", "Spawn a subshell inside the new worktree (exit to return)")
282
+ .option("--print-path", "Print only the worktree path on stdout (for `cd \"$(…)\"`)")
283
+ .option("--json", "Emit the claim + worktree as JSON");
284
+ }
285
+ addClaimFlags(program
286
+ .command("claim")
287
+ .description("Claim an issue (--id, or the next unblocked) and drop into a fresh worktree.")).action((opts) => claimAction(opts).catch(fail));
288
+ addClaimFlags(program
289
+ .command("next")
290
+ .description("Claim the next unblocked issue and drop into a fresh worktree (alias of `claim`).")).action((opts) => claimAction(opts).catch(fail));
291
+ // --- config ----------------------------------------------------------------
292
+ program
293
+ .command("config")
294
+ .description("Show the resolved CLI config (paths + endpoints).")
295
+ .action(() => {
296
+ const cfg = loadConfig();
297
+ console.log(`config file: ${configPath()}`);
298
+ console.log(`deployment url: ${cfg.deploymentUrl}`);
299
+ console.log(`mcp url: ${cfg.mcpUrl}`);
300
+ console.log(`signed in: ${isLoggedIn(cfg) ? `yes${cfg.user?.name ? ` (${cfg.user.name})` : ""}` : "no"}`);
301
+ });
302
+ program.parseAsync(process.argv);
@@ -0,0 +1,145 @@
1
+ // Pure helpers for `retasc claim` / `retasc next` (RTSC-38). Kept side-effect-free
2
+ // (no git, no process spawning) so the slug/branch/worktree-path derivation and the
3
+ // MCP-key resolution can be unit-tested. The git + shell work lives in
4
+ // ../commands/claim.ts.
5
+ import { readFileSync, existsSync } from "node:fs";
6
+ import { join, resolve } from "node:path";
7
+ const DEFAULT_MCP_URL = "https://mcp.retasc.com/mcp";
8
+ /**
9
+ * Resolve the agent API key + MCP URL the same way the workspace's MCP wiring does,
10
+ * so `retasc claim` speaks as the *same agent* the harness already uses. Order:
11
+ * explicit env (RETASC_MCP_KEY / RETASC_MCP_URL — what the watchdog proxy reads)
12
+ * wins, then the workspace `.mcp.json` in both shapes installMcp writes:
13
+ * - stdio watchdog: { command, args, env: { RETASC_MCP_URL, RETASC_MCP_KEY } }
14
+ * - http direct: { type:"http", url, headers:{ Authorization:"Bearer <key>" } }
15
+ * `key` comes back empty if nothing is found, so the caller can error clearly.
16
+ */
17
+ export function resolveMcpConn(opts = {}) {
18
+ const env = opts.env ?? process.env;
19
+ let url = env.RETASC_MCP_URL || "";
20
+ let key = env.RETASC_MCP_KEY || "";
21
+ const entry = opts.mcpJson?.mcpServers?.retasc;
22
+ if (entry) {
23
+ if (!key && typeof entry.env?.RETASC_MCP_KEY === "string")
24
+ key = entry.env.RETASC_MCP_KEY;
25
+ if (!url && typeof entry.env?.RETASC_MCP_URL === "string")
26
+ url = entry.env.RETASC_MCP_URL;
27
+ if (!url && typeof entry.url === "string")
28
+ url = entry.url;
29
+ if (!key && typeof entry.headers?.Authorization === "string") {
30
+ const m = entry.headers.Authorization.match(/^Bearer\s+(.+)$/i);
31
+ if (m)
32
+ key = m[1].trim();
33
+ }
34
+ }
35
+ return { url: url || opts.defaultUrl || DEFAULT_MCP_URL, key };
36
+ }
37
+ /** Read & parse the workspace `.mcp.json` (or null if absent/corrupt). */
38
+ export function readMcpJson(dir = process.cwd()) {
39
+ const p = join(dir, ".mcp.json");
40
+ if (!existsSync(p))
41
+ return null;
42
+ try {
43
+ return JSON.parse(readFileSync(p, "utf8"));
44
+ }
45
+ catch {
46
+ return null;
47
+ }
48
+ }
49
+ /**
50
+ * Kebab-case slug for a branch name: lowercase, non-alphanumeric runs become a
51
+ * single '-', trimmed and capped. Always returns something git-legal.
52
+ */
53
+ export function slugFromTitle(title, max = 50) {
54
+ const slug = (title || "")
55
+ .toLowerCase()
56
+ .replace(/[^a-z0-9]+/g, "-")
57
+ .replace(/^-+|-+$/g, "")
58
+ .slice(0, max)
59
+ .replace(/-+$/g, "");
60
+ return slug || "issue";
61
+ }
62
+ /**
63
+ * Issue ids look like `RTSC-38` (prefix + number). The server is trusted, but the
64
+ * id flows into a git ref and a filesystem path, so we validate its shape before
65
+ * using it — a malformed id (containing `/`, `..`, a leading `-`, …) is rejected
66
+ * rather than turned into a stray path or an injected git flag.
67
+ */
68
+ export function isValidIssueId(id) {
69
+ return typeof id === "string" && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(id);
70
+ }
71
+ /**
72
+ * Derive the branch + sibling worktree path from an issue, matching the CLAUDE.md
73
+ * convention: branch `rtsc-NN/<slug>`, worktree dir `../<repo>-rtsc-NN`.
74
+ */
75
+ export function planWorktree(opts) {
76
+ const lc = opts.issueId.toLowerCase(); // "RTSC-38" → "rtsc-38"
77
+ const branch = `${lc}/${slugFromTitle(opts.title)}`;
78
+ const dirName = `${opts.repoName}-${lc}`;
79
+ return { branch, dirName, path: resolve(opts.parentDir, dirName) };
80
+ }
81
+ /**
82
+ * Normalize a next_issue / claim_issue tool payload. next_issue returns a full
83
+ * issue object (with title); claim_issue returns just the identifier string. An
84
+ * empty next_issue returns {issue:null, ready, blocked, claimed}.
85
+ */
86
+ export function parseClaimResult(result) {
87
+ if (!result || typeof result !== "object")
88
+ return {};
89
+ const r = result;
90
+ const issueId = typeof r.issue === "string" ? r.issue : r.issue?.id;
91
+ // An empty next_issue (issue:null) — or any payload without a usable id — is a
92
+ // stall, never a claim. Surface the queue counts so it's never silent.
93
+ if (!issueId) {
94
+ return { ready: r.ready, blocked: r.blocked, claimed: r.claimed };
95
+ }
96
+ const title = typeof r.issue === "object" ? r.issue?.title : undefined;
97
+ return { issueId, title, claimToken: r.claimToken };
98
+ }
99
+ /** The tool payload (JSON inside result.content[0].text), or the raw result. */
100
+ export function toolResult(resp) {
101
+ try {
102
+ const t = resp?.result?.content?.[0]?.text;
103
+ return t ? JSON.parse(t) : resp?.result;
104
+ }
105
+ catch {
106
+ return resp?.result;
107
+ }
108
+ }
109
+ /**
110
+ * Call one MCP tool over JSON-RPC with the agent key. Throws on transport errors
111
+ * and on tool errors (isError) — surfacing the server's message (CLAIM_LOST,
112
+ * NOT_FOUND, ALREADY_CLAIMED, …) so the caller can print it verbatim.
113
+ */
114
+ export async function mcpCall(conn, name, args = {}, fetchImpl = fetch) {
115
+ const res = await fetchImpl(conn.url, {
116
+ method: "POST",
117
+ headers: {
118
+ Authorization: `Bearer ${conn.key}`,
119
+ "Content-Type": "application/json",
120
+ Accept: "application/json",
121
+ },
122
+ body: JSON.stringify({
123
+ jsonrpc: "2.0",
124
+ id: 1,
125
+ method: "tools/call",
126
+ params: { name, arguments: args },
127
+ }),
128
+ });
129
+ const text = await res.text();
130
+ // A non-2xx (expired/invalid key → 401, server error → 5xx) must not look like
131
+ // an empty queue. Surface the status + a body snippet so a bad key is obvious.
132
+ if (!res.ok) {
133
+ const snippet = text ? `: ${text.slice(0, 200).replace(/\s+/g, " ").trim()}` : "";
134
+ throw new Error(`MCP request failed (HTTP ${res.status})${snippet}`);
135
+ }
136
+ const resp = text ? JSON.parse(text) : null;
137
+ if (resp?.error) {
138
+ throw new Error(resp.error?.message || "MCP transport error");
139
+ }
140
+ if (resp?.result?.isError) {
141
+ const msg = resp.result?.content?.[0]?.text || "tool error";
142
+ throw new Error(msg);
143
+ }
144
+ return toolResult(resp);
145
+ }
@@ -0,0 +1,66 @@
1
+ // Liveness watchdog core (RTSC-44) — pure, harness-agnostic lease tracking.
2
+ //
3
+ // The proxy (proxy.ts) feeds this the MCP tool calls it sees flow through. This
4
+ // module maintains the set of leases THIS session holds (issueId → claimToken),
5
+ // so a timer can heartbeat them deterministically — the LLM never participates.
6
+ // No I/O here on purpose: it's the correctness core, unit-tested in isolation.
7
+ function idOf(issue) {
8
+ if (typeof issue === "string")
9
+ return issue;
10
+ if (issue && typeof issue === "object" && typeof issue.id === "string") {
11
+ return issue.id;
12
+ }
13
+ return undefined;
14
+ }
15
+ /**
16
+ * Update the lease set from one observed tool call (its request args + result).
17
+ * - next_issue / claim_issue response → { issue:{id}|"ID", claimToken } → register
18
+ * - next_batch(claim) response → { issues:[{id, claimToken}] } → register each
19
+ * - release_issue request → drop that issue's lease
20
+ * - save_issue(status: done|canceled) request → drop that issue's lease
21
+ */
22
+ export function applyObservation(leases, o) {
23
+ const r = o.result;
24
+ if (r && typeof r === "object") {
25
+ const obj = r;
26
+ if (typeof obj.claimToken === "string") {
27
+ const id = idOf(obj.issue);
28
+ if (id)
29
+ leases.set(id, obj.claimToken);
30
+ }
31
+ if (Array.isArray(obj.issues)) {
32
+ for (const it of obj.issues) {
33
+ if (it && typeof it.id === "string" && typeof it.claimToken === "string") {
34
+ leases.set(it.id, it.claimToken);
35
+ }
36
+ }
37
+ }
38
+ }
39
+ // Releases / terminal status drop the lease — the issue is named in the REQUEST.
40
+ const id = typeof o.args?.identifier === "string" ? o.args.identifier : undefined;
41
+ if (id) {
42
+ if (o.toolName === "release_issue")
43
+ leases.delete(id);
44
+ if (o.toolName === "save_issue") {
45
+ const status = o.args?.status;
46
+ if (status === "done" || status === "canceled")
47
+ leases.delete(id);
48
+ }
49
+ }
50
+ }
51
+ /** A JSON-RPC `heartbeat` tool call for one lease (the proxy's timer sends these). */
52
+ export function heartbeatRequest(rpcId, issueId, claimToken) {
53
+ return {
54
+ jsonrpc: "2.0",
55
+ id: rpcId,
56
+ method: "tools/call",
57
+ params: { name: "heartbeat", arguments: { identifier: issueId, claimToken } },
58
+ };
59
+ }
60
+ /** A heartbeat result of CLAIM_LOST means the lease is gone — stop tracking it. */
61
+ export function isClaimLost(result) {
62
+ if (!result || typeof result !== "object")
63
+ return false;
64
+ const text = JSON.stringify(result);
65
+ return /CLAIM_LOST/.test(text);
66
+ }