@retasc/cli 1.2.1 → 1.2.3

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.
@@ -1,7 +1,8 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { basename, dirname, resolve, sep } from "node:path";
4
- import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, } from "../lib/claim.js";
4
+ import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, normalizeIssueId, workspacePrefix, } from "../lib/claim.js";
5
+ import { loadConfig } from "../config.js";
5
6
  function git(args, cwd) {
6
7
  return spawnSync("git", args, { encoding: "utf8", cwd });
7
8
  }
@@ -28,6 +29,50 @@ export async function claimAction(opts) {
28
29
  note(" (Looked at $RETASC_MCP_KEY and ./.mcp.json.)");
29
30
  process.exit(1);
30
31
  }
32
+ // RTSC-148: resolve the requested issue id up front — a positional (`claim 143`,
33
+ // `claim RTSC-143`) or --id, normalized to PREFIX-NN. An unresolvable reference
34
+ // fails HERE; it must never fall through to "claim whatever is next".
35
+ // A bare number expands against the WORKSPACE's prefix (whoami over this
36
+ // workspace's key — the key decides the org/project), falling back to the
37
+ // global config only if the server can't be asked.
38
+ let prefix;
39
+ let prefixResolved = false;
40
+ let requestedId;
41
+ for (const [label, raw] of [
42
+ ["issue", opts.issueArg],
43
+ ["--id", opts.id],
44
+ ]) {
45
+ // Only ABSENT skips — an empty string (e.g. `claim "$UNSET_VAR"`) must hit
46
+ // the loud-failure path below, not fall through to next_issue.
47
+ if (raw === undefined)
48
+ continue;
49
+ if (/^\d+$/.test(raw.trim()) && !prefixResolved) {
50
+ prefix = (await workspacePrefix(conn)) ?? loadConfig().defaultProjectPrefix;
51
+ prefixResolved = true;
52
+ }
53
+ const norm = normalizeIssueId(raw, prefix);
54
+ if (!norm) {
55
+ if (/^\d+$/.test(raw.trim()) && !prefix) {
56
+ note(`✗ "${raw}" is a bare number and the project prefix couldn't be resolved from this workspace.`);
57
+ note(" Use the full id (e.g. RTSC-" + raw.trim() + ").");
58
+ }
59
+ else {
60
+ note(`✗ "${raw}" doesn't look like an issue id (expected RTSC-NN or a bare number).`);
61
+ }
62
+ process.exit(1);
63
+ }
64
+ if (requestedId && requestedId !== norm) {
65
+ note(`✗ Conflicting issue ids: positional "${requestedId}" vs ${label} "${norm}". Pass just one.`);
66
+ process.exit(1);
67
+ }
68
+ requestedId = norm;
69
+ }
70
+ // --mine filters the next_issue pull; on a targeted claim it would be
71
+ // silently ignored — reject instead of letting the intent evaporate.
72
+ if (requestedId && opts.mine) {
73
+ note(`✗ --mine only applies when pulling the next issue; drop it to claim ${requestedId} directly.`);
74
+ process.exit(1);
75
+ }
31
76
  const makeWorktree = opts.worktree !== false;
32
77
  // Must be in a git repo to place the worktree. Resolve the *main* checkout so
33
78
  // worktrees are always siblings of it, even when claiming from another worktree.
@@ -52,8 +97,8 @@ export async function claimAction(opts) {
52
97
  // --- claim over MCP ------------------------------------------------------
53
98
  let claim;
54
99
  try {
55
- const result = opts.id
56
- ? await mcpCall(conn, "claim_issue", { identifier: opts.id })
100
+ const result = requestedId
101
+ ? await mcpCall(conn, "claim_issue", { identifier: requestedId })
57
102
  : await mcpCall(conn, "next_issue", opts.mine ? { mine: true } : {});
58
103
  claim = parseClaimResult(result);
59
104
  }
@@ -62,9 +107,9 @@ export async function claimAction(opts) {
62
107
  note(`✗ ${msg}`);
63
108
  // Re-claiming an issue you already hold is the "resume my own work" case —
64
109
  // point at the worktree instead of leaving the user stuck on the error.
65
- if (opts.id && /ALREADY_CLAIMED/.test(msg) && makeWorktree) {
66
- note(` ${opts.id} is held. If it's your session, resume in ${parentDir}/${repoName}-${opts.id.toLowerCase()}`);
67
- note(` or \`retasc release ${opts.id}\` first.`);
110
+ if (requestedId && /ALREADY_CLAIMED/.test(msg) && makeWorktree) {
111
+ note(` ${requestedId} is held. If it's your session, resume in ${parentDir}/${repoName}-${requestedId.toLowerCase()}`);
112
+ note(` or \`retasc release ${requestedId}\` first.`);
68
113
  }
69
114
  process.exit(1);
70
115
  }
package/dist/index.js CHANGED
@@ -414,12 +414,19 @@ function addClaimFlags(cmd) {
414
414
  .option("--print-path", "Print only the worktree path on stdout (for `cd \"$(…)\"`)")
415
415
  .option("--json", "Emit the claim + worktree as JSON");
416
416
  }
417
+ // RTSC-148: `claim` takes the issue as a positional too (`retasc claim 143` /
418
+ // `retasc claim RTSC-143`). Excess arguments are rejected loudly on both
419
+ // commands — Commander otherwise silently drops an undeclared positional, which
420
+ // turned "claim this specific issue" into "claim whatever is next".
417
421
  addClaimFlags(program
418
422
  .command("claim")
419
- .description("Claim an issue (--id, or the next unblocked) and drop into a fresh worktree.")).action((opts) => claimAction(opts).catch(fail));
423
+ .description("Claim an issue (RTSC-NN, a bare number, --id, or the next unblocked) and drop into a fresh worktree.")
424
+ .argument("[issue]", "Issue to claim, e.g. RTSC-143 or 143 (defaults to the next unblocked)")
425
+ .allowExcessArguments(false)).action((issue, opts) => claimAction({ ...opts, issueArg: issue }).catch(fail));
420
426
  addClaimFlags(program
421
427
  .command("next")
422
- .description("Claim the next unblocked issue and drop into a fresh worktree (alias of `claim`).")).action((opts) => claimAction(opts).catch(fail));
428
+ .description("Claim the next unblocked issue and drop into a fresh worktree (alias of `claim`).")
429
+ .allowExcessArguments(false)).action((opts) => claimAction(opts).catch(fail));
423
430
  // --- branch hygiene (close the worktree+branch claim opened) ----------------
