@retasc/cli 1.34.1 → 1.35.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,29 @@ 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.35.0 (2026-08-25)
10
+
11
+ - **RTSC-709** — `retasc issue show` and `retasc issue list` read the queue from a
12
+ terminal. `tidy` and `done` both reason about issue status, and until now a human asked
13
+ to accept their verdict had no way to see that status without opening the Dash. Both
14
+ default to the current `rtsc-NN/` branch's issue, `list` takes the same filters the MCP
15
+ surface does (status, priority, label, author, assignee, SLA, limit), and both take
16
+ `--json`.
17
+ - **RTSC-709** — `retasc checkpoint` and `retasc check-claim` close the claim lifecycle.
18
+ `claim`, `next`, `release` and `done` all existed, so a human could take work, hand it
19
+ back and finish it, but could not record progress on it or answer the question
20
+ `workflow.md` says to settle at runtime: is anything actually renewing my lease?
21
+ `check-claim` prints the last renewal time and exits non-zero when you do not hold the
22
+ issue, so a script can branch on it.
23
+ - **RTSC-709** — `done` reads the issue back before it closes it. It was one word that
24
+ closed an issue with no read path to confirm first, and it closed one whose PR was still
25
+ open and unreviewed. It now prints the id, the title, the status transition and whether
26
+ the branch is merged, and asks before closing on a TTY. `--dry-run` shows the readback
27
+ and the teardown plan without touching anything, `-y` skips the prompt, and a non-TTY
28
+ still closes, so scripts are unchanged.
29
+ - **RTSC-709** — `cli/README.md` now records which MCP tools are deliberately Dash-and-MCP-only,
30
+ and why, so their absence from the CLI reads as a decision rather than an oversight.
31
+
9
32
  ## 1.34.1 (2026-08-25)
10
33
 
11
34
  - **RTSC-741** — a rejected write no longer costs you the lease. The watchdog decided
