@retasc/cli 1.34.1 → 1.35.1
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 +34 -0
- package/README.md +76 -0
- package/dist/api.js +14 -0
- package/dist/commands/claim.js +11 -19
- package/dist/commands/issue.js +163 -0
- package/dist/commands/tidy.js +86 -18
- package/dist/index.js +45 -0
- package/dist/lib/claim.js +17 -0
- package/dist/lib/format.js +156 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,40 @@ 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.1 (2026-08-25)
|
|
10
|
+
|
|
11
|
+
- **RTSC-478** — a network failure now names its own cause. Node reports every failed
|
|
12
|
+
`fetch` as the literal `fetch failed` and puts the diagnosis on `err.cause.code`, which
|
|
13
|
+
the CLI read past, so `retasc login` on a machine whose TLS trust store Node cannot read
|
|
14
|
+
died on `✗ fetch failed` and nothing else. It now prints
|
|
15
|
+
`✗ fetch failed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY)` — a searchable string naming the
|
|
16
|
+
real problem. `cli/README.md` gains a troubleshooting entry keyed on that code, covering
|
|
17
|
+
both causes: a corporate TLS-inspecting proxy, and a Homebrew `openssl@3` keg that lost
|
|
18
|
+
its CA symlink (the confusing one, since `curl` and `npm` keep working).
|
|
19
|
+
|
|
20
|
+
## 1.35.0 (2026-08-25)
|
|
21
|
+
|
|
22
|
+
- **RTSC-709** — `retasc issue show` and `retasc issue list` read the queue from a
|
|
23
|
+
terminal. `tidy` and `done` both reason about issue status, and until now a human asked
|
|
24
|
+
to accept their verdict had no way to see that status without opening the Dash. Both
|
|
25
|
+
default to the current `rtsc-NN/` branch's issue, `list` takes the same filters the MCP
|
|
26
|
+
surface does (status, priority, label, author, assignee, SLA, limit), and both take
|
|
27
|
+
`--json`.
|
|
28
|
+
- **RTSC-709** — `retasc checkpoint` and `retasc check-claim` close the claim lifecycle.
|
|
29
|
+
`claim`, `next`, `release` and `done` all existed, so a human could take work, hand it
|
|
30
|
+
back and finish it, but could not record progress on it or answer the question
|
|
31
|
+
`workflow.md` says to settle at runtime: is anything actually renewing my lease?
|
|
32
|
+
`check-claim` prints the last renewal time and exits non-zero when you do not hold the
|
|
33
|
+
issue, so a script can branch on it.
|
|
34
|
+
- **RTSC-709** — `done` reads the issue back before it closes it. It was one word that
|
|
35
|
+
closed an issue with no read path to confirm first, and it closed one whose PR was still
|
|
36
|
+
open and unreviewed. It now prints the id, the title, the status transition and whether
|
|
37
|
+
the branch is merged, and asks before closing on a TTY. `--dry-run` shows the readback
|
|
38
|
+
and the teardown plan without touching anything, `-y` skips the prompt, and a non-TTY
|
|
39
|
+
still closes, so scripts are unchanged.
|
|
40
|
+
- **RTSC-709** — `cli/README.md` now records which MCP tools are deliberately Dash-and-MCP-only,
|
|
41
|
+
and why, so their absence from the CLI reads as a decision rather than an oversight.
|
|
42
|
+
|
|
9
43
|
## 1.34.1 (2026-08-25)
|
|
10
44
|
|
|
11
45
|
- **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,80 @@ 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
|
+
|
|
125
|
+
## Troubleshooting
|
|
126
|
+
|
|
127
|
+
### `fetch failed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY)` or `(SELF_SIGNED_CERT_IN_CHAIN)`
|
|
128
|
+
|
|
129
|
+
Node cannot verify the TLS certificate, so every request the CLI makes dies before it is
|
|
130
|
+
sent. It usually shows up on `retasc login`, because that is the first command that talks
|
|
131
|
+
to the network. Two causes, and the code tells you which:
|
|
132
|
+
|
|
133
|
+
- **A corporate TLS-inspecting proxy** (Zscaler and similar) re-signs traffic with a root
|
|
134
|
+
Node does not trust — normally `SELF_SIGNED_CERT_IN_CHAIN`. Point Node at your
|
|
135
|
+
organisation's CA bundle:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
export NODE_EXTRA_CA_CERTS=/path/to/your-company-ca.pem
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- **A Homebrew Node whose OpenSSL keg lost its CA symlink** — normally
|
|
142
|
+
`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`, and the confusing one, because `curl` and `npm`
|
|
143
|
+
keep working: they do not read the file Node reads. Relink it:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
brew postinstall openssl@3
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Never set `NODE_TLS_REJECT_UNAUTHORIZED=0`. It turns off certificate verification for
|
|
150
|
+
every connection the process makes, including the one carrying your session token.
|
|
151
|
+
`NODE_EXTRA_CA_CERTS` fixes the same failures with verification left on.
|
|
152
|
+
|
|
77
153
|
## Links
|
|
78
154
|
|
|
79
155
|
- Website — [retasc.com](https://retasc.com)
|
package/dist/api.js
CHANGED
|
@@ -3,6 +3,7 @@ import { makeFunctionReference } from "convex/server";
|
|
|
3
3
|
import { loadConfig } from "./config.js";
|
|
4
4
|
import { refreshSession, deviceLogin } from "./auth.js";
|
|
5
5
|
import { selfCommand } from "./lib/launcher.js";
|
|
6
|
+
import { clean as sanitize } from "./lib/text.js";
|
|
6
7
|
import { VERSION } from "./version.js";
|
|
7
8
|
// Typed-ish references to the public management functions in convex/manage.ts.
|
|
8
9
|
// The CLI is a standalone package, so we reference functions by name rather than
|
|
@@ -100,6 +101,19 @@ export function formatError(e) {
|
|
|
100
101
|
.split("\n")[0]
|
|
101
102
|
.replace(/^.*?Uncaught Error:\s*/, "")
|
|
102
103
|
.trim();
|
|
104
|
+
// RTSC-478: a failed `fetch` throws the literal `TypeError: fetch failed` and puts the
|
|
105
|
+
// actual diagnosis on `cause.code` — `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` when Node cannot
|
|
106
|
+
// read the machine's CA roots, `SELF_SIGNED_CERT_IN_CHAIN` behind a corporate
|
|
107
|
+
// TLS-inspecting proxy, `ECONNREFUSED`, `ENOTFOUND`. Reading `message` alone throws away
|
|
108
|
+
// the diagnosis Node already handed us, and leaves `retasc login` — the FIRST command a
|
|
109
|
+
// new user runs — dead-ended on a string that names nothing and searches for nothing:
|
|
110
|
+
// ✗ fetch failed → ✗ fetch failed (UNABLE_TO_GET_ISSUER_CERT_LOCALLY)
|
|
111
|
+
// Fallback branch only: a structured backend error (above) already carries its own `code`.
|
|
112
|
+
// Sanitized like every other string we print, because `cause` is not always ours.
|
|
113
|
+
const raw = e?.cause?.code;
|
|
114
|
+
const code = typeof raw === "string" ? sanitize(raw).trim() : "";
|
|
115
|
+
if (code && !message.includes(code))
|
|
116
|
+
return { message: `${message} (${code})` };
|
|
103
117
|
return { message };
|
|
104
118
|
}
|
|
105
119
|
/**
|
package/dist/commands/claim.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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 !==
|
|
65
|
-
note(`✗ Conflicting issue ids: positional "${requestedId}" vs ${label} "${
|
|
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 =
|
|
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
|
+
}
|
package/dist/commands/tidy.js
CHANGED
|
@@ -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
|
|
52
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
|
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, "
|
|
285
|
+
issue = await mcpCall(ctx.conn, "get_issue", { identifier: issueId });
|
|
249
286
|
}
|
|
250
287
|
catch (e) {
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|
package/dist/lib/format.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.35.1",
|
|
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": {
|