424
431
  program
425
432
  .command("tidy")
package/dist/lib/claim.js CHANGED
@@ -60,6 +60,36 @@ export function slugFromTitle(title, max = 50) {
60
60
  export function isValidIssueId(id) {
61
61
  return typeof id === "string" && /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(id);
62
62
  }
63
+ /**
64
+ * Normalize a user-typed issue reference (`143`, `rtsc-143`, `RTSC-143`) to the
65
+ * canonical `PREFIX-NN` form. A bare number needs `defaultPrefix` (from the
66
+ * logged-in project) to be resolvable. Returns null when the input can't be an
67
+ * issue id — the caller decides how to fail loudly (RTSC-148: a typo must never
68
+ * silently fall through to `next_issue`).
69
+ */
70
+ /**
71
+ * The workspace's project prefix, asked of the server over the workspace's own
72
+ * key (`whoami`). Org/project routing IS the key — the global config's prefix
73
+ * may belong to a different workspace, so this is the authoritative source for
74
+ * expanding a bare issue number. Best-effort: undefined on any failure.
75
+ */
76
+ export async function workspacePrefix(conn, fetchImpl = fetch) {
77
+ try {
78
+ const who = (await mcpCall(conn, "whoami", {}, fetchImpl));
79
+ const p = who?.project?.prefix;
80
+ return typeof p === "string" && p ? p : undefined;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
86
+ export function normalizeIssueId(input, defaultPrefix) {
87
+ const s = input.trim();
88
+ if (/^\d+$/.test(s)) {
89
+ return defaultPrefix ? `${defaultPrefix.toUpperCase()}-${s}` : null;
90
+ }
91
+ return isValidIssueId(s) ? s.toUpperCase() : null;
92
+ }
63
93
  /**
64
94
  * Derive the branch + sibling worktree path from an issue, matching the CLAUDE.md
65
95
  * convention: branch `rtsc-NN/<slug>`, worktree dir `../<repo>-rtsc-NN`.
@@ -57,10 +57,33 @@ export function heartbeatRequest(rpcId, issueId, claimToken) {
57
57
  params: { name: "heartbeat", arguments: { identifier: issueId, claimToken } },
58
58
  };
59
59
  }
60
- /** A heartbeat result of CLAIM_LOST means the lease is gone — stop tracking it. */
60
+ /**
61
+ * A heartbeat CLAIM_LOST means the lease is gone — stop tracking it.
62
+ *
63
+ * The server throws `Error("CLAIM_LOST: …")` (convex/lib/claims.ts), and http.ts
64
+ * surfaces that as an MCP tool ERROR: `{ isError: true, content:[{text:"CLAIM_LOST: …"}] }`.
65
+ * Match ONLY that signal (or a top-level `error`/`code` field, for a future
66
+ * structured shape) — NEVER `JSON.stringify` the whole result and regex it, or a
67
+ * benign payload field that merely CONTAINS "CLAIM_LOST" (an issue title, a
68
+ * checkpoint note quoting an error, a comment body) would silently drop a live
69
+ * lease and let the reclaimer hand our work to someone else (RTSC-150).
70
+ */
61
71
  export function isClaimLost(result) {
62
72
  if (!result || typeof result !== "object")
63
73
  return false;
64
- const text = JSON.stringify(result);
65
- return /CLAIM_LOST/.test(text);
74
+ const r = result;
75
+ // A structured error signal on a top-level field (belt-and-suspenders; also the
76
+ // shape the older unit test asserts). Bounded to top-level scalars, not nested.
77
+ if (r.code === "CLAIM_LOST")
78
+ return true;
79
+ if (typeof r.error === "string" && /CLAIM_LOST/.test(r.error))
80
+ return true;
81
+ // The live shape: only an MCP error envelope, and only its text blocks — success
82
+ // payloads (isError absent/false) are never treated as a lost claim.
83
+ if (r.isError !== true || !Array.isArray(r.content))
84
+ return false;
85
+ return r.content.some((c) => c !== null &&
86
+ typeof c === "object" &&
87
+ typeof c.text === "string" &&
88
+ /CLAIM_LOST/.test(c.text));
66
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
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": {