package/README.md CHANGED
@@ -57,6 +57,10 @@ Run `retasc whoami` (shows this folder's binding) or `retasc doctor` (checks it)
57
57
  them.
58
58
  - **A persistent, multi-tenant, identity-bearing store** of human-legible work.
59
59
 
60
+ The CLI speaks the same lease: `retasc claim`, `retasc checkpoint`, `retasc check-claim`,
61
+ `retasc release` and `retasc done` are the human forms of the tools an agent calls, over
62
+ this folder's own key. A person and an agent working the same queue see the same rows.
63
+
60
64
  ## Common commands
61
65
 
62
66
  | Command | What it does |
@@ -72,8 +76,52 @@ Run `retasc whoami` (shows this folder's binding) or `retasc doctor` (checks it)
72
76
  | `retasc gate install` | Install a commit↔issue traceability gate in a repo |
73
77
  | `retasc config` | Show the resolved CLI config (paths + endpoints) |
74
78
 
79
+ Working an issue from the terminal:
80
+
81
+ | Command | What it does |
82
+ |---------|--------------|
83
+ | `retasc claim [issue]` / `retasc next` | Claim an issue (or the next unblocked one) and drop into a fresh worktree |
84
+ | `retasc issue show [issue]` | Show one issue in full (defaults to the current `rtsc-NN/` branch's issue) |
85
+ | `retasc issue list` | List issues, filtered by status, priority, label, author, assignee or SLA state |
86
+ | `retasc checkpoint [issue]` | Record a handoff note on your claimed issue, and renew its lease |
87
+ | `retasc check-claim [issue]` | Does this session still hold the lease, and is anything renewing it? |
88
+ | `retasc release <issue>` | Hand a claim back to the queue. Leaves your worktree and branch alone |
89
+ | `retasc done` | Close the issue and tear down its worktree + branch (asks first; `--dry-run` previews) |
90
+ | `retasc tidy` | Reconcile `rtsc-NN/*` branches against issue status; reap the done + merged ones |
91
+
75
92
  Run `retasc --help` or `retasc <command> --help` for the full set.
76
93
 
94
+ ## What the CLI deliberately does not do
95
+
96
+ The MCP server exposes 49 tools. The CLI has commands for 27 of them, and the gap is a decision
97
+ rather than a backlog (RTSC-709). The CLI's job is to sign you in, set up a workspace, and
98
+ let a **human** take, track and close a piece of work at a terminal. Everything else is for
99
+ the **agent** (over MCP, where it already is) or the **Dash** (where a person can see it).
100
+
101
+ So these have no CLI command, on purpose:
102
+
103
+ | Area | Tools | Why not here |
104
+ |------|-------|--------------|
105
+ | Comments | `save_comment`, `list_comments`, `retract_comment` | A thread is something you read, not something you page through a terminal. Agents comment over MCP; people comment in the Dash |
106
+ | Attachments | `save_attachment`, `list_attachments`, `get_attachment`, `get_attachment_file`, `save_attachment_file`, `prepare_attachment_upload`, `obsolete_attachment` | Binary in, binary out. The Dash previews them; a terminal can only tell you they exist. `retasc proxy` already serves these for your agent, which is who attaches things |
107
+ | Dependency graph | `add_relation`, `remove_relation` | Editing the graph is planning work, and planning wants the whole graph in front of you |
108
+ | Labels | `save_label`, `list_labels` | Org-wide taxonomy, set once in the Dash |
109
+ | Wave dispatch | `next_batch` | It claims N issues at once for a fleet. One human at one terminal takes one issue: that is `retasc claim` |
110
+ | Queue diagnosis | `queue_status` | The Dash queue view is this, with the graph drawn |
111
+ | Member state | `suspend_member`, `reactivate_member` | Owner administration, next to the rest of it in the Dash |
112
+ | Projects | `get_project`, `list_projects` | A workspace is bound to exactly one project (`retasc whoami` says which). Listing the others is a Dash question |
113
+ | Connectors | `list_connectors`, `revoke_connector` | OAuth-shaped setup, done where the OAuth happens |
114
+
115
+ Two more are reachable but not as their own command, which is also deliberate:
116
+
117
+ - **`heartbeat`** — `retasc checkpoint` renews the lease and leaves the note that makes a
118
+ handoff resumable. A bare renewal with nothing to say is the watchdog's job, and
119
+ `retasc bind` wires that up for you.
120
+ - **`get_issue`** — it is what `retasc issue show` and `retasc tidy` call.
121
+
122
+ If you want any of the above from a terminal anyway, say so in an issue: the point of this
123
+ list is that their absence is a choice we wrote down, not one nobody has noticed.
124
+
77
125
  ## Links
78
126
 
79
127
  - Website — [retasc.com](https://retasc.com)
@@ -1,7 +1,7 @@
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, normalizeIssueId, workspacePrefix, } from "../lib/claim.js";
4
+ import { resolveMcpConn, readMcpJson, mcpCall, parseClaimResult, planWorktree, isValidIssueId, normalizeIssueId, resolveIssueRef, } from "../lib/claim.js";
5
5
  import { loadConfig } from "../config.js";
6
6
  function git(args, cwd) {
7
7
  return spawnSync("git", args, { encoding: "utf8", cwd });
@@ -35,8 +35,10 @@ export async function claimAction(opts) {
35
35
  // A bare number expands against the WORKSPACE's prefix (whoami over this
36
36
  // workspace's key — the key decides the org/project), falling back to the
37
37
  // global config only if the server can't be asked.
38
- let prefix;
39
- let prefixResolved = false;
38
+ // RTSC-709: the expansion rule itself now lives in lib/claim.ts (`resolveIssueRef`),
39
+ // shared with `issue show` / `checkpoint` / `check-claim`. The cache keeps this loop to
40
+ // a single `whoami` when both forms are passed, exactly as the inline version did.
41
+ const prefixCache = { fallback: () => loadConfig().defaultProjectPrefix };
40
42
  let requestedId;
41
43
  for (const [label, raw] of [
42
44
  ["issue", opts.issueArg],
@@ -46,26 +48,16 @@ export async function claimAction(opts) {
46
48
  // the loud-failure path below, not fall through to next_issue.
47
49
  if (raw === undefined)
48
50
  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
- }
51
+ const resolved = await resolveIssueRef(conn, raw, prefixCache);
52
+ if (resolved.error) {
53
+ note(`✗ ${resolved.error}`);
62
54
  process.exit(1);
63
55
  }
64
- if (requestedId && requestedId !== norm) {
65
- note(`✗ Conflicting issue ids: positional "${requestedId}" vs ${label} "${norm}". Pass just one.`);
56
+ if (requestedId && requestedId !== resolved.id) {
57
+ note(`✗ Conflicting issue ids: positional "${requestedId}" vs ${label} "${resolved.id}". Pass just one.`);
66
58
  process.exit(1);
67
59
  }
68
- requestedId = norm;
60
+ requestedId = resolved.id;
69
61
  }
70
62
  // --all-lanes widens the next_issue pull; on a targeted claim it would be
71
63
  // silently ignored — reject instead of letting the intent evaporate.
@@ -0,0 +1,163 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { resolveMcpConn, readMcpJson, mcpCall, resolveIssueRef, isValidIssueId, } from "../lib/claim.js";
3
+ import { issueIdFromBranch } from "../lib/tidy.js";
4
+ import { loadConfig } from "../config.js";
5
+ import { issueView, issueListView, claimStatusView } from "../lib/format.js";
6
+ // RTSC-709 — the read half of the claim lifecycle.
7
+ //
8
+ // The CLI could claim work, release it and close it, but never SHOW it. That gap is
9
+ // load-bearing in two places: `retasc tidy` gives a verdict per branch derived from issue
10
+ // status, and `retasc done` writes a terminal status, and a human judging either had to
11
+ // open the Dash to see what they were judging. `checkpoint` and `check-claim` are the same
12
+ // gap on the lease: you could take a lease and hand it back, but not record progress on it
13
+ // or ask whether anything was still renewing it.
14
+ //
15
+ // Everything here is a thin pass to the MCP tool of the same name over the workspace's own
16
+ // key, so the CLI and the agent read exactly the same rows. The formatting lives in
17
+ // lib/format.ts, pure and pinned by tests.
18
+ /** The workspace's MCP connection, or a message naming how to get one. */
19
+ function conn() {
20
+ const c = resolveMcpConn({ mcpJson: readMcpJson() });
21
+ if (!c.key) {
22
+ throw new Error("No Retasc MCP key found for this folder.\n" +
23
+ " Run `retasc bind` here (or `retasc mcp install`), or set RETASC_MCP_KEY.");
24
+ }
25
+ return c;
26
+ }
27
+ function currentBranch() {
28
+ const r = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
29
+ return r.status === 0 ? (r.stdout ?? "").trim() || null : null;
30
+ }
31
+ /**
32
+ * The issue this command is about: what was typed, or the one the current `rtsc-NN/`
33
+ * branch is for.
34
+ *
35
+ * The branch fallback is the same one `retasc done` uses, and it is the form that matters
36
+ * in a worktree: you are standing in the checkout for exactly one issue, and naming it
37
+ * again is a chance to name the wrong one.
38
+ */
39
+ async function targetIssue(c, raw) {
40
+ if (raw !== undefined) {
41
+ const cache = { fallback: () => loadConfig().defaultProjectPrefix };
42
+ const resolved = await resolveIssueRef(c, raw, cache);
43
+ if (resolved.error !== undefined)
44
+ throw new Error(resolved.error);
45
+ return resolved.id;
46
+ }
47
+ const branch = currentBranch();
48
+ const fromBranch = branch ? issueIdFromBranch(branch) : null;
49
+ if (!fromBranch || !isValidIssueId(fromBranch)) {
50
+ throw new Error("Which issue? Pass one (e.g. RTSC-42), or run this from its `rtsc-NN/` branch.");
51
+ }
52
+ return fromBranch;
53
+ }
54
+ // --- issue show / issue list ------------------------------------------------
55
+ export async function issueShowAction(opts) {
56
+ const c = conn();
57
+ const identifier = await targetIssue(c, opts.issueArg);
58
+ const issue = (await mcpCall(c, "get_issue", { identifier }));
59
+ console.log(opts.json ? JSON.stringify(issue, null, 2) : issueView(issue));
60
+ }
61
+ // A local copy of the server's `STATUS` union (convex/schema.ts), kept so a typo fails
62
+ // here with the list in it instead of as a validator error from the wire. It is drift-
63
+ // guarded: convex/cliStatusDrift709.test.ts fails if the two ever disagree.
64
+ const STATUSES = ["todo", "doing", "blocked", "review", "done", "canceled"];
65
+ const SLA_STATES = ["ok", "warning", "breaching", "breached"];
66
+ export async function issueListAction(opts) {
67
+ const args = {};
68
+ if (opts.status) {
69
+ // Comma-separated because the server takes one status or many, and a filter you can
70
+ // only pass one value to is the reason someone goes back to the Dash.
71
+ const wanted = opts.status.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
72
+ const bad = wanted.filter((s) => !STATUSES.includes(s));
73
+ if (bad.length) {
74
+ throw new Error(`Unknown status: ${bad.join(", ")}. Pick from ${STATUSES.join(" | ")}.`);
75
+ }
76
+ args.status = wanted.length === 1 ? wanted[0] : wanted;
77
+ }
78
+ if (opts.priority !== undefined) {
79
+ const p = Number(opts.priority);
80
+ // NaN would reach the server as `null` and silently widen the filter to everything.
81
+ if (!Number.isInteger(p) || p < 0 || p > 4)
82
+ throw new Error("--priority takes 0..4 (0 = none, 1 = urgent).");
83
+ args.priority = p;
84
+ }
85
+ if (opts.sla) {
86
+ const s = opts.sla.trim().toLowerCase();
87
+ if (!SLA_STATES.includes(s)) {
88
+ throw new Error(`Unknown --sla: ${opts.sla}. Pick from ${SLA_STATES.join(" | ")}.`);
89
+ }
90
+ args.slaState = s;
91
+ }
92
+ if (opts.limit !== undefined) {
93
+ const n = Number(opts.limit);
94
+ if (!Number.isInteger(n) || n < 1)
95
+ throw new Error("--limit takes a positive whole number (max 200).");
96
+ args.limit = n;
97
+ }
98
+ for (const k of ["label", "author", "assignee"])
99
+ if (opts[k])
100
+ args[k] = opts[k];
101
+ const res = (await mcpCall(conn(), "list_issues", args));
102
+ if (opts.json) {
103
+ console.log(JSON.stringify(res, null, 2));
104
+ return;
105
+ }
106
+ const rows = Array.isArray(res?.issues) ? res.issues : [];
107
+ console.log(issueListView(rows));
108
+ // The default filter hides done + canceled, so a short list is not necessarily a small
109
+ // project. Say which set was counted rather than letting the number imply the whole one.
110
+ if (rows.length) {
111
+ console.log("");
112
+ console.log(`${rows.length} issue${rows.length === 1 ? "" : "s"}${opts.status ? "" : " (active work; done and canceled are hidden, pass --status to include them)"}.`);
113
+ }
114
+ }
115
+ // --- checkpoint / check-claim ----------------------------------------------
116
+ /**
117
+ * `retasc checkpoint [issue] --note … --claim-token …`
118
+ *
119
+ * The lease half of the gap. `workflow.md` tells a worker to checkpoint at any long-silence
120
+ * boundary, and until now that instruction could only be followed by an agent: a human at a
121
+ * terminal could take a lease and give it back, but not leave the note the next holder
122
+ * resumes from. It renews the lease too, which is the other reason to run it.
123
+ */
124
+ export async function checkpointAction(opts) {
125
+ if (!opts.note?.trim()) {
126
+ throw new Error("A checkpoint needs a note: `retasc checkpoint --note \"what's done / what's next\"`.");
127
+ }
128
+ // Fenced by the server on claim_token (only the holder may write the note), and the CLI
129
+ // stores no tokens, so refusing here with the reason beats sending a call that comes back
130
+ // as a fence error with no context. Same contract as `retasc release`.
131
+ if (!opts.claimToken) {
132
+ throw new Error("Checkpointing needs the issue's claim token: `retasc checkpoint --claim-token <token>`.\n" +
133
+ " The token is printed when you claim, and is in the `--json` output of `retasc claim`.\n" +
134
+ " It proves you are the session holding the lease, which is what stops one agent\n" +
135
+ " overwriting another's handoff note.");
136
+ }
137
+ const c = conn();
138
+ const identifier = await targetIssue(c, opts.issueArg);
139
+ const res = (await mcpCall(c, "checkpoint", {
140
+ identifier,
141
+ note: opts.note,
142
+ claimToken: opts.claimToken,
143
+ }));
144
+ console.log(`✓ Checkpointed ${identifier}.`);
145
+ if (typeof res?.claimExpiresAt === "number") {
146
+ const mins = Math.round((res.claimExpiresAt - Date.now()) / 60000);
147
+ console.log(` Lease renewed, ${mins}m left. The note survives a release or a reclaim.`);
148
+ }
149
+ }
150
+ /**
151
+ * `retasc check-claim [issue]`
152
+ *
153
+ * Read-only. Exits non-zero when this session does not hold the lease, so it can gate the
154
+ * thing its own tool description tells you to gate: `retasc check-claim && git commit …`.
155
+ */
156
+ export async function checkClaimAction(opts) {
157
+ const c = conn();
158
+ const identifier = await targetIssue(c, opts.issueArg);
159
+ const res = (await mcpCall(c, "check_claim", { identifier }));
160
+ console.log(opts.json ? JSON.stringify(res, null, 2) : claimStatusView(res));
161
+ if (!res?.youHold)
162
+ process.exitCode = 1;
163
+ }
@@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import { dirname, resolve, sep } from "node:path";
3
3
  import { resolveMcpConn, readMcpJson, mcpCall, isValidIssueId } from "../lib/claim.js";
4
4
  import { issueIdFromBranch, classifyBranch, parseWorktreePorcelain, } from "../lib/tidy.js";
5
+ import { confirm, isInteractive } from "../lib/prompt.js";
6
+ import { doneReadback } from "../lib/format.js";
5
7
  // `retasc tidy` / `retasc done` (RTSC-93). The CLI already creates the worktree +
6
8
  // branch on claim (commands/claim.ts); this closes the loop — it tears them down
7
9
  // when the issue is done, reading status read-only from the control plane and
@@ -37,6 +39,14 @@ function setup() {
37
39
  const currentBranch = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]);
38
40
  return { conn, mainCheckout, currentBranch };
39
41
  }
42
+ /** Short ref names under a refspec (`origin/` stripped when asked). */
43
+ function refList(mainCheckout, refspec, strip) {
44
+ return (gitOut(["for-each-ref", "--format=%(refname:short)", refspec], mainCheckout) ?? "")
45
+ .split("\n")
46
+ .map((s) => s.trim())
47
+ .filter(Boolean)
48
+ .map((s) => (strip ? s.replace(strip, "") : s));
49
+ }
40
50
  /** Build the reconciled branch table: every issue-shaped branch (local ∪ remote)
41
51
  * joined with its issue status (read-only) and merged-into-main state. */
42
52
  async function scan(ctx, only) {
@@ -48,13 +58,8 @@ async function scan(ctx, only) {
48
58
  for (const w of worktrees)
49
59
  if (w.branch)
50
60
  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");
61
+ const locals = refList(mainCheckout, "refs/heads/");
62
+ const remotes = refList(mainCheckout, "refs/remotes/origin/", "origin/").filter((b) => b !== "HEAD");
58
63
  const localSet = new Set(locals);
59
64
  const remoteSet = new Set(remotes);
60
65
  let branches = [...new Set([...locals, ...remotes])]
@@ -223,35 +228,98 @@ export async function tidyAction(opts) {
223
228
  if (failed)
224
229
  process.exitCode = 1; // let scripts/CI see a partial failure
225
230
  }
231
+ /**
232
+ * Is every branch for this issue already on origin/main? null when it has no branch here.
233
+ *
234
+ * Pessimistic on purpose: if ANY branch for the issue is unmerged, this says unmerged. The
235
+ * readback exists to stop a close over open work, so the cautious answer is the true one.
236
+ */
237
+ function issueBranchMerged(ctx, issueId) {
238
+ const locals = refList(ctx.mainCheckout, "refs/heads/");
239
+ const remotes = refList(ctx.mainCheckout, "refs/remotes/origin/", "origin/").filter((b) => b !== "HEAD");
240
+ const remoteSet = new Set(remotes);
241
+ const branches = [...new Set([...locals, ...remotes])].filter((b) => issueIdFromBranch(b) === issueId);
242
+ if (branches.length === 0)
243
+ return null;
244
+ return branches.every((b) => git(["merge-base", "--is-ancestor", remoteSet.has(b) ? `origin/${b}` : b, BASE], ctx.mainCheckout)
245
+ .status === 0);
246
+ }
226
247
  /**
227
248
  * `retasc done` — the symmetric close to `retasc claim`: mark the current issue
228
249
  * (from the `rtsc-NN/` branch, or --id) done, then tear down its worktree+branch.
229
250
  * Run it after the PR merges; an unmerged branch is kept (reported as an orphan)
230
251
  * unless you pass --force. Run from the main checkout for full teardown — from
231
252
  * inside the worktree it can't remove the tree you're standing in.
253
+ *
254
+ * RTSC-709 — it reads the issue back and asks before closing it. This command writes a
255
+ * terminal status from a single word, and it shipped with nothing in front of that write:
256
+ * a stray `retasc done` closed RTSC-706 while its PR was open and unreviewed, printed a
257
+ * tick, and went on to offer branch deletion. `tidy` already asks before it deletes a
258
+ * branch; closing an issue is the less reversible of the two.
259
+ *
260
+ * The prompt is skipped when stdin is not a TTY, so scripts and CI keep working exactly as
261
+ * before — the accident this guards against is a human typing a word, and a pipeline that
262
+ * hangs on a question nobody can answer is a worse failure than the one being fixed.
232
263
  */
233
264
  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"]);
265
+ // setup() resolves the MCP key AND the main checkout. Both are needed either way, and
266
+ // failing on a missing repo BEFORE the write beats closing the issue and then finding
267
+ // the teardown can't run.
268
+ const ctx = setup();
240
269
  // Normalize an explicit --id to the canonical uppercase form so `done --id rtsc-93`
241
270
  // matches the uppercased branch ids (and is sent canonically to save_issue).
242
- const issueId = opts.id ? opts.id.toUpperCase() : currentBranch ? issueIdFromBranch(currentBranch) : null;
271
+ const issueId = opts.id
272
+ ? opts.id.toUpperCase()
273
+ : ctx.currentBranch
274
+ ? issueIdFromBranch(ctx.currentBranch)
275
+ : null;
243
276
  if (!issueId || !isValidIssueId(issueId)) {
244
277
  note("✗ Not on a rtsc-NN/ branch — pass --id <RTSC-NN>.");
245
278
  process.exit(1);
246
279
  }
280
+ // Refresh before judging merged-state: a stale origin/main reports merged work as
281
+ // unmerged, which is the warning that teaches people to ignore the warning.
282
+ git(["fetch", "origin", "--prune"], ctx.mainCheckout);
283
+ let issue = null;
247
284
  try {
248
- await mcpCall(conn, "save_issue", { identifier: issueId, status: "done" });
285
+ issue = await mcpCall(ctx.conn, "get_issue", { identifier: issueId });
249
286
  }
250
287
  catch (e) {
251
- note(`✗ Couldn't mark ${issueId} done: ${String(e?.message ?? e).split("\n")[0]}`);
252
- process.exit(1);
288
+ // A failed read must not become a silent close. Say so, and let the confirmation
289
+ // below carry the decision with what little is known.
290
+ note(`⚠ Couldn't read ${issueId}: ${String(e?.message ?? e).split("\n")[0]}`);
291
+ }
292
+ note(doneReadback({ ...(issue ?? {}), id: issueId }, issueBranchMerged(ctx, issueId)));
293
+ if (opts.dryRun) {
294
+ note("");
295
+ note(`· Dry run: ${issueId} was NOT closed. Below is what the teardown would do.`);
296
+ note("");
297
+ await tidyAction({ force: opts.force, only: issueId });
298
+ return;
299
+ }
300
+ // Already done: go straight to the teardown. Re-sending the write would cost a metered
301
+ // write and bump updatedAt to say something changed when nothing did.
302
+ if (issue?.status === "done") {
303
+ note("");
304
+ note(`· ${issueId} is already done; tidying its branch.`);
305
+ }
306
+ else {
307
+ if (isInteractive() && !opts.yes) {
308
+ note("");
309
+ if (!(await confirm(`Close ${issueId} and tear down its branch?`))) {
310
+ note("· Nothing closed.");
311
+ return;
312
+ }
313
+ }
314
+ try {
315
+ await mcpCall(ctx.conn, "save_issue", { identifier: issueId, status: "done" });
316
+ }
317
+ catch (e) {
318
+ note(`✗ Couldn't mark ${issueId} done: ${String(e?.message ?? e).split("\n")[0]}`);
319
+ process.exit(1);
320
+ }
321
+ note(`✓ ${issueId} → done`);
253
322
  }
254
- note(`✓ ${issueId} → done`);
255
323
  note(" tidying its branch…");
256
324
  await tidyAction({ prune: true, force: opts.force, only: issueId });
257
325
  }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { billingAction } from "./commands/billing.js";
17
17
  import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
18
18
  import { whoamiView, orgCreatedView, projectCreatedView, keyListView, inviteListView, } from "./lib/format.js";
19
19
  import { tidyAction, doneAction } from "./commands/tidy.js";
20
+ import { issueShowAction, issueListAction, checkpointAction, checkClaimAction, } from "./commands/issue.js";
20
21
  import { runProxy } from "./proxy.js";
21
22
  import { deviceLogin } from "./auth.js";
22
23
  import { api, formatError } from "./api.js";
@@ -620,7 +621,51 @@ program
620
621
  .description("Mark the current issue (rtsc-NN/ branch, or --id) done and tear down its worktree+branch.")
621
622
  .option("--id <RTSC-NN>", "The issue to close (default: derived from the current branch)")
622
623
  .option("--force", "Tear down even if the branch isn't merged into main yet")
624
+ // RTSC-709: a read before the write. `done` is one word that closes an issue, and it
625
+ // closed one whose PR was still open. --dry-run shows the readback and the teardown plan
626
+ // without touching anything; -y is for the human who already knows (scripts never prompt).
627
+ .option("--dry-run", "Show what would be closed and torn down, then stop")
628
+ .option("-y, --yes", "Skip the confirmation prompt")
623
629
  .action((opts) => doneAction(opts).catch(fail));
630
+ // --- reading the queue from a terminal (RTSC-709) ---------------------------
631
+ //
632
+ // `tidy` and `done` both reason about issue status, and until these existed a human could
633
+ // not see the status they were being asked to act on without opening the Dash.
634
+ const issue = program.command("issue").description("Read issues from the terminal.");
635
+ issue
636
+ .command("show [issue]")
637
+ .description("Show one issue in full (defaults to the current rtsc-NN/ branch's issue).")
638
+ .option("--json", "Emit the raw payload instead of the view.")
639
+ .allowExcessArguments(false)
640
+ .action((issueArg, opts) => issueShowAction({ issueArg, ...opts }).catch(fail));
641
+ issue
642
+ .command("list")
643
+ .description("List issues in this folder's project. Defaults to active work.")
644
+ .option("--status <statuses>", "todo | doing | blocked | review | done | canceled (comma-separated)")
645
+ .option("--priority <0-4>", "Filter by priority (0 = none, 1 = urgent)")
646
+ .option("--label <name>", "Filter by label")
647
+ .option("--author <name|me>", "Filter by who created it")
648
+ .option("--assignee <name|me|none>", "Filter by the responsible human")
649
+ .option("--sla <state>", "ok | warning | breaching | breached")
650
+ .option("--limit <n>", "How many to return (default 50, max 200)")
651
+ .option("--json", "Emit the raw payload instead of the table.")
652
+ .action((opts) => issueListAction(opts).catch(fail));
653
+ // --- the lease, from a terminal (RTSC-709) ----------------------------------
654
+ program
655
+ .command("checkpoint [issue]")
656
+ .description("Record a handoff note on your claimed issue (and renew its lease).")
657
+ .option("--note <text>", "What's done, what's next, and the gotchas")
658
+ // Not a requiredOption, unlike `release`: commander's "required option not specified"
659
+ // can't say WHERE the token comes from, and the action's own error does.
660
+ .option("--claim-token <token>", "The claim token printed when you claimed it (the server fences on it)")
661
+ .allowExcessArguments(false)
662
+ .action((issueArg, opts) => checkpointAction({ issueArg, ...opts }).catch(fail));
663
+ program
664
+ .command("check-claim [issue]")
665
+ .description("Does THIS session still hold the issue, and is anything renewing the lease? Exits non-zero if not.")
666
+ .option("--json", "Emit the raw payload instead of the view.")
667
+ .allowExcessArguments(false)
668
+ .action((issueArg, opts) => checkClaimAction({ issueArg, ...opts }).catch(fail));
624
669
  // --- config ----------------------------------------------------------------
625
670
  program
626
671
  .command("config")
package/dist/lib/claim.js CHANGED
@@ -162,3 +162,20 @@ export async function mcpCall(conn, name, args = {}, fetchImpl = fetch) {
162
162
  }
163
163
  return toolResult(resp);
164
164
  }
165
+ export async function resolveIssueRef(conn, raw, cache = {}, fetchImpl = fetch) {
166
+ const bare = /^\d+$/.test(raw.trim());
167
+ if (bare && !cache.resolved) {
168
+ cache.prefix = (await workspacePrefix(conn, fetchImpl)) ?? cache.fallback?.();
169
+ cache.resolved = true;
170
+ }
171
+ const norm = normalizeIssueId(raw, cache.prefix);
172
+ if (norm)
173
+ return { id: norm };
174
+ if (bare && !cache.prefix) {
175
+ return {
176
+ error: `"${raw}" is a bare number and the project prefix couldn't be resolved from this workspace.\n` +
177
+ ` Use the full id (e.g. RTSC-${raw.trim()}).`,
178
+ };
179
+ }
180
+ return { error: `"${raw}" doesn't look like an issue id (expected RTSC-NN or a bare number).` };
181
+ }
@@ -163,3 +163,159 @@ export function inviteListView(rows) {
163
163
  i.status === "pending" ? day(i.expiresAt) : "—",
164
164
  ]), "No invites yet. Create one with `retasc members invite --org-id <id>`.");
165
165
  }
166
+ /**
167
+ * Who a member is, in one phrase.
168
+ *
169
+ * An agent's `principal` is kept because it answers the question the name raises: three
170
+ * rows reading "Em de Vries's claude-code" are three sessions of one person, and without
171
+ * the principal a reader cannot tell an agent's name from a human's.
172
+ */
173
+ export function memberRef(m, fallback = "none") {
174
+ if (!m || !m.name)
175
+ return fallback;
176
+ const name = clean(m.name);
177
+ return m.principal && m.principal !== m.name ? `${name} (for ${clean(m.principal)})` : name;
178
+ }
179
+ /**
180
+ * `clean()`, line by line, so newlines survive.
181
+ *
182
+ * `clean()` maps every control character to a space, and a newline is one, which is right
183
+ * for a name in a table and wrong for the two fields that are prose. A body run through it
184
+ * whole comes back as a single unreadable paragraph (caught the first time `issue show` was
185
+ * pointed at a real issue), and the body is the reason the command exists.
186
+ */
187
+ function block(s) {
188
+ return String(s)
189
+ .split(/\r?\n/)
190
+ .map(clean)
191
+ .join("\n");
192
+ }
193
+ /**
194
+ * One issue, as a person reads it: the header line first, then the fields that decide
195
+ * whether to act on it, then the body.
196
+ *
197
+ * The body is printed in full rather than truncated. This command exists so `tidy` and
198
+ * `done` can be judged without the Dash, and a body cut off at N lines sends the reader
199
+ * straight back to the Dash for the rest.
200
+ */
201
+ export function issueView(i, now = Date.now()) {
202
+ const rows = [
203
+ // Status first: it is the field `done` and `tidy` both turn on.
204
+ ["Status", clean(i.status ?? "—") + (i.work === false ? " (container, not claimable)" : "")],
205
+ ["Priority", clean(i.priorityLabel ?? (i.priority != null ? String(i.priority) : "—"))],
206
+ ["Assignee", memberRef(i.assignee)],
207
+ ["Author", memberRef(i.createdBy, "—")],
208
+ ];
209
+ if (i.labels?.length)
210
+ rows.push(["Labels", clean(i.labels.join(", "))]);
211
+ // Said only when true. "Blocked no" on every issue is a line every reader learns to skip,
212
+ // and then misses on the one issue where it says yes.
213
+ if (i.blocked) {
214
+ const by = (i.blockedByOpen ?? []).map((b) => `${clean(b.id)} (${clean(b.status)})`).join(", ");
215
+ rows.push(["Blocked by", by || "yes"]);
216
+ }
217
+ if (i.dueAt != null)
218
+ rows.push(["Deadline", `${day(i.dueAt)}${i.slaState ? ` (${clean(i.slaState)})` : ""}`]);
219
+ rows.push(["Created", ago(i.createdAt, now)], ["Updated", ago(i.updatedAt, now)]);
220
+ if (i.reclaimCount)
221
+ rows.push(["Reclaimed", `${i.reclaimCount}×`]);
222
+ if (i.sourceUrl)
223
+ rows.push(["Source", clean(i.sourceUrl)]);
224
+ if (i.cancelReason)
225
+ rows.push(["Canceled", clean(i.cancelReason)]);
226
+ // The checkpoint is the handoff note, so it is shown whole, under its own label.
227
+ if (i.checkpoint?.note) {
228
+ const by = i.checkpoint.by ? ` by ${clean(i.checkpoint.by)}` : "";
229
+ rows.push(["Checkpoint", `${ago(i.checkpoint.at, now)}${by}\n${block(i.checkpoint.note)}`]);
230
+ }
231
+ const rel = i.relations ?? {};
232
+ for (const [label, ids] of [
233
+ ["Blocks", rel.blocks],
234
+ ["Blocked by", i.blocked ? undefined : rel.blockedBy],
235
+ ["Related", rel.related],
236
+ ["Duplicate of", rel.duplicateOf],
237
+ ]) {
238
+ if (ids?.length)
239
+ rows.push([label, clean(ids.join(", "))]);
240
+ }
241
+ const head = `${clean(i.id ?? "—")} ${clean(i.title ?? "")}`.trimEnd();
242
+ const body = (i.body ?? "").trim();
243
+ return [head, "", labelled(rows), ...(body ? ["", block(body)] : [])].join("\n");
244
+ }
245
+ /**
246
+ * The compact list. Titles are not truncated: `list_issues` is already capped by `--limit`,
247
+ * and a title cut mid-sentence is the version a reader has to re-run the command to finish.
248
+ */
249
+ export function issueListView(rows, now = Date.now()) {
250
+ return table(["ID", "STATUS", "PRIORITY", "ASSIGNEE", "UPDATED", "TITLE"], rows.map((r) => [
251
+ clean(r.id ?? "—"),
252
+ clean(r.status ?? "—"),
253
+ clean(r.priorityLabel ?? "—"),
254
+ // list_issues sends the assignee as a plain name; get_issue sends an object. Take both
255
+ // so one row shape can never render as "[object Object]".
256
+ typeof r.assignee === "string" ? clean(r.assignee) : memberRef(r.assignee, "—"),
257
+ ago(r.updatedAt, now),
258
+ // The SLA state rides on the title so a breaching issue cannot be skimmed past, and
259
+ // "ok" is omitted, which is every issue without a deadline.
260
+ `${r.slaState && r.slaState !== "ok" ? `[${clean(r.slaState)}] ` : ""}${clean(r.title ?? "")}`,
261
+ ]), "No issues match. Try `retasc issue list --status todo` or widen the filters.");
262
+ }
263
+ /**
264
+ * `check_claim`, rendered.
265
+ *
266
+ * `lastRenewedAt` is the whole point (RTSC-646): the CLI is where a human runs this twice
267
+ * across a long stretch to prove something is renewing the lease. So it is printed as a
268
+ * clock time AND as an elapsed one, because "17:42" is what you compare between two runs
269
+ * and "8m ago" is what tells you it moved.
270
+ */
271
+ export function claimStatusView(c, now = Date.now()) {
272
+ const rows = [
273
+ ["Issue", clean(c.issue ?? "—")],
274
+ ["Holder", c.youHold ? "you (this session)" : clean(c.status ?? "—")],
275
+ ];
276
+ if (!c.youHold && c.heldBy) {
277
+ const h = c.heldBy;
278
+ const who = [h.holder && clean(h.holder), h.principal && `for ${clean(h.principal)}`, h.session && `session ${clean(h.session)}`]
279
+ .filter(Boolean)
280
+ .join(" · ");
281
+ if (who)
282
+ rows.push(["Held by", who]);
283
+ }
284
+ if (c.expiresAt != null) {
285
+ const left = Math.round((c.expiresAt - now) / 60000);
286
+ rows.push(["Expires", `${time(c.expiresAt)} (${left > 0 ? `in ${left}m` : "expired"})`]);
287
+ }
288
+ if (c.lastRenewedAt != null)
289
+ rows.push(["Last renewed", `${time(c.lastRenewedAt)} (${ago(c.lastRenewedAt, now)})`]);
290
+ const tail = c.status === "other_session"
291
+ ? // RTSC-658: the holder named above is often this human's own previous session, and the
292
+ // remedy is a re-claim, not waiting out the lease.
293
+ "\n\nA restart or /resume looks like a different session. If that holder was you,\nre-claim it with the same token: retasc claim " +
294
+ clean(c.issue ?? "<RTSC-NN>")
295
+ : c.lastRenewedAt != null && c.youHold
296
+ ? "\n\nRun this again later: a Last renewed that moved proves something is renewing you,\none frozen at claim time means nothing is."
297
+ : "";
298
+ return labelled(rows) + tail;
299
+ }
300
+ /** Wall-clock, local, for a lease you are comparing between two runs. */
301
+ function time(ms) {
302
+ return new Date(ms).toTimeString().slice(0, 5);
303
+ }
304
+ /**
305
+ * The readback `retasc done` prints before it closes anything (RTSC-709).
306
+ *
307
+ * `done` is one word that writes a terminal status, and it shipped with no read path in
308
+ * front of it: it closed RTSC-706 while that PR was open and unreviewed, printed a tick,
309
+ * and moved on to offering branch deletion. This is the sentence that would have caught it.
310
+ */
311
+ export function doneReadback(i, merged) {
312
+ const rows = [
313
+ ["Issue", `${clean(i.id ?? "—")} ${clean(i.title ?? "")}`.trimEnd()],
314
+ ["Status", `${clean(i.status ?? "—")} → done`],
315
+ ];
316
+ if (i.assignee)
317
+ rows.push(["Assignee", memberRef(i.assignee)]);
318
+ if (merged !== null)
319
+ rows.push(["Branch", merged ? "merged into origin/main" : "NOT merged into origin/main"]);
320
+ return labelled(rows);
321
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@retasc/cli",
3
- "version": "1.34.1",
3
+ "version": "1.35.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": {