@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 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)),
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { mkdirSync, writeFileSync, existsSync, chmodSync } from "node:fs";
3
- import { join, dirname } from "node:path";
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
- const existed = existsSync(hookPath);
185
- writeFile(hookPath, hookTemplate(opts.prefix), 0o755);
186
- const enabled = setHooksPath(root);
187
- console.log(`✓ ${existed ? "Updated" : "Wrote"} commit-msg hook (.githooks/commit-msg).`);
188
- if (enabled) {
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
- const existed = existsSync(actionPath);
198
- writeFile(actionPath, actionTemplate(opts.prefix));
199
- console.log(`✓ ${existed ? "Updated" : "Wrote"} GitHub Action (.github/workflows/check-commit-message.yml).`);
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.36.0",
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": {