@retasc/cli 1.36.0 → 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 +26 -0
- package/dist/api.js +12 -0
- package/dist/commands/gate.js +145 -10
- 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,32 @@ 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
|
+
|
|
23
|
+
## 1.36.1 (2026-08-25)
|
|
24
|
+
|
|
25
|
+
- **RTSC-645** — `retasc gate install` no longer throws away your edits. It rewrites the
|
|
26
|
+
commit-msg hook and the Action on every run, so a gate you had customized (say, one that
|
|
27
|
+
also checks the branch number) used to vanish behind a green "✓ Updated" with nothing
|
|
28
|
+
saying so. Generated files now carry a hash of their own contents, so a later run can tell
|
|
29
|
+
"still exactly what we wrote" from "someone changed this". A changed file is copied to
|
|
30
|
+
`.bak` first, keeping its permissions, and the run tells you where the copy went; a second
|
|
31
|
+
round of edits goes to `.bak.2` rather than overwriting the first. Untouched files are
|
|
32
|
+
replaced silently as before, including when you re-key the gate to a different prefix, so
|
|
33
|
+
the common path gained no prompts and no flags.
|
|
34
|
+
|
|
9
35
|
## 1.36.0 (2026-08-25)
|
|
10
36
|
|
|
11
37
|
- **RTSC-520** — the CLI now says when it is out of date. There was no version check
|
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)),
|
package/dist/commands/gate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, writeFileSync, readFileSync, copyFileSync, statSync, existsSync, chmodSync, } from "node:fs";
|
|
4
|
+
import { join, dirname, relative } from "node:path";
|
|
4
5
|
import { resolveMcpConn, readMcpJson, workspacePrefix } from "../lib/claim.js";
|
|
5
6
|
import { claudeLocalRetascEntry } from "../lib/binding.js";
|
|
6
7
|
import { getBinding } from "../lib/keystore.js";
|
|
@@ -163,6 +164,135 @@ function writeFile(path, body, mode) {
|
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
}
|
|
167
|
+
// RTSC-645: never discard someone's edits silently. Both files are rewritten on
|
|
168
|
+
// every run, so a hand-customized gate (e.g. one that also matches the branch
|
|
169
|
+
// number) used to vanish behind a green "✓ Updated". We stamp what we generate
|
|
170
|
+
// with a hash of its own body, so a later run can tell "still exactly what we
|
|
171
|
+
// wrote" from "someone changed this" without keeping any state outside the file.
|
|
172
|
+
//
|
|
173
|
+
// Hashing the body against its OWN stamp — rather than re-rendering the current
|
|
174
|
+
// template and diffing — is what keeps this stable across CLI versions: changing
|
|
175
|
+
// template text would otherwise make every pristine file on every machine look
|
|
176
|
+
// edited and warn on upgrade.
|
|
177
|
+
const STAMP_PREFIX = "# retasc-gate: sha256=";
|
|
178
|
+
const STAMP_RE = /^# retasc-gate: sha256=([0-9a-f]{64})$/;
|
|
179
|
+
/** Compare and hash on normalized text: a CRLF checkout (Git for Windows'
|
|
180
|
+
* autocrlf default) or a stray BOM must not make our own untouched file look
|
|
181
|
+
* edited on every single run. */
|
|
182
|
+
function normalizeText(s) {
|
|
183
|
+
return s.replace(/^/, "").replace(/\r\n/g, "\n");
|
|
184
|
+
}
|
|
185
|
+
function bodyHash(body) {
|
|
186
|
+
return createHash("sha256").update(normalizeText(body), "utf8").digest("hex");
|
|
187
|
+
}
|
|
188
|
+
/** Append the stamp as the final line — after the shebang and after any YAML, so
|
|
189
|
+
* placement never depends on the file's syntax. Both layers take `#` comments. */
|
|
190
|
+
export function withStamp(body) {
|
|
191
|
+
return `${body}${STAMP_PREFIX}${bodyHash(body)}\n`;
|
|
192
|
+
}
|
|
193
|
+
/** Split a stamped file back into (body, recorded hash), or null when the last
|
|
194
|
+
* line isn't one of our stamps. */
|
|
195
|
+
function splitStamp(raw) {
|
|
196
|
+
const trimmed = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
|
197
|
+
const cut = trimmed.lastIndexOf("\n");
|
|
198
|
+
const last = cut === -1 ? trimmed : trimmed.slice(cut + 1);
|
|
199
|
+
const m = last.match(STAMP_RE);
|
|
200
|
+
if (!m)
|
|
201
|
+
return null;
|
|
202
|
+
return { body: cut === -1 ? "" : trimmed.slice(0, cut + 1), hash: m[1] };
|
|
203
|
+
}
|
|
204
|
+
/** Classify what's on disk. Anything we can't positively prove we generated —
|
|
205
|
+
* no stamp, a broken stamp, an unreadable file — counts as `modified`, because
|
|
206
|
+
* the cost of a needless backup is a stray file and the cost of the wrong call
|
|
207
|
+
* is someone's work. */
|
|
208
|
+
export function classifyGateFile(path) {
|
|
209
|
+
if (!existsSync(path))
|
|
210
|
+
return "absent";
|
|
211
|
+
let raw;
|
|
212
|
+
try {
|
|
213
|
+
raw = readFileSync(path, "utf8");
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return "modified";
|
|
217
|
+
}
|
|
218
|
+
// Normalize BEFORE splitting: on a CRLF checkout the stamp line ends in \r and
|
|
219
|
+
// would never match, so our own untouched file would look edited on every run
|
|
220
|
+
// and pile up a fresh .bak each time.
|
|
221
|
+
const s = splitStamp(normalizeText(raw));
|
|
222
|
+
if (!s)
|
|
223
|
+
return "modified";
|
|
224
|
+
return s.hash === bodyHash(s.body) ? "pristine" : "modified";
|
|
225
|
+
}
|
|
226
|
+
/** Pick where this backup goes. `<path>.bak` when it's free, or already holds
|
|
227
|
+
* exactly these bytes (a repeated run of the same edit shouldn't pile up
|
|
228
|
+
* copies); otherwise the first free `<path>.bak.2`, `.bak.3`, ... Overwriting a
|
|
229
|
+
* backup that holds DIFFERENT bytes would lose the only copy of an earlier
|
|
230
|
+
* edit — someone who edits, installs, edits again and installs again — which is
|
|
231
|
+
* the exact data loss this change exists to stop. */
|
|
232
|
+
function nextBackupPath(path) {
|
|
233
|
+
const first = `${path}.bak`;
|
|
234
|
+
if (!existsSync(first))
|
|
235
|
+
return first;
|
|
236
|
+
// Unreadable source (it classified as `modified` precisely because we couldn't
|
|
237
|
+
// read it): we can't compare, so take a fresh slot rather than crash or clobber.
|
|
238
|
+
let current = null;
|
|
239
|
+
try {
|
|
240
|
+
current = readFileSync(path);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
current = null;
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
if (current && readFileSync(first).equals(current))
|
|
247
|
+
return first;
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
/* unreadable backup: fall through and take a fresh slot rather than clobber it */
|
|
251
|
+
}
|
|
252
|
+
for (let n = 2; n < 100; n++) {
|
|
253
|
+
const candidate = `${path}.bak.${n}`;
|
|
254
|
+
if (!existsSync(candidate))
|
|
255
|
+
return candidate;
|
|
256
|
+
try {
|
|
257
|
+
if (current && readFileSync(candidate).equals(current))
|
|
258
|
+
return candidate;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
/* keep looking */
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
throw new Error(`refusing to overwrite an existing backup: ${first} through ${path}.bak.99 are all taken — clear the ones you don't need and re-run`);
|
|
265
|
+
}
|
|
266
|
+
/** Copy to the slot `nextBackupPath` picked, preserving the mode so a restored
|
|
267
|
+
* hook is still executable (a `mv` of a 0644 backup would silently stop
|
|
268
|
+
* running). */
|
|
269
|
+
function backupFile(path) {
|
|
270
|
+
const bak = nextBackupPath(path);
|
|
271
|
+
copyFileSync(path, bak);
|
|
272
|
+
try {
|
|
273
|
+
chmodSync(bak, statSync(path).mode & 0o777);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
/* best effort (e.g. Windows) */
|
|
277
|
+
}
|
|
278
|
+
return bak;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Write one gate layer, backing up first when the file on disk isn't ours.
|
|
282
|
+
* Returns the line to print, so both layers report identically.
|
|
283
|
+
*/
|
|
284
|
+
function writeLayer(root, path, body, mode) {
|
|
285
|
+
const state = classifyGateFile(path);
|
|
286
|
+
const rel = relative(root, path);
|
|
287
|
+
const lines = [];
|
|
288
|
+
if (state === "modified") {
|
|
289
|
+
const bak = backupFile(path);
|
|
290
|
+
lines.push(`⚠ ${rel} had local edits — saved to ${relative(root, bak)} before replacing it.`);
|
|
291
|
+
}
|
|
292
|
+
writeFile(path, withStamp(body), mode);
|
|
293
|
+
lines.push(`✓ ${state === "absent" ? "Wrote" : "Updated"} ${rel}`);
|
|
294
|
+
return lines;
|
|
295
|
+
}
|
|
166
296
|
/**
|
|
167
297
|
* Install the commit↔issue gate into the current repo, parameterized by `prefix`.
|
|
168
298
|
* Writes the requested layers (hook and/or Action) and enables the hook path.
|
|
@@ -179,13 +309,14 @@ export function installGate(opts) {
|
|
|
179
309
|
if (!hook && !action) {
|
|
180
310
|
throw new Error("nothing to install — drop --no-hook/--no-action or pick at least one layer");
|
|
181
311
|
}
|
|
312
|
+
let backedUp = false;
|
|
182
313
|
if (hook) {
|
|
183
314
|
const hookPath = join(root, ".githooks", "commit-msg");
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (
|
|
315
|
+
backedUp = classifyGateFile(hookPath) === "modified" || backedUp;
|
|
316
|
+
for (const line of writeLayer(root, hookPath, hookTemplate(opts.prefix), 0o755)) {
|
|
317
|
+
console.log(line);
|
|
318
|
+
}
|
|
319
|
+
if (setHooksPath(root)) {
|
|
189
320
|
console.log(" Enabled: git config core.hooksPath .githooks");
|
|
190
321
|
}
|
|
191
322
|
else {
|
|
@@ -194,9 +325,10 @@ export function installGate(opts) {
|
|
|
194
325
|
}
|
|
195
326
|
if (action) {
|
|
196
327
|
const actionPath = join(root, ".github", "workflows", "check-commit-message.yml");
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
328
|
+
backedUp = classifyGateFile(actionPath) === "modified" || backedUp;
|
|
329
|
+
for (const line of writeLayer(root, actionPath, actionTemplate(opts.prefix))) {
|
|
330
|
+
console.log(line);
|
|
331
|
+
}
|
|
200
332
|
}
|
|
201
333
|
console.log("");
|
|
202
334
|
console.log(`Gate is keyed to prefix "${opts.prefix}" — commits/PRs need ${opts.prefix}-NN or [no-issue].`);
|
|
@@ -206,5 +338,8 @@ export function installGate(opts) {
|
|
|
206
338
|
if (hook) {
|
|
207
339
|
console.log("The hook is local fast-feedback only: opt-in per clone (git won't auto-install it) and bypassable with --no-verify.");
|
|
208
340
|
}
|
|
341
|
+
if (backedUp) {
|
|
342
|
+
console.log("\nA file you had customized was replaced by the standard one. Diff the .bak against it before committing, and re-apply anything you want to keep.");
|
|
343
|
+
}
|
|
209
344
|
console.log("\nCommit the written files so the gate ships with the repo.");
|
|
210
345
|
}
|
|
@@ -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": {
|