scripts/garden-inbox.mjs
scripts/garden-inbox.mjsBrowse 3 files
7,180 tokens
29,125 bytes
Token encoding: o200k_base
Snapshot 5b913e7
← Back to SKILL.md
1#!/usr/bin/env node2 3import { createHash } from "node:crypto";4import { execFileSync } from "node:child_process";5import { mkdirSync, readFileSync, writeFileSync } from "node:fs";6import { dirname, resolve } from "node:path";7import { pathToFileURL } from "node:url";8 9const TERMINAL_STATUSES = new Set(["done", "cancelled"]);10const OFFERED_BUCKETS = new Set(["A", "B", "C"]);11const DEFAULT_STALE_DAYS = 60;12const INTERACTION_LIMIT = 200;13const INBOX_MINE_STATUSES = ["backlog", "todo", "in_progress", "in_review", "blocked", "done"];14const INBOX_QUERY_CAP = 500;15 16class ApiError extends Error {17 constructor(status, message, body = null) {18 super(`${status}: ${message}`);19 this.name = "ApiError";20 this.status = status;21 this.body = body;22 }23}24 25function usage() {26 return `Usage:27 garden-inbox.mjs [scan] [--user-id UUID] [--stale-days 60] [--output-dir DIR]28 garden-inbox.mjs confirm [--issue-id ID] [--candidates FILE] [--unselect ISSUE_ID]... [--dry-run]29 garden-inbox.mjs apply [--issue-id ID] (--interaction-id ID | --interaction-file FILE) [--candidates FILE] [--dry-run]30 31Common environment:32 PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_RUN_ID, PAPERCLIP_TASK_ID`;33}34 35function parseArgs(argv) {36 const args = [...argv];37 let command = "scan";38 if (args[0] && !args[0].startsWith("-")) command = args.shift();39 if (!["scan", "confirm", "apply"].includes(command)) throw new Error(`Unknown command: ${command}`);40 41 const options = {};42 while (args.length > 0) {43 const token = args.shift();44 if (token === "--help" || token === "-h") {45 options.help = true;46 continue;47 }48 if (!token.startsWith("--")) throw new Error(`Unexpected argument: ${token}`);49 const key = token.slice(2).replaceAll("-", "_");50 if (key === "dry_run") {51 options[key] = true;52 continue;53 }54 const value = args.shift();55 if (value === undefined || value.startsWith("--")) throw new Error(`${token} requires a value`);56 if (options[key] === undefined) options[key] = value;57 else options[key] = Array.isArray(options[key]) ? [...options[key], value] : [options[key], value];58 }59 return { command, options };60}61 62function required(value, name) {63 if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} is required`);64 return value.trim();65}66 67function normalizeApiBase(value) {68 let base = required(value, "PAPERCLIP_API_URL").replace(/\/+$/, "");69 if (base.endsWith("/api")) base = base.slice(0, -4);70 return base;71}72 73function runtime(options, { requireApi = true } = {}) {74 const apiUrl = options.api_url ?? process.env.PAPERCLIP_API_URL;75 const apiKey = options.api_key ?? process.env.PAPERCLIP_API_KEY;76 return {77 apiBase: requireApi ? normalizeApiBase(apiUrl) : (apiUrl ? normalizeApiBase(apiUrl) : null),78 apiKey: requireApi ? required(apiKey, "PAPERCLIP_API_KEY") : apiKey ?? null,79 runId: options.run_id ?? process.env.PAPERCLIP_RUN_ID ?? null,80 };81}82 83async function apiRequest(context, path, { method = "GET", body } = {}) {84 const headers = { Accept: "application/json", Authorization: `Bearer ${context.apiKey}` };85 if (body !== undefined) headers["Content-Type"] = "application/json";86 if (method !== "GET" && context.runId) headers["X-Paperclip-Run-Id"] = context.runId;87 const response = await fetch(`${context.apiBase}/api${path}`, {88 method,89 headers,90 body: body === undefined ? undefined : JSON.stringify(body),91 });92 const text = await response.text();93 let parsed = null;94 if (text) {95 try { parsed = JSON.parse(text); } catch { parsed = text; }96 }97 if (!response.ok) {98 const message = parsed && typeof parsed === "object"99 ? parsed.error ?? parsed.message ?? response.statusText100 : parsed ?? response.statusText;101 throw new ApiError(response.status, String(message), parsed);102 }103 return parsed;104}105 106function decodeJwtPayload(token) {107 const parts = required(token, "PAPERCLIP_API_KEY").split(".");108 if (parts.length < 2) throw new Error("PAPERCLIP_API_KEY is not a JWT; pass --user-id explicitly");109 try {110 return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));111 } catch {112 throw new Error("Could not decode the PAPERCLIP_API_KEY JWT payload; pass --user-id explicitly");113 }114}115 116function resolveUserId(options, apiKey) {117 if (options.user_id) return required(options.user_id, "--user-id");118 const userId = decodeJwtPayload(apiKey).responsible_user_id;119 if (typeof userId !== "string" || userId.trim() === "") {120 throw new Error("Run JWT has no responsible_user_id; pass --user-id explicitly");121 }122 return userId.trim();123}124 125function parsePositiveInteger(value, fallback, name) {126 if (value === undefined) return fallback;127 const parsed = Number(value);128 if (!Number.isInteger(parsed) || parsed < 1 || parsed > 3650) {129 throw new Error(`${name} must be an integer from 1 to 3650`);130 }131 return parsed;132}133 134function validDate(value) {135 if (!value) return null;136 const date = new Date(value);137 return Number.isNaN(date.getTime()) ? null : date;138}139 140function latestDate(...values) {141 return values.flat().map(validDate).filter(Boolean).sort((left, right) => right.getTime() - left.getTime())[0] ?? null;142}143 144function runGit(args, cwd) {145 try {146 return execFileSync("git", ["-C", cwd, ...args], {147 encoding: "utf8",148 stdio: ["ignore", "pipe", "ignore"],149 timeout: 5_000,150 }).trim();151 } catch {152 return "";153 }154}155 156function gitLastCommitAt(branchName, roots) {157 if (!branchName) return null;158 for (const root of [...new Set(roots.filter(Boolean))]) {159 const parsed = validDate(runGit(["log", "-1", "--format=%cI", branchName], root));160 if (parsed) return parsed;161 }162 return null;163}164 165function findLocalBranch(identifier, roots) {166 if (!identifier) return null;167 const needle = identifier.toLowerCase();168 for (const root of [...new Set(roots.filter(Boolean))]) {169 const branches = runGit(["for-each-ref", "--format=%(refname:short)", "refs/heads"], root)170 .split("\n").map((branch) => branch.trim()).filter(Boolean)171 .filter((branch) => branch.toLowerCase().includes(needle));172 if (branches.length === 1) return branches[0];173 }174 return null;175}176 177async function mapLimit(values, limit, mapper) {178 const results = new Array(values.length);179 let cursor = 0;180 async function worker() {181 while (cursor < values.length) {182 const index = cursor++;183 results[index] = await mapper(values[index], index);184 }185 }186 await Promise.all(Array.from({ length: Math.min(limit, values.length) }, worker));187 return results;188}189 190async function fetchMineInboxRows(context, userId) {191 const statusResults = await Promise.all(INBOX_MINE_STATUSES.map(async (status) => {192 const query = new URLSearchParams({ userId, status });193 const rows = await apiRequest(context, `/agents/me/inbox/mine?${query}`);194 if (!Array.isArray(rows)) throw new Error(`Inbox API returned a non-array response for status ${status}`);195 return { status, rows };196 }));197 const rowsById = new Map();198 const statusCounts = {};199 const cappedStatuses = [];200 let duplicateCount = 0;201 for (const { status, rows } of statusResults) {202 statusCounts[status] = rows.length;203 if (rows.length >= INBOX_QUERY_CAP) cappedStatuses.push(status);204 for (const issue of rows) {205 if (rowsById.has(issue.id)) duplicateCount += 1;206 rowsById.set(issue.id, issue);207 }208 }209 return {210 rows: [...rowsById.values()],211 coverage: {212 strategy: "per_status",213 queryCap: INBOX_QUERY_CAP,214 statusCounts,215 cappedStatuses,216 duplicateCount,217 complete: cappedStatuses.length === 0,218 },219 };220}221 222async function inspectWorkspace(context, workspaceId, issueIdentifiers, gitRoot) {223 let workspace = null;224 let readiness = null;225 let gone = false;226 let error = null;227 try {228 workspace = await apiRequest(context, `/execution-workspaces/${encodeURIComponent(workspaceId)}`);229 } catch (caught) {230 if (caught instanceof ApiError && caught.status === 404) gone = true;231 else error = caught.message;232 }233 if (workspace) {234 try {235 readiness = await apiRequest(context, `/execution-workspaces/${encodeURIComponent(workspaceId)}/close-readiness`);236 } catch (caught) {237 error = error ?? caught.message;238 }239 }240 241 const roots = [workspace?.cwd, workspace?.providerRef, readiness?.git?.repoRoot, readiness?.git?.workspacePath, gitRoot];242 let branchName = workspace?.branchName ?? readiness?.git?.branchName ?? null;243 if (!branchName && gone) {244 const matches = issueIdentifiers.map((identifier) => findLocalBranch(identifier, roots)).filter(Boolean);245 if (new Set(matches).size === 1) branchName = matches[0];246 }247 const branchCommitAt = gitLastCommitAt(branchName, roots);248 return {249 id: workspaceId,250 gone,251 error,252 status: workspace?.status ?? null,253 branchName,254 lastUsedAt: workspace?.lastUsedAt ?? null,255 branchCommitAt: branchCommitAt?.toISOString() ?? null,256 readiness,257 };258}259 260function reason(code, message, facts = {}) {261 return { code, message, facts };262}263 264function classify(issue, workspaceInfo, staleDays, now) {265 const terminal = TERMINAL_STATUSES.has(issue.status);266 const workspaceArchived = workspaceInfo?.status === "archived";267 const workspaceGone = Boolean(workspaceInfo?.gone);268 const readiness = workspaceInfo?.readiness ?? null;269 const linkedIssues = Array.isArray(readiness?.linkedIssues) ? readiness.linkedIssues : [];270 const allLinkedIssuesTerminal = Boolean(readiness) && linkedIssues.every((linked) => linked.isTerminal === true);271 const mergedIntoBase = readiness?.git?.isMergedIntoBase === true;272 const aheadCount = Number.isInteger(readiness?.git?.aheadCount) ? readiness.git.aheadCount : null;273 const blockerAttention = issue.blockerAttention?.state;274 const hasOpenBlockers = issue.status === "blocked"275 || (Array.isArray(issue.blockedBy) && issue.blockedBy.some((blocker) => !TERMINAL_STATUSES.has(blocker.status)))276 || (typeof blockerAttention === "string" && blockerAttention !== "none");277 const awaitingUser = issue.blockedInboxAttention?.state === "awaiting_decision"278 || (issue.status === "in_review" && Boolean(issue.assigneeUserId));279 const issueActivity = latestDate(issue.lastActivityAt, issue.updatedAt, issue.createdAt);280 const branchActivity = latestDate(workspaceInfo?.branchCommitAt, workspaceInfo?.lastUsedAt);281 const lastActivity = latestDate(issueActivity, branchActivity) ?? new Date(0);282 const cutoff = new Date(now.getTime() - staleDays * 86_400_000);283 const stale = lastActivity.getTime() <= cutoff.getTime();284 const facts = {285 issueStatus: issue.status,286 workspaceStatus: workspaceInfo?.status ?? null,287 workspaceGone,288 workspaceInspectionError: workspaceInfo?.error ?? null,289 mergedIntoBase,290 allLinkedIssuesTerminal,291 aheadCount,292 staleDays,293 };294 295 if (workspaceInfo?.error) {296 return {297 bucket: "D",298 reason: reason("workspace_inspection_failed", "Keep: workspace archive safety could not be verified.", facts),299 lastActivity,300 };301 }302 if (hasOpenBlockers) return { bucket: "D", reason: reason("open_blockers", "Keep: the issue has unresolved blocker or liveness attention.", facts), lastActivity };303 if (awaitingUser) return { bucket: "D", reason: reason("pending_user_action", "Keep: the issue is awaiting a user decision or review.", facts), lastActivity };304 if (!terminal) {305 const code = issue.status === "in_review" ? "pending_interaction_or_review" : "non_terminal_status";306 return { bucket: "D", reason: reason(code, `Keep: issue status ${issue.status} is not terminal.`, facts), lastActivity };307 }308 if ((mergedIntoBase || workspaceArchived) && allLinkedIssuesTerminal) {309 const code = workspaceArchived ? "workspace_archived_finished" : "merged_finished";310 return { bucket: "A", reason: reason(code, "Archive candidate: work is merged or archived and every linked issue is terminal.", facts), lastActivity };311 }312 if (!stale) return { bucket: "D", reason: reason("recent_activity", `Keep: activity is newer than ${staleDays} days.`, facts), lastActivity };313 if (aheadCount !== null && aheadCount > 0) {314 return { bucket: "C", reason: reason("stale_unmerged_commits", `Review manually: stale branch is ${aheadCount} commit(s) ahead of base.`, facts), lastActivity };315 }316 if (terminal || workspaceGone) {317 return { bucket: "B", reason: reason("stale_terminal_or_workspace_gone", `Archive candidate: no issue or branch activity for at least ${staleDays} days and the issue is terminal or workspace is gone.`, facts), lastActivity };318 }319 return { bucket: "D", reason: reason("insufficient_archive_evidence", "Keep: archive safety conditions were not met.", facts), lastActivity };320}321 322function candidateFrom(issue, workspace, classification) {323 return {324 issueId: issue.id,325 identifier: issue.identifier ?? null,326 title: issue.title,327 bucket: classification.bucket,328 reason: classification.reason,329 executionWorkspaceId: issue.executionWorkspaceId ?? null,330 workspaceStatus: workspace?.status ?? null,331 workspaceGone: Boolean(workspace?.gone),332 branchName: workspace?.branchName ?? null,333 lastActivityAt: classification.lastActivity.toISOString(),334 };335}336 337function markdownEscape(value) {338 return String(value ?? "").replaceAll("|", "\\|").replaceAll("\n", " ");339}340 341function renderReport(scanResult) {342 const labels = {343 A: "A — merged and finished (default checked)",344 B: "B — stale (default checked)",345 C: "C — stale but unmerged (default unchecked)",346 D: "D — keep (not offered)",347 };348 const lines = [349 "# Garden inbox scan", "",350 `- Generated: ${scanResult.generatedAt}`,351 `- Target user: \`${scanResult.userId}\``,352 `- Stale threshold: ${scanResult.staleDays} days`,353 `- Inbox rows classified: ${scanResult.items.length}`,354 `- Archive candidates: ${scanResult.candidates.length}`,355 `- Scan coverage: ${scanResult.coverage.complete ? "complete across per-status queries" : "possibly truncated"}`,356 ];357 if (!scanResult.coverage.complete) {358 lines.push(359 "",360 "> [!WARNING]",361 `> Coverage may be incomplete: ${scanResult.coverage.cappedStatuses.map((status) => `\`${status}\``).join(", ")} returned at least ${scanResult.coverage.queryCap} rows, the Mine endpoint cap.`,362 );363 }364 for (const bucket of ["A", "B", "C", "D"]) {365 const items = scanResult.items.filter((item) => item.bucket === bucket);366 lines.push("", `## ${labels[bucket]} (${items.length})`, "");367 if (items.length === 0) {368 lines.push("_None._");369 continue;370 }371 lines.push("| Issue | Title | Reason | Workspace / branch | Last activity |", "|---|---|---|---|---|");372 for (const item of items) {373 const issue = item.identifier ? `\`${markdownEscape(item.identifier)}\`` : `\`${item.issueId}\``;374 const workspace = [item.workspaceStatus, item.branchName].filter(Boolean).join(" / ") || (item.workspaceGone ? "gone" : "none");375 lines.push(`| ${issue} | ${markdownEscape(item.title)} | \`${item.reason.code}\` — ${markdownEscape(item.reason.message)} | ${markdownEscape(workspace)} | ${item.lastActivityAt} |`);376 }377 }378 lines.push("");379 return lines.join("\n");380}381 382function writeText(path, contents) {383 const target = resolve(path);384 mkdirSync(dirname(target), { recursive: true });385 writeFileSync(target, contents);386 return target;387}388 389function readJson(path) {390 return JSON.parse(readFileSync(resolve(path), "utf8"));391}392 393function stableScanId(scanResult) {394 const fingerprint = {395 userId: scanResult.userId,396 staleDays: scanResult.staleDays,397 generatedAt: scanResult.generatedAt,398 candidates: scanResult.candidates.map((item) => ({399 issueId: item.issueId,400 bucket: item.bucket,401 reason: item.reason.code,402 lastActivityAt: item.lastActivityAt,403 })),404 };405 return createHash("sha256").update(JSON.stringify(fingerprint)).digest("hex").slice(0, 24);406}407 408async function scan(options) {409 const context = runtime(options);410 const userId = resolveUserId(options, context.apiKey);411 const staleDays = parsePositiveInteger(options.stale_days, DEFAULT_STALE_DAYS, "--stale-days");412 const now = options.now ? validDate(options.now) : new Date();413 if (!now) throw new Error("--now must be an ISO date");414 const outputDir = options.output_dir ?? ".";415 const candidatesPath = options.candidates ?? resolve(outputDir, "candidates.json");416 const reportPath = options.report ?? resolve(outputDir, "garden-inbox-report.md");417 418 const { rows, coverage } = await fetchMineInboxRows(context, userId);419 const issuesByWorkspace = new Map();420 for (const issue of rows) {421 if (!issue.executionWorkspaceId) continue;422 const identifiers = issuesByWorkspace.get(issue.executionWorkspaceId) ?? [];423 if (issue.identifier) identifiers.push(issue.identifier);424 issuesByWorkspace.set(issue.executionWorkspaceId, identifiers);425 }426 const workspaceIds = [...issuesByWorkspace.keys()];427 const inspected = await mapLimit(workspaceIds, 8, (workspaceId) => inspectWorkspace(428 context, workspaceId, issuesByWorkspace.get(workspaceId), options.git_root ?? process.cwd(),429 ));430 const workspaceById = new Map(inspected.map((entry) => [entry.id, entry]));431 const items = rows.map((issue) => {432 const workspace = issue.executionWorkspaceId ? workspaceById.get(issue.executionWorkspaceId) ?? null : null;433 return candidateFrom(issue, workspace, classify(issue, workspace, staleDays, now));434 });435 const scanResult = {436 schemaVersion: 1,437 generatedAt: now.toISOString(),438 userId,439 staleDays,440 sourceCount: rows.length,441 coverage,442 items,443 candidates: items.filter((item) => OFFERED_BUCKETS.has(item.bucket)),444 };445 scanResult.scanId = stableScanId(scanResult);446 const output = {447 schemaVersion: 1,448 scanId: scanResult.scanId,449 generatedAt: scanResult.generatedAt,450 userId,451 staleDays,452 sourceCount: rows.length,453 coverage,454 candidates: scanResult.candidates,455 kept: items.filter((item) => item.bucket === "D"),456 };457 const candidatesFile = writeText(candidatesPath, `${JSON.stringify(output, null, 2)}\n`);458 const report = renderReport(scanResult);459 const reportFile = writeText(reportPath, report);460 process.stdout.write(`${report}\nCandidates JSON: ${candidatesFile}\nReport: ${reportFile}\n`);461 return output;462}463 464function truncate(value, limit) {465 const text = String(value ?? "").trim();466 if (text.length <= limit) return text;467 return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;468}469 470function candidateFile(options) {471 const data = readJson(options.candidates ?? "candidates.json");472 if (data?.schemaVersion !== 1473 || typeof data.scanId !== "string"474 || typeof data.userId !== "string"475 || data.userId.trim() === ""476 || !Array.isArray(data.candidates)) {477 throw new Error("Candidates file is not a garden-inbox schemaVersion 1 scan");478 }479 for (const item of data.candidates) {480 if (!item?.issueId || !OFFERED_BUCKETS.has(item.bucket)) {481 throw new Error("Candidates file contains an invalid or non-offered item");482 }483 }484 return data;485}486 487function archiveTargetBody(scanData) {488 return { userId: scanData.userId };489}490 491function chunk(values, size) {492 const chunks = [];493 for (let index = 0; index < values.length; index += size) chunks.push(values.slice(index, index + size));494 return chunks;495}496 497function unselectedKeySuffix(candidates, unselectedIds) {498 const idsInCard = candidates499 .map((candidate) => candidate.issueId)500 .filter((issueId) => unselectedIds.has(issueId))501 .sort();502 if (idsInCard.length === 0) return "";503 const fingerprint = createHash("sha256")504 .update(idsInCard.join("\n"))505 .digest("hex")506 .slice(0, 16);507 return `:${fingerprint}`;508}509 510function confirmationBody(scanData, candidates, index, count, unselectedIds = new Set()) {511 const options = candidates.map((candidate) => ({512 id: candidate.issueId,513 label: truncate(`${candidate.identifier ?? candidate.issueId} — ${candidate.title}`, 120),514 description: truncate(`${candidate.reason.message} Last activity: ${candidate.lastActivityAt}.${515 unselectedIds.has(candidate.issueId) ? " Declined in a previous pass; starts unchecked." : ""516 }`, 500),517 }));518 const part = count > 1 ? ` (${index + 1}/${count})` : "";519 return {520 kind: "request_checkbox_confirmation",521 idempotencyKey: `garden-inbox:${scanData.scanId}:${index + 1}:${count}${unselectedKeySuffix(candidates, unselectedIds)}`,522 title: `Confirm inbox archive candidates${part}`,523 summary: `Choose which reversible inbox entries to archive${part}.`,524 continuationPolicy: "wake_assignee",525 payload: {526 version: 1,527 prompt: `Select the inbox entries to archive${part}. Unchecked entries will remain visible.`,528 options,529 defaultSelectedOptionIds: candidates530 .filter((candidate) => (candidate.bucket === "A" || candidate.bucket === "B")531 && !unselectedIds.has(candidate.issueId))532 .map((candidate) => candidate.issueId),533 minSelected: 0,534 acceptLabel: "Archive selected",535 rejectLabel: "Keep everything",536 detailsMarkdown: `Scan \`${scanData.scanId}\` used a ${scanData.staleDays}-day stale threshold. Buckets A and B start checked; stale branches with unmerged commits (C) start unchecked. Archiving changes only this user's inbox visibility and is reversible.`,537 },538 };539}540 541async function confirm(options) {542 const data = candidateFile(options);543 const drivingIssueId = options.issue_id ?? process.env.PAPERCLIP_TASK_ID;544 if (!options.dry_run) required(drivingIssueId, "--issue-id or PAPERCLIP_TASK_ID");545 const candidateIds = new Set(data.candidates.map((candidate) => candidate.issueId));546 const unselectedIds = new Set(optionValues(options.unselect).map((id) => required(id, "--unselect")));547 for (const id of unselectedIds) {548 if (!candidateIds.has(id)) throw new Error(`--unselect ${id} is not an offered candidate in this scan`);549 }550 const groups = chunk(data.candidates, INTERACTION_LIMIT);551 if (groups.length === 0) {552 process.stdout.write("No archive candidates; no confirmation interaction created.\n");553 return [];554 }555 const bodies = groups.map((candidates, index) => confirmationBody(data, candidates, index, groups.length, unselectedIds));556 if (options.dry_run) {557 process.stdout.write(`${JSON.stringify({ dryRun: true, issueId: drivingIssueId ?? null, interactions: bodies }, null, 2)}\n`);558 return bodies;559 }560 561 const context = runtime(options);562 const created = [];563 for (const body of bodies) {564 created.push(await apiRequest(context, `/issues/${encodeURIComponent(drivingIssueId)}/interactions`, {565 method: "POST",566 body,567 }));568 }569 process.stdout.write(`${JSON.stringify({570 scanId: data.scanId,571 createdInteractions: created.map((item) => ({572 id: item.id,573 status: item.status,574 idempotencyKey: item.idempotencyKey,575 })),576 }, null, 2)}\n`);577 return created;578}579 580function flattenInteractionFile(value) {581 if (Array.isArray(value)) return value;582 if (Array.isArray(value?.interactions)) return value.interactions;583 if (value?.interaction && typeof value.interaction === "object") return [value.interaction];584 return value && typeof value === "object" ? [value] : [];585}586 587function optionValues(value) {588 if (value === undefined) return [];589 return Array.isArray(value) ? value : [value];590}591 592async function resolveInteractions(options, context, drivingIssueId) {593 const files = optionValues(options.interaction_file);594 const ids = optionValues(options.interaction_id);595 const fromFiles = files.flatMap((file) => flattenInteractionFile(readJson(file)));596 if (ids.length === 0) return fromFiles;597 required(drivingIssueId, "--issue-id or PAPERCLIP_TASK_ID");598 const live = await apiRequest(context, `/issues/${encodeURIComponent(drivingIssueId)}/interactions`);599 if (!Array.isArray(live)) throw new Error("Interactions API returned a non-array response");600 const byId = new Map(live.map((interaction) => [interaction.id, interaction]));601 for (const id of ids) {602 if (!byId.has(id)) throw new Error(`Interaction ${id} was not found on the driving issue`);603 fromFiles.push(byId.get(id));604 }605 return fromFiles;606}607 608function acceptedCandidates(interaction, scanData, candidateById) {609 if (interaction?.kind !== "request_checkbox_confirmation") {610 throw new Error(`Interaction ${interaction?.id ?? "(unknown)"} is not request_checkbox_confirmation`);611 }612 if (typeof interaction.idempotencyKey !== "string"613 || !interaction.idempotencyKey.startsWith(`garden-inbox:${scanData.scanId}:`)) {614 throw new Error(`Interaction ${interaction.id ?? "(unknown)"} does not belong to scan ${scanData.scanId}`);615 }616 if (interaction.status !== "accepted" || interaction.result?.outcome !== "accepted") return [];617 const selected = interaction.result?.selectedOptionIds ?? [];618 if (!Array.isArray(selected)) throw new Error("Accepted interaction has invalid selectedOptionIds");619 const optionIds = new Set((interaction.payload?.options ?? []).map((option) => option.id));620 const seen = new Set();621 return selected.map((issueId) => {622 if (seen.has(issueId)) throw new Error(`Interaction selected duplicate option id ${issueId}`);623 seen.add(issueId);624 if (!optionIds.has(issueId)) throw new Error(`Selected id ${issueId} was not an option in the interaction`);625 const candidate = candidateById.get(issueId);626 if (!candidate || !OFFERED_BUCKETS.has(candidate.bucket)) {627 throw new Error(`Selected id ${issueId} is not an offered candidate in the originating scan`);628 }629 return candidate;630 });631}632 633function renderApplySummary(results, interactions, dryRun, userId) {634 const lines = [635 `# Garden inbox ${dryRun ? "dry-run " : ""}apply summary`, "",636 `- Interactions inspected: ${interactions.length}`,637 `- Entries ${dryRun ? "that would be archived" : "archived"}: ${results.filter((result) => result.ok).length}`,638 `- Failures: ${results.filter((result) => !result.ok).length}`,639 ];640 if (results.length === 0) {641 lines.push("", "Nothing was archived: the interaction was rejected, unresolved, expired, or accepted with no selections.");642 }643 for (const result of results) {644 lines.push("", `- ${result.ok ? "OK" : "FAILED"}: ${result.identifier ?? result.issueId} — ${result.message}`);645 if (result.ok) {646 lines.push(` Undo: \`DELETE /api/issues/${result.issueId}/inbox-archive\` with body \`${JSON.stringify({ userId })}\`, or use **Unarchive** in the issue properties pane.`);647 }648 }649 return `${lines.join("\n")}\n`;650}651 652async function applyAccepted(options) {653 const data = candidateFile(options);654 const drivingIssueId = options.issue_id ?? process.env.PAPERCLIP_TASK_ID ?? null;655 const needsLiveApi = optionValues(options.interaction_id).length > 0 || !options.dry_run;656 const context = runtime(options, { requireApi: needsLiveApi });657 const interactions = await resolveInteractions(options, context, drivingIssueId);658 if (interactions.length === 0) throw new Error("Provide --interaction-id or --interaction-file");659 const candidateById = new Map(data.candidates.map((candidate) => [candidate.issueId, candidate]));660 const accepted = [];661 const seen = new Set();662 for (const interaction of interactions) {663 for (const candidate of acceptedCandidates(interaction, data, candidateById)) {664 if (!seen.has(candidate.issueId)) accepted.push(candidate);665 seen.add(candidate.issueId);666 }667 }668 669 const results = [];670 for (const candidate of accepted) {671 if (options.dry_run) {672 results.push({ ...candidate, ok: true, message: "would archive this accepted inbox entry" });673 continue;674 }675 try {676 await apiRequest(context, `/issues/${encodeURIComponent(candidate.issueId)}/inbox-archive`, {677 method: "POST",678 body: archiveTargetBody(data),679 });680 results.push({ ...candidate, ok: true, message: "archived accepted inbox entry" });681 } catch (error) {682 results.push({ ...candidate, ok: false, message: error.message });683 }684 }685 686 const summary = renderApplySummary(results, interactions, Boolean(options.dry_run), data.userId);687 process.stdout.write(summary);688 if (options.summary) writeText(options.summary, summary);689 if (results.some((result) => !result.ok)) process.exitCode = 1;690 return results;691}692 693export {694 acceptedCandidates,695 archiveTargetBody,696 classify,697 confirm,698 confirmationBody,699 decodeJwtPayload,700 fetchMineInboxRows,701 normalizeApiBase,702 scan,703};704 705async function main() {706 const { command, options } = parseArgs(process.argv.slice(2));707 if (options.help) {708 process.stdout.write(`${usage()}\n`);709 return;710 }711 if (command === "scan") await scan(options);712 else if (command === "confirm") await confirm(options);713 else await applyAccepted(options);714}715 716if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {717 main().catch((error) => {718 process.stderr.write(`garden-inbox: ${error.message}\n`);719 process.exitCode = 1;720 });721}722