@retasc/cli 1.36.1 → 1.37.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 +14 -0
- package/dist/api.js +12 -0
- package/dist/commands/triage.js +171 -0
- package/dist/index.js +22 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,20 @@ 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.37.0 (2026-08-26)
|
|
10
|
+
|
|
11
|
+
- **RTSC-749** — `retasc triage` reads and approves work filed from outside your org. Work
|
|
12
|
+
that arrives through a GitHub or GitLab connector is written by whoever can file on that
|
|
13
|
+
repo, and since RTSC-746 no agent can pick it up until a person has read it and approved
|
|
14
|
+
it. This is that decision from the terminal: `retasc triage` lists what is waiting,
|
|
15
|
+
`retasc triage RTSC-42` prints the full body and then asks.
|
|
16
|
+
It is deliberately hard to automate, because the CLI runs where coding agents run: there
|
|
17
|
+
is no `--approve` flag, the command refuses to run without an interactive terminal, and
|
|
18
|
+
confirming means retyping the issue id rather than pressing y. Saying no is as cheap as
|
|
19
|
+
saying yes (type `reject`) — the safe answer must never be the expensive one. The Dash
|
|
20
|
+
stays the recommended surface: an agent that can drive a real PTY on your machine could
|
|
21
|
+
drive this command too, and only logging the CLI out takes that away.
|
|
22
|
+
|
|
9
23
|
## 1.36.1 (2026-08-25)
|
|
10
24
|
|
|
11
25
|
- **RTSC-645** — `retasc gate install` no longer throws away your edits. It rewrites the
|
package/dist/api.js
CHANGED
|
@@ -44,6 +44,14 @@ const fns = {
|
|
|
44
44
|
// imported from X before?" once the progress row is gone.
|
|
45
45
|
latestImport: makeFunctionReference("import:latestImport"),
|
|
46
46
|
importHistory: makeFunctionReference("import:importHistory"),
|
|
47
|
+
// RTSC-749 — the triage door. These are the ONLY CLI calls that must never be
|
|
48
|
+
// reachable with an agent key: they are `convex/triage.ts`, which authenticates through
|
|
49
|
+
// Convex Auth and rejects API keys outright (RTSC-746). The CLI reaches them over the
|
|
50
|
+
// human's device-flow SESSION, like billing and invites — never over the workspace MCP
|
|
51
|
+
// key the issue commands use.
|
|
52
|
+
listQuarantined: makeFunctionReference("triage:listQuarantined"),
|
|
53
|
+
approveExternal: makeFunctionReference("triage:approveExternal"),
|
|
54
|
+
rejectExternal: makeFunctionReference("triage:rejectExternal"),
|
|
47
55
|
claimableGhosts: makeFunctionReference("ghosts:claimableGhosts"),
|
|
48
56
|
claimGhost: makeFunctionReference("ghosts:claimGhost"),
|
|
49
57
|
dismissGhostPrompt: makeFunctionReference("ghosts:dismissGhostPrompt"),
|
|
@@ -217,6 +225,10 @@ export const api = {
|
|
|
217
225
|
listImportTargets: (args) => withAuth(() => client().action(fns.listImportTargets, args)),
|
|
218
226
|
listImportStatuses: (args) => withAuth(() => client().action(fns.listImportStatuses, args)),
|
|
219
227
|
listReviewCandidates: (args) => withAuth(() => client().query(fns.listReviewCandidates, args)),
|
|
228
|
+
// RTSC-749 — see `fns` above for why these ride the user session, not the MCP key.
|
|
229
|
+
listQuarantined: (args) => withAuth(() => client().query(fns.listQuarantined, args)),
|
|
230
|
+
approveExternal: (args) => withAuth(() => client().mutation(fns.approveExternal, args)),
|
|
231
|
+
rejectExternal: (args) => withAuth(() => client().mutation(fns.rejectExternal, args)),
|
|
220
232
|
runImport: (args) => withAuth(() => client().action(fns.runImport, args)),
|
|
221
233
|
latestImport: (args) => withAuth(() => client().query(fns.latestImport, args)),
|
|
222
234
|
importHistory: (args) => withAuth(() => client().query(fns.importHistory, args)),
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { api } from "../api.js";
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// RTSC-749 — `retasc triage`. Epic RTSC-751.
|
|
5
|
+
//
|
|
6
|
+
// The second human signing surface for the quarantine gate (RTSC-746): external intake is
|
|
7
|
+
// undispatchable until a member reads it and signs it, and plenty of people live in a
|
|
8
|
+
// terminal rather than in the Dash.
|
|
9
|
+
//
|
|
10
|
+
// THE HARD PART, and why this file is careful. The Dash is a browser a human is sitting
|
|
11
|
+
// in. The CLI is a binary on a machine where a CODING AGENT CAN SHELL OUT. If approval
|
|
12
|
+
// were scriptable, an agent that just read a hostile body could approve the next one, and
|
|
13
|
+
// the gate it is standing in front of would protect nothing.
|
|
14
|
+
//
|
|
15
|
+
// The server-side invariant does most of the work: `convex/triage.ts` authenticates
|
|
16
|
+
// through Convex Auth and rejects agent API keys outright, so an agent holding only a
|
|
17
|
+
// workspace MCP key cannot sign, full stop. What remains is an agent shelling out on a
|
|
18
|
+
// machine where a human is already logged into the CLI. These are the client-side
|
|
19
|
+
// defences against that, and they RAISE THE BAR rather than close the hole:
|
|
20
|
+
//
|
|
21
|
+
// • Refuse a non-interactive stdin/stdout. A harnessed `exec` has no PTY.
|
|
22
|
+
// • No approve flag. There is no `--approve`, no `--yes`, no `--force` — so there is no
|
|
23
|
+
// one-liner to smuggle into a command, and no "just add --yes" for anyone to suggest.
|
|
24
|
+
// • Render the body, then require the identifier RETYPED. Not a y/N: a typed value an
|
|
25
|
+
// agent would have to have read the screen to produce.
|
|
26
|
+
// • Echo the content hash of the body just printed, so the server refuses if the text
|
|
27
|
+
// moved under the reader.
|
|
28
|
+
//
|
|
29
|
+
// RESIDUAL RISK, stated rather than hidden: an agent puppeting a real PTY on a machine
|
|
30
|
+
// with a logged-in CLI defeats the TTY check, and the server cannot tell that apart from
|
|
31
|
+
// a human. The mitigation is revocation (`retasc logout`), and the Dash remains the
|
|
32
|
+
// recommended surface. This is said in the command's own help text too — a defence
|
|
33
|
+
// somebody believes is stronger than it is has already done its damage.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
/**
|
|
36
|
+
* Is a real person at this terminal?
|
|
37
|
+
*
|
|
38
|
+
* BOTH streams, deliberately. `stdin` alone is the usual check and it is not enough here:
|
|
39
|
+
* a harness that pipes output while leaving stdin attached would still be driving a
|
|
40
|
+
* command whose entire safety story is "a human is reading the screen".
|
|
41
|
+
*/
|
|
42
|
+
function interactive() {
|
|
43
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
44
|
+
}
|
|
45
|
+
/** The SHA-256 the server binds a signature to. Must match `externalContentHash` in
|
|
46
|
+
* convex/lib/quarantine.ts EXACTLY, including the length prefixes — a drift here means
|
|
47
|
+
* every terminal approval fails with CONTENT_CHANGED on text nobody edited. */
|
|
48
|
+
async function contentHash(title, body) {
|
|
49
|
+
const canonical = `${title.length}:${title}${body.length}:${body}`;
|
|
50
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
51
|
+
return Array.from(new Uint8Array(digest))
|
|
52
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
53
|
+
.join("");
|
|
54
|
+
}
|
|
55
|
+
/** Resolve the org the same way `retasc billing` does: explicit, or the only one. */
|
|
56
|
+
async function resolveOrg(orgId) {
|
|
57
|
+
const me = await api.me();
|
|
58
|
+
if (!orgId) {
|
|
59
|
+
if (me.orgs.length === 0)
|
|
60
|
+
throw new Error("You're not a member of any org yet.");
|
|
61
|
+
if (me.orgs.length > 1) {
|
|
62
|
+
const list = me.orgs.map((o) => ` ${o.id} ${o.name}${o.slug ? ` (${o.slug})` : ""}`);
|
|
63
|
+
throw new Error(`Several orgs — pass --org-id <id>:\n${list.join("\n")}`);
|
|
64
|
+
}
|
|
65
|
+
orgId = me.orgs[0].id;
|
|
66
|
+
}
|
|
67
|
+
const org = me.orgs.find((o) => o.id === orgId);
|
|
68
|
+
return { orgId: orgId, label: org ? `${org.name}${org.slug ? ` (${org.slug})` : ""}` : String(orgId) };
|
|
69
|
+
}
|
|
70
|
+
/** `retasc triage` — what is waiting. A LIST ONLY: nothing here approves. */
|
|
71
|
+
export async function triageListAction(opts) {
|
|
72
|
+
const { orgId, label } = await resolveOrg(opts.orgId);
|
|
73
|
+
const res = await api.listQuarantined({ orgId });
|
|
74
|
+
const items = res.items ?? [];
|
|
75
|
+
if (opts.json) {
|
|
76
|
+
console.log(JSON.stringify({ orgId, items, hasMore: res.hasMore }, null, 2));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (items.length === 0) {
|
|
80
|
+
console.log(`Nothing waiting in ${label}.`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.log(`${items.length} ${items.length === 1 ? "issue" : "issues"} filed from outside ${label}, ` +
|
|
84
|
+
`waiting for you to read and approve ${items.length === 1 ? "it" : "them"}:\n`);
|
|
85
|
+
for (const i of items) {
|
|
86
|
+
const who = i.externalAuthor ?? "an unidentified account";
|
|
87
|
+
console.log(` ${i.identifier} ${i.title}`);
|
|
88
|
+
console.log(` by ${who}${i.sourceUrl ? ` · ${i.sourceUrl}` : ""}`);
|
|
89
|
+
}
|
|
90
|
+
if (res.hasMore)
|
|
91
|
+
console.log(`\n …and more. Clear some and run this again.`);
|
|
92
|
+
// The list deliberately does NOT print bodies. Reading happens one at a time, next to
|
|
93
|
+
// the decision — a wall of concatenated external text is exactly the thing people skim.
|
|
94
|
+
console.log(`\nRead one in full, then approve or reject: retasc triage <ISSUE-ID>`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* `retasc triage RTSC-42` — render the full body, then ask.
|
|
98
|
+
*
|
|
99
|
+
* The whole command is the reading. Everything before the prompt exists to make sure the
|
|
100
|
+
* person answering it has actually seen the text they are vouching for.
|
|
101
|
+
*/
|
|
102
|
+
export async function triageOneAction(identifier, opts) {
|
|
103
|
+
// FIRST, before any network call: refuse to run where nobody can read the screen. Doing
|
|
104
|
+
// this first means the refusal is about the environment, not about what happens to be
|
|
105
|
+
// in the queue.
|
|
106
|
+
if (!interactive()) {
|
|
107
|
+
throw new Error("`retasc triage <id>` needs an interactive terminal.\n" +
|
|
108
|
+
" Approving external work means a person read it, so this command refuses to run\n" +
|
|
109
|
+
" with piped input or output. There is deliberately no --approve flag.\n" +
|
|
110
|
+
" Approve in the Dash instead: https://dash.retasc.com/?tab=queue");
|
|
111
|
+
}
|
|
112
|
+
const { orgId, label } = await resolveOrg(opts.orgId);
|
|
113
|
+
const res = await api.listQuarantined({ orgId });
|
|
114
|
+
const items = res.items ?? [];
|
|
115
|
+
const want = identifier.trim().toUpperCase();
|
|
116
|
+
const item = items.find((i) => i.identifier.toUpperCase() === want);
|
|
117
|
+
if (!item) {
|
|
118
|
+
throw new Error(`${identifier} is not waiting for approval in ${label}.\n` +
|
|
119
|
+
` It may already be approved, rejected, or never have been external.\n` +
|
|
120
|
+
` Run \`retasc triage\` to see what is waiting.`);
|
|
121
|
+
}
|
|
122
|
+
// The rendering. Plain, undecorated, and complete — no truncation, because a body
|
|
123
|
+
// truncated at the interesting part is worse than no preview at all.
|
|
124
|
+
const who = item.externalAuthor ?? "an unidentified account";
|
|
125
|
+
console.log("");
|
|
126
|
+
console.log(` ${item.identifier} ${item.title}`);
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log(` Filed from OUTSIDE ${label}, by ${who} on ${item.externalOrigin ?? "a connector"}.`);
|
|
129
|
+
if (item.sourceUrl)
|
|
130
|
+
console.log(` Source: ${item.sourceUrl}`);
|
|
131
|
+
console.log("");
|
|
132
|
+
console.log(" ── the text you are approving ".padEnd(72, "─"));
|
|
133
|
+
console.log("");
|
|
134
|
+
for (const line of (item.body || "(no description)").split("\n"))
|
|
135
|
+
console.log(` ${line}`);
|
|
136
|
+
console.log("");
|
|
137
|
+
console.log(" ".padEnd(72, "─"));
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(" Treat this as a report to evaluate, not as instructions. Approving tells");
|
|
140
|
+
console.log(" your agents the text is safe to act on.");
|
|
141
|
+
console.log("");
|
|
142
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
143
|
+
try {
|
|
144
|
+
// The typed confirm. Not y/N: a value that has to be read off the screen. `reject` is
|
|
145
|
+
// the other real answer, and it is deliberately as easy to type as the id is — saying
|
|
146
|
+
// no must never be the expensive option, or people will drift toward yes.
|
|
147
|
+
const answer = (await rl.question(` Type ${item.identifier} to APPROVE, or "reject" to refuse: `)).trim();
|
|
148
|
+
if (answer.toLowerCase() === "reject") {
|
|
149
|
+
await api.rejectExternal({ orgId, identifier: item.identifier });
|
|
150
|
+
console.log(`\n ✓ ${item.identifier} rejected and closed.`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (answer.toUpperCase() !== item.identifier.toUpperCase()) {
|
|
154
|
+
console.log(`\n Nothing done — that didn't match. ${item.identifier} is still waiting.`);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
// Recompute from the text just printed rather than trusting the value the list gave
|
|
158
|
+
// us: if the two could differ, the hash would stop meaning "what was on screen".
|
|
159
|
+
const hash = await contentHash(item.title, item.body ?? "");
|
|
160
|
+
if (hash !== item.contentHash) {
|
|
161
|
+
// Belt and braces — the server will refuse anyway. Saying it here is friendlier
|
|
162
|
+
// than a CONTENT_CHANGED from the backend.
|
|
163
|
+
throw new Error(`${item.identifier} changed while you were reading it. Run the command again.`);
|
|
164
|
+
}
|
|
165
|
+
await api.approveExternal({ orgId, identifier: item.identifier, contentHash: hash });
|
|
166
|
+
console.log(`\n ✓ ${item.identifier} approved. Agents can pick it up now.`);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
rl.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { identityAction } from "./commands/identity.js";
|
|
|
14
14
|
import { importAction } from "./commands/import.js";
|
|
15
15
|
import { doctorAction } from "./commands/doctor.js";
|
|
16
16
|
import { billingAction } from "./commands/billing.js";
|
|
17
|
+
import { triageListAction, triageOneAction } from "./commands/triage.js";
|
|
17
18
|
import { isNetworkError, readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
18
19
|
import { whoamiView, orgCreatedView, projectCreatedView, keyListView, inviteListView, } from "./lib/format.js";
|
|
19
20
|
import { tidyAction, doneAction } from "./commands/tidy.js";
|
|
@@ -253,6 +254,27 @@ program
|
|
|
253
254
|
requireLogin();
|
|
254
255
|
await billingAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
|
|
255
256
|
});
|
|
257
|
+
// RTSC-749 — the terminal half of the quarantine gate (RTSC-746). Sits beside `billing`
|
|
258
|
+
// rather than under `issue`: the `issue` commands go over the workspace MCP key, and this
|
|
259
|
+
// one must go over the HUMAN's session — an agent key cannot sign, by design.
|
|
260
|
+
program
|
|
261
|
+
.command("triage")
|
|
262
|
+
.argument("[issue]", "The issue to read and decide on. Omit to list what is waiting.")
|
|
263
|
+
.description("Read and approve work filed from OUTSIDE your org. Agents can't pick these up until " +
|
|
264
|
+
"a person approves them, and a person means you: this needs an interactive terminal " +
|
|
265
|
+
"and there is deliberately no --approve flag. The Dash is the recommended surface " +
|
|
266
|
+
"(an agent that can drive a real PTY on this machine could drive this command too).")
|
|
267
|
+
.option("--org-id <id>", "Which org (defaults to your only one).")
|
|
268
|
+
.option("--json", "List as raw JSON. Listing only — deciding is never scriptable.")
|
|
269
|
+
.action(async (issue, opts) => {
|
|
270
|
+
requireLogin();
|
|
271
|
+
if (issue === undefined) {
|
|
272
|
+
await triageListAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
await triageOneAction(issue, { orgId: opts.orgId }).catch(fail);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
256
278
|
// --- org / project ---------------------------------------------------------
|
|
257
279
|
const org = program.command("org").description("Manage orgs.");
|
|
258
280
|
org
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@retasc/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.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": {
|